From 1c19cebff53e9297f8e186aa6b6cfe5e685bb234 Mon Sep 17 00:00:00 2001 From: Julian Pawlowski <75446+jpawlowski@users.noreply.github.com> Date: Thu, 11 Dec 2025 23:07:06 +0000 Subject: [PATCH] fix: support main and subunit currency --- .../tibber_prices/binary_sensor/attributes.py | 139 ++++++++++--- .../tibber_prices/binary_sensor/core.py | 28 ++- custom_components/tibber_prices/const.py | 28 +++ .../coordinator/period_handlers/core.py | 11 +- .../period_handlers/period_building.py | 25 ++- .../period_handlers/period_statistics.py | 44 ++--- .../coordinator/period_handlers/relaxation.py | 7 +- .../tibber_prices/sensor/chart_metadata.py | 13 +- .../tibber_prices/sensor/core.py | 20 +- .../tibber_prices/services/formatters.py | 16 +- .../services/get_apexcharts_yaml.py | 183 ++++++++++-------- .../tibber_prices/services/get_chartdata.py | 12 +- 12 files changed, 348 insertions(+), 178 deletions(-) diff --git a/custom_components/tibber_prices/binary_sensor/attributes.py b/custom_components/tibber_prices/binary_sensor/attributes.py index 4737a2a..875b2d9 100644 --- a/custom_components/tibber_prices/binary_sensor/attributes.py +++ b/custom_components/tibber_prices/binary_sensor/attributes.py @@ -4,9 +4,15 @@ from __future__ import annotations from typing import TYPE_CHECKING +from custom_components.tibber_prices.const import get_display_unit_factor from custom_components.tibber_prices.coordinator.helpers import get_intervals_for_day_offsets from custom_components.tibber_prices.entity_utils import add_icon_color_attribute +# Constants for price display conversion +_SUBUNIT_FACTOR = 100 # Conversion factor for subunit currency (ct/øre) +_SUBUNIT_PRECISION = 2 # Decimal places for subunit currency +_BASE_PRECISION = 4 # Decimal places for base currency + # Import TypedDict definitions for documentation (not used in signatures) if TYPE_CHECKING: @@ -66,6 +72,7 @@ def get_price_intervals_attributes( *, time: TibberPricesTimeService, reverse_sort: bool, + config_entry: TibberPricesConfigEntry, ) -> dict | None: """ Build attributes for period-based sensors (best/peak price). @@ -76,11 +83,13 @@ def get_price_intervals_attributes( 1. Get period summaries from coordinator (already filtered and fully calculated) 2. Add the current timestamp 3. Find current or next period based on time + 4. Convert prices to display units based on user configuration Args: coordinator_data: Coordinator data dict time: TibberPricesTimeService instance (required) reverse_sort: True for peak_price (highest first), False for best_price (lowest first) + config_entry: Config entry for display unit configuration Returns: Attributes dict with current/next period and all periods list @@ -101,11 +110,20 @@ def get_price_intervals_attributes( if not period_summaries: return build_no_periods_result(time=time) + # Filter periods for today+tomorrow (sensors don't show yesterday's periods) + # Coordinator cache contains yesterday/today/tomorrow, but sensors only need today+tomorrow + now = time.now() + today_start = time.start_of_local_day(now) + filtered_periods = [period for period in period_summaries if period.get("end") and period["end"] >= today_start] + + if not filtered_periods: + return build_no_periods_result(time=time) + # Find current or next period based on current time current_period = None # First pass: find currently active period - for period in period_summaries: + for period in filtered_periods: start = period.get("start") end = period.get("end") if start and end and time.is_current_interval(start, end): @@ -114,14 +132,14 @@ def get_price_intervals_attributes( # Second pass: find next future period if none is active if not current_period: - for period in period_summaries: + for period in filtered_periods: start = period.get("start") if start and time.is_in_future(start): current_period = period break - # Build final attributes - return build_final_attributes_simple(current_period, period_summaries, time=time) + # Build final attributes (use filtered_periods for display) + return build_final_attributes_simple(current_period, filtered_periods, time=time, config_entry=config_entry) def build_no_periods_result(*, time: TibberPricesTimeService) -> dict: @@ -166,28 +184,58 @@ def add_decision_attributes(attributes: dict, current_period: dict) -> None: attributes["rating_difference_%"] = current_period["rating_difference_%"] -def add_price_attributes(attributes: dict, current_period: dict) -> None: - """Add price statistics attributes (priority 3).""" +def add_price_attributes(attributes: dict, current_period: dict, factor: int) -> None: + """ + Add price statistics attributes (priority 3). + + Args: + attributes: Target dict to add attributes to + current_period: Period dict with price data (in base currency) + factor: Display unit conversion factor (100 for subunit, 1 for base) + + """ + # Convert prices from base currency to display units + precision = _SUBUNIT_PRECISION if factor == _SUBUNIT_FACTOR else _BASE_PRECISION + if "price_mean" in current_period: - attributes["price_mean"] = current_period["price_mean"] + attributes["price_mean"] = round(current_period["price_mean"] * factor, precision) if "price_median" in current_period: - attributes["price_median"] = current_period["price_median"] + attributes["price_median"] = round(current_period["price_median"] * factor, precision) if "price_min" in current_period: - attributes["price_min"] = current_period["price_min"] + attributes["price_min"] = round(current_period["price_min"] * factor, precision) if "price_max" in current_period: - attributes["price_max"] = current_period["price_max"] + attributes["price_max"] = round(current_period["price_max"] * factor, precision) if "price_spread" in current_period: - attributes["price_spread"] = current_period["price_spread"] + attributes["price_spread"] = round(current_period["price_spread"] * factor, precision) if "volatility" in current_period: - attributes["volatility"] = current_period["volatility"] + attributes["volatility"] = current_period["volatility"] # Volatility is not a price, keep as-is -def add_comparison_attributes(attributes: dict, current_period: dict) -> None: - """Add price comparison attributes (priority 4).""" +def add_comparison_attributes(attributes: dict, current_period: dict, factor: int) -> None: + """ + Add price comparison attributes (priority 4). + + Args: + attributes: Target dict to add attributes to + current_period: Period dict with price diff data (in base currency) + factor: Display unit conversion factor (100 for subunit, 1 for base) + + """ + # Convert price differences from base currency to display units + precision = _SUBUNIT_PRECISION if factor == _SUBUNIT_FACTOR else _BASE_PRECISION + if "period_price_diff_from_daily_min" in current_period: - attributes["period_price_diff_from_daily_min"] = current_period["period_price_diff_from_daily_min"] + attributes["period_price_diff_from_daily_min"] = round( + current_period["period_price_diff_from_daily_min"] * factor, precision + ) if "period_price_diff_from_daily_min_%" in current_period: attributes["period_price_diff_from_daily_min_%"] = current_period["period_price_diff_from_daily_min_%"] + if "period_price_diff_from_daily_max" in current_period: + attributes["period_price_diff_from_daily_max"] = round( + current_period["period_price_diff_from_daily_max"] * factor, precision + ) + if "period_price_diff_from_daily_max_%" in current_period: + attributes["period_price_diff_from_daily_max_%"] = current_period["period_price_diff_from_daily_max_%"] def add_detail_attributes(attributes: dict, current_period: dict) -> None: @@ -219,11 +267,51 @@ def add_relaxation_attributes(attributes: dict, current_period: dict) -> None: attributes["relaxation_threshold_applied_%"] = current_period["relaxation_threshold_applied_%"] +def _convert_periods_to_display_units(period_summaries: list[dict], factor: int) -> list[dict]: + """ + Convert price values in periods array to display units. + + Args: + period_summaries: List of period dicts with price data (in base currency) + factor: Display unit conversion factor (100 for subunit, 1 for base) + + Returns: + New list with converted period dicts + + """ + precision = _SUBUNIT_PRECISION if factor == _SUBUNIT_FACTOR else _BASE_PRECISION + converted_periods = [] + + for period in period_summaries: + converted = period.copy() + + # Convert all price fields + price_fields = ["price_mean", "price_median", "price_min", "price_max", "price_spread"] + for field in price_fields: + if field in converted: + converted[field] = round(converted[field] * factor, precision) + + # Convert price differences (not percentages) + if "period_price_diff_from_daily_min" in converted: + converted["period_price_diff_from_daily_min"] = round( + converted["period_price_diff_from_daily_min"] * factor, precision + ) + if "period_price_diff_from_daily_max" in converted: + converted["period_price_diff_from_daily_max"] = round( + converted["period_price_diff_from_daily_max"] * factor, precision + ) + + converted_periods.append(converted) + + return converted_periods + + def build_final_attributes_simple( current_period: dict | None, period_summaries: list[dict], *, time: TibberPricesTimeService, + config_entry: TibberPricesConfigEntry, ) -> dict: """ Build the final attributes dictionary from coordinator's period summaries. @@ -232,6 +320,7 @@ def build_final_attributes_simple( 1. Adds the current timestamp (only thing calculated every 15min) 2. Uses the current/next period from summaries 3. Adds nested period summaries + 4. Converts prices to display units based on user configuration Attributes are ordered following the documented priority: 1. Time information (timestamp, start, end, duration) @@ -247,6 +336,7 @@ def build_final_attributes_simple( current_period: The current or next period (already complete from coordinator) period_summaries: All period summaries from coordinator time: TibberPricesTimeService instance (required) + config_entry: Config entry for display unit configuration Returns: Complete attributes dict with all fields @@ -256,6 +346,9 @@ def build_final_attributes_simple( current_minute = (now.minute // 15) * 15 timestamp = now.replace(minute=current_minute, second=0, microsecond=0) + # Get display unit factor (100 for subunit, 1 for base currency) + factor = get_display_unit_factor(config_entry) + if current_period: # Build attributes in priority order using helper methods attributes = {} @@ -266,11 +359,11 @@ def build_final_attributes_simple( # 2. Core decision attributes add_decision_attributes(attributes, current_period) - # 3. Price statistics - add_price_attributes(attributes, current_period) + # 3. Price statistics (converted to display units) + add_price_attributes(attributes, current_period, factor) - # 4. Price differences - add_comparison_attributes(attributes, current_period) + # 4. Price differences (converted to display units) + add_comparison_attributes(attributes, current_period, factor) # 5. Detail information add_detail_attributes(attributes, current_period) @@ -278,15 +371,15 @@ def build_final_attributes_simple( # 6. Relaxation information (only if period was relaxed) add_relaxation_attributes(attributes, current_period) - # 7. Meta information (periods array) - attributes["periods"] = period_summaries + # 7. Meta information (periods array - prices converted to display units) + attributes["periods"] = _convert_periods_to_display_units(period_summaries, factor) return attributes - # No current/next period found - return all periods with timestamp + # No current/next period found - return all periods with timestamp (prices converted) return { "timestamp": timestamp, - "periods": period_summaries, + "periods": _convert_periods_to_display_units(period_summaries, factor), } diff --git a/custom_components/tibber_prices/binary_sensor/core.py b/custom_components/tibber_prices/binary_sensor/core.py index 6db516e..3d9f345 100644 --- a/custom_components/tibber_prices/binary_sensor/core.py +++ b/custom_components/tibber_prices/binary_sensor/core.py @@ -138,7 +138,12 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity, RestoreEn """Return True if the current time is within a best price period.""" if not self.coordinator.data: return None - attrs = get_price_intervals_attributes(self.coordinator.data, reverse_sort=False, time=self.coordinator.time) + attrs = get_price_intervals_attributes( + self.coordinator.data, + reverse_sort=False, + time=self.coordinator.time, + config_entry=self.coordinator.config_entry, + ) if not attrs: return False # Should not happen, but safety fallback start = attrs.get("start") @@ -152,7 +157,12 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity, RestoreEn """Return True if the current time is within a peak price period.""" if not self.coordinator.data: return None - attrs = get_price_intervals_attributes(self.coordinator.data, reverse_sort=True, time=self.coordinator.time) + attrs = get_price_intervals_attributes( + self.coordinator.data, + reverse_sort=True, + time=self.coordinator.time, + config_entry=self.coordinator.config_entry, + ) if not attrs: return False # Should not happen, but safety fallback start = attrs.get("start") @@ -270,9 +280,19 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity, RestoreEn key = self.entity_description.key if key == "peak_price_period": - return get_price_intervals_attributes(self.coordinator.data, reverse_sort=True, time=self.coordinator.time) + return get_price_intervals_attributes( + self.coordinator.data, + reverse_sort=True, + time=self.coordinator.time, + config_entry=self.coordinator.config_entry, + ) if key == "best_price_period": - return get_price_intervals_attributes(self.coordinator.data, reverse_sort=False, time=self.coordinator.time) + return get_price_intervals_attributes( + self.coordinator.data, + reverse_sort=False, + time=self.coordinator.time, + config_entry=self.coordinator.config_entry, + ) if key == "tomorrow_data_available": return self._get_tomorrow_data_available_attributes() diff --git a/custom_components/tibber_prices/const.py b/custom_components/tibber_prices/const.py index 47a8310..1cf7c4a 100644 --- a/custom_components/tibber_prices/const.py +++ b/custom_components/tibber_prices/const.py @@ -179,6 +179,16 @@ CURRENCY_INFO = { "GBP": ("£", "p", "Pence"), } +# Base currency names: ISO code -> full currency name (in local language) +CURRENCY_NAMES = { + "EUR": "Euro", + "NOK": "Norske kroner", + "SEK": "Svenska kronor", + "DKK": "Danske kroner", + "USD": "US Dollar", + "GBP": "British Pound", +} + def get_currency_info(currency_code: str | None) -> tuple[str, str, str]: """ @@ -228,6 +238,24 @@ def format_price_unit_subunit(currency_code: str | None) -> str: return f"{subunit_symbol}/{UnitOfPower.KILO_WATT}{UnitOfTime.HOURS}" +def get_currency_name(currency_code: str | None) -> str: + """ + Get the full name of the base currency. + + Args: + currency_code: ISO 4217 currency code (e.g., 'EUR', 'NOK', 'SEK') + + Returns: + Full currency name like 'Euro' or 'Norwegian Krone' + Defaults to 'Euro' if currency is not recognized + + """ + if not currency_code: + currency_code = "EUR" + + return CURRENCY_NAMES.get(currency_code.upper(), CURRENCY_NAMES["EUR"]) + + # ============================================================================ # Currency Display Mode Configuration # ============================================================================ diff --git a/custom_components/tibber_prices/coordinator/period_handlers/core.py b/custom_components/tibber_prices/coordinator/period_handlers/core.py index 87bfdf8..bc4aeb7 100644 --- a/custom_components/tibber_prices/coordinator/period_handlers/core.py +++ b/custom_components/tibber_prices/coordinator/period_handlers/core.py @@ -35,7 +35,6 @@ def calculate_periods( *, config: TibberPricesPeriodConfig, time: TibberPricesTimeService, - config_entry: Any, # ConfigEntry type ) -> dict[str, Any]: """ Calculate price periods (best or peak) from price data. @@ -57,7 +56,6 @@ def calculate_periods( config: Period configuration containing reverse_sort, flex, min_distance_from_avg, min_period_length, threshold_low, and threshold_high. time: TibberPricesTimeService instance (required). - config_entry: Config entry to get display unit configuration. Returns: Dict with: @@ -185,12 +183,14 @@ def calculate_periods( # Step 5: Add interval ends add_interval_ends(raw_periods, time=time) - # Step 6: Filter periods by end date (keep periods ending today or later) + # Step 6: Filter periods by end date (keep periods ending yesterday or later) + # This ensures coordinator cache contains yesterday/today/tomorrow periods + # Sensors filter further for today+tomorrow, services can access all cached periods raw_periods = filter_periods_by_end_date(raw_periods, time=time) # Step 8: Extract lightweight period summaries (no full price data) - # Note: Filtering for current/future is done here based on end date, - # not start date. This preserves periods that started yesterday but end today. + # Note: Periods are filtered by end date to keep yesterday/today/tomorrow. + # This preserves periods that started day-before-yesterday but end yesterday. thresholds = TibberPricesThresholdConfig( threshold_low=threshold_low, threshold_high=threshold_high, @@ -205,7 +205,6 @@ def calculate_periods( price_context, thresholds, time=time, - config_entry=config_entry, ) return { diff --git a/custom_components/tibber_prices/coordinator/period_handlers/period_building.py b/custom_components/tibber_prices/coordinator/period_handlers/period_building.py index e69af8f..a8468bf 100644 --- a/custom_components/tibber_prices/coordinator/period_handlers/period_building.py +++ b/custom_components/tibber_prices/coordinator/period_handlers/period_building.py @@ -248,19 +248,21 @@ def add_interval_ends(periods: list[list[dict]], *, time: TibberPricesTimeServic def filter_periods_by_end_date(periods: list[list[dict]], *, time: TibberPricesTimeService) -> list[list[dict]]: """ - Filter periods to keep only relevant ones for today and tomorrow. + Filter periods to keep only relevant ones for yesterday, today, and tomorrow. Keep periods that: - - End in the future (> now) - - End today but after the start of the day (not exactly at midnight) + - End yesterday or later (>= start of yesterday) This removes: - - Periods that ended yesterday - - Periods that ended exactly at midnight today (they're completely in the past) + - Periods that ended before yesterday (day-before-yesterday or earlier) + + Rationale: Coordinator caches periods for yesterday/today/tomorrow so that: + - Binary sensors can filter for today+tomorrow (current/next periods) + - Services can access yesterday's periods when user requests "yesterday" data """ now = time.now() - today = now.date() - midnight_today = time.start_of_local_day(now) + # Calculate start of yesterday (midnight yesterday) + yesterday_start = time.start_of_local_day(now) - time.get_interval_duration() * 96 # 96 intervals = 24 hours filtered = [] for period in periods: @@ -274,13 +276,8 @@ def filter_periods_by_end_date(periods: list[list[dict]], *, time: TibberPricesT if not period_end: continue - # Keep if period ends in the future - if time.is_in_future(period_end): - filtered.append(period) - continue - - # Keep if period ends today but AFTER midnight (not exactly at midnight) - if period_end.date() == today and period_end > midnight_today: + # Keep if period ends yesterday or later + if period_end >= yesterday_start: filtered.append(period) return filtered diff --git a/custom_components/tibber_prices/coordinator/period_handlers/period_statistics.py b/custom_components/tibber_prices/coordinator/period_handlers/period_statistics.py index 136fb9b..50b5879 100644 --- a/custom_components/tibber_prices/coordinator/period_handlers/period_statistics.py +++ b/custom_components/tibber_prices/coordinator/period_handlers/period_statistics.py @@ -8,7 +8,6 @@ if TYPE_CHECKING: from datetime import datetime from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService - from homeassistant.config_entries import ConfigEntry from .types import ( TibberPricesPeriodData, @@ -16,7 +15,6 @@ if TYPE_CHECKING: TibberPricesThresholdConfig, ) -from custom_components.tibber_prices.const import get_display_unit_factor from custom_components.tibber_prices.utils.average import calculate_median from custom_components.tibber_prices.utils.price import ( aggregate_period_levels, @@ -29,7 +27,6 @@ def calculate_period_price_diff( price_mean: float, start_time: datetime, price_context: dict[str, Any], - config_entry: ConfigEntry, ) -> tuple[float | None, float | None]: """ Calculate period price difference from daily reference (min or max). @@ -37,10 +34,9 @@ def calculate_period_price_diff( Uses reference price from start day of the period for consistency. Args: - price_mean: Mean price of the period (already in display units). + price_mean: Mean price of the period (in base currency). start_time: Start time of the period. price_context: Dictionary with ref_prices per day. - config_entry: Config entry to get display unit configuration. Returns: Tuple of (period_price_diff, period_price_diff_pct) or (None, None) if no reference available. @@ -56,10 +52,9 @@ def calculate_period_price_diff( if ref_price is None: return None, None - # Convert reference price to display units - factor = get_display_unit_factor(config_entry) - ref_price_display = round(ref_price * factor, 2) - period_price_diff = round(price_mean - ref_price_display, 2) + # Both prices are in base currency, no conversion needed + ref_price_display = round(ref_price, 4) + period_price_diff = round(price_mean - ref_price_display, 4) period_price_diff_pct = None if ref_price_display != 0: # CRITICAL: Use abs() for negative prices (same logic as calculate_difference_percentage) @@ -96,23 +91,22 @@ def calculate_aggregated_rating_difference(period_price_data: list[dict]) -> flo def calculate_period_price_statistics( period_price_data: list[dict], - config_entry: ConfigEntry, ) -> dict[str, float]: """ Calculate price statistics for a period. Args: period_price_data: List of price data dictionaries with "total" field. - config_entry: Config entry to get display unit configuration. Returns: - Dictionary with price_mean, price_median, price_min, price_max, price_spread (all in display units). + Dictionary with price_mean, price_median, price_min, price_max, price_spread (all in base currency). Note: price_spread is calculated based on price_mean (max - min range as percentage of mean). """ - # Convert prices to display units based on configuration - factor = get_display_unit_factor(config_entry) - prices_display = [round(float(p["total"]) * factor, 2) for p in period_price_data] + # Keep prices in base currency (Euro/NOK/SEK) for internal storage + # Conversion to display units (ct/øre) happens in services/formatting layer + factor = 1 # Always use base currency for storage + prices_display = [round(float(p["total"]) * factor, 4) for p in period_price_data] if not prices_display: return { @@ -123,12 +117,12 @@ def calculate_period_price_statistics( "price_spread": 0.0, } - price_mean = round(sum(prices_display) / len(prices_display), 2) + price_mean = round(sum(prices_display) / len(prices_display), 4) median_value = calculate_median(prices_display) - price_median = round(median_value, 2) if median_value is not None else 0.0 - price_min = round(min(prices_display), 2) - price_max = round(max(prices_display), 2) - price_spread = round(price_max - price_min, 2) + price_median = round(median_value, 4) if median_value is not None else 0.0 + price_min = round(min(prices_display), 4) + price_max = round(max(prices_display), 4) + price_spread = round(price_max - price_min, 4) return { "price_mean": price_mean, @@ -224,14 +218,13 @@ def build_period_summary_dict( return summary -def extract_period_summaries( # noqa: PLR0913 +def extract_period_summaries( periods: list[list[dict]], all_prices: list[dict], price_context: dict[str, Any], thresholds: TibberPricesThresholdConfig, *, time: TibberPricesTimeService, - config_entry: ConfigEntry, ) -> list[dict]: """ Extract complete period summaries with all aggregated attributes. @@ -253,7 +246,6 @@ def extract_period_summaries( # noqa: PLR0913 price_context: Dictionary with ref_prices and avg_prices per day. thresholds: Threshold configuration for calculations. time: TibberPricesTimeService instance (required). - config_entry: Config entry to get display unit configuration. """ from .types import ( # noqa: PLC0415 - Avoid circular import @@ -311,12 +303,12 @@ def extract_period_summaries( # noqa: PLR0913 thresholds.threshold_high, ) - # Calculate price statistics (in display units based on configuration) - price_stats = calculate_period_price_statistics(period_price_data, config_entry) + # Calculate price statistics (in base currency, conversion happens in presentation layer) + price_stats = calculate_period_price_statistics(period_price_data) # Calculate period price difference from daily reference period_price_diff, period_price_diff_pct = calculate_period_price_diff( - price_stats["price_mean"], start_time, price_context, config_entry + price_stats["price_mean"], start_time, price_context ) # Extract prices for volatility calculation (coefficient of variation) diff --git a/custom_components/tibber_prices/coordinator/period_handlers/relaxation.py b/custom_components/tibber_prices/coordinator/period_handlers/relaxation.py index 0550610..5572cac 100644 --- a/custom_components/tibber_prices/coordinator/period_handlers/relaxation.py +++ b/custom_components/tibber_prices/coordinator/period_handlers/relaxation.py @@ -276,8 +276,9 @@ def calculate_periods_with_relaxation( # noqa: PLR0913, PLR0915 - Per-day relax ) # === BASELINE CALCULATION (process ALL prices together, including yesterday) === - # Periods that ended yesterday will be filtered out later by filter_periods_by_end_date() - baseline_result = calculate_periods(all_prices, config=config, time=time, config_entry=config_entry) + # Periods that ended before yesterday will be filtered out later by filter_periods_by_end_date() + # This keeps yesterday/today/tomorrow periods in the cache + baseline_result = calculate_periods(all_prices, config=config, time=time) all_periods = baseline_result["periods"] # Count periods per day for min_periods check @@ -465,7 +466,7 @@ def relax_all_prices( # noqa: PLR0913 - Comprehensive filter relaxation require ) # Process ALL prices together (allows midnight crossing) - result = calculate_periods(all_prices, config=relaxed_config, time=time, config_entry=config_entry) + result = calculate_periods(all_prices, config=relaxed_config, time=time) new_periods = result["periods"] _LOGGER_DETAILS.debug( diff --git a/custom_components/tibber_prices/sensor/chart_metadata.py b/custom_components/tibber_prices/sensor/chart_metadata.py index aea52ea..c142df6 100644 --- a/custom_components/tibber_prices/sensor/chart_metadata.py +++ b/custom_components/tibber_prices/sensor/chart_metadata.py @@ -4,7 +4,12 @@ from __future__ import annotations from typing import TYPE_CHECKING -from custom_components.tibber_prices.const import DATA_CHART_METADATA_CONFIG, DOMAIN +from custom_components.tibber_prices.const import ( + CONF_CURRENCY_DISPLAY_MODE, + DATA_CHART_METADATA_CONFIG, + DISPLAY_MODE_SUBUNIT, + DOMAIN, +) if TYPE_CHECKING: from datetime import datetime @@ -41,9 +46,11 @@ async def call_chartdata_service_for_metadata_async( # Force metadata to "only" - this sensor ONLY provides metadata service_params["metadata"] = "only" - # Default to subunit_currency=True for ApexCharts compatibility (can be overridden in configuration.yaml) + # Use user's display unit preference from config_entry + # This ensures chart_metadata yaxis values match the user's configured currency display mode if "subunit_currency" not in service_params: - service_params["subunit_currency"] = True + display_mode = config_entry.options.get(CONF_CURRENCY_DISPLAY_MODE, DISPLAY_MODE_SUBUNIT) + service_params["subunit_currency"] = display_mode == DISPLAY_MODE_SUBUNIT # Call get_chartdata service using official HA service system try: diff --git a/custom_components/tibber_prices/sensor/core.py b/custom_components/tibber_prices/sensor/core.py index 3879d9e..af6bc66 100644 --- a/custom_components/tibber_prices/sensor/core.py +++ b/custom_components/tibber_prices/sensor/core.py @@ -18,6 +18,7 @@ from custom_components.tibber_prices.const import ( DEFAULT_PRICE_RATING_THRESHOLD_LOW, DISPLAY_MODE_BASE, DOMAIN, + format_price_unit_base, get_display_unit_factor, get_display_unit_string, ) @@ -851,6 +852,11 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor): if self.coordinator.data: currency = self.coordinator.data.get("currency") + # Special case: Energy Dashboard sensor always uses base currency + # regardless of user display mode configuration + if self.entity_description.key == "current_interval_price_base": + return format_price_unit_base(currency) + # Get unit based on user configuration (major or minor) return get_display_unit_string(self.coordinator.config_entry, currency) @@ -861,7 +867,12 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor): """Check if the current time is within a best price period.""" if not self.coordinator.data: return False - attrs = get_price_intervals_attributes(self.coordinator.data, reverse_sort=False, time=self.coordinator.time) + attrs = get_price_intervals_attributes( + self.coordinator.data, + reverse_sort=False, + time=self.coordinator.time, + config_entry=self.coordinator.config_entry, + ) if not attrs: return False start = attrs.get("start") @@ -876,7 +887,12 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor): """Check if the current time is within a peak price period.""" if not self.coordinator.data: return False - attrs = get_price_intervals_attributes(self.coordinator.data, reverse_sort=True, time=self.coordinator.time) + attrs = get_price_intervals_attributes( + self.coordinator.data, + reverse_sort=True, + time=self.coordinator.time, + config_entry=self.coordinator.config_entry, + ) if not attrs: return False start = attrs.get("start") diff --git a/custom_components/tibber_prices/services/formatters.py b/custom_components/tibber_prices/services/formatters.py index 7087cd6..546b0f3 100644 --- a/custom_components/tibber_prices/services/formatters.py +++ b/custom_components/tibber_prices/services/formatters.py @@ -198,7 +198,7 @@ def aggregate_hourly_exact( # noqa: PLR0913, PLR0912, PLR0915 return hourly_data -def get_period_data( # noqa: PLR0913, PLR0912, PLR0915, C901 +def get_period_data( # noqa: PLR0913, PLR0912, PLR0915 *, coordinator: Any, period_filter: str, @@ -347,11 +347,12 @@ def get_period_data( # noqa: PLR0913, PLR0912, PLR0915, C901 # Median is more representative than mean for periods with gap tolerance # (single "normal" intervals between cheap/expensive ones don't skew the display) price_median = period.get("price_median", 0.0) - # Convert to subunit currency if subunit_currency=True (periods stored in major) + # Convert to subunit currency if subunit_currency=True (periods stored in base currency) if subunit_currency: price_median = price_median * 100 - if round_decimals is not None: - price_median = round(price_median, round_decimals) + # Apply rounding: use round_decimals if provided, otherwise default precision + precision = round_decimals if round_decimals is not None else (2 if subunit_currency else 4) + price_median = round(price_median, precision) data_point[price_field] = price_median # Level (only if requested and present) @@ -373,11 +374,12 @@ def get_period_data( # noqa: PLR0913, PLR0912, PLR0915, C901 # 3. End time with NULL (cleanly terminate segment for ApexCharts) # Use price_median for consistency with sensor states (more representative for periods) price_median = period.get("price_median", 0.0) - # Convert to subunit currency if subunit_currency=True (periods stored in major) + # Convert to subunit currency if subunit_currency=True (periods stored in base currency) if subunit_currency: price_median = price_median * 100 - if round_decimals is not None: - price_median = round(price_median, round_decimals) + # Apply rounding: use round_decimals if provided, otherwise default precision + precision = round_decimals if round_decimals is not None else (2 if subunit_currency else 4) + price_median = round(price_median, precision) start = period["start"] end = period.get("end") start_serialized = start.isoformat() if hasattr(start, "isoformat") else start diff --git a/custom_components/tibber_prices/services/get_apexcharts_yaml.py b/custom_components/tibber_prices/services/get_apexcharts_yaml.py index 466f08c..878f3a5 100644 --- a/custom_components/tibber_prices/services/get_apexcharts_yaml.py +++ b/custom_components/tibber_prices/services/get_apexcharts_yaml.py @@ -23,6 +23,8 @@ from typing import TYPE_CHECKING, Any, Final import voluptuous as vol from custom_components.tibber_prices.const import ( + CONF_CURRENCY_DISPLAY_MODE, + DISPLAY_MODE_SUBUNIT, DOMAIN, PRICE_LEVEL_CHEAP, PRICE_LEVEL_EXPENSIVE, @@ -32,7 +34,7 @@ from custom_components.tibber_prices.const import ( PRICE_RATING_HIGH, PRICE_RATING_LOW, PRICE_RATING_NORMAL, - format_price_unit_subunit, + get_display_unit_string, get_translation, ) from homeassistant.exceptions import ServiceValidationError @@ -298,11 +300,15 @@ async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]: # noqa: # Get user's language from hass config user_language = hass.config.language or "en" - # Get coordinator to access price data (for currency) - _, coordinator, _ = get_entry_and_data(hass, entry_id) + # Get coordinator to access price data (for currency) and config entry for display settings + config_entry, coordinator, _ = get_entry_and_data(hass, entry_id) # Get currency from coordinator data currency = coordinator.data.get("currency", "EUR") - price_unit = format_price_unit_subunit(currency) + + # Get user's display unit preference (subunit or base currency) + display_mode = config_entry.options.get(CONF_CURRENCY_DISPLAY_MODE, DISPLAY_MODE_SUBUNIT) + use_subunit = display_mode == DISPLAY_MODE_SUBUNIT + price_unit = get_display_unit_string(config_entry, currency) # Get entity registry for mapping entity_registry = async_get_entity_registry(hass) @@ -326,6 +332,54 @@ async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]: # noqa: (PRICE_LEVEL_VERY_EXPENSIVE, "#e74c3c"), ] series = [] + + # Get translated name for best price periods (needed for layer) + best_price_name = get_translation(["apexcharts", "best_price_period_name"], user_language) or "Best Price Period" + + # Add best price period highlight overlay FIRST (so it renders behind all other series) + if highlight_best_price and entity_map: + # Create vertical highlight bands using separate Y-axis (0-1 range) + # This creates a semi-transparent overlay from bottom to top without affecting price scale + # Conditionally include day parameter (omit for rolling window mode) + # For rolling_window and rolling_window_autozoom, omit day parameter (dynamic selection) + day_param = "" if day in ("rolling_window", "rolling_window_autozoom", None) else f"day: ['{day}'], " + + # Store original prices for tooltip, but map to 1 for full-height overlay + # Use user's display unit preference for period data too + subunit_param = "true" if use_subunit else "false" + best_price_generator = ( + f"const response = await hass.callWS({{ " + f"type: 'call_service', " + f"domain: 'tibber_prices', " + f"service: 'get_chartdata', " + f"return_response: true, " + f"service_data: {{ entry_id: '{entry_id}', {day_param}" + f"period_filter: 'best_price', " + f"output_format: 'array_of_arrays', insert_nulls: 'segments', subunit_currency: {subunit_param} }} }}); " + f"const originalData = response.response.data; " + f"return originalData.map((point, i) => {{ " + f"const result = [point[0], point[1] === null ? null : 1]; " + f"result.originalPrice = point[1]; " + f"return result; " + f"}});" + ) + + # Use first entity from entity_map (reuse existing entity to avoid extra header entries) + best_price_entity = next(iter(entity_map.values())) + + series.append( + { + "entity": best_price_entity, + "name": best_price_name, + "type": "area", + "color": "rgba(46, 204, 113, 0.05)", # Ultra-subtle green overlay (barely visible) + "yaxis_id": "highlight", # Use separate Y-axis (0-1) for full-height overlay + "show": {"legend_value": False, "in_header": False, "in_legend": False}, + "data_generator": best_price_generator, + "stroke_width": 0, + } + ) + # Only create series for levels that have a matching entity (filter out missing levels) for level_key, color in series_levels: # Skip levels that don't have a corresponding sensor @@ -346,6 +400,8 @@ async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]: # noqa: # For rolling window modes, we'll capture metadata for dynamic config # For static day modes, just return data array + # Use user's display unit preference for all data requests + subunit_param = "true" if use_subunit else "false" if day in ("rolling_window", "rolling_window_autozoom", None): data_generator = ( f"const response = await hass.callWS({{ " @@ -354,7 +410,7 @@ async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]: # noqa: f"service: 'get_chartdata', " f"return_response: true, " f"service_data: {{ entry_id: '{entry_id}', {day_param}{filter_param}, " - f"output_format: 'array_of_arrays', insert_nulls: 'segments', subunit_currency: true, " + f"output_format: 'array_of_arrays', insert_nulls: 'segments', subunit_currency: {subunit_param}, " f"connect_segments: true }} }}); " f"return response.response.data;" ) @@ -367,7 +423,7 @@ async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]: # noqa: f"service: 'get_chartdata', " f"return_response: true, " f"service_data: {{ entry_id: '{entry_id}', {day_param}{filter_param}, " - f"output_format: 'array_of_arrays', insert_nulls: 'segments', subunit_currency: true, " + f"output_format: 'array_of_arrays', insert_nulls: 'segments', subunit_currency: {subunit_param}, " f"connect_segments: true }} }}); " f"return response.response.data;" ) @@ -396,52 +452,6 @@ async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]: # noqa: # Note: Extrema markers don't work with data_generator approach # ApexCharts card requires direct entity data for extremas feature, not dynamically generated data - # Get translated name for best price periods (needed for tooltip formatter) - best_price_name = get_translation(["apexcharts", "best_price_period_name"], user_language) or "Best Price Period" - - # Add best price period highlight overlay (vertical bands from top to bottom) - if highlight_best_price and entity_map: - # Create vertical highlight bands using separate Y-axis (0-1 range) - # This creates a semi-transparent overlay from bottom to top without affecting price scale - # Conditionally include day parameter (omit for rolling window mode) - # For rolling_window and rolling_window_autozoom, omit day parameter (dynamic selection) - day_param = "" if day in ("rolling_window", "rolling_window_autozoom", None) else f"day: ['{day}'], " - - # Store original prices for tooltip, but map to 1 for full-height overlay - # We use a custom tooltip formatter to show the real price - best_price_generator = ( - f"const response = await hass.callWS({{ " - f"type: 'call_service', " - f"domain: 'tibber_prices', " - f"service: 'get_chartdata', " - f"return_response: true, " - f"service_data: {{ entry_id: '{entry_id}', {day_param}" - f"period_filter: 'best_price', " - f"output_format: 'array_of_arrays', insert_nulls: 'segments', subunit_currency: true }} }}); " - f"const originalData = response.response.data; " - f"return originalData.map((point, i) => {{ " - f"const result = [point[0], point[1] === null ? null : 1]; " - f"result.originalPrice = point[1]; " - f"return result; " - f"}});" - ) - - # Use first entity from entity_map (reuse existing entity to avoid extra header entries) - best_price_entity = next(iter(entity_map.values())) - - series.append( - { - "entity": best_price_entity, - "name": best_price_name, - "type": "area", - "color": "rgba(46, 204, 113, 0.05)", # Ultra-subtle green overlay (barely visible) - "yaxis_id": "highlight", # Use separate Y-axis (0-1) for full-height overlay - "show": {"legend_value": False, "in_header": False, "in_legend": False}, - "data_generator": best_price_generator, - "stroke_width": 0, - } - ) - # Get translated title based on level_type title_key = "title_rating_level" if level_type == "rating_level" else "title_level" title = get_translation(["apexcharts", title_key], user_language) or ( @@ -506,15 +516,12 @@ async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]: # noqa: "shade": "light", "type": "vertical", "shadeIntensity": 0.2, - "opacityFrom": 0.7, + "opacityFrom": [0.5, 0.7, 0.7, 0.7, 0.7, 0.7], "opacityTo": 0.25, + "stops": [50, 100], }, }, "dataLabels": {"enabled": False}, - "tooltip": { - "x": {"format": "HH:mm"}, - "y": {"title": {"formatter": f"function() {{ return '{price_unit}'; }}"}}, - }, "legend": { "show": False, "position": "bottom", @@ -522,22 +529,35 @@ async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]: # noqa: }, "grid": { "show": True, - "borderColor": "#f5f5f5", + "borderColor": "rgba(144, 164, 174, 0.35)", "strokeDashArray": 0, "xaxis": {"lines": {"show": False}}, "yaxis": {"lines": {"show": True}}, }, "markers": { "size": 0, # No markers on data points - "hover": {"size": 2}, # Show marker only on hover - "strokeWidth": 1, + "hover": {"size": 3}, # Show marker only on hover + "colors": "#ff0000", + "fillOpacity": 0.5, + "strokeWidth": 5, + "strokeColors": "#ff0000", + "strokeOpacity": 0.15, + "showNullDataPoints": False, + }, + "tooltip": { + "enabled": True, + "enabledOnSeries": [1, 2, 3, 4, 5], # Enable for all price level series + "marker": { + "show": False, + }, + "x": { + "show": False, + }, }, }, "yaxis": [ { "id": "price", - "decimals": 2, - "min": 0, "apex_config": {"title": {"text": price_unit}}, }, { @@ -553,6 +573,9 @@ async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]: # noqa: if day == "rolling_window_autozoom" else {"show": True, "color": "#8e24aa", "label": "🕒 LIVE"} ), + "all_series_config": { + "float_precision": 2, + }, "series": series, } @@ -632,7 +655,7 @@ async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]: # noqa: trigger_entities.append(current_price_sensor) # Get metadata from chart_metadata sensor (preferred) or static fallback - # The chart_metadata sensor provides yaxis_min, yaxis_max, and gradient_stop + # The chart_metadata sensor provides yaxis_min and yaxis_max # as attributes, avoiding the need for async service calls in templates chart_metadata_sensor = next( ( @@ -662,18 +685,14 @@ async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]: # noqa: # Sensor not found - will show notification metadata_warning = True - # Fixed gradient stop at 50% (visual appeal, no semantic meaning) - gradient_stops = [50, 100] - # Set fallback values if sensor not used if not use_sensor_metadata: # Build yaxis config (only include min/max if not None) yaxis_price_config = { "id": "price", - "decimals": 2, "apex_config": { "title": {"text": price_unit}, - "decimalsInFloat": 0, + "decimalsInFloat": 0 if use_subunit else 1, "forceNiceScale": True, }, } @@ -687,13 +706,12 @@ async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]: # noqa: # Build yaxis config with template variables yaxis_price_config = { "id": "price", - "decimals": 2, "min": "${v_yaxis_min}", "max": "${v_yaxis_max}", "apex_config": { "title": {"text": price_unit}, - "decimalsInFloat": 0, - "forceNiceScale": False, + "decimalsInFloat": 0 if use_subunit else 1, + "forceNiceScale": True, }, } @@ -735,10 +753,9 @@ async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]: # noqa: "shade": "light", "type": "vertical", "shadeIntensity": 0.2, - "opacityFrom": 0.7, + "opacityFrom": [0.5, 0.7, 0.7, 0.7, 0.7, 0.7], "opacityTo": 0.25, - "gradientToColors": ["#transparent"], - "stops": gradient_stops, + "stops": [50, 100], }, }, }, @@ -795,7 +812,7 @@ async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]: # noqa: template_value = f"states['{tomorrow_data_sensor}'].state === 'on' ? '+1d' : '+0d'" # Get metadata from chart_metadata sensor (preferred) or static fallback - # The chart_metadata sensor provides yaxis_min, yaxis_max, and gradient_stop + # The chart_metadata sensor provides yaxis_min and yaxis_max # as attributes, avoiding the need for async service calls in templates chart_metadata_sensor = next( ( @@ -825,18 +842,14 @@ async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]: # noqa: # Sensor not found - will show notification metadata_warning = True - # Fixed gradient stop at 50% (visual appeal, no semantic meaning) - gradient_stops = [50, 100] - # Set fallback values if sensor not used if not use_sensor_metadata: # Build yaxis config (only include min/max if not None) yaxis_price_config = { "id": "price", - "decimals": 2, "apex_config": { "title": {"text": price_unit}, - "decimalsInFloat": 0, + "decimalsInFloat": 0 if use_subunit else 1, "forceNiceScale": True, }, } @@ -850,13 +863,12 @@ async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]: # noqa: # Build yaxis config with template variables yaxis_price_config = { "id": "price", - "decimals": 2, "min": "${v_yaxis_min}", "max": "${v_yaxis_max}", "apex_config": { "title": {"text": price_unit}, - "decimalsInFloat": 0, - "forceNiceScale": False, + "decimalsInFloat": 0 if use_subunit else 1, + "forceNiceScale": True, }, } @@ -900,10 +912,9 @@ async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]: # noqa: "shade": "light", "type": "vertical", "shadeIntensity": 0.2, - "opacityFrom": 0.7, + "opacityFrom": [0.5, 0.7, 0.7, 0.7, 0.7, 0.7], "opacityTo": 0.25, - "gradientToColors": ["#transparent"], - "stops": gradient_stops, + "stops": [50, 100], }, }, }, diff --git a/custom_components/tibber_prices/services/get_chartdata.py b/custom_components/tibber_prices/services/get_chartdata.py index e5b0df3..27ad60f 100644 --- a/custom_components/tibber_prices/services/get_chartdata.py +++ b/custom_components/tibber_prices/services/get_chartdata.py @@ -45,6 +45,7 @@ from custom_components.tibber_prices.const import ( format_price_unit_base, format_price_unit_subunit, get_currency_info, + get_currency_name, ) from custom_components.tibber_prices.coordinator.helpers import ( get_intervals_for_day_offsets, @@ -97,6 +98,7 @@ def _calculate_metadata( # noqa: PLR0912, PLR0913, PLR0915 currency_obj = { "code": currency, "symbol": base_symbol, + "name": get_currency_name(currency), # Full currency name (e.g., "Euro") "unit": format_price_unit_base(currency), } @@ -154,13 +156,15 @@ def _calculate_metadata( # noqa: PLR0912, PLR0913, PLR0915 # Position precision: 2 decimals for subunit currency, 4 for base currency position_decimals = 2 if subunit_currency else 4 + # Price precision: 2 decimals for subunit currency, 4 for base currency + price_decimals = 2 if subunit_currency else 4 return { - "min": round(min_val, 2), - "max": round(max_val, 2), - "avg": round(avg_val, 2), + "min": round(min_val, price_decimals), + "max": round(max_val, price_decimals), + "avg": round(avg_val, price_decimals), "avg_position": round(avg_position, position_decimals), - "median": round(median_val, 2), + "median": round(median_val, price_decimals), "median_position": round(median_position, position_decimals), }