From edabb49309c06a378541f9cfcbd2ee5a1df083fc Mon Sep 17 00:00:00 2001 From: Julian Pawlowski Date: Thu, 9 Apr 2026 18:27:36 +0000 Subject: [PATCH] feat(sensors): expose energy/tax breakdown as sensor attributes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add energy_price and tax attributes to interval and daily stat sensors: - Interval sensors (current/next/previous): energy_price and tax from the specific 15-minute interval - Daily min/max sensors: energy_price and tax from the extreme interval - Daily average sensors: energy_price_mean, energy_price_median, tax_mean, tax_median — matching the existing mean/median pattern used for the main price attribute Calculator caches both mean and median for energy/tax using calculate_median() from utils/average. All new attributes are excluded from Recorder to prevent database bloat. Impact: Users can see price composition (spot price vs. taxes) on all major price sensors. Enables solar feed-in and net metering automations based on raw energy prices. --- .../sensor/attributes/daily_stat.py | 53 +++++++++++++++++-- .../sensor/attributes/interval.py | 36 +++++++++++++ .../sensor/calculators/daily_stat.py | 38 +++++++++++++ .../tibber_prices/sensor/core.py | 7 +++ 4 files changed, 130 insertions(+), 4 deletions(-) diff --git a/custom_components/tibber_prices/sensor/attributes/daily_stat.py b/custom_components/tibber_prices/sensor/attributes/daily_stat.py index e4a7bfc..123a73e 100644 --- a/custom_components/tibber_prices/sensor/attributes/daily_stat.py +++ b/custom_components/tibber_prices/sensor/attributes/daily_stat.py @@ -4,7 +4,10 @@ from __future__ import annotations from typing import TYPE_CHECKING -from custom_components.tibber_prices.const import PRICE_RATING_MAPPING +from custom_components.tibber_prices.const import ( + PRICE_RATING_MAPPING, + get_display_unit_factor, +) from custom_components.tibber_prices.coordinator.helpers import ( get_intervals_for_day_offsets, ) @@ -19,6 +22,43 @@ if TYPE_CHECKING: from .helpers import add_alternate_average_attribute +def _add_energy_tax_from_interval( + attributes: dict, + interval_data: dict, + *, + config_entry: TibberPricesConfigEntry, +) -> None: + """Add energy_price and tax from a single interval dict.""" + factor = get_display_unit_factor(config_entry) + energy = interval_data.get("energy") + if energy is not None: + attributes["energy_price"] = round(float(energy) * factor, 2) + tax = interval_data.get("tax") + if tax is not None: + attributes["tax"] = round(float(tax) * factor, 2) + + +def _add_energy_tax_averages_from_cache( + attributes: dict, + cached_data: dict, + *, + config_entry: TibberPricesConfigEntry, +) -> None: + """Add cached mean/median energy_price and tax values.""" + energy_mean, energy_median, tax_mean, tax_median = cached_data.get( + "last_energy_tax_averages", (None, None, None, None) + ) + factor = get_display_unit_factor(config_entry) + if energy_mean is not None: + attributes["energy_price_mean"] = round(float(energy_mean) * factor, 2) + if energy_median is not None: + attributes["energy_price_median"] = round(float(energy_median) * factor, 2) + if tax_mean is not None: + attributes["tax_mean"] = round(float(tax_mean) * factor, 2) + if tax_median is not None: + attributes["tax_median"] = round(float(tax_median) * factor, 2) + + def _get_day_midnight_timestamp(key: str, *, time: TibberPricesTimeService) -> datetime: """Get midnight timestamp for a given day sensor key (returns datetime object).""" # Determine which day based on sensor key @@ -117,7 +157,7 @@ def add_statistics_attributes( ) return - # Extreme value sensors - show when the extreme occurs + # Extreme value sensors - show when the extreme occurs + energy/tax breakdown extreme_sensors = { "lowest_price_today", "highest_price_today", @@ -125,10 +165,13 @@ def add_statistics_attributes( "highest_price_tomorrow", } if key in extreme_sensors: - if cached_data.get("last_extreme_interval"): - extreme_starts_at = cached_data["last_extreme_interval"].get("startsAt") + extreme_interval = cached_data.get("last_extreme_interval") + if extreme_interval: + extreme_starts_at = extreme_interval.get("startsAt") if extreme_starts_at: attributes["timestamp"] = extreme_starts_at + # Add energy_price and tax from the extreme interval + _add_energy_tax_from_interval(attributes, extreme_interval, config_entry=config_entry) return # Daily average sensors - show midnight to indicate whole day + add alternate value @@ -142,6 +185,8 @@ def add_statistics_attributes( key, # base_key = key itself ("average_price_today" or "average_price_tomorrow") config_entry=config_entry, ) + # Add energy/tax averages from cached calculator data + _add_energy_tax_averages_from_cache(attributes, cached_data, config_entry=config_entry) return # Daily aggregated level/rating sensors - show midnight to indicate whole day diff --git a/custom_components/tibber_prices/sensor/attributes/interval.py b/custom_components/tibber_prices/sensor/attributes/interval.py index 91d2876..6f289ea 100644 --- a/custom_components/tibber_prices/sensor/attributes/interval.py +++ b/custom_components/tibber_prices/sensor/attributes/interval.py @@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, Any from custom_components.tibber_prices.const import ( PRICE_LEVEL_MAPPING, PRICE_RATING_MAPPING, + get_display_unit_factor, ) from custom_components.tibber_prices.entity_utils import add_icon_color_attribute from custom_components.tibber_prices.utils.price import find_price_data_for_interval @@ -89,6 +90,38 @@ def _get_interval_data_for_attributes( return None +def _add_energy_tax_attributes( + attributes: dict, + interval_data: dict | None, + *, + config_entry: TibberPricesConfigEntry, +) -> None: + """ + Add energy_price and tax attributes from interval data. + + The API provides `energy` (raw spot price) and `tax` (tax component) in base + currency. These are converted to display units using the same factor as `total`. + + Args: + attributes: Dictionary to add attributes to + interval_data: Price interval data dict (may contain energy/tax fields) + config_entry: Config entry for display unit preference + + """ + if not interval_data: + return + + factor = get_display_unit_factor(config_entry) + + energy = interval_data.get("energy") + if energy is not None: + attributes["energy_price"] = round(float(energy) * factor, 2) + + tax = interval_data.get("tax") + if tax is not None: + attributes["tax"] = round(float(tax) * factor, 2) + + def add_current_interval_price_attributes( # noqa: PLR0913 attributes: dict, key: str, @@ -126,6 +159,9 @@ def add_current_interval_price_attributes( # noqa: PLR0913 if interval_data and "level" in interval_data: level = interval_data["level"] add_icon_color_attribute(attributes, key="price_level", state_value=level) + + # Add energy_price and tax attributes from raw API data + _add_energy_tax_attributes(attributes, interval_data, config_entry=config_entry) elif key in ["current_hour_average_price", "next_hour_average_price"]: # For hour-based price sensors, get level from cached_data level = cached_data.get("rolling_hour_level") diff --git a/custom_components/tibber_prices/sensor/calculators/daily_stat.py b/custom_components/tibber_prices/sensor/calculators/daily_stat.py index 41da70f..91f7e69 100644 --- a/custom_components/tibber_prices/sensor/calculators/daily_stat.py +++ b/custom_components/tibber_prices/sensor/calculators/daily_stat.py @@ -15,6 +15,7 @@ from custom_components.tibber_prices.sensor.helpers import ( aggregate_level_data, aggregate_rating_data, ) +from custom_components.tibber_prices.utils.average import calculate_median from .base import TibberPricesBaseCalculator @@ -44,6 +45,10 @@ class TibberPricesDailyStatCalculator(TibberPricesBaseCalculator): """ super().__init__(coordinator) self._last_extreme_interval: dict | None = None + self._last_energy_mean: float | None = None + self._last_energy_median: float | None = None + self._last_tax_mean: float | None = None + self._last_tax_median: float | None = None def get_daily_stat_value( self, @@ -107,6 +112,8 @@ class TibberPricesDailyStatCalculator(TibberPricesBaseCalculator): # Store the interval (for avg, use first interval as reference) if price_intervals: self._last_extreme_interval = price_intervals[0]["interval"] + # Compute and cache energy/tax averages for attribute builders + self._cache_energy_tax_averages(price_intervals) # Convert to display currency units based on config avg_result = round(get_price_value(value, config_entry=self.coordinator.config_entry), 2) median_result = ( @@ -198,3 +205,34 @@ class TibberPricesDailyStatCalculator(TibberPricesBaseCalculator): """ return self._last_extreme_interval + + def get_last_energy_tax_averages( + self, + ) -> tuple[float | None, float | None, float | None, float | None]: + """ + Get cached mean and median energy and tax values from last average calculation. + + Returns: + Tuple of (energy_mean, energy_median, tax_mean, tax_median) in base currency, + or (None, None, None, None). + + """ + return self._last_energy_mean, self._last_energy_median, self._last_tax_mean, self._last_tax_median + + def _cache_energy_tax_averages(self, price_intervals: list[dict]) -> None: + """Compute and cache energy/tax mean and median from price intervals.""" + energy_prices: list[float] = [] + tax_prices: list[float] = [] + for pi in price_intervals: + interval = pi["interval"] + energy = interval.get("energy") + if energy is not None: + energy_prices.append(float(energy)) + tax = interval.get("tax") + if tax is not None: + tax_prices.append(float(tax)) + + self._last_energy_mean = sum(energy_prices) / len(energy_prices) if energy_prices else None + self._last_energy_median = calculate_median(energy_prices) if energy_prices else None + self._last_tax_mean = sum(tax_prices) / len(tax_prices) if tax_prices else None + self._last_tax_median = calculate_median(tax_prices) if tax_prices else None diff --git a/custom_components/tibber_prices/sensor/core.py b/custom_components/tibber_prices/sensor/core.py index 4e2c2d5..f585b55 100644 --- a/custom_components/tibber_prices/sensor/core.py +++ b/custom_components/tibber_prices/sensor/core.py @@ -138,6 +138,12 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor): "second_half_12h_diff_from_current_%", # Static/Rarely Changing "tomorrow_expected_after", + "energy_price", + "energy_price_mean", + "energy_price_median", + "tax", + "tax_mean", + "tax_median", "level_value", "rating_value", "level_id", @@ -1145,6 +1151,7 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor): "trend_change_attributes": self._trend_calculator.get_trend_change_attributes(), "volatility_attributes": self._volatility_calculator.get_volatility_attributes(), "last_extreme_interval": self._daily_stat_calculator.get_last_extreme_interval(), + "last_energy_tax_averages": self._daily_stat_calculator.get_last_energy_tax_averages(), "last_price_level": self._interval_calculator.get_last_price_level(), "last_rating_difference": self._interval_calculator.get_last_rating_difference(), "last_rating_level": self._interval_calculator.get_last_rating_level(),