diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 01793e1..ef51a26 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,31 +1,41 @@ -// See https://aka.ms/vscode-remote/devcontainer.json for format details. { - "image": "ghcr.io/ludeeus/devcontainer/integration:stable", - "name": "Nordpool integration development", - "context": "..", - "appPort": [ - "9123:8123" + "name": "ludeeus/integration_blueprint", + "image": "mcr.microsoft.com/devcontainers/python:3.12", + "postCreateCommand": "scripts/setup", + "forwardPorts": [ + 8123 ], - "postCreateCommand": "container install", - "extensions": [ - "ms-python.python", - "github.vscode-pull-request-github", - "ryanluker.vscode-coverage-gutters", - "ms-python.vscode-pylance" - ], - "settings": { - "files.eol": "\n", - "editor.tabSize": 4, - "terminal.integrated.shell.linux": "/bin/bash", - "python.pythonPath": "/usr/bin/python3", - "python.analysis.autoSearchPaths": false, - "python.linting.pylintEnabled": true, - "python.linting.enabled": true, - "python.formatting.provider": "black", - "editor.formatOnPaste": false, - "editor.formatOnSave": true, - "editor.formatOnType": true, - "files.trimTrailingWhitespace": true - } - + "portsAttributes": { + "8123": { + "label": "Home Assistant", + "onAutoForward": "notify" + } + }, + "customizations": { + "vscode": { + "extensions": [ + "charliermarsh.ruff", + "github.vscode-pull-request-github", + "ms-python.python", + "ms-python.vscode-pylance", + "ryanluker.vscode-coverage-gutters" + ], + "settings": { + "files.eol": "\n", + "editor.tabSize": 4, + "editor.formatOnPaste": true, + "editor.formatOnSave": true, + "editor.formatOnType": false, + "files.trimTrailingWhitespace": true, + "python.analysis.typeCheckingMode": "basic", + "python.analysis.autoImportCompletions": true, + "python.defaultInterpreterPath": "/usr/local/bin/python", + "[python]": { + "editor.defaultFormatter": "charliermarsh.ruff" + } + } + } + }, + "remoteUser": "vscode", + "features": {} } \ No newline at end of file diff --git a/.gitignore b/.gitignore index af6d502..0147390 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,9 @@ __pycache__/ # C extensions *.so +# Dont commit the config folder. +config/ + # Distribution / packaging .Python build/ diff --git a/README.md b/README.md index f2554df..e9b0832 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ [![Donate](https://img.shields.io/badge/Donate-PayPal-green.svg)](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=MAXZPYVPD8XS6) [!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/2ys3cdCZk) -Nord Pool is a service provider that operates an electricity market and power system services, including the exchange of electricity on a spot market Nordics and Baltic countries. +Nord Pool is a service provider that operates an electricity market and power system services, including the exchange of electricity on a spot market Nordics and Baltic countries. -This integration provides the spot market (hourly) electricity prices for the Nordic, Baltic and part of Western Europe. +This integration provides the spot market (hourly) electricity prices for the Nordic, Baltic and part of Western Europe. The Nordpool sensor provides the current price with today's and tomorrow's prices as attributes. Prices become available around 13:00. @@ -31,8 +31,8 @@ The Nordpool sensor provides the current price with today's and tomorrow's price - Restart Home Assistant *or* -- Go to `HACS` -> `Integrations`, -- Select `+`, +- Go to `HACS` -> `Integrations`, +- Select `+`, - Search for `nordpool` and install it, - Restart Home Assistant @@ -44,15 +44,15 @@ cd YOUR_HASS_CONFIG_DIRECTORY # same place as configuration.yaml mkdir -p custom_components/nordpool cd custom_components/nordpool unzip nordpool-X.Y.Z.zip -mv nordpool-X.Y.Z/custom_components/nordpool/* . +mv nordpool-X.Y.Z/custom_components/nordpool/* . ``` ## Usage ### Configuration Variables -| Configuration | Required | Description | +| Configuration | Required | Description | |----------------------| -------- | ----------------------------- | -| Region | **yes** | Country/region to get the energy prices for. See Country/region codes below for details.| +| Region | **yes** | Country/region to get the energy prices for. See Country/region codes below for details.| | Currency | no | *Default: local currency*
Currency used to fetch the prices from the API.| | Include VAT | no | *Default: true*
Add Value Added Taxes (VAT) or not.| | Decimal precision | no | *Default: 3*
Energy price rounding precision. | @@ -76,25 +76,25 @@ Set up the sensor using in `configuration.yaml`. ```yaml sensor: - platform: nordpool - region: "NO1" + region: "NO1" ``` #### Example configuration: ```yaml sensor: - platform: nordpool - # Country/region to get the energy prices for. + # Country/region to get the energy prices for. region: "NO1" - + # Override HA local currency used to fetch the prices from the API. currency: "EUR" - + # Add Value Added Taxes (VAT)? VAT: True - + # Energy price rounding precision. precision: 3 - + # Percentage of average price to set the low price attribute # low_price = hour_price < average * low_price_cutoff low_price_cutoff: 0.95 @@ -107,13 +107,13 @@ sensor: # Template to specify additional cost to be added to the tariff. # The template price is in EUR, DKK, NOK or SEK (not in cents). - # For example: "{{ current_price * 0.19 + 0.023 | float}}" + # For example: "{{ current_price * 0.19 + 0.023 | float}}" additional_costs: "{{0.0|float}}" ``` ### Regions See the [Nord Pool region map](https://www.nordpoolgroup.com/en/maps/) for details -| Country | Region code | +| Country | Region code | | --------- | ----------- | | Austria | AT | | Belgium | BE | @@ -132,30 +132,30 @@ See the [Nord Pool region map](https://www.nordpoolgroup.com/en/maps/) for detai | Sweden | SE1,
SE2,
SE3,
SE4 | ### Additional costs -The idea behind `additional_costs` is to allow the users to add costs related to the official price from Nordpool: +The idea behind `additional_costs` is to allow the users to add costs related to the official price from Nordpool: - Add simple or complex tariffs - Calculate VAT There are two special special arguments in that can be used in the template ([in addition to all default from Homeassistant](https://www.home-assistant.io/docs/configuration/templating/)): - ```now()```: this always refer to the current hour of the price -- ```current_price```: price for the current hour. This can be used for example be used to calculate your own VAT or add overhead cost. +- ```current_price```: price for the current hour. This can be used for example be used to calculate your own VAT or add overhead cost. Note: When configuring Nordpool using the UI, things like VAT and additional costs cannot be changed. If your energy supplier or region changes the additional costs or taxes on a semi-regular basis, the YAML configuration or a helper (example 4) work best. #### Example 1: Overhead per kWh -Add 1,3 cents per kWh overhead cost to the current hour's price +Add 1,3 cents per kWh overhead cost to the current hour's price ```{{ 0.013 | float }}``` #### Example 2: Percentage (VAT) -Add 19 % VAT of the current hour's price +Add 19 % VAT of the current hour's price ```{{ (current_price * 0.19) | float }}``` #### Example 3: Overhead and VAT -Add 1,3 cents per kWh overhead cost, 0.002 flat tax and 19% VAT to the current hour's price +Add 1,3 cents per kWh overhead cost, 0.002 flat tax and 19% VAT to the current hour's price ```{{ (0.013 + 0.002 + (current_price * 0.19)) | float }}``` @@ -208,7 +208,7 @@ Add 21% tax and overhead cost stored in a helper - ```country```: What Country data is fetched for - ```region```: The specific region of prices - ```low_price```: If price is below low_price_threshold -- ```price_percent_to_average```: +- ```price_percent_to_average```: - ```today```: List of all values - ```tomorrow```: list of all values - ```tomorrow_valid```: If tomorow´s values is in yet @@ -218,9 +218,27 @@ Add 21% tax and overhead cost stored in a helper - ```additional_costs_current_hour```: If there is any additional costs this hour - ```price_in_cents```: Boolean if prices is in cents -### One sensor per hour +## Actions +Actions has recently been added. The action will just forward the raw response from the Nordpool API so you can capture the value your are interested in. -By default, one sensor is created with the current energy price. The prices for other hours are stored in the attributes of this sensor. Most example code you will find uses the default one sensor option, but you can run the `create_template` script to create separate sensors for every hour. See the help options with ```python create_template --help```. You can run the script on any system where Python is installed (install the required packages `pyyaml` and `click` using `pip install pyyaml click`) +Example for an automation that get the last months averge price. +```yaml +alias: Example automation action call with storing with parsing and storing result +triggers: null +actions: + - action: nordpool.yearly + data: + currency: NOK + area: NO2 + year: "2024" + response_variable: np_result + - action: input_text.set_value + target: + entity_id: input_text.test + data: + value: "{{np_result.prices[0].averagePerArea.NO2 | float}}" +mode: single +``` ## Troubleshooting diff --git a/custom_components/nordpool/__init__.py b/custom_components/nordpool/__init__.py index 8ea9d5b..b2aca72 100644 --- a/custom_components/nordpool/__init__.py +++ b/custom_components/nordpool/__init__.py @@ -1,10 +1,9 @@ import logging from collections import defaultdict from datetime import timedelta -from random import randint + import backoff -import voluptuous as vol from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.const import Platform @@ -16,25 +15,21 @@ from .aio_price import AioPrices, InvalidValueException from .events import async_track_time_change_in_tz +from .services import async_setup_services + +from .const import ( + NAME, + VERSION, + ISSUEURL, + DOMAIN, + EVENT_NEW_DAY, + EVENT_NEW_HOUR, + EVENT_NEW_PRICE, + _CURRENCY_LIST, + RANDOM_MINUTE, + RANDOM_SECOND, +) -DOMAIN = "nordpool" -_LOGGER = logging.getLogger(__name__) -RANDOM_MINUTE = randint(10, 30) -RANDOM_SECOND = randint(0, 59) -EVENT_NEW_HOUR = "nordpool_update_hour" -EVENT_NEW_DAY = "nordpool_update_day" -EVENT_NEW_PRICE = "nordpool_update_new_price" -SENTINEL = object() - -_CURRENCY_LIST = ["DKK", "EUR", "NOK", "SEK"] - - -CONFIG_SCHEMA = vol.Schema({DOMAIN: vol.Schema({})}, extra=vol.ALLOW_EXTRA) - - -NAME = DOMAIN -VERSION = "0.0.16" -ISSUEURL = "https://github.com/custom-components/nordpool/issues" STARTUP = f""" ------------------------------------------------------------------- @@ -46,6 +41,8 @@ ------------------------------------------------------------------- """ +_LOGGER = logging.getLogger(__name__) + PLATFORMS: list[Platform] = [Platform.SENSOR] @@ -76,7 +73,9 @@ async def _update(self, type_="today", dt=None, areas=None): # Keeping this for now, but this should be changed. for currency in self.currency: spot = AioPrices(currency, client) - data = await spot.hourly(end_date=dt, areas=self.areas if len(self.areas) > 0 else None) + data = await spot.hourly( + end_date=dt, areas=self.areas if len(self.areas) > 0 else None + ) if data: self._data[currency][type_] = data["areas"] @@ -92,7 +91,11 @@ async def update_tomorrow(self, areas=None): _LOGGER.debug("Updating tomorrows prices.") if areas is not None: self.areas += [area for area in areas if area not in self.areas] - await self._update(type_="tomorrow", dt=dt_utils.now() + timedelta(hours=24), areas=self.areas if len(self.areas) > 0 else None) + await self._update( + type_="tomorrow", + dt=dt_utils.now() + timedelta(hours=24), + areas=self.areas if len(self.areas) > 0 else None, + ) async def _someday(self, area: str, currency: str, day: str): """Returns today's or tomorrow's prices in an area in the currency""" @@ -103,7 +106,7 @@ async def _someday(self, area: str, currency: str, day: str): ) if area not in self.areas: - self.areas.append(area); + self.areas.append(area) # This is needed as the currency is # set in the sensor. if currency not in self.currency: @@ -126,12 +129,11 @@ async def _someday(self, area: str, currency: str, day: str): async def today(self, area: str, currency: str) -> dict: """Returns today's prices in an area in the requested currency""" return await self._someday(area, currency, "today") - async def tomorrow(self, area: str, currency: str): """Returns tomorrow's prices in an area in the requested currency""" return await self._someday(area, currency, "tomorrow") - + async def _dry_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up using yaml config file.""" @@ -139,6 +141,7 @@ async def _dry_setup(hass: HomeAssistant, config: ConfigType) -> bool: api = NordpoolData(hass) hass.data[DOMAIN] = api _LOGGER.debug("Added %s to hass.data", DOMAIN) + await async_setup_services(hass) async def new_day_cb(_): """Cb to handle some house keeping when it a new day.""" @@ -161,7 +164,11 @@ async def new_hr(_): @backoff.on_exception( backoff.constant, (InvalidValueException), - logger=_LOGGER, interval=600, max_time=7200, jitter=None) + logger=_LOGGER, + interval=600, + max_time=7200, + jitter=None, + ) async def new_data_cb(_): """Callback to fetch new data for tomorrows prices at 1300ish CET and notify any sensors, about the new data diff --git a/custom_components/nordpool/aio_price.py b/custom_components/nordpool/aio_price.py index c30777c..7f17704 100644 --- a/custom_components/nordpool/aio_price.py +++ b/custom_components/nordpool/aio_price.py @@ -2,100 +2,30 @@ import logging from collections import defaultdict from datetime import date, datetime, timedelta +from datetime import timezone as ts -import aiohttp -import backoff +# import aiohttp +# import backoff from dateutil.parser import parse as parse_dt from homeassistant.util import dt as dt_utils -from nordpool.base import CurrencyMismatch -from nordpool.elspot import Prices + +# from nordpool.elspot import Prices from pytz import timezone, utc from .misc import add_junk +from .const import tzs, INVALID_VALUES _LOGGER = logging.getLogger(__name__) -tzs = { - "DK1": "Europe/Copenhagen", - "DK2": "Europe/Copenhagen", - "FI": "Europe/Helsinki", - "EE": "Europe/Tallinn", - "LT": "Europe/Vilnius", - "LV": "Europe/Riga", - "NO1": "Europe/Oslo", - "NO2": "Europe/Oslo", - "NO3": "Europe/Oslo", - "NO4": "Europe/Oslo", - "NO5": "Europe/Oslo", - "SE1": "Europe/Stockholm", - "SE2": "Europe/Stockholm", - "SE3": "Europe/Stockholm", - "SE4": "Europe/Stockholm", - # What zone is this? - "SYS": "Europe/Stockholm", - "FR": "Europe/Paris", - "NL": "Europe/Amsterdam", - "BE": "Europe/Brussels", - "AT": "Europe/Vienna", - "GER": "Europe/Berlin", -} - -# List of page index for hourly data -# Some are disabled as they don't contain the other currencies, NOK etc, -# or there are some issues with data parsing for some ones' DataStartdate. -# Lets come back and fix that later, just need to adjust the self._parser. -# DataEnddate: "2021-02-11T00:00:00" -# DataStartdate: "0001-01-01T00:00:00" -COUNTRY_BASE_PAGE = { - # "SYS": 17, - "NO": 23, - "SE": 29, - "DK": 41, - # "FI": 35, - # "EE": 47, - # "LT": 53, - # "LV": 59, - # "AT": 298578, - # "BE": 298736, - # "DE-LU": 299565, - # "FR": 299568, - # "NL": 299571, - # "PL": 391921, -} - -AREA_TO_COUNTRY = { - "SYS": "SYS", - "SE1": "SE", - "SE2": "SE", - "SE3": "SE", - "SE4": "SE", - "FI": "FI", - "DK1": "DK", - "DK2": "DK", - "OSLO": "NO", - "KR.SAND": "NO", - "BERGEN": "NO", - "MOLDE": "NO", - "TR.HEIM": "NO", - "TROMSØ": "NO", - "EE": "EE", - "LV": "LV", - "LT": "LT", - "AT": "AT", - "BE": "BE", - "DE-LU": "DE-LU", - "FR": "FR", - "NL": "NL", - "PL ": "PL", -} - -INVALID_VALUES = frozenset((None, float("inf"))) - class InvalidValueException(ValueError): pass +class CurrencyMismatch(ValueError): # pylint: disable=missing-class-docstring + pass + + async def join_result_for_correct_time(results, dt): """Parse a list of responses from the api to extract the correct hours in there timezone. @@ -103,7 +33,10 @@ async def join_result_for_correct_time(results, dt): # utc = datetime.utcnow() fin = defaultdict(dict) # _LOGGER.debug("join_result_for_correct_time %s", dt) - utc = dt + if dt is None: + utc = datetime.now(ts.utc) + else: + utc = dt for day_ in results: for key, value in day_.get("areas", {}).items(): @@ -145,28 +78,34 @@ async def join_result_for_correct_time(results, dt): "Hour has the same start and end, most likly due to dst change %s exluded this hour", val, ) - elif val['value'] in INVALID_VALUES: - raise InvalidValueException(f"Invalid value in {val} for area '{key}'") + elif val["value"] in INVALID_VALUES: + raise InvalidValueException( + f"Invalid value in {val} for area '{key}'" + ) else: fin["areas"][key]["values"].append(val) return fin -class AioPrices(Prices): +class AioPrices: """Interface""" def __init__(self, currency, client, timeezone=None): - super().__init__(currency) + # super().__init__(currency) self.client = client self.timeezone = timeezone - (self.HOURLY, self.DAILY, self.WEEKLY, self.MONTHLY, self.YEARLY) = ("DayAheadPrices", "AggregatePrices", - "AggregatePrices", "AggregatePrices", - "AggregatePrices") + (self.HOURLY, self.DAILY, self.WEEKLY, self.MONTHLY, self.YEARLY) = ( + "DayAheadPrices", + "AggregatePrices", + "AggregatePrices", + "AggregatePrices", + "AggregatePrices/GetAnnuals", + ) self.API_URL = "https://dataportal-api.nordpoolgroup.com/api/%s" + self.currency = currency async def _io(self, url, **kwargs): - resp = await self.client.get(url, params=kwargs) _LOGGER.debug("requested %s %s", resp.url, kwargs) @@ -176,13 +115,13 @@ async def _io(self, url, **kwargs): return await resp.json() def _parse_dt(self, time_str): - ''' Parse datetimes to UTC from Stockholm time, which Nord Pool uses. ''' + """Parse datetimes to UTC from Stockholm time, which Nord Pool uses.""" time = parse_dt(time_str, tzinfos={"Z": timezone("Europe/Stockholm")}) if time.tzinfo is None: - return timezone('Europe/Stockholm').localize(time).astimezone(utc) + return timezone("Europe/Stockholm").localize(time).astimezone(utc) return time.astimezone(utc) - def _parse_json(self, data, areas=None): + def _parse_json(self, data, areas=None, data_type=None): """ Parse json response from fetcher. Returns dictionary with @@ -197,16 +136,33 @@ def _parse_json(self, data, areas=None): if areas is None: areas = [] - # If areas isn't a list, make it one - if not isinstance(areas, list): - areas = list(areas) + if not isinstance(areas, list) and areas is not None: + areas = [i.strip() for i in areas.split(",")] + + _LOGGER.debug("data type in _parser %s, areas %s", data_type, areas) + + # Ripped from Kipe's nordpool + if data_type == self.HOURLY: + data_source = ("multiAreaEntries", "entryPerArea") + elif data_type == self.DAILY: + data_source = ("multiAreaDailyAggregates", "averagePerArea") + elif data_type == self.WEEKLY: + data_source = ("multiAreaWeeklyAggregates", "averagePerArea") + elif data_type == self.MONTHLY: + data_source = ("multiAreaMonthlyAggregates", "averagePerArea") + elif data_type == self.YEARLY: + data_source = ("prices", "averagePerArea") + else: + data_source = ("multiAreaEntries", "entryPerArea") if data.get("status", 200) != 200 and "version" not in data: raise Exception(f"Invalid response from Nordpool API: {data}") # Update currency from data - currency = data['currency'] + # currency it not avaiable in yearly... We just have to trust that the one + # we set in the class is correct. + currency = data.get("currency", self.currency) # Ensure that the provided currency match the requested one if currency != self.currency: @@ -214,22 +170,22 @@ def _parse_json(self, data, areas=None): start_time = None end_time = None - - if len(data['multiAreaEntries']) > 0: - start_time = self._parse_dt(data['multiAreaEntries'][0]['deliveryStart']) - end_time = self._parse_dt(data['multiAreaEntries'][-1]['deliveryEnd']) - updated = self._parse_dt(data['updatedAt']) + # multiAreaDailyAggregates + if len(data[data_source[0]]) > 0: + start_time = self._parse_dt(data[data_source[0]][0]["deliveryStart"]) + end_time = self._parse_dt(data[data_source[0]][-1]["deliveryEnd"]) + updated = self._parse_dt(data["updatedAt"]) area_data = {} # Loop through response rows - for r in data['multiAreaEntries']: - row_start_time = self._parse_dt(r['deliveryStart']) - row_end_time = self._parse_dt(r['deliveryEnd']) + for r in data[data_source[0]]: + row_start_time = self._parse_dt(r["deliveryStart"]) + row_end_time = self._parse_dt(r["deliveryEnd"]) # Loop through columns - for area_key in r['entryPerArea'].keys(): - area_price = r['entryPerArea'][area_key] + for area_key in r[data_source[1]].keys(): + area_price = r[data_source[1]][area_key] # If areas is defined and name isn't in areas, skip column if area_key not in areas: continue @@ -237,27 +193,32 @@ def _parse_json(self, data, areas=None): # If name isn't in area_data, initialize dictionary if area_key not in area_data: area_data[area_key] = { - 'values': [], + "values": [], } # Append dictionary to value list - area_data[area_key]['values'].append({ - 'start': row_start_time, - 'end': row_end_time, - 'value': self._conv_to_float(area_price), - }) + area_data[area_key]["values"].append( + { + "start": row_start_time, + "end": row_end_time, + "value": self._conv_to_float(area_price), + } + ) return { - 'start': start_time, - 'end': end_time, - 'updated': updated, - 'currency': currency, - 'areas': area_data + "start": start_time, + "end": end_time, + "updated": updated, + "currency": currency, + "areas": area_data, } async def _fetch_json(self, data_type, end_date=None, areas=None): """Fetch JSON from API""" # If end_date isn't set, default to tomorrow + if data_type is None: + data_type = self.HOURLY + if areas is None or len(areas) == 0: raise Exception("Cannot query with empty areas") if end_date is None: @@ -266,23 +227,29 @@ async def _fetch_json(self, data_type, end_date=None, areas=None): if not isinstance(end_date, date) and not isinstance(end_date, datetime): end_date = parse_dt(end_date) + if not isinstance(areas, list) and areas is not None: + areas = [i.strip() for i in areas.split(",")] + kws = { + "currency": self.currency, + "market": "DayAhead", + "deliveryArea": ",".join(areas), + # This one is default for hourly.. + "date": end_date.strftime("%Y-%m-%d"), + } - return await self._io( - self.API_URL % data_type, - currency=self.currency, - market="DayAhead", - deliveryArea=",".join(areas), - date=end_date.strftime("%Y-%m-%d"), - ) + if data_type != self.HOURLY: + kws.pop("date") + kws["year"] = end_date.strftime("%Y") + + return await self._io(self.API_URL % data_type, **kws) # Add more exceptions as we find them. KeyError is raised when the api return # junk due to currency not being available in the data. - @backoff.on_exception( - backoff.expo, - (aiohttp.ClientError, KeyError), - logger=_LOGGER, max_value=20) - async def fetch(self, data_type, end_date=None, areas=None): + # @backoff.on_exception( + # backoff.expo, (aiohttp.ClientError, KeyError), logger=_LOGGER, max_value=20 + # ) + async def fetch(self, data_type, end_date=None, areas=None, raw=False): """ Fetch data from API. Inputs: @@ -306,33 +273,52 @@ async def fetch(self, data_type, end_date=None, areas=None): if areas is None: areas = [] - yesterday = datetime.now() - timedelta(days=1) - today = datetime.now() - tomorrow = datetime.now() + timedelta(days=1) + if end_date is None: + end_date = datetime.now() + + if isinstance(end_date, str): + end_date = parse_dt(end_date) - jobs = [ - self._fetch_json(data_type, yesterday, areas), - self._fetch_json(data_type, today, areas), - self._fetch_json(data_type, tomorrow, areas), - ] + today = end_date + yesterday = today - timedelta(days=1) + tomorrow = today + timedelta(days=1) + + if data_type == self.HOURLY: + if raw: + return await self._fetch_json(data_type, today, areas) + jobs = [ + self._fetch_json(data_type, yesterday, areas), + self._fetch_json(data_type, today, areas), + self._fetch_json(data_type, tomorrow, areas), + ] + else: + # This is really not today but a year.. + # All except from hourly returns the raw values + return await self._fetch_json(data_type, today, areas) res = await asyncio.gather(*jobs) - raw = [await self._async_parse_json(i, areas) for i in res if i] + raw = [ + await self._async_parse_json(i, areas, data_type=data_type) + for i in res + if i + ] return await join_result_for_correct_time(raw, end_date) - async def _async_parse_json(self, data, areas): + async def _async_parse_json(self, data, areas, data_type): """ Async version of _parse_json to prevent blocking calls inside the event loop. """ loop = asyncio.get_running_loop() - return await loop.run_in_executor(None, self._parse_json, data, areas) + return await loop.run_in_executor( + None, self._parse_json, data, areas, data_type + ) - async def hourly(self, end_date=None, areas=None): + async def hourly(self, end_date=None, areas=None, raw=False): """Helper to fetch hourly data, see Prices.fetch()""" if areas is None: areas = [] - return await self.fetch(self.HOURLY, end_date, areas) + return await self.fetch(self.HOURLY, end_date, areas, raw=raw) async def daily(self, end_date=None, areas=None): """Helper to fetch daily data, see Prices.fetch()""" diff --git a/custom_components/nordpool/const.py b/custom_components/nordpool/const.py new file mode 100644 index 0000000..be43382 --- /dev/null +++ b/custom_components/nordpool/const.py @@ -0,0 +1,135 @@ +import voluptuous as vol +from random import randint + +DOMAIN = "nordpool" +RANDOM_MINUTE = randint(10, 30) +RANDOM_SECOND = randint(0, 59) +EVENT_NEW_HOUR = "nordpool_update_hour" +EVENT_NEW_DAY = "nordpool_update_day" +EVENT_NEW_PRICE = "nordpool_update_new_price" +SENTINEL = object() + +_CURRENCY_LIST = ["DKK", "EUR", "NOK", "SEK"] + + +CONFIG_SCHEMA = vol.Schema({DOMAIN: vol.Schema({})}, extra=vol.ALLOW_EXTRA) + +NAME = DOMAIN +VERSION = "0.0.16" +ISSUEURL = "https://github.com/custom-components/nordpool/issues" + + +tzs = { + "DK1": "Europe/Copenhagen", + "DK2": "Europe/Copenhagen", + "FI": "Europe/Helsinki", + "EE": "Europe/Tallinn", + "LT": "Europe/Vilnius", + "LV": "Europe/Riga", + "NO1": "Europe/Oslo", + "NO2": "Europe/Oslo", + "NO3": "Europe/Oslo", + "NO4": "Europe/Oslo", + "NO5": "Europe/Oslo", + "SE1": "Europe/Stockholm", + "SE2": "Europe/Stockholm", + "SE3": "Europe/Stockholm", + "SE4": "Europe/Stockholm", + # What zone is this? + "SYS": "Europe/Stockholm", + "FR": "Europe/Paris", + "NL": "Europe/Amsterdam", + "BE": "Europe/Brussels", + "AT": "Europe/Vienna", + "GER": "Europe/Berlin", +} + +# List of page index for hourly data +# Some are disabled as they don't contain the other currencies, NOK etc, +# or there are some issues with data parsing for some ones' DataStartdate. +# Lets come back and fix that later, just need to adjust the self._parser. +# DataEnddate: "2021-02-11T00:00:00" +# DataStartdate: "0001-01-01T00:00:00" +COUNTRY_BASE_PAGE = { + # "SYS": 17, + "NO": 23, + "SE": 29, + "DK": 41, + # "FI": 35, + # "EE": 47, + # "LT": 53, + # "LV": 59, + # "AT": 298578, + # "BE": 298736, + # "DE-LU": 299565, + # "FR": 299568, + # "NL": 299571, + # "PL": 391921, +} + +AREA_TO_COUNTRY = { + "SYS": "SYS", + "SE1": "SE", + "SE2": "SE", + "SE3": "SE", + "SE4": "SE", + "FI": "FI", + "DK1": "DK", + "DK2": "DK", + "OSLO": "NO", + "KR.SAND": "NO", + "BERGEN": "NO", + "MOLDE": "NO", + "TR.HEIM": "NO", + "TROMSØ": "NO", + "EE": "EE", + "LV": "LV", + "LT": "LT", + "AT": "AT", + "BE": "BE", + "DE-LU": "DE-LU", + "FR": "FR", + "NL": "NL", + "PL ": "PL", +} + +INVALID_VALUES = frozenset((None, float("inf"))) + +DEFAULT_TEMPLATE = "{{0.0|float}}" + + +_CENT_MULTIPLIER = 100 +_PRICE_IN = {"kWh": 1000, "MWh": 1, "Wh": 1000 * 1000} +_REGIONS = { + "DK1": ["DKK", "Denmark", 0.25], + "DK2": ["DKK", "Denmark", 0.25], + "FI": ["EUR", "Finland", 0.255], + "EE": ["EUR", "Estonia", 0.22], + "LT": ["EUR", "Lithuania", 0.21], + "LV": ["EUR", "Latvia", 0.21], + "NO1": ["NOK", "Norway", 0.25], + "NO2": ["NOK", "Norway", 0.25], + "NO3": ["NOK", "Norway", 0.25], + "NO4": ["NOK", "Norway", 0.25], + "NO5": ["NOK", "Norway", 0.25], + "SE1": ["SEK", "Sweden", 0.25], + "SE2": ["SEK", "Sweden", 0.25], + "SE3": ["SEK", "Sweden", 0.25], + "SE4": ["SEK", "Sweden", 0.25], + # What zone is this? + "SYS": ["EUR", "System zone", 0.25], + "FR": ["EUR", "France", 0.055], + "NL": ["EUR", "Netherlands", 0.21], + "BE": ["EUR", "Belgium", 0.06], + "AT": ["EUR", "Austria", 0.20], + # Unsure about tax rate, correct if wrong + "GER": ["EUR", "Germany", 0.23], +} + +# Needed incase a user wants the prices in non local currency +_CURRENCY_TO_LOCAL = {"DKK": "Kr", "NOK": "Kr", "SEK": "Kr", "EUR": "€"} +_CURRENTY_TO_CENTS = {"DKK": "Øre", "NOK": "Øre", "SEK": "Öre", "EUR": "c"} + +DEFAULT_CURRENCY = "NOK" +DEFAULT_REGION = list(_REGIONS.keys())[0] +DEFAULT_NAME = "Elspot" diff --git a/custom_components/nordpool/create_template.py b/custom_components/nordpool/create_template.py deleted file mode 100644 index 3b3cd0b..0000000 --- a/custom_components/nordpool/create_template.py +++ /dev/null @@ -1,49 +0,0 @@ -if __name__ == "__main__": - import yaml - import click - - """ - For usage example do: python create_template --help - """ - - @click.command({"help_option_names": ["-h", "--h"]}) - @click.argument('entity_id') - @click.argument('friendly_name') - @click.option('--icon', default="mdi:cash", help="The icon you want to use.") - @click.option('--unit', default="NOK/kWh", help="the currency the sensor should use") - @click.option('--path', default="result.yaml", help="What path to write the file to.") - def make_sensors(entity_id, friendly_name, icon, unit, path): - """A simple tool to make 48 template sensors, one for each hour.""" - - entity_id = entity_id.replace("sensor.", "") - - head = {"sensor": [{"platform": "template", "sensors": {}}]} - - name = "nordpool_%s_hr_%02d_%02d" - state_attr = '{{ state_attr("sensor.%s", "%s")[%s] }}' - - for i in range(24): - sensor = { - "friendly_name": friendly_name + " today h %s" % (i + 1), - "icon_template": icon, - "unit_of_measurement": unit, - "value_template": state_attr % (entity_id, "today", i), - } - - head["sensor"][0]["sensors"][name % ("today", i, i + 1)] = sensor - - for z in range(24): - sensor = { - "friendly_name": friendly_name + " tomorrow h %s" % (z + 1), - "icon_template": icon, - "unit_of_measurement": unit, - "value_template": state_attr % (entity_id, "tomorrow", z), - } - - head["sensor"][0]["sensors"][name % ("tomorrow", z, z + 1)] = sensor - - with open(path, "w") as yaml_file: - yaml.dump(head, yaml_file, default_flow_style=False) - click.echo("All done, wrote file to %s" % path) - - make_sensors() diff --git a/custom_components/nordpool/events.py b/custom_components/nordpool/events.py index e173312..76ea97a 100644 --- a/custom_components/nordpool/events.py +++ b/custom_components/nordpool/events.py @@ -1,6 +1,5 @@ from datetime import datetime, timedelta from typing import Any, Optional -from collections.abc import Awaitable, Callable # from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback, HassJob @@ -10,18 +9,12 @@ async_track_time_interval, async_track_point_in_utc_time, ) -from pytz import timezone # For targeted patching in tests time_tracker_utcnow = dt_util.utcnow -__ALL__ = ["stock", "async_track_time_change_in_tz"] - - -def stock(d): - """convert datetime to stocholm time.""" - return d.astimezone(timezone("Europe/Stockholm")) +__ALL__ = ["async_track_time_change_in_tz"] @callback diff --git a/custom_components/nordpool/sensor.py b/custom_components/nordpool/sensor.py index 5912e84..aad5f03 100644 --- a/custom_components/nordpool/sensor.py +++ b/custom_components/nordpool/sensor.py @@ -12,10 +12,15 @@ from homeassistant.util import dt as dt_utils # Import sensor entity and classes. -from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass, SensorEntity +from homeassistant.components.sensor.const import ( + SensorDeviceClass, + SensorStateClass, +) + +from homeassistant.components.sensor import SensorEntity from jinja2 import pass_context -from . import ( +from .const import ( DOMAIN, EVENT_NEW_DAY, EVENT_NEW_PRICE, @@ -23,51 +28,18 @@ SENTINEL, RANDOM_MINUTE, RANDOM_SECOND, + DEFAULT_TEMPLATE, + DEFAULT_REGION, + _PRICE_IN, + _REGIONS, + _CURRENTY_TO_CENTS, + _CENT_MULTIPLIER, ) -from .misc import start_of, stock, round_decimal +from .misc import start_of, stock _LOGGER = logging.getLogger(__name__) -_CENT_MULTIPLIER = 100 -_PRICE_IN = {"kWh": 1000, "MWh": 1, "Wh": 1000 * 1000} -_REGIONS = { - "DK1": ["DKK", "Denmark", 0.25], - "DK2": ["DKK", "Denmark", 0.25], - "FI": ["EUR", "Finland", 0.255], - "EE": ["EUR", "Estonia", 0.22], - "LT": ["EUR", "Lithuania", 0.21], - "LV": ["EUR", "Latvia", 0.21], - "NO1": ["NOK", "Norway", 0.25], - "NO2": ["NOK", "Norway", 0.25], - "NO3": ["NOK", "Norway", 0.25], - "NO4": ["NOK", "Norway", 0.25], - "NO5": ["NOK", "Norway", 0.25], - "SE1": ["SEK", "Sweden", 0.25], - "SE2": ["SEK", "Sweden", 0.25], - "SE3": ["SEK", "Sweden", 0.25], - "SE4": ["SEK", "Sweden", 0.25], - # What zone is this? - "SYS": ["EUR", "System zone", 0.25], - "FR": ["EUR", "France", 0.055], - "NL": ["EUR", "Netherlands", 0.21], - "BE": ["EUR", "Belgium", 0.06], - "AT": ["EUR", "Austria", 0.20], - # Unsure about tax rate, correct if wrong - "GER": ["EUR", "Germany", 0.23], -} - -# Needed incase a user wants the prices in non local currency -_CURRENCY_TO_LOCAL = {"DKK": "Kr", "NOK": "Kr", "SEK": "Kr", "EUR": "€"} -_CURRENTY_TO_CENTS = {"DKK": "Øre", "NOK": "Øre", "SEK": "Öre", "EUR": "c"} - -DEFAULT_CURRENCY = "NOK" -DEFAULT_REGION = list(_REGIONS.keys())[0] -DEFAULT_NAME = "Elspot" - - -DEFAULT_TEMPLATE = "{{0.0|float}}" - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -118,7 +90,7 @@ def _dry_setup(hass, config, add_devices, discovery_info=None): add_devices([sensor]) -async def async_setup_platform(hass, config, add_devices, discovery_info=None) -> None: +async def async_setup_platform(hass, config, add_devices, discovery_info=None) -> True: _dry_setup(hass, config, add_devices) return True @@ -132,9 +104,11 @@ async def async_setup_entry(hass, config_entry, async_add_devices): class NordpoolSensor(SensorEntity): "Sensors data" + _attr_device_class = SensorDeviceClass.MONETARY _attr_suggested_display_precision = None _attr_state_class = SensorStateClass.TOTAL + def __init__( self, friendly_name, diff --git a/custom_components/nordpool/services.py b/custom_components/nordpool/services.py new file mode 100644 index 0000000..5cd3d8a --- /dev/null +++ b/custom_components/nordpool/services.py @@ -0,0 +1,149 @@ +import logging +from datetime import datetime + +from typing import Any + +import voluptuous as vol + +from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv +from homeassistant.util import dt as dt_util + +from .const import _REGIONS + + +_LOGGER = logging.getLogger(__name__) + + +def check_setting(value): + def validator(value): + c = any([i for i in value if i in list(_REGIONS.keys())]) + if c is not True: + vol.Invalid( + f"{value} in not in on of the supported areas {','.join(_REGIONS.keys())}" + ) + return value + + return validator + + +HOURLY_SCHEMA = vol.Schema( + { + vol.Required("currency"): str, + vol.Required("date"): cv.date, + vol.Required("area"): check_setting(cv.ensure_list), + } +) + + +YEAR_SCHEMA = vol.Schema( + { + vol.Required("currency"): str, + vol.Required("year", default=dt_util.now().strftime("Y")): cv.matches_regex( + r"^[1|2]\d{3}$" + ), + vol.Required("area"): check_setting(cv.ensure_list), + } +) + + +async def async_setup_services(hass: HomeAssistant): + _LOGGER.debug("Setting up services") + from .aio_price import AioPrices + + client = async_get_clientsession(hass) + + async def hourly(service_call: ServiceCall) -> Any: + sc = service_call.data + _LOGGER.debug("called hourly with %r", sc) + + # Convert the date to datetime as the rest of the code expects a datetime. We will want to keep date as it easier for ppl to use. + end_date = datetime( + year=sc["date"].year, month=sc["date"].month, day=sc["date"].day + ) + + value = await AioPrices(sc["currency"], client).hourly( + areas=sc["area"], end_date=end_date, raw=True + ) + + _LOGGER.debug("Got value %r", value) + return value + + async def yearly(service_call: ServiceCall): + sc = service_call.data + _LOGGER.debug("called yearly with %r", sc) + + value = await AioPrices(sc["currency"], client).yearly( + areas=sc["area"], end_date=sc["year"] + ) + + _LOGGER.debug("Got value %r", value) + return value + + async def weekly(service_call: ServiceCall): + sc = service_call.data + _LOGGER.debug("called weekly with %r", sc) + + value = await AioPrices(sc["currency"], client).yearly( + areas=sc["area"], end_date=sc["year"] + ) + + _LOGGER.debug("Got value %r", value) + return value + + async def monthly(service_call: ServiceCall): + sc = service_call.data + _LOGGER.debug("called monthly with %r", sc) + + value = await AioPrices(sc["currency"], client).monthly( + areas=sc["area"], end_date=sc["year"] + ) + _LOGGER.debug("Got value %r", value) + return value + + async def daily(service_call: ServiceCall): + sc = service_call.data + _LOGGER.debug("called daily with %r", sc) + + value = await AioPrices(sc["currency"], client).daily( + areas=sc["area"], end_date=sc["year"] + ) + _LOGGER.debug("Got value %r", value) + return value + + hass.services.async_register( + domain="nordpool", + service="hourly", + service_func=hourly, + schema=HOURLY_SCHEMA, + supports_response=SupportsResponse.OPTIONAL, + ) + hass.services.async_register( + domain="nordpool", + service="yearly", + service_func=yearly, + schema=YEAR_SCHEMA, + supports_response=SupportsResponse.OPTIONAL, + ) + hass.services.async_register( + domain="nordpool", + service="monthly", + service_func=monthly, + schema=YEAR_SCHEMA, + supports_response=SupportsResponse.OPTIONAL, + ) + hass.services.async_register( + domain="nordpool", + service="daily", + service_func=daily, + schema=YEAR_SCHEMA, + supports_response=SupportsResponse.OPTIONAL, + ) + hass.services.async_register( + domain="nordpool", + service="weekly", + service_func=weekly, + schema=YEAR_SCHEMA, + supports_response=SupportsResponse.OPTIONAL, + ) diff --git a/custom_components/nordpool/services.yaml b/custom_components/nordpool/services.yaml new file mode 100644 index 0000000..87e374a --- /dev/null +++ b/custom_components/nordpool/services.yaml @@ -0,0 +1,60 @@ +yearly: + name: yearly + description: >- + Action that gets the raw aggrigated yearly prices from Nordpool + fields: + currency: + description: "What currecy should the prices be returned in" + example: "NOK" + year: + description: "For what year, default to currect year" + example: "2024" + area: + description: "Return the prices for what price area" + example: "NO2" + +daily: + name: daily + description: >- + Action that gets the raw aggrigated daily prices from Nordpool + fields: + currency: + description: "What currecy should the prices be returned in" + example: "NOK" + year: + description: "For what year, default to currect year" + example: "2024" + area: + description: "Return the prices for what price area" + example: "NO2" + + +monthly: + name: monthly + description: >- + Action that gets the raw aggrigated monthly prices from Nordpool + fields: + currency: + description: "What currecy should the prices be returned in" + example: "NOK" + year: + description: "For what year, default to currect year" + example: "2024" + area: + description: "Return the prices for what price area" + example: "NO2" + +hourly: + name: hourly + description: >- + Action that gets the raw hourly price for spesific date from Nordpool + fields: + currency: + description: "What currecy should the prices be returned in" + example: "NOK" + date: + description: "For what year, default to currect year" + example: "YYYY-MM-DD" + area: + description: "Return the prices for what price area" + example: "NO2" \ No newline at end of file diff --git a/custom_components/nordpool/test_parser.py b/custom_components/nordpool/test_parser.py deleted file mode 100644 index b344057..0000000 --- a/custom_components/nordpool/test_parser.py +++ /dev/null @@ -1,393 +0,0 @@ -import asyncio -import logging -from collections import defaultdict -from datetime import date, datetime, timedelta -from operator import itemgetter - -import aiohttp -from aiozoneinfo import async_get_time_zone -# https://repl.it/repls/WildImpishMass -from dateutil import tz -from dateutil.parser import parse as parse_dt -from nordpool.base import CurrencyMismatch -from nordpool.elspot import Prices - -_LOGGER = logging.getLogger(__name__) -logging.basicConfig(level=logging.DEBUG) - - -INVALID_VALUES = frozenset((None, float("inf"))) - - -class InvalidValueException(ValueError): - pass - - - -class AioPrices(Prices): - """Interface""" - - def __init__(self, currency, client, timeezone=None): - super().__init__(currency) - self.client = client - self.timeezone = timeezone - (self.HOURLY, self.DAILY, self.WEEKLY, self.MONTHLY, self.YEARLY) = ("DayAheadPrices", "AggregatePrices", - "AggregatePrices", "AggregatePrices", - "AggregatePrices") - self.API_URL = "https://dataportal-api.nordpoolgroup.com/api/%s" - - async def _io(self, url, **kwargs): - - resp = await self.client.get(url, params=kwargs, headers={ - 'Origin': 'https://data.nordpoolgroup.com' - }) - _LOGGER.debug("requested %s %s", resp.url, kwargs) - - return await resp.json() - - def _parse_json(self, data, areas=None): - """ - Parse json response from fetcher. - Returns dictionary with - - start time - - end time - - update time - - currency - - dictionary of areas, based on selection - - list of values (dictionary with start and endtime and value) - - possible other values, such as min, max, average for hourly - """ - - # If areas isn't a list, make it one - if areas is None: - areas = [] - if not isinstance(areas, list): - areas = list(areas) - - if data.get("status", 200) != 200 and "version" not in data: - raise Exception(f"Invalid response from Nordpool API: {data}") - - # Update currency from data - currency = data['currency'] - - # Ensure that the provided currency match the requested one - if currency != self.currency: - raise CurrencyMismatch - - start_time = None - end_time = None - - if len(data['multiAreaEntries']) > 0: - start_time = self._parse_dt(data['multiAreaEntries'][0]['deliveryStart']) - end_time = self._parse_dt(data['multiAreaEntries'][-1]['deliveryEnd']) - updated = self._parse_dt(data['updatedAt']) - - area_data = {} - - # Loop through response rows - for r in data['multiAreaEntries']: - row_start_time = self._parse_dt(r['deliveryStart']) - row_end_time = self._parse_dt(r['deliveryEnd']) - - # Loop through columns - for area_key in r['entryPerArea'].keys(): - area_price = r['entryPerArea'][area_key] - # If areas is defined and name isn't in areas, skip column - if area_key not in areas: - continue - - # If name isn't in area_data, initialize dictionary - if area_key not in area_data: - area_data[area_key] = { - 'values': [], - } - - # Append dictionary to value list - area_data[area_key]['values'].append({ - 'start': row_start_time, - 'end': row_end_time, - 'value': self._conv_to_float(area_price), - }) - - return { - 'start': start_time, - 'end': end_time, - 'updated': updated, - 'currency': currency, - 'areas': area_data - } - - async def _fetch_json(self, data_type, end_date=None, areas=None): - """Fetch JSON from API""" - # If end_date isn't set, default to tomorrow - if areas is None: - areas = [] - if end_date is None: - end_date = date.today() + timedelta(days=1) - # If end_date isn't a date or datetime object, try to parse a string - if not isinstance(end_date, date) and not isinstance(end_date, datetime): - end_date = parse_dt(end_date) - - - - return await self._io( - self.API_URL % data_type, - currency=self.currency, - market="DayAhead", - deliveryArea=",".join(areas), - date=end_date.strftime("%Y-%m-%d"), - ) - - # Add more exceptions as we find them. KeyError is raised when the api return - # junk due to currency not being available in the data. - - async def fetch(self, data_type, end_date=None, areas=None): - """ - Fetch data from API. - Inputs: - - data_type - API page id, one of Prices.HOURLY, Prices.DAILY etc - - end_date - datetime to end the data fetching - defaults to tomorrow - - areas - list of areas to fetch, such as ['SE1', 'SE2', 'FI'] - defaults to all areas - Returns dictionary with - - start time - - end time - - update time - - currency - - dictionary of areas, based on selection - - list of values (dictionary with start and endtime and value) - - possible other values, such as min, max, average for hourly - """ - if areas is None: - areas = [] - - yesterday = datetime.now() - timedelta(days=1) - today = datetime.now() - tomorrow = datetime.now() + timedelta(days=1) - - jobs = [ - self._fetch_json(data_type, yesterday, areas), - self._fetch_json(data_type, today, areas), - self._fetch_json(data_type, tomorrow, areas), - ] - - res = await asyncio.gather(*jobs) - raw = [await self._async_parse_json(i, areas) for i in res] - - return await join_result_for_correct_time(raw, end_date) - - async def _async_parse_json(self, data, areas): - """ - Async version of _parse_json to prevent blocking calls inside the event loop. - """ - loop = asyncio.get_running_loop() - return await loop.run_in_executor(None, self._parse_json, data, areas) - - async def hourly(self, end_date=None, areas=None): - """Helper to fetch hourly data, see Prices.fetch()""" - if areas is None: - areas = [] - return await self.fetch(self.HOURLY, end_date, areas) - - async def daily(self, end_date=None, areas=None): - """Helper to fetch daily data, see Prices.fetch()""" - if areas is None: - areas = [] - return await self.fetch(self.DAILY, end_date, areas) - - async def weekly(self, end_date=None, areas=None): - """Helper to fetch weekly data, see Prices.fetch()""" - if areas is None: - areas = [] - return await self.fetch(self.WEEKLY, end_date, areas) - - async def monthly(self, end_date=None, areas=None): - """Helper to fetch monthly data, see Prices.fetch()""" - if areas is None: - areas = [] - return await self.fetch(self.MONTHLY, end_date, areas) - - async def yearly(self, end_date=None, areas=None): - """Helper to fe -tch yearly data, see Prices.fetch()""" - if areas is None: - areas = [] - return await self.fetch(self.YEARLY, end_date, areas) - - def _conv_to_float(self, s): - """Convert numbers to float. Return infinity, if conversion fails.""" - # Skip if already float - if isinstance(s, float): - return s - try: - return float(s.replace(",", ".").replace(" ", "")) - except ValueError: - return float("inf") - - - - - - - - - - - - - - - - -tzs = { - "DK1": "Europe/Copenhagen", - "DK2": "Europe/Copenhagen", - "FI": "Europe/Helsinki", - "EE": "Europe/Tallinn", - "LT": "Europe/Vilnius", - "LV": "Europe/Riga", - "Oslo": "Europe/Oslo", - "Kr.sand": "Europe/Oslo", - "Bergen": "Europe/Oslo", - "Molde": "Europe/Oslo", - "Tr.heim": "Europe/Oslo", - "Tromsø": "Europe/Oslo", - "SE1": "Europe/Stockholm", - "SE2": "Europe/Stockholm", - "SE3": "Europe/Stockholm", - "SE4": "Europe/Stockholm", - # What zone is this? - "SYS": "Europe/Stockholm", - "FR": "Europe/Paris", - "BE": "Europe/Brussels", - "AT": "Europe/Vienna", - "DE-LU": "Europe/Berlin" -} - - -def add_junk(d): - # move this - for key in ["Average", "Min", "Max", "Off-peak 1", "Off-peak 2", "Peak"]: - d[key] = float("inf") - - return d - - -async def join_result_for_correct_time(results, dt): - """Parse a list of responses from the api - to extract the correct hours in there timezone. - """ - # utc = datetime.utcnow() - fin = defaultdict(dict) - # _LOGGER.debug("join_result_for_correct_time %s", dt) - utc = dt - - for day_ in results: - for key, value in day_.get("areas", {}).items(): - zone = tzs.get(key) - if zone is None: - _LOGGER.debug("Skipping %s", key) - continue - else: - zone = await async_get_time_zone(zone) - - # We add junk here as the peak etc - # from the api is based on cet, not the - # hours in the we want so invalidate them - # its later corrected in the sensor. - value = add_junk(value) - - values = day_["areas"][key].pop("values") - - # We need to check this so we dont overwrite stuff. - if key not in fin["areas"]: - fin["areas"][key] = {} - fin["areas"][key].update(value) - if "values" not in fin["areas"][key]: - fin["areas"][key]["values"] = [] - - start_of_day = utc.astimezone(zone).replace( - hour=0, minute=0, second=0, microsecond=0 - ) - end_of_day = utc.astimezone(zone).replace( - hour=23, minute=59, second=59, microsecond=999999 - ) - - for val in values: - local = val["start"].astimezone(zone) - local_end = val["end"].astimezone(zone) - if start_of_day <= local and local <= end_of_day: - if local == local_end: - _LOGGER.info( - "Hour has the same start and end, most likly due to dst change %s exluded this hour", - val, - ) - elif val['value'] in INVALID_VALUES: - raise InvalidValueException(f"Invalid value in {val} for area '{key}'") - else: - fin["areas"][key]["values"].append(val) - - return fin - - - - -if __name__ == "__main__": - import asyncclick as click - - @click.command() - @click.option('--region', '-r', default="Kr.sand") - @click.option('--currency', '-c', default="NOK") - @click.option('--vat', '-v', default=0) - async def manual_check(region, currency, vat): - - - ts = tz.gettz(tzs[region]) - utc = datetime.utcnow() - - # Convert time zone - lt = utc.astimezone(ts) - dt_today = lt - dt_yesterday = lt + timedelta(days=-1) - - spot = AioPrices(currency, aiohttp.client.ClientSession()) - yesterday = await spot.hourly(end_date=dt_yesterday, areas=[region]) - today = await spot.hourly(end_date=dt_today, areas=[region]) - tomorrow = await spot.hourly(end_date=dt_today + timedelta(days=1), areas=[region]) - #print(today) - #print(pprint(today.get("areas"))) - #return - - results = [yesterday, today, tomorrow] - - rsults = await join_result_for_correct_time(results, dt_today) - - values = [] - for key, value in rsults["areas"].items(): - values = [] - if key == region or region is None: - for v in rsults["areas"][key]["values"]: - zone = tzs.get(key) - if zone is None: - continue - zone = tz.gettz(zone) - - i = { - "value": v["value"], - "start": v["start"].astimezone(zone), - "end": v["end"].astimezone(zone), - } - values.append(i) - - if len(values): - print("Report for region %s" % key) - for vvv in sorted(values, key=itemgetter("start")): - print("from %s to %s price %s" % (vvv["start"], vvv["end"], vvv["value"])) - if len(values): - print("total hours %s" % len(values)) - - asyncio.run(manual_check()) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4f24b9d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +nordpool==0.4.2 +backoff \ No newline at end of file diff --git a/scripts/setup b/scripts/setup index abe537a..e87a899 100644 --- a/scripts/setup +++ b/scripts/setup @@ -3,5 +3,6 @@ set -e cd "$(dirname "$0")/.." - +python3 -m pip install homeassistant +# This might fail if the file is missing, we dont care python3 -m pip install --requirement requirements.txt \ No newline at end of file