├── tests ├── __init__.py ├── conftest.py ├── utils.py └── test_sensor.py ├── custom_components ├── __init__.py └── frank_energie │ ├── strings.json │ ├── manifest.json │ ├── const.py │ ├── translations │ ├── nl.json │ └── en.json │ ├── __init__.py │ ├── config_flow.py │ ├── coordinator.py │ └── sensor.py ├── conftest.py ├── images ├── example_1.png └── example_2.png ├── requirements.txt ├── .gitignore ├── hacs.json ├── .github ├── workflows │ ├── flake8-matchers.json │ ├── ci.yaml │ └── release.yaml └── helpers │ └── update_manifest.py └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /custom_components/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /conftest.py: -------------------------------------------------------------------------------- 1 | # File used to instruct pytest each directory is a module. 2 | -------------------------------------------------------------------------------- /images/example_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bajansen/home-assistant-frank_energie/HEAD/images/example_1.png -------------------------------------------------------------------------------- /images/example_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bajansen/home-assistant-frank_energie/HEAD/images/example_2.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pytest-homeassistant-custom-component~=0.13.7 2 | flake8~=5.0.4 3 | pytest~=7.2.1 4 | python-frank-energie~=4.1.0 5 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from os.path import abspath, dirname 3 | 4 | root_dir = abspath(dirname(__file__) + "/../custom_components/") 5 | sys.path.append(root_dir) 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # All pycache folders in all folders 2 | __pycache__/ 3 | 4 | # HA files to allow for local development 5 | .HA_VERSION 6 | .storage/ 7 | *.yaml 8 | blueprints/ 9 | *.log* 10 | *.db 11 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Frank Energie", 3 | "country": "NL", 4 | "domains": "sensor", 5 | "render_readme": true, 6 | "homeassistant": "2022.3.0", 7 | "zip_release": true, 8 | "filename": "frank_energie.zip" 9 | } 10 | -------------------------------------------------------------------------------- /custom_components/frank_energie/strings.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "description": "[%key:common::config_flow::description::confirm_setup%]" 6 | } 7 | }, 8 | "abort": { 9 | "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /custom_components/frank_energie/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "frank_energie", 3 | "name": "Frank Energie", 4 | "config_flow": true, 5 | "codeowners": ["@bajansen"], 6 | "documentation": "https://github.com/bajansen/home-assistant-frank_energie", 7 | "issue_tracker": "https://github.com/bajansen/home-assistant-frank_energie/issues", 8 | "iot_class": "cloud_polling", 9 | "requirements": [ 10 | "python-frank-energie==6.2.0" 11 | ], 12 | "version": "0.0.0" 13 | } 14 | -------------------------------------------------------------------------------- /custom_components/frank_energie/const.py: -------------------------------------------------------------------------------- 1 | """Constants for the Frank Energie integration.""" 2 | from __future__ import annotations 3 | 4 | ATTRIBUTION = "Data provided by Frank Energie" 5 | DOMAIN = "frank_energie" 6 | DATA_URL = "https://frank-graphql-prod.graphcdn.app/" 7 | ICON = "mdi:currency-eur" 8 | COMPONENT_TITLE = "Frank Energie" 9 | 10 | CONF_COORDINATOR = "coordinator" 11 | ATTR_TIME = "from_time" 12 | 13 | DATA_ELECTRICITY = "electricity" 14 | DATA_GAS = "gas" 15 | DATA_MONTH_SUMMARY = "month_summary" 16 | DATA_INVOICES = "invoices" 17 | 18 | SERVICE_NAME_PRICES = "Prices" 19 | SERVICE_NAME_COSTS = "Costs" 20 | -------------------------------------------------------------------------------- /.github/workflows/flake8-matchers.json: -------------------------------------------------------------------------------- 1 | { 2 | "problemMatcher": [ 3 | { 4 | "owner": "flake8-error", 5 | "severity": "error", 6 | "pattern": [ 7 | { 8 | "regexp": "^(.*):(\\d+):(\\d+):\\s([EF]\\d{3}\\s.*)$", 9 | "file": 1, 10 | "line": 2, 11 | "column": 3, 12 | "message": 4 13 | } 14 | ] 15 | }, 16 | { 17 | "owner": "flake8-warning", 18 | "severity": "warning", 19 | "pattern": [ 20 | { 21 | "regexp": "^(.*):(\\d+):(\\d+):\\s([CDNW]\\d{3}\\s.*)$", 22 | "file": 1, 23 | "line": 2, 24 | "column": 3, 25 | "message": 4 26 | } 27 | ] 28 | } 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /.github/helpers/update_manifest.py: -------------------------------------------------------------------------------- 1 | """Update the manifest file. 2 | 3 | Sets the version number in the manifest file to the version number 4 | """ 5 | 6 | import sys 7 | import json 8 | import os 9 | 10 | 11 | def update_manifest(): 12 | """Update the manifest file.""" 13 | version = "0.0.0" 14 | for index, value in enumerate(sys.argv): 15 | if value in ["--version", "-V"]: 16 | version = sys.argv[index + 1] 17 | 18 | # Remove the v from the version number if it exists 19 | if version[0] == "v": 20 | version = version[1:] 21 | 22 | with open( 23 | f"{os.getcwd()}/custom_components/frank_energie/manifest.json" 24 | ) as manifestfile: 25 | manifest = json.load(manifestfile) 26 | 27 | manifest["version"] = version 28 | 29 | with open( 30 | f"{os.getcwd()}/custom_components/frank_energie/manifest.json", "w" 31 | ) as manifestfile: 32 | manifestfile.write(json.dumps(manifest, indent=4, sort_keys=True)) 33 | 34 | 35 | update_manifest() 36 | -------------------------------------------------------------------------------- /custom_components/frank_energie/translations/nl.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "Account is al toegevoegd", 5 | "reauth_successful": "Opnieuw inloggen gelukt" 6 | }, 7 | "error": { 8 | "invalid_auth": "Inloggegevens niet geaccepteerd, controleer de gegevens en probeer het opnieuw." 9 | }, 10 | "step": { 11 | "login": { 12 | "data": { 13 | "password": "Wachtwoord", 14 | "username": "Gebruikersnaam / e-mail" 15 | }, 16 | "title": "Inloggen bij Frank Energie" 17 | }, 18 | "user": { 19 | "data": { 20 | "authentication": "Gebruik Frank Energie-accountgegevens" 21 | }, 22 | "title": "Wil je inloggen bij Frank Energie?", 23 | "description": "Inloggen is niet nodig om deze integratie te kunnen gebruiken maar biedt wel de mogelijkheid extra gegevens op te halen." 24 | } 25 | } 26 | }, 27 | "title": "Frank Energie" 28 | } 29 | -------------------------------------------------------------------------------- /custom_components/frank_energie/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "Account is already configured", 5 | "reauth_successful": "Re-authentication was successful" 6 | }, 7 | "error": { 8 | "invalid_auth": "Credentials rejected, please check your username and password." 9 | }, 10 | "step": { 11 | "login": { 12 | "data": { 13 | "password": "Password", 14 | "username": "Username / e-mail" 15 | }, 16 | "title": "Enter your Frank Energie account credentials" 17 | }, 18 | "user": { 19 | "data": { 20 | "authentication": "Use Frank Energie account credentials" 21 | }, 22 | "title": "Do you want to sign in to Frank Energie?", 23 | "description": "Signing in is not required for obtaining energy price data but will add additional sensors specific to your account." 24 | } 25 | } 26 | }, 27 | "title": "Frank Energie" 28 | } 29 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: Flake8 and pytest 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | run: 14 | name: Run flake8/pytest 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - name: Checkout code 19 | uses: actions/checkout@v3 20 | - name: Set up Python 3.10 21 | uses: actions/setup-python@v4 22 | with: 23 | python-version: '3.10' 24 | cache: 'pip' 25 | - name: Install dependencies 26 | run: | 27 | python -m pip install --upgrade pip 28 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 29 | - name: Register flake8 problem matcher 30 | run: | 31 | echo "::add-matcher::.github/workflows/flake8-matchers.json" 32 | - name: Lint with flake8 33 | run: | 34 | # stop the build if there are Python syntax errors or undefined names 35 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 36 | # exit-zero treats all errors as warnings. 37 | flake8 . --count --max-complexity=10 --max-line-length=120 --statistics 38 | - name: Test with pytest 39 | run: | 40 | pytest 41 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | # Combine files and upload it as zip to the release 2 | # Original file from https://github.com/hacs/integration/blob/main/.github/workflows/release.yml 3 | 4 | name: Release 5 | 6 | on: 7 | release: 8 | types: [published] 9 | 10 | jobs: 11 | release_zip_file: 12 | name: Prepare release asset 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Check out repository 16 | uses: actions/checkout@v2 17 | 18 | - name: Set up Python 19 | uses: actions/setup-python@v3 20 | with: 21 | python-version: "3.10" 22 | 23 | - name: "Set version number" 24 | run: | 25 | python3 ${{ github.workspace }}/.github/helpers/update_manifest.py --version ${GITHUB_REF##*/} 26 | 27 | - name: Combine ZIP 28 | run: | 29 | cd ${{ github.workspace }}/custom_components/frank_energie 30 | zip frank_energie.zip -r ./ 31 | 32 | - name: Get release 33 | id: get_release 34 | uses: bruceadams/get-release@v1.3.2 35 | env: 36 | GITHUB_TOKEN: ${{ github.token }} 37 | 38 | - name: Upload Release Asset 39 | id: upload-release-asset 40 | uses: actions/upload-release-asset@v1 41 | env: 42 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 43 | with: 44 | upload_url: ${{ steps.get_release.outputs.upload_url }} 45 | asset_path: ${{ github.workspace }}/custom_components/frank_energie/frank_energie.zip 46 | asset_name: frank_energie.zip 47 | asset_content_type: application/zip 48 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | from http import HTTPStatus 3 | 4 | from homeassistant.const import CONTENT_TYPE_JSON 5 | from pytest_homeassistant_custom_component.test_util.aiohttp import ( 6 | AiohttpClientMockResponse, 7 | ) 8 | 9 | from custom_components.frank_energie import const 10 | 11 | 12 | class ResponseMocks: 13 | """Simple iterator to iterate through a set of responses.""" 14 | 15 | def __init__(self): 16 | self._responses = [] 17 | self._index = 0 18 | self._cyclic = False 19 | 20 | def __iter__(self): 21 | return self 22 | 23 | def __next__(self): 24 | if self._index < len(self._responses): 25 | response = self._responses[self._index] 26 | self._index += 1 27 | if self._cyclic: 28 | self._index %= len(self._responses) 29 | return response 30 | 31 | raise StopIteration 32 | 33 | def cyclic(self): 34 | """Makes the iterator cycle endlessly through the set responses.""" 35 | self._cyclic = True 36 | 37 | def add( 38 | self, 39 | start_date: datetime, 40 | electricity_prices: list, 41 | gas_prices: list, 42 | http_status: int = HTTPStatus.OK, 43 | ): 44 | """Add a response mock.""" 45 | self._responses.append( 46 | AiohttpClientMockResponse( 47 | "POST", 48 | const.DATA_URL, 49 | json={ 50 | "data": { 51 | "marketPricesElectricity": self._generate_prices_response( 52 | start_date, electricity_prices 53 | ), 54 | "marketPricesGas": self._generate_prices_response( 55 | start_date, gas_prices 56 | ), 57 | } 58 | }, 59 | headers={"Content-Type": CONTENT_TYPE_JSON}, 60 | status=http_status, 61 | ) 62 | ) 63 | 64 | def _generate_prices_response(self, start: datetime, all_in_prices: list | range): 65 | """Generate a list of prices.""" 66 | start = start.replace(second=0, microsecond=0) 67 | return [ 68 | { 69 | "from": (start + timedelta(hours=i)).astimezone().isoformat(), 70 | "till": (start + timedelta(hours=i + 1)).astimezone().isoformat(), 71 | "marketPrice": 0.7 * price, 72 | "marketPriceTax": 0.05 * price, 73 | "sourcingMarkupPrice": 0.1 * price, 74 | "energyTaxPrice": 0.15 * price, 75 | } 76 | for i, price in enumerate(all_in_prices) 77 | ] 78 | -------------------------------------------------------------------------------- /custom_components/frank_energie/__init__.py: -------------------------------------------------------------------------------- 1 | """The Frank Energie component.""" 2 | from __future__ import annotations 3 | 4 | from homeassistant.config_entries import ConfigEntry 5 | from homeassistant.const import CONF_ACCESS_TOKEN, Platform, CONF_TOKEN 6 | from homeassistant.core import HomeAssistant 7 | from homeassistant.helpers.aiohttp_client import async_get_clientsession 8 | from python_frank_energie import FrankEnergie 9 | 10 | from .const import CONF_COORDINATOR, DOMAIN 11 | from .coordinator import FrankEnergieCoordinator 12 | 13 | PLATFORMS = [Platform.SENSOR] 14 | 15 | 16 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 17 | """Set up the Frank Energie component from a config entry.""" 18 | 19 | # For backwards compatibility, set unique ID 20 | if entry.unique_id is None or entry.unique_id == "frank_energie_component": 21 | hass.config_entries.async_update_entry(entry, unique_id=str("frank_energie")) 22 | 23 | # Select site-reference, or find first one that has status 'IN_DELIVERY' if not set 24 | if entry.data.get("site_reference") is None and entry.data.get(CONF_ACCESS_TOKEN) is not None: 25 | api = FrankEnergie( 26 | clientsession=async_get_clientsession(hass), 27 | auth_token=entry.data.get(CONF_ACCESS_TOKEN, None), 28 | refresh_token=entry.data.get(CONF_TOKEN, None), 29 | ) 30 | me = await api.me() 31 | 32 | # filter out all sites that are not in delivery 33 | me.deliverySites = [site for site in me.deliverySites if site.status == "IN_DELIVERY"] 34 | 35 | if len(me.deliverySites) == 0: 36 | raise Exception("No suitable sites found for this account") 37 | 38 | site = me.deliverySites[0] 39 | hass.config_entries.async_update_entry(entry, data={**entry.data, "site_reference": site.reference}) 40 | 41 | # Update title 42 | title = f"{site.address_street} {site.address_houseNumber}" 43 | if site.address_houseNumberAddition is not None: 44 | title += f" {site.address_houseNumberAddition}" 45 | hass.config_entries.async_update_entry(entry, title=title) 46 | 47 | # Initialise the coordinator and save it as domain-data 48 | api = FrankEnergie( 49 | clientsession=async_get_clientsession(hass), 50 | auth_token=entry.data.get(CONF_ACCESS_TOKEN, None), 51 | refresh_token=entry.data.get(CONF_TOKEN, None), 52 | ) 53 | frank_coordinator = FrankEnergieCoordinator(hass, entry, api) 54 | 55 | # Fetch initial data, so we have data when entities subscribe and set up the platform 56 | await frank_coordinator.async_config_entry_first_refresh() 57 | hass.data.setdefault(DOMAIN, {}) 58 | hass.data[DOMAIN][entry.entry_id] = { 59 | CONF_COORDINATOR: frank_coordinator, 60 | } 61 | 62 | await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) 63 | 64 | return True 65 | 66 | 67 | async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 68 | """Unload a config entry.""" 69 | unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) 70 | if unload_ok: 71 | hass.data[DOMAIN].pop(entry.entry_id) 72 | 73 | return unload_ok 74 | -------------------------------------------------------------------------------- /custom_components/frank_energie/config_flow.py: -------------------------------------------------------------------------------- 1 | """Config flow for Picnic integration.""" 2 | from __future__ import annotations 3 | 4 | import logging 5 | from collections.abc import Mapping 6 | from typing import Any 7 | 8 | import voluptuous as vol 9 | from homeassistant import config_entries 10 | from homeassistant.const import ( 11 | CONF_ACCESS_TOKEN, 12 | CONF_AUTHENTICATION, 13 | CONF_PASSWORD, 14 | CONF_TOKEN, 15 | CONF_USERNAME, 16 | ) 17 | from homeassistant.data_entry_flow import FlowResult 18 | from python_frank_energie import FrankEnergie 19 | from python_frank_energie.exceptions import AuthException 20 | 21 | from .const import DOMAIN 22 | 23 | _LOGGER = logging.getLogger(__name__) 24 | 25 | 26 | class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 27 | """Handle the config flow for Frank Energie.""" 28 | 29 | VERSION = 1 30 | 31 | def __init__(self) -> None: 32 | """Initialize the config flow.""" 33 | self._reauth_entry = None 34 | 35 | async def async_step_login(self, user_input=None, errors=None) -> FlowResult: 36 | """Handle login with credentials by user.""" 37 | if not user_input: 38 | username = ( 39 | self._reauth_entry.data[CONF_USERNAME] if self._reauth_entry else None 40 | ) 41 | 42 | data_schema = vol.Schema( 43 | { 44 | vol.Required(CONF_USERNAME, default=username): str, 45 | vol.Required(CONF_PASSWORD): str, 46 | } 47 | ) 48 | 49 | return self.async_show_form( 50 | step_id="login", 51 | data_schema=data_schema, 52 | errors=errors, 53 | ) 54 | 55 | async with FrankEnergie() as api: 56 | try: 57 | auth = await api.login( 58 | user_input[CONF_USERNAME], user_input[CONF_PASSWORD] 59 | ) 60 | except AuthException as ex: 61 | _LOGGER.exception("Error during login", exc_info=ex) 62 | return await self.async_step_login(errors={"base": "invalid_auth"}) 63 | 64 | data = { 65 | CONF_USERNAME: user_input[CONF_USERNAME], 66 | CONF_ACCESS_TOKEN: auth.authToken, 67 | CONF_TOKEN: auth.refreshToken, 68 | } 69 | 70 | if self._reauth_entry: 71 | self.hass.config_entries.async_update_entry( 72 | self._reauth_entry, 73 | data=data, 74 | ) 75 | 76 | self.hass.async_create_task( 77 | self.hass.config_entries.async_reload(self._reauth_entry.entry_id) 78 | ) 79 | 80 | return self.async_abort(reason="reauth_successful") 81 | 82 | await self.async_set_unique_id(user_input[CONF_USERNAME]) 83 | self._abort_if_unique_id_configured() 84 | 85 | return await self._async_create_entry(data) 86 | 87 | async def async_step_user(self, user_input=None) -> FlowResult: 88 | """Handle a flow initiated by the user.""" 89 | if not user_input: 90 | data_schema = vol.Schema( 91 | { 92 | vol.Required(CONF_AUTHENTICATION): bool, 93 | } 94 | ) 95 | 96 | return self.async_show_form(step_id="user", data_schema=data_schema) 97 | 98 | if user_input[CONF_AUTHENTICATION]: 99 | return await self.async_step_login() 100 | 101 | data = {} 102 | 103 | return await self._async_create_entry(data) 104 | 105 | async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: 106 | """Handle configuration by re-auth.""" 107 | self._reauth_entry = self.hass.config_entries.async_get_entry( 108 | self.context["entry_id"] 109 | ) 110 | return await self.async_step_login() 111 | 112 | async def _async_create_entry(self, data): 113 | await self.async_set_unique_id(data.get(CONF_USERNAME, "frank_energie")) 114 | self._abort_if_unique_id_configured() 115 | 116 | return self.async_create_entry( 117 | title=data.get(CONF_USERNAME, "Frank Energie"), data=data 118 | ) 119 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![latest version](https://img.shields.io/github/tag/bajansen/home-assistant-frank_energie?include_prereleases=&sort=semver&label=Versie)](https://github.com/bajansen/home-assistant-frank_energie/releases/) 2 | ![installations](https://img.shields.io/badge/dynamic/json?label=Installaties&query=%24.frank_energie.total&url=https%3A%2F%2Fanalytics.home-assistant.io%2Fcustom_integrations.json) 3 | 4 | # Frank Energie Custom Component voor Home Assistant 5 | Middels deze integratie wordt de huidige prijsinformatie van Frank Energie beschikbaar gemaakt binnen Home Assistant. 6 | 7 | De waarden van de prijssensoren kunnen bijvoorbeeld gebruikt worden om apparatuur te schakelen op basis van de huidige energieprijs. 8 | 9 | ## Installatie 10 | Plaats de map `frank_energie` uit de map `custom_components` binnen deze repo in de `custom_components` map van je Home Assistant installatie. 11 | 12 | ### HACS 13 | [![hacs_badge](https://img.shields.io/badge/HACS-Custom-41BDF5.svg)](https://github.com/hacs/integration) 14 | 15 | Installatie via HACS is mogelijk door deze repository toe te voegen als [custom repository](https://hacs.xyz/docs/faq/custom_repositories) met de categorie 'Integratie'. 16 | 17 | ### Configuratie 18 | 19 | 20 | 21 | 22 | 23 | De Frank Energie integratie kan worden toegevoegd via de 'Integraties' pagina in de instellingen. 24 | Vervolgens kunnen sensoren per stuk worden uitgeschakeld of verborgen indien gewenst. 25 | 26 | #### Let op! 27 | 28 | Indien je deze plugin al gebruikte en hebt ingesteld via `configuration.yaml` dien je deze instellingen te verwijderen en Frank Energie opnieuw in te stellen middels de config flow zoals hierboven beschreven. 29 | 30 | #### Inloggen 31 | 32 | Bij het instellen van de integratie wordt de mogelijkheid gegeven in te loggen met je Frank Energie-account. Inloggen is geen vereiste voor werking van deze integratie maar biedt de mogelijkheid om ook klantspecifieke gegevens op te halen. Op dit moment krijg je na inloggen naast de gebruikelijke tariefsensoren ook de beschikking over sensoren voor de verwachte en daadwerkelijke verbruikskosten voor de huidige maand. 33 | 34 | ### Gebruik 35 | 36 | Een aantal sensors hebben een `prices` attribuut die alle bekende prijzen bevat. Dit kan worden gebruikt om zelf met een template nieuwe sensors te maken. 37 | 38 | Voorbeeld om de hoogst bekende prijs na het huidige uur te bepalen: 39 | ``` 40 | {{ state_attr('sensor.current_electricity_price_all_in', 'prices') | selectattr('from', 'gt', now()) | max(attribute='price') }} 41 | ``` 42 | 43 | Laagste prijs vandaag: 44 | ``` 45 | {{ state_attr('sensor.current_electricity_price_all_in', 'prices') | selectattr('till', 'le', now().replace(hour=23)) | min(attribute='price') }} 46 | ``` 47 | 48 | Laagste prijs in de komende zes uren: 49 | ``` 50 | {{ state_attr('sensor.current_electricity_price_all_in', 'prices') | selectattr('from', 'gt', now()) | selectattr('till', 'lt', now() + timedelta(hours=6)) | min(attribute='price') }} 51 | ``` 52 | 53 | ### Grafiek (voorbeelden) 54 | Middels [apex-card](https://github.com/RomRider/apexcharts-card) is het mogelijk de toekomstige prijzen te plotten: 55 | 56 | #### Voorbeeld 1 - Alle data 57 | 58 | ![Apex graph voorbeeld 1](/images/example_1.png "Voorbeeld 1") 59 | 60 | ```yaml 61 | type: custom:apexcharts-card 62 | graph_span: 48h 63 | span: 64 | start: day 65 | now: 66 | show: true 67 | label: Nu 68 | header: 69 | show: true 70 | title: Energieprijs per uur (€/kwh) 71 | series: 72 | - entity: sensor.current_electricity_price_all_in 73 | show: 74 | legend_value: false 75 | stroke_width: 2 76 | float_precision: 3 77 | type: column 78 | opacity: 0.3 79 | color: '#03b2cb' 80 | data_generator: | 81 | return entity.attributes.prices.map((record, index) => { 82 | return [record.from, record.price]; 83 | }); 84 | ``` 85 | 86 | #### Voorbeeld 2 - Komende 10 uur 87 | 88 | ![Apex graph voorbeeld 2](/images/example_2.png "Voorbeeld 2") 89 | 90 | ```yaml 91 | type: custom:apexcharts-card 92 | graph_span: 14h 93 | span: 94 | start: hour 95 | offset: '-3h' 96 | now: 97 | show: true 98 | label: Nu 99 | header: 100 | show: true 101 | show_states: true 102 | colorize_states: true 103 | yaxis: 104 | - decimals: 2 105 | min: 0 106 | max: '|+0.10|' 107 | series: 108 | - entity: sensor.current_electricity_price_all_in 109 | show: 110 | in_header: raw 111 | legend_value: false 112 | stroke_width: 2 113 | float_precision: 4 114 | type: column 115 | opacity: 0.3 116 | color: '#03b2cb' 117 | data_generator: | 118 | return entity.attributes.prices.map((record, index) => { 119 | return [record.from, record.price]; 120 | }); 121 | ``` 122 | -------------------------------------------------------------------------------- /custom_components/frank_energie/coordinator.py: -------------------------------------------------------------------------------- 1 | """Coordinator implementation for Frank Energie integration.""" 2 | from __future__ import annotations 3 | 4 | import logging 5 | from datetime import datetime, timedelta, date 6 | from typing import TypedDict 7 | 8 | from homeassistant.config_entries import ConfigEntry 9 | from homeassistant.core import HomeAssistant 10 | from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN 11 | from homeassistant.exceptions import ConfigEntryAuthFailed 12 | from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed 13 | from python_frank_energie import FrankEnergie 14 | from python_frank_energie.exceptions import RequestException, AuthException 15 | from python_frank_energie.models import PriceData, MonthSummary, Invoices, MarketPrices 16 | 17 | from .const import DATA_ELECTRICITY, DATA_GAS, DATA_MONTH_SUMMARY, DATA_INVOICES 18 | 19 | LOGGER = logging.getLogger(__name__) 20 | 21 | 22 | class FrankEnergieData(TypedDict): 23 | DATA_ELECTRICITY: PriceData 24 | DATA_GAS: PriceData 25 | DATA_MONTH_SUMMARY: MonthSummary | None 26 | DATA_INVOICES: Invoices | None 27 | 28 | 29 | class FrankEnergieCoordinator(DataUpdateCoordinator): 30 | """Get the latest data and update the states.""" 31 | 32 | api: FrankEnergie 33 | 34 | def __init__( 35 | self, hass: HomeAssistant, entry: ConfigEntry, api: FrankEnergie, 36 | ) -> None: 37 | """Initialize the data object.""" 38 | self.hass = hass 39 | self.entry = entry 40 | self.api = api 41 | self.site_reference = entry.data.get("site_reference", None) 42 | 43 | super().__init__( 44 | hass, 45 | LOGGER, 46 | name="Frank Energie coordinator", 47 | update_interval=timedelta(minutes=60), 48 | ) 49 | 50 | async def _async_update_data(self) -> FrankEnergieData: 51 | """Get the latest data from Frank Energie.""" 52 | LOGGER.debug("Fetching Frank Energie data") 53 | 54 | # We request data for today up until the day after tomorrow. 55 | # This is to ensure we always request all available data. 56 | today = datetime.utcnow().date() 57 | tomorrow = today + timedelta(days=1) 58 | day_after_tomorrow = today + timedelta(days=2) 59 | 60 | # Fetch data for today and tomorrow separately, 61 | # because the gas prices response only contains data for the first day of the query 62 | try: 63 | prices_today = await self.__fetch_prices_with_fallback(today, tomorrow) 64 | prices_tomorrow = await self.__fetch_prices_with_fallback(tomorrow, day_after_tomorrow) 65 | 66 | data_month_summary = ( 67 | await self.api.month_summary(self.site_reference) if self.api.is_authenticated else None 68 | ) 69 | data_invoices = ( 70 | await self.api.invoices(self.site_reference) if self.api.is_authenticated else None 71 | ) 72 | except UpdateFailed as err: 73 | # Check if we still have data to work with, if so, return this data. Still log the error as warning 74 | if ( 75 | self.data[DATA_ELECTRICITY].get_future_prices() 76 | and self.data[DATA_GAS].get_future_prices() 77 | ): 78 | LOGGER.warning(str(err)) 79 | return self.data 80 | # Re-raise the error if there's no data from future left 81 | raise err 82 | except RequestException as ex: 83 | if str(ex).startswith("user-error:"): 84 | raise ConfigEntryAuthFailed from ex 85 | 86 | raise UpdateFailed(ex) from ex 87 | 88 | except AuthException as ex: 89 | LOGGER.debug("Authentication tokens expired, trying to renew them (%s)", ex) 90 | await self.__try_renew_token() 91 | # Tell we have no data, so update coordinator tries again with renewed tokens 92 | raise UpdateFailed(ex) from ex 93 | 94 | return { 95 | DATA_ELECTRICITY: prices_today.electricity + prices_tomorrow.electricity, 96 | DATA_GAS: prices_today.gas + prices_tomorrow.gas, 97 | DATA_MONTH_SUMMARY: data_month_summary, 98 | DATA_INVOICES: data_invoices, 99 | } 100 | 101 | async def __fetch_prices_with_fallback(self, start_date: date, end_date: date) -> MarketPrices: 102 | if not self.api.is_authenticated: 103 | return await self.api.prices(start_date, end_date) 104 | else: 105 | user_prices = await self.api.user_prices(start_date, self.site_reference) 106 | 107 | if len(user_prices.gas.all) > 0 and len(user_prices.electricity.all) > 0: 108 | # If user_prices are available for both gas and electricity return them 109 | return user_prices 110 | else: 111 | public_prices = await self.api.prices(start_date, end_date) 112 | 113 | # Use public prices if no user prices are available 114 | if len(user_prices.gas.all) == 0: 115 | LOGGER.info("No gas prices found for user, falling back to public prices") 116 | user_prices.gas = public_prices.gas 117 | 118 | if len(user_prices.electricity.all) == 0: 119 | LOGGER.info("No electricity prices found for user, falling back to public prices") 120 | user_prices.electricity = public_prices.electricity 121 | 122 | return user_prices 123 | 124 | async def __try_renew_token(self): 125 | 126 | try: 127 | updated_tokens = await self.api.renew_token() 128 | 129 | data = { 130 | CONF_ACCESS_TOKEN: updated_tokens.authToken, 131 | CONF_TOKEN: updated_tokens.refreshToken, 132 | } 133 | self.hass.config_entries.async_update_entry(self.entry, data=data) 134 | 135 | LOGGER.debug("Successfully renewed token") 136 | 137 | except AuthException as ex: 138 | LOGGER.error("Failed to renew token: %s. Starting user reauth flow", ex) 139 | raise ConfigEntryAuthFailed from ex 140 | -------------------------------------------------------------------------------- /tests/test_sensor.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | from unittest.mock import patch 3 | 4 | import pytest 5 | from homeassistant import config_entries 6 | from homeassistant.core import HomeAssistant 7 | from homeassistant.helpers import entity_registry 8 | from homeassistant.helpers.entity import generate_entity_id 9 | from homeassistant.util import dt 10 | from pytest_homeassistant_custom_component.common import ( 11 | async_fire_time_changed, 12 | MockConfigEntry, 13 | ) 14 | from pytest_homeassistant_custom_component.test_util.aiohttp import AiohttpClientMocker 15 | 16 | from custom_components.frank_energie import const, sensor 17 | from tests.utils import ResponseMocks 18 | 19 | 20 | @pytest.fixture 21 | async def frank_energie_config_entry(hass: HomeAssistant, enable_custom_integrations): 22 | config_entry = MockConfigEntry( 23 | domain=const.DOMAIN, 24 | data={}, 25 | unique_id=const.UNIQUE_ID, 26 | ) 27 | config_entry.add_to_hass(hass) 28 | 29 | return config_entry 30 | 31 | 32 | @pytest.fixture 33 | def aioclient_responses(aioclient_mock: AiohttpClientMocker, socket_enabled): 34 | responses = ResponseMocks() 35 | 36 | async def next_response(*_): 37 | return next(responses) 38 | 39 | aioclient_mock.post(const.DATA_URL, side_effect=next_response) 40 | 41 | return responses 42 | 43 | 44 | def price_generator(base: float, var: float) -> list: 45 | """ 46 | Return a list of 24 prices which has two peaks of price `base` and 3 bottoms of `base - 6 * var`. 47 | :param base: 48 | :param var: 49 | :return: 50 | """ 51 | return [round(base - var * abs(6 - (i % 12)), 3) for i in range(24)] 52 | 53 | 54 | async def enable_all_sensors(hass): 55 | """Enable all sensors of the integration.""" 56 | er = entity_registry.async_get(hass) 57 | for sensor_type in sensor.SENSOR_TYPES: 58 | if sensor_type.entity_registry_enabled_default is False: 59 | entity_id = generate_entity_id("sensor.{}", sensor_type.name, hass=hass) 60 | er.async_update_entity(entity_id, disabled_by=None) 61 | await hass.async_block_till_done() 62 | await trigger_update(hass) 63 | 64 | 65 | async def trigger_update(hass, delta_seconds=config_entries.RELOAD_AFTER_UPDATE_DELAY): 66 | """Trigger a reload of the data""" 67 | async_fire_time_changed( 68 | hass, 69 | dt.utcnow() + timedelta(seconds=delta_seconds + 1), 70 | ) 71 | await hass.async_block_till_done() 72 | 73 | 74 | @patch("frank_energie.price_data.dt.now") 75 | async def test_sensors( 76 | dt_mock, 77 | aioclient_responses: ResponseMocks, 78 | frank_energie_config_entry: MockConfigEntry, 79 | hass: HomeAssistant, 80 | ): 81 | hass.config.set_time_zone("Europe/Amsterdam") 82 | dt_mock.return_value = ( 83 | datetime.utcnow() 84 | .replace(hour=14, minute=15, second=0, microsecond=0) 85 | .astimezone() 86 | ) 87 | start_of_day = datetime.utcnow().replace(hour=0, minute=0) 88 | aioclient_responses.add( 89 | start_of_day, 90 | [0.2] * 10 + [0.25, 0.3, 0.5, 0.4] + [0.15] * 10, 91 | [1.75] * 6 + [1.23] * 18, 92 | ) 93 | aioclient_responses.add( 94 | start_of_day + timedelta(days=1), 95 | [0.3] * 12 + [0.15] * 12, 96 | [1.23] * 24, 97 | ) 98 | aioclient_responses.cyclic() 99 | 100 | await hass.config_entries.async_setup(frank_energie_config_entry.entry_id) 101 | await hass.async_block_till_done() 102 | 103 | # Check the state of all sensors which are enabled by default 104 | assert hass.states.get("sensor.current_electricity_price_all_in").state == "0.15" 105 | assert hass.states.get("sensor.current_electricity_market_price").state == "0.105" 106 | assert ( 107 | hass.states.get("sensor.current_electricity_price_including_tax").state 108 | == "0.1125" 109 | ) 110 | assert hass.states.get("sensor.current_gas_price_all_in").state == "1.23" 111 | assert hass.states.get("sensor.current_gas_market_price").state == "0.861" 112 | assert hass.states.get("sensor.current_gas_price_including_tax").state == "0.9225" 113 | assert hass.states.get("sensor.lowest_gas_price_today").state == "1.23" 114 | assert hass.states.get("sensor.highest_gas_price_today").state == "1.75" 115 | assert hass.states.get("sensor.lowest_energy_price_today").state == "0.15" 116 | assert hass.states.get("sensor.highest_energy_price_today").state == "0.5" 117 | assert hass.states.get("sensor.average_electricity_price_today").state == "0.20625" 118 | 119 | # Check that the disabled sensors are None 120 | assert hass.states.get("sensor.current_electricity_vat_price") is None 121 | assert hass.states.get("sensor.current_electricity_sourcing_markup") is None 122 | assert hass.states.get("sensor.current_electricity_tax_only") is None 123 | assert hass.states.get("sensor.current_gas_vat_price") is None 124 | assert hass.states.get("sensor.current_gas_sourcing_price") is None 125 | assert hass.states.get("sensor.current_gas_tax_only") is None 126 | 127 | # Enable all sensor and check their expected values 128 | await enable_all_sensors(hass) 129 | assert hass.states.get("sensor.current_electricity_vat_price").state == "0.0075" 130 | assert ( 131 | hass.states.get("sensor.current_electricity_sourcing_markup").state == "0.015" 132 | ) 133 | assert hass.states.get("sensor.current_electricity_tax_only").state == "0.0225" 134 | assert hass.states.get("sensor.current_gas_vat_price").state == "0.0615" 135 | assert hass.states.get("sensor.current_gas_sourcing_price").state == "0.123" 136 | assert hass.states.get("sensor.current_gas_tax_only").state == "0.1845" 137 | 138 | 139 | @patch("frank_energie.price_data.dt.now") 140 | async def test_sensors_get_data_of_current_hour( 141 | dt_mock, 142 | aioclient_responses: ResponseMocks, 143 | frank_energie_config_entry: MockConfigEntry, 144 | hass: HomeAssistant, 145 | ): 146 | hass.config.set_time_zone("Europe/Amsterdam") 147 | dt_mock.return_value = ( 148 | datetime.utcnow() 149 | .replace(hour=5, minute=15, second=0, microsecond=0) 150 | .astimezone() 151 | ) 152 | start_of_day = datetime.utcnow().replace(hour=0, minute=0) 153 | aioclient_responses.add( 154 | start_of_day, [0.3] * 12 + [0.15] * 12, [1.75] * 6 + [1.23] * 18 155 | ) 156 | aioclient_responses.add( 157 | start_of_day + timedelta(days=1), 158 | [0.25] * 12 + [0.1] * 12, 159 | [1.23] * 6 + [1.11] * 18, 160 | ) 161 | aioclient_responses.cyclic() 162 | 163 | await hass.config_entries.async_setup(frank_energie_config_entry.entry_id) 164 | await hass.async_block_till_done() 165 | 166 | # Check the state at 5:15 167 | assert hass.states.get("sensor.current_electricity_price_all_in").state == "0.3" 168 | assert hass.states.get("sensor.current_gas_price_all_in").state == "1.75" 169 | 170 | # Change time to 12:15 171 | dt_mock.return_value = ( 172 | datetime.utcnow() 173 | .replace(hour=12, minute=15, second=0, microsecond=0) 174 | .astimezone() 175 | ) 176 | await trigger_update(hass, 7 * 3600) 177 | 178 | assert hass.states.get("sensor.current_electricity_price_all_in").state == "0.15" 179 | assert hass.states.get("sensor.current_gas_price_all_in").state == "1.23" 180 | 181 | 182 | @patch("frank_energie.price_data.dt.now") 183 | async def test_sensors_no_data_for_tomorrow( 184 | dt_mock, 185 | aioclient_responses: ResponseMocks, 186 | frank_energie_config_entry: MockConfigEntry, 187 | hass: HomeAssistant, 188 | ): 189 | hass.config.set_time_zone("Europe/Amsterdam") 190 | dt_mock.return_value = ( 191 | datetime.utcnow() 192 | .replace(hour=20, minute=0, second=0, microsecond=0) 193 | .astimezone() 194 | ) 195 | start_of_day = datetime.utcnow().replace(hour=0, minute=0) 196 | 197 | # First response is for today's data, 2nd for tomorrow's data 198 | aioclient_responses.add(start_of_day, [0.3] * 24, [1.75] * 6 + [1.23] * 18) 199 | aioclient_responses.add(start_of_day + timedelta(days=1), [], []) 200 | 201 | await hass.config_entries.async_setup(frank_energie_config_entry.entry_id) 202 | await hass.async_block_till_done() 203 | 204 | # Check the state at 5:15 205 | assert hass.states.get("sensor.current_electricity_price_all_in").state == "0.3" 206 | assert hass.states.get("sensor.current_gas_price_all_in").state == "1.23" 207 | 208 | 209 | @patch("frank_energie.price_data.dt.now") 210 | async def test_sensors_hour_price_attr( 211 | dt_mock, 212 | aioclient_responses: ResponseMocks, 213 | frank_energie_config_entry: MockConfigEntry, 214 | hass: HomeAssistant, 215 | ): 216 | hass.config.set_time_zone("Europe/Amsterdam") 217 | dt_mock.return_value = ( 218 | datetime.utcnow() 219 | .replace(hour=20, minute=0, second=0, microsecond=0) 220 | .astimezone() 221 | ) 222 | start_of_day = datetime.utcnow().replace(hour=0, minute=0) 223 | 224 | # First response is for today's data, 2nd for tomorrow's data 225 | aioclient_responses.add( 226 | start_of_day, price_generator(0.25, 0.05), gas_prices=[1.75] * 6 + [1.23] * 18 227 | ) 228 | aioclient_responses.add( 229 | start_of_day + timedelta(days=1), 230 | price_generator(0.3, 0.02), 231 | gas_prices=[1.23] * 6 + [0.75] * 18, 232 | ) 233 | 234 | await hass.config_entries.async_setup(frank_energie_config_entry.entry_id) 235 | await hass.async_block_till_done() 236 | 237 | # Check the all in electricity prices 238 | price_attr = [ 239 | a["price"] 240 | for a in hass.states.get("sensor.current_electricity_price_all_in").attributes[ 241 | "prices" 242 | ] 243 | ] 244 | assert price_attr == price_generator(0.25, 0.05) + price_generator(0.3, 0.02) 245 | 246 | # Check the all in electricity prices 247 | price_attr = [ 248 | a["price"] 249 | for a in hass.states.get("sensor.current_gas_price_all_in").attributes["prices"] 250 | ] 251 | assert price_attr == [1.75] * 6 + [1.23] * 24 + [0.75] * 18 252 | 253 | # For the other sensors just check if the prices attribute is there 254 | assert 48 == len( 255 | hass.states.get("sensor.current_electricity_market_price").attributes["prices"] 256 | ) 257 | assert 48 == len( 258 | hass.states.get("sensor.current_electricity_price_including_tax").attributes[ 259 | "prices" 260 | ] 261 | ) 262 | assert 48 == len( 263 | hass.states.get("sensor.current_gas_market_price").attributes["prices"] 264 | ) 265 | assert 48 == len( 266 | hass.states.get("sensor.current_gas_price_including_tax").attributes["prices"] 267 | ) 268 | -------------------------------------------------------------------------------- /custom_components/frank_energie/sensor.py: -------------------------------------------------------------------------------- 1 | """Frank Energie current electricity and gas price information service.""" 2 | from __future__ import annotations 3 | 4 | import logging 5 | from dataclasses import dataclass 6 | from datetime import timedelta 7 | from typing import Any, Callable 8 | 9 | from homeassistant.components.sensor import ( 10 | SensorDeviceClass, 11 | SensorEntity, 12 | SensorEntityDescription, 13 | SensorStateClass, 14 | ) 15 | from homeassistant.config_entries import ConfigEntry 16 | from homeassistant.const import ( 17 | CURRENCY_EURO, 18 | UnitOfEnergy, 19 | UnitOfVolume, 20 | ) 21 | from homeassistant.core import HassJob, HomeAssistant 22 | from homeassistant.helpers import event 23 | from homeassistant.helpers.device_registry import DeviceEntryType 24 | from homeassistant.helpers.entity import DeviceInfo 25 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 26 | from homeassistant.helpers.typing import StateType 27 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 28 | from homeassistant.util import utcnow 29 | 30 | from .const import ( 31 | ATTR_TIME, 32 | ATTRIBUTION, 33 | CONF_COORDINATOR, 34 | DATA_ELECTRICITY, 35 | DATA_GAS, 36 | DATA_INVOICES, 37 | DATA_MONTH_SUMMARY, 38 | DOMAIN, 39 | ICON, 40 | SERVICE_NAME_PRICES, 41 | SERVICE_NAME_COSTS, 42 | ) 43 | from .coordinator import FrankEnergieCoordinator 44 | 45 | _LOGGER = logging.getLogger(__name__) 46 | 47 | 48 | @dataclass 49 | class FrankEnergieEntityDescription(SensorEntityDescription): 50 | """Describes Frank Energie sensor entity.""" 51 | 52 | authenticated: bool = False 53 | service_name: str | None = SERVICE_NAME_PRICES 54 | value_fn: Callable[[dict], StateType] = None 55 | attr_fn: Callable[[dict], dict[str, StateType | list]] = lambda _: {} 56 | 57 | 58 | SENSOR_TYPES: tuple[FrankEnergieEntityDescription, ...] = ( 59 | FrankEnergieEntityDescription( 60 | key="elec_markup", 61 | name="Current electricity price (All-in)", 62 | native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", 63 | suggested_display_precision=2, 64 | state_class=SensorStateClass.MEASUREMENT, 65 | value_fn=lambda data: data[DATA_ELECTRICITY].current_hour.total, 66 | attr_fn=lambda data: {"prices": data[DATA_ELECTRICITY].asdict("total")}, 67 | ), 68 | FrankEnergieEntityDescription( 69 | key="elec_market", 70 | name="Current electricity market price", 71 | native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", 72 | suggested_display_precision=2, 73 | state_class=SensorStateClass.MEASUREMENT, 74 | value_fn=lambda data: data[DATA_ELECTRICITY].current_hour.market_price, 75 | attr_fn=lambda data: {"prices": data[DATA_ELECTRICITY].asdict("market_price")}, 76 | ), 77 | FrankEnergieEntityDescription( 78 | key="elec_tax", 79 | name="Current electricity price including tax", 80 | native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", 81 | suggested_display_precision=2, 82 | state_class=SensorStateClass.MEASUREMENT, 83 | value_fn=lambda data: data[DATA_ELECTRICITY].current_hour.market_price_with_tax, 84 | attr_fn=lambda data: { 85 | "prices": data[DATA_ELECTRICITY].asdict("market_price_with_tax") 86 | }, 87 | ), 88 | FrankEnergieEntityDescription( 89 | key="elec_tax_vat", 90 | name="Current electricity VAT price", 91 | native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", 92 | suggested_display_precision=2, 93 | state_class=SensorStateClass.MEASUREMENT, 94 | value_fn=lambda data: data[DATA_ELECTRICITY].current_hour.market_price_tax, 95 | entity_registry_enabled_default=False, 96 | ), 97 | FrankEnergieEntityDescription( 98 | key="elec_sourcing", 99 | name="Current electricity sourcing markup", 100 | native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", 101 | suggested_display_precision=2, 102 | state_class=SensorStateClass.MEASUREMENT, 103 | value_fn=lambda data: data[DATA_ELECTRICITY].current_hour.sourcing_markup_price, 104 | entity_registry_enabled_default=False, 105 | ), 106 | FrankEnergieEntityDescription( 107 | key="elec_tax_only", 108 | name="Current electricity tax only", 109 | native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", 110 | suggested_display_precision=2, 111 | state_class=SensorStateClass.MEASUREMENT, 112 | value_fn=lambda data: data[DATA_ELECTRICITY].current_hour.energy_tax_price, 113 | entity_registry_enabled_default=False, 114 | ), 115 | FrankEnergieEntityDescription( 116 | key="gas_markup", 117 | name="Current gas price (All-in)", 118 | native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfVolume.CUBIC_METERS}", 119 | suggested_display_precision=2, 120 | state_class=SensorStateClass.MEASUREMENT, 121 | value_fn=lambda data: data[DATA_GAS].current_hour.total, 122 | attr_fn=lambda data: {"prices": data[DATA_GAS].asdict("total")}, 123 | ), 124 | FrankEnergieEntityDescription( 125 | key="gas_market", 126 | name="Current gas market price", 127 | native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfVolume.CUBIC_METERS}", 128 | suggested_display_precision=2, 129 | state_class=SensorStateClass.MEASUREMENT, 130 | value_fn=lambda data: data[DATA_GAS].current_hour.market_price, 131 | attr_fn=lambda data: {"prices": data[DATA_GAS].asdict("market_price")}, 132 | ), 133 | FrankEnergieEntityDescription( 134 | key="gas_tax", 135 | name="Current gas price including tax", 136 | native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfVolume.CUBIC_METERS}", 137 | suggested_display_precision=2, 138 | state_class=SensorStateClass.MEASUREMENT, 139 | value_fn=lambda data: data[DATA_GAS].current_hour.market_price_with_tax, 140 | attr_fn=lambda data: {"prices": data[DATA_GAS].asdict("market_price_with_tax")}, 141 | ), 142 | FrankEnergieEntityDescription( 143 | key="gas_tax_vat", 144 | name="Current gas VAT price", 145 | native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfVolume.CUBIC_METERS}", 146 | suggested_display_precision=2, 147 | state_class=SensorStateClass.MEASUREMENT, 148 | value_fn=lambda data: data[DATA_GAS].current_hour.market_price_tax, 149 | entity_registry_enabled_default=False, 150 | ), 151 | FrankEnergieEntityDescription( 152 | key="gas_sourcing", 153 | name="Current gas sourcing price", 154 | native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfVolume.CUBIC_METERS}", 155 | suggested_display_precision=2, 156 | state_class=SensorStateClass.MEASUREMENT, 157 | value_fn=lambda data: data[DATA_GAS].current_hour.sourcing_markup_price, 158 | entity_registry_enabled_default=False, 159 | ), 160 | FrankEnergieEntityDescription( 161 | key="gas_tax_only", 162 | name="Current gas tax only", 163 | native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfVolume.CUBIC_METERS}", 164 | suggested_display_precision=2, 165 | state_class=SensorStateClass.MEASUREMENT, 166 | value_fn=lambda data: data[DATA_GAS].current_hour.energy_tax_price, 167 | entity_registry_enabled_default=False, 168 | ), 169 | FrankEnergieEntityDescription( 170 | key="gas_min", 171 | name="Lowest gas price today", 172 | native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfVolume.CUBIC_METERS}", 173 | suggested_display_precision=2, 174 | state_class=SensorStateClass.MEASUREMENT, 175 | value_fn=lambda data: data[DATA_GAS].today_min.total, 176 | attr_fn=lambda data: {ATTR_TIME: data[DATA_GAS].today_min.date_from}, 177 | ), 178 | FrankEnergieEntityDescription( 179 | key="gas_max", 180 | name="Highest gas price today", 181 | native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfVolume.CUBIC_METERS}", 182 | suggested_display_precision=2, 183 | state_class=SensorStateClass.MEASUREMENT, 184 | value_fn=lambda data: data[DATA_GAS].today_max.total, 185 | attr_fn=lambda data: {ATTR_TIME: data[DATA_GAS].today_max.date_from}, 186 | ), 187 | FrankEnergieEntityDescription( 188 | key="elec_min", 189 | name="Lowest energy price today", 190 | native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", 191 | suggested_display_precision=2, 192 | state_class=SensorStateClass.MEASUREMENT, 193 | value_fn=lambda data: data[DATA_ELECTRICITY].today_min.total, 194 | attr_fn=lambda data: {ATTR_TIME: data[DATA_ELECTRICITY].today_min.date_from}, 195 | ), 196 | FrankEnergieEntityDescription( 197 | key="elec_max", 198 | name="Highest energy price today", 199 | native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", 200 | suggested_display_precision=2, 201 | state_class=SensorStateClass.MEASUREMENT, 202 | value_fn=lambda data: data[DATA_ELECTRICITY].today_max.total, 203 | attr_fn=lambda data: {ATTR_TIME: data[DATA_ELECTRICITY].today_max.date_from}, 204 | ), 205 | FrankEnergieEntityDescription( 206 | key="elec_avg", 207 | name="Average electricity price today", 208 | native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", 209 | suggested_display_precision=2, 210 | state_class=SensorStateClass.MEASUREMENT, 211 | value_fn=lambda data: data[DATA_ELECTRICITY].today_avg, 212 | ), 213 | FrankEnergieEntityDescription( 214 | key="actual_costs_until_last_meter_reading_date", 215 | name="Actual monthly cost", 216 | device_class=SensorDeviceClass.MONETARY, 217 | state_class=SensorStateClass.TOTAL, 218 | native_unit_of_measurement=CURRENCY_EURO, 219 | authenticated=True, 220 | service_name=SERVICE_NAME_COSTS, 221 | value_fn=lambda data: data[ 222 | DATA_MONTH_SUMMARY 223 | ].actualCostsUntilLastMeterReadingDate, 224 | attr_fn=lambda data: { 225 | "Last update": data[DATA_MONTH_SUMMARY].lastMeterReadingDate 226 | }, 227 | ), 228 | FrankEnergieEntityDescription( 229 | key="expected_costs_until_last_meter_reading_date", 230 | name="Expected monthly cost until now", 231 | device_class=SensorDeviceClass.MONETARY, 232 | state_class=SensorStateClass.TOTAL, 233 | native_unit_of_measurement=CURRENCY_EURO, 234 | authenticated=True, 235 | service_name=SERVICE_NAME_COSTS, 236 | value_fn=lambda data: data[ 237 | DATA_MONTH_SUMMARY 238 | ].expectedCostsUntilLastMeterReadingDate, 239 | attr_fn=lambda data: { 240 | "Last update": data[DATA_MONTH_SUMMARY].lastMeterReadingDate 241 | }, 242 | ), 243 | FrankEnergieEntityDescription( 244 | key="expected_costs_this_month", 245 | name="Expected cost this month", 246 | device_class=SensorDeviceClass.MONETARY, 247 | state_class=SensorStateClass.TOTAL, 248 | native_unit_of_measurement=CURRENCY_EURO, 249 | authenticated=True, 250 | service_name=SERVICE_NAME_COSTS, 251 | value_fn=lambda data: data[DATA_MONTH_SUMMARY].expectedCosts, 252 | ), 253 | FrankEnergieEntityDescription( 254 | key="invoice_previous_period", 255 | name="Invoice previous period", 256 | device_class=SensorDeviceClass.MONETARY, 257 | state_class=SensorStateClass.TOTAL, 258 | native_unit_of_measurement=CURRENCY_EURO, 259 | authenticated=True, 260 | service_name=SERVICE_NAME_COSTS, 261 | value_fn=lambda data: data[DATA_INVOICES].previousPeriodInvoice.TotalAmount 262 | if data[DATA_INVOICES].previousPeriodInvoice 263 | else None, 264 | attr_fn=lambda data: { 265 | "Start date": data[DATA_INVOICES].previousPeriodInvoice.StartDate, 266 | "Description": data[DATA_INVOICES].previousPeriodInvoice.PeriodDescription, 267 | }, 268 | ), 269 | FrankEnergieEntityDescription( 270 | key="invoice_current_period", 271 | name="Invoice current period", 272 | device_class=SensorDeviceClass.MONETARY, 273 | state_class=SensorStateClass.TOTAL, 274 | native_unit_of_measurement=CURRENCY_EURO, 275 | authenticated=True, 276 | service_name=SERVICE_NAME_COSTS, 277 | value_fn=lambda data: data[DATA_INVOICES].currentPeriodInvoice.TotalAmount 278 | if data[DATA_INVOICES].currentPeriodInvoice 279 | else None, 280 | attr_fn=lambda data: { 281 | "Start date": data[DATA_INVOICES].currentPeriodInvoice.StartDate, 282 | "Description": data[DATA_INVOICES].currentPeriodInvoice.PeriodDescription, 283 | }, 284 | ), 285 | FrankEnergieEntityDescription( 286 | key="invoice_upcoming_period", 287 | name="Invoice upcoming period", 288 | device_class=SensorDeviceClass.MONETARY, 289 | state_class=SensorStateClass.TOTAL, 290 | native_unit_of_measurement=CURRENCY_EURO, 291 | authenticated=True, 292 | service_name=SERVICE_NAME_COSTS, 293 | value_fn=lambda data: data[DATA_INVOICES].upcomingPeriodInvoice.TotalAmount 294 | if data[DATA_INVOICES].upcomingPeriodInvoice 295 | else None, 296 | attr_fn=lambda data: { 297 | "Start date": data[DATA_INVOICES].upcomingPeriodInvoice.StartDate, 298 | "Description": data[DATA_INVOICES].upcomingPeriodInvoice.PeriodDescription, 299 | }, 300 | ), 301 | ) 302 | 303 | 304 | async def async_setup_entry( 305 | hass: HomeAssistant, 306 | config_entry: ConfigEntry, 307 | async_add_entities: AddEntitiesCallback, 308 | ) -> None: 309 | """Set up Frank Energie sensor entries.""" 310 | frank_coordinator = hass.data[DOMAIN][config_entry.entry_id][CONF_COORDINATOR] 311 | 312 | # Add an entity for each sensor type, when authenticated is True, 313 | # only add the entity if the user is authenticated 314 | async_add_entities( 315 | [ 316 | FrankEnergieSensor(frank_coordinator, description, config_entry) 317 | for description in SENSOR_TYPES 318 | if not description.authenticated or frank_coordinator.api.is_authenticated 319 | ], 320 | True, 321 | ) 322 | 323 | 324 | class FrankEnergieSensor(CoordinatorEntity, SensorEntity): 325 | """Representation of a Frank Energie sensor.""" 326 | 327 | _attr_attribution = ATTRIBUTION 328 | _attr_icon = ICON 329 | 330 | def __init__( 331 | self, 332 | coordinator: FrankEnergieCoordinator, 333 | description: FrankEnergieEntityDescription, 334 | entry: ConfigEntry, 335 | ) -> None: 336 | """Initialize the sensor.""" 337 | self.entity_description: FrankEnergieEntityDescription = description 338 | self._attr_unique_id = f"{entry.unique_id}.{description.key}" 339 | 340 | # Do not set extra identifier for default service, backwards compatibility 341 | if description.service_name is SERVICE_NAME_PRICES: 342 | device_info_identifiers = {(DOMAIN, f"{entry.entry_id}")} 343 | else: 344 | device_info_identifiers = {(DOMAIN, f"{entry.entry_id}", description.service_name)} 345 | 346 | self._attr_device_info = DeviceInfo( 347 | identifiers=device_info_identifiers, 348 | name=f"Frank Energie - {description.service_name}", 349 | manufacturer="Frank Energie", 350 | entry_type=DeviceEntryType.SERVICE, 351 | configuration_url="https://www.frankenergie.nl/goedkoop", 352 | ) 353 | 354 | self._update_job = HassJob(self._handle_scheduled_update) 355 | self._unsub_update = None 356 | 357 | super().__init__(coordinator) 358 | 359 | async def async_update(self) -> None: 360 | """Get the latest data and updates the states.""" 361 | try: 362 | self._attr_native_value = self.entity_description.value_fn( 363 | self.coordinator.data 364 | ) 365 | except (TypeError, IndexError, ValueError): 366 | # No data available 367 | self._attr_native_value = None 368 | 369 | # Cancel the currently scheduled event if there is any 370 | if self._unsub_update: 371 | self._unsub_update() 372 | self._unsub_update = None 373 | 374 | # Schedule the next update at exactly the next whole hour sharp 375 | self._unsub_update = event.async_track_point_in_utc_time( 376 | self.hass, 377 | self._update_job, 378 | utcnow().replace(minute=0, second=0) + timedelta(hours=1), 379 | ) 380 | 381 | async def _handle_scheduled_update(self, _): 382 | """Handle a scheduled update.""" 383 | # Only handle the scheduled update for entities which have a reference to hass, 384 | # which disabled sensors don't have. 385 | if self.hass is None: 386 | return 387 | 388 | self.async_schedule_update_ha_state(True) 389 | 390 | @property 391 | def extra_state_attributes(self) -> dict[str, Any]: 392 | """Return the state attributes.""" 393 | return self.entity_description.attr_fn(self.coordinator.data) 394 | 395 | @property 396 | def available(self) -> bool: 397 | return super().available and self.native_value is not None 398 | --------------------------------------------------------------------------------