From bc6fe7b1760ca3751ad5840e8e08725431dc6fc5 Mon Sep 17 00:00:00 2001 From: Metroseksuaali Date: Tue, 27 Jan 2026 20:00:50 +0200 Subject: [PATCH 1/7] Fix multiple bugs in services and const - Fix weekly service calling yearly() instead of weekly() - Fix area validator not raising vol.Invalid (missing raise keyword) - Fix year schema default using wrong strftime format ("Y" -> "%Y") - Fix typo in AREA_TO_COUNTRY: "PL " -> "PL" (trailing space) - Fix typo in error message: "in not in on of" -> "is not in one of" --- custom_components/nordpool/const.py | 2 +- custom_components/nordpool/services.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/custom_components/nordpool/const.py b/custom_components/nordpool/const.py index 67f4249..6400dc4 100644 --- a/custom_components/nordpool/const.py +++ b/custom_components/nordpool/const.py @@ -90,7 +90,7 @@ "DE-LU": "DE-LU", "FR": "FR", "NL": "NL", - "PL ": "PL", + "PL": "PL", } INVALID_VALUES = frozenset((None, float("inf"))) diff --git a/custom_components/nordpool/services.py b/custom_components/nordpool/services.py index 5cd3d8a..6daae2f 100644 --- a/custom_components/nordpool/services.py +++ b/custom_components/nordpool/services.py @@ -20,8 +20,8 @@ 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())}" + raise vol.Invalid( + f"{value} is not in one of the supported areas {','.join(_REGIONS.keys())}" ) return value @@ -40,7 +40,7 @@ def validator(value): YEAR_SCHEMA = vol.Schema( { vol.Required("currency"): str, - vol.Required("year", default=dt_util.now().strftime("Y")): cv.matches_regex( + 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), @@ -85,7 +85,7 @@ async def weekly(service_call: ServiceCall): sc = service_call.data _LOGGER.debug("called weekly with %r", sc) - value = await AioPrices(sc["currency"], client).yearly( + value = await AioPrices(sc["currency"], client).weekly( areas=sc["area"], end_date=sc["year"] ) From 95bee1494f42d3a587014fa8469cacc15228b48e Mon Sep 17 00:00:00 2001 From: Metroseksuaali Date: Thu, 29 Jan 2026 17:22:29 +0200 Subject: [PATCH 2/7] Fix area validation in service schemas The check_setting validator had two pre-existing bugs exposed by the raise fix in the previous commit: 1. cv.ensure_list was passed as an argument to check_setting but never called, so the area value remained a string instead of becoming a list 2. The validator iterated characters of the string (e.g. "S","E","4") instead of checking the whole area code, causing all areas to fail validation Replace check_setting with _validate_areas which properly converts the input to a list via cv.ensure_list and validates each area code as a whole string against _REGIONS. --- custom_components/nordpool/services.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/custom_components/nordpool/services.py b/custom_components/nordpool/services.py index 6daae2f..36dc9c0 100644 --- a/custom_components/nordpool/services.py +++ b/custom_components/nordpool/services.py @@ -16,23 +16,22 @@ _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: +def _validate_areas(value): + """Validate area codes and ensure list format.""" + areas = cv.ensure_list(value) + for area in areas: + if area not in _REGIONS: raise vol.Invalid( - f"{value} is not in one of the supported areas {','.join(_REGIONS.keys())}" + f"{area} is not in one of the supported areas {','.join(_REGIONS.keys())}" ) - return value - - return validator + return areas HOURLY_SCHEMA = vol.Schema( { vol.Required("currency"): str, vol.Required("date"): cv.date, - vol.Required("area"): check_setting(cv.ensure_list), + vol.Required("area"): _validate_areas, } ) @@ -43,7 +42,7 @@ def validator(value): 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), + vol.Required("area"): _validate_areas, } ) From 9ca386523b130b3c4e0590fb173f4e92fbd1e997 Mon Sep 17 00:00:00 2001 From: Metroseksuaali Date: Fri, 30 Jan 2026 01:59:37 +0200 Subject: [PATCH 3/7] Fix non-hourly services returning wrong aggregation data DAILY, WEEKLY, and MONTHLY all share the same data_type value "AggregatePrices", so _parse_json could not distinguish between them. Added aggregation parameter throughout the fetch chain to select the correct JSON data source for each aggregation level. Also fixed fetch() to parse non-hourly responses instead of returning raw JSON. --- custom_components/nordpool/aio_price.py | 40 +++++++++++++------------ 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/custom_components/nordpool/aio_price.py b/custom_components/nordpool/aio_price.py index 92b9ce9..e300fc2 100644 --- a/custom_components/nordpool/aio_price.py +++ b/custom_components/nordpool/aio_price.py @@ -121,7 +121,7 @@ def _parse_dt(self, time_str): return timezone("Europe/Stockholm").localize(time).astimezone(utc) return time.astimezone(utc) - def _parse_json(self, data, areas=None, data_type=None): + def _parse_json(self, data, areas=None, data_type=None, aggregation=None): """ Parse json response from fetcher. Returns dictionary with @@ -142,16 +142,16 @@ def _parse_json(self, data, areas=None, data_type=None): _LOGGER.debug("data type in _parser %s, areas %s", data_type, areas) - # Ripped from Kipe's nordpool - if data_type == self.HOURLY: + # Select the correct data source based on aggregation level + if aggregation == "hourly": data_source = ("multiAreaEntries", "entryPerArea") - elif data_type == self.DAILY: + elif aggregation == "daily": data_source = ("multiAreaDailyAggregates", "averagePerArea") - elif data_type == self.WEEKLY: + elif aggregation == "weekly": data_source = ("multiAreaWeeklyAggregates", "averagePerArea") - elif data_type == self.MONTHLY: + elif aggregation == "monthly": data_source = ("multiAreaMonthlyAggregates", "averagePerArea") - elif data_type == self.YEARLY: + elif aggregation == "yearly": data_source = ("prices", "averagePerArea") else: data_source = ("multiAreaEntries", "entryPerArea") @@ -249,7 +249,7 @@ async def _fetch_json(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): + async def fetch(self, data_type, end_date=None, areas=None, raw=False, aggregation=None): """ Fetch data from API. Inputs: @@ -294,57 +294,59 @@ async def fetch(self, data_type, end_date=None, areas=None, raw=False): 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) + # All except from hourly returns a single response + raw_data = await self._fetch_json(data_type, today, areas) + if raw_data is None: + return None + return await self._async_parse_json(raw_data, areas, data_type=data_type, aggregation=aggregation) res = await asyncio.gather(*jobs) raw = [ - await self._async_parse_json(i, areas, data_type=data_type) + await self._async_parse_json(i, areas, data_type=data_type, aggregation=aggregation) for i in res if i ] return await join_result_for_correct_time(raw, end_date) - async def _async_parse_json(self, data, areas, data_type): + async def _async_parse_json(self, data, areas, data_type, aggregation=None): """ 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, data_type + None, self._parse_json, data, areas, data_type, aggregation ) 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, raw=raw) + return await self.fetch(self.HOURLY, end_date, areas, raw=raw, aggregation="hourly") 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) + return await self.fetch(self.DAILY, end_date, areas, aggregation="daily") 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) + return await self.fetch(self.WEEKLY, end_date, areas, aggregation="weekly") 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) + return await self.fetch(self.MONTHLY, end_date, areas, aggregation="monthly") async def yearly(self, end_date=None, areas=None): """Helper to fetch yearly data, see Prices.fetch()""" if areas is None: areas = [] - return await self.fetch(self.YEARLY, end_date, areas) + return await self.fetch(self.YEARLY, end_date, areas, aggregation="yearly") def _conv_to_float(self, s): """Convert numbers to float. Return infinity, if conversion fails.""" From 416414f9bf62634a87ee364048ba2d240b52077e Mon Sep 17 00:00:00 2001 From: Metroseksuaali Date: Sat, 31 Jan 2026 17:04:59 +0200 Subject: [PATCH 4/7] Fix _conv_to_float crash when API returns int values Aggregate price API sometimes returns prices as int instead of float, causing AttributeError on .replace() call. Handle both int and float numeric types. --- custom_components/nordpool/aio_price.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/custom_components/nordpool/aio_price.py b/custom_components/nordpool/aio_price.py index e300fc2..d81bd44 100644 --- a/custom_components/nordpool/aio_price.py +++ b/custom_components/nordpool/aio_price.py @@ -350,9 +350,9 @@ async def yearly(self, end_date=None, areas=None): 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 + # Skip if already numeric + if isinstance(s, (int, float)): + return float(s) try: return float(s.replace(",", ".").replace(" ", "")) except ValueError: From 1bbd44a71923b5316856f920a716f0db226e8b12 Mon Sep 17 00:00:00 2001 From: Metroseksuaali Date: Sat, 31 Jan 2026 21:29:46 +0200 Subject: [PATCH 5/7] Update README Actions section with new response structure Actions no longer return raw API JSON. Updated documentation with service table, response format, template path examples, and automation examples for yearly and monthly average prices. --- README.md | 65 ++++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 59 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 5bd901e..0696fee 100644 --- a/README.md +++ b/README.md @@ -219,24 +219,77 @@ Add 21% tax and overhead cost stored in a helper - ```price_in_cents```: Boolean if prices is in cents ## 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. -Example for an automation that get the last months averge price. +The following actions are available for fetching Nordpool price data: + +| Action | Description | +|--------|-------------| +| `nordpool.hourly` | Hourly prices (today + tomorrow if available) | +| `nordpool.daily` | Daily average prices for the current month | +| `nordpool.weekly` | Weekly average prices | +| `nordpool.monthly` | Monthly average prices | +| `nordpool.yearly` | Yearly average prices | + +All actions accept `currency`, `area` and optionally `year` as parameters. +The `nordpool.hourly` action also accepts an optional `date` parameter. + +### Response structure + +All actions return data in the following format: + ```yaml -alias: Example automation action call with storing with parsing and storing result +start: "2025-12-31T23:00:00+00:00" +end: "2021-12-30T23:00:00+00:00" +updated: "2024-03-26T13:32:39.733019+00:00" +currency: NOK +areas: + NO2: + values: + - start: "2025-12-31T23:00:00+00:00" + end: "2026-01-30T23:00:00+00:00" + value: 1230.05 + - start: "2024-12-31T23:00:00+00:00" + end: "2025-12-30T23:00:00+00:00" + value: 767.23 +``` + +To access a value in a template, use: `{{ np_result.areas..values[].value }}` + +### Example: Store yearly average price + +```yaml +alias: Get yearly average price from Nordpool 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 + entity_id: input_text.yearly_price + data: + value: "{{ np_result.areas.NO2.values[0].value | float }}" +mode: single +``` + +### Example: Store this month's average price + +```yaml +alias: Get monthly average price from Nordpool +triggers: null +actions: + - action: nordpool.monthly + data: + currency: EUR + area: FI + response_variable: np_result + - action: input_text.set_value + target: + entity_id: input_text.monthly_price data: - value: "{{np_result.prices[0].averagePerArea.NO2 | float}}" + value: "{{ np_result.areas.FI.values[0].value | float }}" mode: single ``` From aec5e80deb3639be086931fd26c5414e572dd138 Mon Sep 17 00:00:00 2001 From: Metroseksuaali Date: Sun, 1 Feb 2026 10:44:42 +0200 Subject: [PATCH 6/7] Fix Jinja2 template notation in README examples Use bracket notation ["values"] instead of .values to avoid conflict with Python's built-in dict.values() method in Jinja2 templates. --- README.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 0696fee..280dfd9 100644 --- a/README.md +++ b/README.md @@ -253,7 +253,9 @@ areas: value: 767.23 ``` -To access a value in a template, use: `{{ np_result.areas..values[].value }}` +To access a value in a template, use: `{{ np_result.areas.["values"][].value }}` + +> **Note:** Use bracket notation `["values"]` instead of `.values` to avoid conflicts with Jinja2's built-in `dict.values()` method. ### Example: Store yearly average price @@ -270,7 +272,7 @@ actions: target: entity_id: input_text.yearly_price data: - value: "{{ np_result.areas.NO2.values[0].value | float }}" + value: "{{ np_result.areas.NO2[\"values\"][0].value | float }}" mode: single ``` @@ -289,7 +291,7 @@ actions: target: entity_id: input_text.monthly_price data: - value: "{{ np_result.areas.FI.values[0].value | float }}" + value: "{{ np_result.areas.FI[\"values\"][0].value | float }}" mode: single ``` From b4ee31b0649614f4f264ce07c1f72b7f31a4c91f Mon Sep 17 00:00:00 2001 From: Metroseksuaali Date: Fri, 13 Feb 2026 10:30:17 +0200 Subject: [PATCH 7/7] Fix incorrect avg/peak/off-peak with 15-min price data The hardcoded slice indices [0:8], [8:20], [20:] assumed 24 hourly entries per day. With Nordpool's switch to 15-minute intervals (96 entries/day), these sliced wrong time periods. Now dynamically calculates entries-per-hour to support both granularities. Also fixes tomorrow_valid threshold which was hardcoded to >= 23. Fixes #506 --- custom_components/nordpool/misc.py | 8 +++++--- custom_components/nordpool/sensor.py | 12 ++++++++---- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/custom_components/nordpool/misc.py b/custom_components/nordpool/misc.py index 864ffe9..cd39e09 100644 --- a/custom_components/nordpool/misc.py +++ b/custom_components/nordpool/misc.py @@ -124,9 +124,11 @@ def extract_attrs(data) -> dict: if len(data): data = sorted(data, key=itemgetter("start")) - offpeak1 = [i.get("value") for i in data[0:8]] - peak = [i.get("value") for i in data[8:20]] - offpeak2 = [i.get("value") for i in data[20:]] + # Calculate entries per hour to support both hourly (24) and quarterly (96) data + h = max(len(data) // 24, 1) + offpeak1 = [i.get("value") for i in data[0 : 8 * h]] + peak = [i.get("value") for i in data[8 * h : 20 * h]] + offpeak2 = [i.get("value") for i in data[20 * h :]] d["Peak"] = mean(peak) d["Off-peak 1"] = mean(offpeak1) diff --git a/custom_components/nordpool/sensor.py b/custom_components/nordpool/sensor.py index f4efb8e..77c37bc 100644 --- a/custom_components/nordpool/sensor.py +++ b/custom_components/nordpool/sensor.py @@ -314,9 +314,11 @@ def _update(self): self._average = mean(today) self._min = min(today) self._max = max(today) - self._off_peak_1 = mean(today[0:8]) - self._off_peak_2 = mean(today[20:]) - self._peak = mean(today[8:20]) + # Calculate entries per hour to support both hourly (24) and quarterly (96) data + h = max(len(today) // 24, 1) + self._off_peak_1 = mean(today[0 : 8 * h]) + self._off_peak_2 = mean(today[20 * h :]) + self._peak = mean(today[8 * h : 20 * h]) self._mean = median(today) @property @@ -424,7 +426,9 @@ def raw_tomorrow(self) -> list: def tomorrow_valid(self) -> bool: """Verify that we have the values for tomorrow.""" # this should be checked a better way - return len([i for i in self.tomorrow if i not in (None, float("inf"))]) >= 23 + # Use today's data length to determine expected entry count (24 hourly or 96 quarterly) + today_len = len(self.today) if self.today else 24 + return len([i for i in self.tomorrow if i not in (None, float("inf"))]) >= today_len - 1 async def _update_current_price(self) -> None: """update the current price (price this hour)"""