fix: support main and subunit currency

This commit is contained in:
Julian Pawlowski 2025-12-11 23:07:06 +00:00
parent be34e87fa6
commit 1c19cebff5
12 changed files with 348 additions and 178 deletions

View file

@ -4,9 +4,15 @@ from __future__ import annotations
from typing import TYPE_CHECKING 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.coordinator.helpers import get_intervals_for_day_offsets
from custom_components.tibber_prices.entity_utils import add_icon_color_attribute 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) # Import TypedDict definitions for documentation (not used in signatures)
if TYPE_CHECKING: if TYPE_CHECKING:
@ -66,6 +72,7 @@ def get_price_intervals_attributes(
*, *,
time: TibberPricesTimeService, time: TibberPricesTimeService,
reverse_sort: bool, reverse_sort: bool,
config_entry: TibberPricesConfigEntry,
) -> dict | None: ) -> dict | None:
""" """
Build attributes for period-based sensors (best/peak price). 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) 1. Get period summaries from coordinator (already filtered and fully calculated)
2. Add the current timestamp 2. Add the current timestamp
3. Find current or next period based on time 3. Find current or next period based on time
4. Convert prices to display units based on user configuration
Args: Args:
coordinator_data: Coordinator data dict coordinator_data: Coordinator data dict
time: TibberPricesTimeService instance (required) time: TibberPricesTimeService instance (required)
reverse_sort: True for peak_price (highest first), False for best_price (lowest first) reverse_sort: True for peak_price (highest first), False for best_price (lowest first)
config_entry: Config entry for display unit configuration
Returns: Returns:
Attributes dict with current/next period and all periods list Attributes dict with current/next period and all periods list
@ -101,11 +110,20 @@ def get_price_intervals_attributes(
if not period_summaries: if not period_summaries:
return build_no_periods_result(time=time) 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 # Find current or next period based on current time
current_period = None current_period = None
# First pass: find currently active period # First pass: find currently active period
for period in period_summaries: for period in filtered_periods:
start = period.get("start") start = period.get("start")
end = period.get("end") end = period.get("end")
if start and end and time.is_current_interval(start, 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 # Second pass: find next future period if none is active
if not current_period: if not current_period:
for period in period_summaries: for period in filtered_periods:
start = period.get("start") start = period.get("start")
if start and time.is_in_future(start): if start and time.is_in_future(start):
current_period = period current_period = period
break break
# Build final attributes # Build final attributes (use filtered_periods for display)
return build_final_attributes_simple(current_period, period_summaries, time=time) return build_final_attributes_simple(current_period, filtered_periods, time=time, config_entry=config_entry)
def build_no_periods_result(*, time: TibberPricesTimeService) -> dict: 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_%"] attributes["rating_difference_%"] = current_period["rating_difference_%"]
def add_price_attributes(attributes: dict, current_period: dict) -> None: def add_price_attributes(attributes: dict, current_period: dict, factor: int) -> None:
"""Add price statistics attributes (priority 3).""" """
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: 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: 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: 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: 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: 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: 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: def add_comparison_attributes(attributes: dict, current_period: dict, factor: int) -> None:
"""Add price comparison attributes (priority 4).""" """
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: 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: 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_%"] = 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: 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_%"] 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( def build_final_attributes_simple(
current_period: dict | None, current_period: dict | None,
period_summaries: list[dict], period_summaries: list[dict],
*, *,
time: TibberPricesTimeService, time: TibberPricesTimeService,
config_entry: TibberPricesConfigEntry,
) -> dict: ) -> dict:
""" """
Build the final attributes dictionary from coordinator's period summaries. 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) 1. Adds the current timestamp (only thing calculated every 15min)
2. Uses the current/next period from summaries 2. Uses the current/next period from summaries
3. Adds nested period summaries 3. Adds nested period summaries
4. Converts prices to display units based on user configuration
Attributes are ordered following the documented priority: Attributes are ordered following the documented priority:
1. Time information (timestamp, start, end, duration) 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) current_period: The current or next period (already complete from coordinator)
period_summaries: All period summaries from coordinator period_summaries: All period summaries from coordinator
time: TibberPricesTimeService instance (required) time: TibberPricesTimeService instance (required)
config_entry: Config entry for display unit configuration
Returns: Returns:
Complete attributes dict with all fields Complete attributes dict with all fields
@ -256,6 +346,9 @@ def build_final_attributes_simple(
current_minute = (now.minute // 15) * 15 current_minute = (now.minute // 15) * 15
timestamp = now.replace(minute=current_minute, second=0, microsecond=0) 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: if current_period:
# Build attributes in priority order using helper methods # Build attributes in priority order using helper methods
attributes = {} attributes = {}
@ -266,11 +359,11 @@ def build_final_attributes_simple(
# 2. Core decision attributes # 2. Core decision attributes
add_decision_attributes(attributes, current_period) add_decision_attributes(attributes, current_period)
# 3. Price statistics # 3. Price statistics (converted to display units)
add_price_attributes(attributes, current_period) add_price_attributes(attributes, current_period, factor)
# 4. Price differences # 4. Price differences (converted to display units)
add_comparison_attributes(attributes, current_period) add_comparison_attributes(attributes, current_period, factor)
# 5. Detail information # 5. Detail information
add_detail_attributes(attributes, current_period) add_detail_attributes(attributes, current_period)
@ -278,15 +371,15 @@ def build_final_attributes_simple(
# 6. Relaxation information (only if period was relaxed) # 6. Relaxation information (only if period was relaxed)
add_relaxation_attributes(attributes, current_period) add_relaxation_attributes(attributes, current_period)
# 7. Meta information (periods array) # 7. Meta information (periods array - prices converted to display units)
attributes["periods"] = period_summaries attributes["periods"] = _convert_periods_to_display_units(period_summaries, factor)
return attributes 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 { return {
"timestamp": timestamp, "timestamp": timestamp,
"periods": period_summaries, "periods": _convert_periods_to_display_units(period_summaries, factor),
} }

View file

@ -138,7 +138,12 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity, RestoreEn
"""Return True if the current time is within a best price period.""" """Return True if the current time is within a best price period."""
if not self.coordinator.data: if not self.coordinator.data:
return None 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: if not attrs:
return False # Should not happen, but safety fallback return False # Should not happen, but safety fallback
start = attrs.get("start") 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.""" """Return True if the current time is within a peak price period."""
if not self.coordinator.data: if not self.coordinator.data:
return None 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: if not attrs:
return False # Should not happen, but safety fallback return False # Should not happen, but safety fallback
start = attrs.get("start") start = attrs.get("start")
@ -270,9 +280,19 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity, RestoreEn
key = self.entity_description.key key = self.entity_description.key
if key == "peak_price_period": 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": 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": if key == "tomorrow_data_available":
return self._get_tomorrow_data_available_attributes() return self._get_tomorrow_data_available_attributes()

View file

@ -179,6 +179,16 @@ CURRENCY_INFO = {
"GBP": ("£", "p", "Pence"), "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]: 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}" 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 # Currency Display Mode Configuration
# ============================================================================ # ============================================================================

View file

@ -35,7 +35,6 @@ def calculate_periods(
*, *,
config: TibberPricesPeriodConfig, config: TibberPricesPeriodConfig,
time: TibberPricesTimeService, time: TibberPricesTimeService,
config_entry: Any, # ConfigEntry type
) -> dict[str, Any]: ) -> dict[str, Any]:
""" """
Calculate price periods (best or peak) from price data. 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, config: Period configuration containing reverse_sort, flex, min_distance_from_avg,
min_period_length, threshold_low, and threshold_high. min_period_length, threshold_low, and threshold_high.
time: TibberPricesTimeService instance (required). time: TibberPricesTimeService instance (required).
config_entry: Config entry to get display unit configuration.
Returns: Returns:
Dict with: Dict with:
@ -185,12 +183,14 @@ def calculate_periods(
# Step 5: Add interval ends # Step 5: Add interval ends
add_interval_ends(raw_periods, time=time) 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) raw_periods = filter_periods_by_end_date(raw_periods, time=time)
# Step 8: Extract lightweight period summaries (no full price data) # Step 8: Extract lightweight period summaries (no full price data)
# Note: Filtering for current/future is done here based on end date, # Note: Periods are filtered by end date to keep yesterday/today/tomorrow.
# not start date. This preserves periods that started yesterday but end today. # This preserves periods that started day-before-yesterday but end yesterday.
thresholds = TibberPricesThresholdConfig( thresholds = TibberPricesThresholdConfig(
threshold_low=threshold_low, threshold_low=threshold_low,
threshold_high=threshold_high, threshold_high=threshold_high,
@ -205,7 +205,6 @@ def calculate_periods(
price_context, price_context,
thresholds, thresholds,
time=time, time=time,
config_entry=config_entry,
) )
return { return {

View file

@ -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]]: 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: Keep periods that:
- End in the future (> now) - End yesterday or later (>= start of yesterday)
- End today but after the start of the day (not exactly at midnight)
This removes: This removes:
- Periods that ended yesterday - Periods that ended before yesterday (day-before-yesterday or earlier)
- Periods that ended exactly at midnight today (they're completely in the past)
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() now = time.now()
today = now.date() # Calculate start of yesterday (midnight yesterday)
midnight_today = time.start_of_local_day(now) yesterday_start = time.start_of_local_day(now) - time.get_interval_duration() * 96 # 96 intervals = 24 hours
filtered = [] filtered = []
for period in periods: for period in periods:
@ -274,13 +276,8 @@ def filter_periods_by_end_date(periods: list[list[dict]], *, time: TibberPricesT
if not period_end: if not period_end:
continue continue
# Keep if period ends in the future # Keep if period ends yesterday or later
if time.is_in_future(period_end): if period_end >= yesterday_start:
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:
filtered.append(period) filtered.append(period)
return filtered return filtered

View file

@ -8,7 +8,6 @@ if TYPE_CHECKING:
from datetime import datetime from datetime import datetime
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
from homeassistant.config_entries import ConfigEntry
from .types import ( from .types import (
TibberPricesPeriodData, TibberPricesPeriodData,
@ -16,7 +15,6 @@ if TYPE_CHECKING:
TibberPricesThresholdConfig, 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.average import calculate_median
from custom_components.tibber_prices.utils.price import ( from custom_components.tibber_prices.utils.price import (
aggregate_period_levels, aggregate_period_levels,
@ -29,7 +27,6 @@ def calculate_period_price_diff(
price_mean: float, price_mean: float,
start_time: datetime, start_time: datetime,
price_context: dict[str, Any], price_context: dict[str, Any],
config_entry: ConfigEntry,
) -> tuple[float | None, float | None]: ) -> tuple[float | None, float | None]:
""" """
Calculate period price difference from daily reference (min or max). 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. Uses reference price from start day of the period for consistency.
Args: 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. start_time: Start time of the period.
price_context: Dictionary with ref_prices per day. price_context: Dictionary with ref_prices per day.
config_entry: Config entry to get display unit configuration.
Returns: Returns:
Tuple of (period_price_diff, period_price_diff_pct) or (None, None) if no reference available. 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: if ref_price is None:
return None, None return None, None
# Convert reference price to display units # Both prices are in base currency, no conversion needed
factor = get_display_unit_factor(config_entry) ref_price_display = round(ref_price, 4)
ref_price_display = round(ref_price * factor, 2) period_price_diff = round(price_mean - ref_price_display, 4)
period_price_diff = round(price_mean - ref_price_display, 2)
period_price_diff_pct = None period_price_diff_pct = None
if ref_price_display != 0: if ref_price_display != 0:
# CRITICAL: Use abs() for negative prices (same logic as calculate_difference_percentage) # 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( def calculate_period_price_statistics(
period_price_data: list[dict], period_price_data: list[dict],
config_entry: ConfigEntry,
) -> dict[str, float]: ) -> dict[str, float]:
""" """
Calculate price statistics for a period. Calculate price statistics for a period.
Args: Args:
period_price_data: List of price data dictionaries with "total" field. period_price_data: List of price data dictionaries with "total" field.
config_entry: Config entry to get display unit configuration.
Returns: 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). Note: price_spread is calculated based on price_mean (max - min range as percentage of mean).
""" """
# Convert prices to display units based on configuration # Keep prices in base currency (Euro/NOK/SEK) for internal storage
factor = get_display_unit_factor(config_entry) # Conversion to display units (ct/øre) happens in services/formatting layer
prices_display = [round(float(p["total"]) * factor, 2) for p in period_price_data] 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: if not prices_display:
return { return {
@ -123,12 +117,12 @@ def calculate_period_price_statistics(
"price_spread": 0.0, "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) median_value = calculate_median(prices_display)
price_median = round(median_value, 2) if median_value is not None else 0.0 price_median = round(median_value, 4) if median_value is not None else 0.0
price_min = round(min(prices_display), 2) price_min = round(min(prices_display), 4)
price_max = round(max(prices_display), 2) price_max = round(max(prices_display), 4)
price_spread = round(price_max - price_min, 2) price_spread = round(price_max - price_min, 4)
return { return {
"price_mean": price_mean, "price_mean": price_mean,
@ -224,14 +218,13 @@ def build_period_summary_dict(
return summary return summary
def extract_period_summaries( # noqa: PLR0913 def extract_period_summaries(
periods: list[list[dict]], periods: list[list[dict]],
all_prices: list[dict], all_prices: list[dict],
price_context: dict[str, Any], price_context: dict[str, Any],
thresholds: TibberPricesThresholdConfig, thresholds: TibberPricesThresholdConfig,
*, *,
time: TibberPricesTimeService, time: TibberPricesTimeService,
config_entry: ConfigEntry,
) -> list[dict]: ) -> list[dict]:
""" """
Extract complete period summaries with all aggregated attributes. 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. price_context: Dictionary with ref_prices and avg_prices per day.
thresholds: Threshold configuration for calculations. thresholds: Threshold configuration for calculations.
time: TibberPricesTimeService instance (required). time: TibberPricesTimeService instance (required).
config_entry: Config entry to get display unit configuration.
""" """
from .types import ( # noqa: PLC0415 - Avoid circular import from .types import ( # noqa: PLC0415 - Avoid circular import
@ -311,12 +303,12 @@ def extract_period_summaries( # noqa: PLR0913
thresholds.threshold_high, thresholds.threshold_high,
) )
# Calculate price statistics (in display units based on configuration) # Calculate price statistics (in base currency, conversion happens in presentation layer)
price_stats = calculate_period_price_statistics(period_price_data, config_entry) price_stats = calculate_period_price_statistics(period_price_data)
# Calculate period price difference from daily reference # Calculate period price difference from daily reference
period_price_diff, period_price_diff_pct = calculate_period_price_diff( 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) # Extract prices for volatility calculation (coefficient of variation)

View file

@ -276,8 +276,9 @@ def calculate_periods_with_relaxation( # noqa: PLR0913, PLR0915 - Per-day relax
) )
# === BASELINE CALCULATION (process ALL prices together, including yesterday) === # === BASELINE CALCULATION (process ALL prices together, including yesterday) ===
# Periods that ended yesterday will be filtered out later by filter_periods_by_end_date() # Periods that ended before 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) # This keeps yesterday/today/tomorrow periods in the cache
baseline_result = calculate_periods(all_prices, config=config, time=time)
all_periods = baseline_result["periods"] all_periods = baseline_result["periods"]
# Count periods per day for min_periods check # 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) # 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"] new_periods = result["periods"]
_LOGGER_DETAILS.debug( _LOGGER_DETAILS.debug(

View file

@ -4,7 +4,12 @@ from __future__ import annotations
from typing import TYPE_CHECKING 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: if TYPE_CHECKING:
from datetime import datetime 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 # Force metadata to "only" - this sensor ONLY provides metadata
service_params["metadata"] = "only" 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: 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 # Call get_chartdata service using official HA service system
try: try:

View file

@ -18,6 +18,7 @@ from custom_components.tibber_prices.const import (
DEFAULT_PRICE_RATING_THRESHOLD_LOW, DEFAULT_PRICE_RATING_THRESHOLD_LOW,
DISPLAY_MODE_BASE, DISPLAY_MODE_BASE,
DOMAIN, DOMAIN,
format_price_unit_base,
get_display_unit_factor, get_display_unit_factor,
get_display_unit_string, get_display_unit_string,
) )
@ -851,6 +852,11 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor):
if self.coordinator.data: if self.coordinator.data:
currency = self.coordinator.data.get("currency") 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) # Get unit based on user configuration (major or minor)
return get_display_unit_string(self.coordinator.config_entry, currency) 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.""" """Check if the current time is within a best price period."""
if not self.coordinator.data: if not self.coordinator.data:
return False 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: if not attrs:
return False return False
start = attrs.get("start") start = attrs.get("start")
@ -876,7 +887,12 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor):
"""Check if the current time is within a peak price period.""" """Check if the current time is within a peak price period."""
if not self.coordinator.data: if not self.coordinator.data:
return False 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: if not attrs:
return False return False
start = attrs.get("start") start = attrs.get("start")

View file

@ -198,7 +198,7 @@ def aggregate_hourly_exact( # noqa: PLR0913, PLR0912, PLR0915
return hourly_data return hourly_data
def get_period_data( # noqa: PLR0913, PLR0912, PLR0915, C901 def get_period_data( # noqa: PLR0913, PLR0912, PLR0915
*, *,
coordinator: Any, coordinator: Any,
period_filter: str, 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 # Median is more representative than mean for periods with gap tolerance
# (single "normal" intervals between cheap/expensive ones don't skew the display) # (single "normal" intervals between cheap/expensive ones don't skew the display)
price_median = period.get("price_median", 0.0) 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: if subunit_currency:
price_median = price_median * 100 price_median = price_median * 100
if round_decimals is not None: # Apply rounding: use round_decimals if provided, otherwise default precision
price_median = round(price_median, round_decimals) 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 data_point[price_field] = price_median
# Level (only if requested and present) # 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) # 3. End time with NULL (cleanly terminate segment for ApexCharts)
# Use price_median for consistency with sensor states (more representative for periods) # Use price_median for consistency with sensor states (more representative for periods)
price_median = period.get("price_median", 0.0) 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: if subunit_currency:
price_median = price_median * 100 price_median = price_median * 100
if round_decimals is not None: # Apply rounding: use round_decimals if provided, otherwise default precision
price_median = round(price_median, round_decimals) 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"] start = period["start"]
end = period.get("end") end = period.get("end")
start_serialized = start.isoformat() if hasattr(start, "isoformat") else start start_serialized = start.isoformat() if hasattr(start, "isoformat") else start

View file

@ -23,6 +23,8 @@ from typing import TYPE_CHECKING, Any, Final
import voluptuous as vol import voluptuous as vol
from custom_components.tibber_prices.const import ( from custom_components.tibber_prices.const import (
CONF_CURRENCY_DISPLAY_MODE,
DISPLAY_MODE_SUBUNIT,
DOMAIN, DOMAIN,
PRICE_LEVEL_CHEAP, PRICE_LEVEL_CHEAP,
PRICE_LEVEL_EXPENSIVE, PRICE_LEVEL_EXPENSIVE,
@ -32,7 +34,7 @@ from custom_components.tibber_prices.const import (
PRICE_RATING_HIGH, PRICE_RATING_HIGH,
PRICE_RATING_LOW, PRICE_RATING_LOW,
PRICE_RATING_NORMAL, PRICE_RATING_NORMAL,
format_price_unit_subunit, get_display_unit_string,
get_translation, get_translation,
) )
from homeassistant.exceptions import ServiceValidationError 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 # Get user's language from hass config
user_language = hass.config.language or "en" user_language = hass.config.language or "en"
# Get coordinator to access price data (for currency) # Get coordinator to access price data (for currency) and config entry for display settings
_, coordinator, _ = get_entry_and_data(hass, entry_id) config_entry, coordinator, _ = get_entry_and_data(hass, entry_id)
# Get currency from coordinator data # Get currency from coordinator data
currency = coordinator.data.get("currency", "EUR") 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 # Get entity registry for mapping
entity_registry = async_get_entity_registry(hass) 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"), (PRICE_LEVEL_VERY_EXPENSIVE, "#e74c3c"),
] ]
series = [] 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) # Only create series for levels that have a matching entity (filter out missing levels)
for level_key, color in series_levels: for level_key, color in series_levels:
# Skip levels that don't have a corresponding sensor # 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 rolling window modes, we'll capture metadata for dynamic config
# For static day modes, just return data array # 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): if day in ("rolling_window", "rolling_window_autozoom", None):
data_generator = ( data_generator = (
f"const response = await hass.callWS({{ " 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"service: 'get_chartdata', "
f"return_response: true, " f"return_response: true, "
f"service_data: {{ entry_id: '{entry_id}', {day_param}{filter_param}, " 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"connect_segments: true }} }}); "
f"return response.response.data;" 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"service: 'get_chartdata', "
f"return_response: true, " f"return_response: true, "
f"service_data: {{ entry_id: '{entry_id}', {day_param}{filter_param}, " 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"connect_segments: true }} }}); "
f"return response.response.data;" 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 # Note: Extrema markers don't work with data_generator approach
# ApexCharts card requires direct entity data for extremas feature, not dynamically generated data # 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 # Get translated title based on level_type
title_key = "title_rating_level" if level_type == "rating_level" else "title_level" title_key = "title_rating_level" if level_type == "rating_level" else "title_level"
title = get_translation(["apexcharts", title_key], user_language) or ( 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", "shade": "light",
"type": "vertical", "type": "vertical",
"shadeIntensity": 0.2, "shadeIntensity": 0.2,
"opacityFrom": 0.7, "opacityFrom": [0.5, 0.7, 0.7, 0.7, 0.7, 0.7],
"opacityTo": 0.25, "opacityTo": 0.25,
"stops": [50, 100],
}, },
}, },
"dataLabels": {"enabled": False}, "dataLabels": {"enabled": False},
"tooltip": {
"x": {"format": "HH:mm"},
"y": {"title": {"formatter": f"function() {{ return '{price_unit}'; }}"}},
},
"legend": { "legend": {
"show": False, "show": False,
"position": "bottom", "position": "bottom",
@ -522,22 +529,35 @@ async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]: # noqa:
}, },
"grid": { "grid": {
"show": True, "show": True,
"borderColor": "#f5f5f5", "borderColor": "rgba(144, 164, 174, 0.35)",
"strokeDashArray": 0, "strokeDashArray": 0,
"xaxis": {"lines": {"show": False}}, "xaxis": {"lines": {"show": False}},
"yaxis": {"lines": {"show": True}}, "yaxis": {"lines": {"show": True}},
}, },
"markers": { "markers": {
"size": 0, # No markers on data points "size": 0, # No markers on data points
"hover": {"size": 2}, # Show marker only on hover "hover": {"size": 3}, # Show marker only on hover
"strokeWidth": 1, "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": [ "yaxis": [
{ {
"id": "price", "id": "price",
"decimals": 2,
"min": 0,
"apex_config": {"title": {"text": price_unit}}, "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" if day == "rolling_window_autozoom"
else {"show": True, "color": "#8e24aa", "label": "🕒 LIVE"} else {"show": True, "color": "#8e24aa", "label": "🕒 LIVE"}
), ),
"all_series_config": {
"float_precision": 2,
},
"series": series, "series": series,
} }
@ -632,7 +655,7 @@ async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]: # noqa:
trigger_entities.append(current_price_sensor) trigger_entities.append(current_price_sensor)
# Get metadata from chart_metadata sensor (preferred) or static fallback # 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 # as attributes, avoiding the need for async service calls in templates
chart_metadata_sensor = next( 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 # Sensor not found - will show notification
metadata_warning = True metadata_warning = True
# Fixed gradient stop at 50% (visual appeal, no semantic meaning)
gradient_stops = [50, 100]
# Set fallback values if sensor not used # Set fallback values if sensor not used
if not use_sensor_metadata: if not use_sensor_metadata:
# Build yaxis config (only include min/max if not None) # Build yaxis config (only include min/max if not None)
yaxis_price_config = { yaxis_price_config = {
"id": "price", "id": "price",
"decimals": 2,
"apex_config": { "apex_config": {
"title": {"text": price_unit}, "title": {"text": price_unit},
"decimalsInFloat": 0, "decimalsInFloat": 0 if use_subunit else 1,
"forceNiceScale": True, "forceNiceScale": True,
}, },
} }
@ -687,13 +706,12 @@ async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]: # noqa:
# Build yaxis config with template variables # Build yaxis config with template variables
yaxis_price_config = { yaxis_price_config = {
"id": "price", "id": "price",
"decimals": 2,
"min": "${v_yaxis_min}", "min": "${v_yaxis_min}",
"max": "${v_yaxis_max}", "max": "${v_yaxis_max}",
"apex_config": { "apex_config": {
"title": {"text": price_unit}, "title": {"text": price_unit},
"decimalsInFloat": 0, "decimalsInFloat": 0 if use_subunit else 1,
"forceNiceScale": False, "forceNiceScale": True,
}, },
} }
@ -735,10 +753,9 @@ async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]: # noqa:
"shade": "light", "shade": "light",
"type": "vertical", "type": "vertical",
"shadeIntensity": 0.2, "shadeIntensity": 0.2,
"opacityFrom": 0.7, "opacityFrom": [0.5, 0.7, 0.7, 0.7, 0.7, 0.7],
"opacityTo": 0.25, "opacityTo": 0.25,
"gradientToColors": ["#transparent"], "stops": [50, 100],
"stops": gradient_stops,
}, },
}, },
}, },
@ -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'" template_value = f"states['{tomorrow_data_sensor}'].state === 'on' ? '+1d' : '+0d'"
# Get metadata from chart_metadata sensor (preferred) or static fallback # 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 # as attributes, avoiding the need for async service calls in templates
chart_metadata_sensor = next( 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 # Sensor not found - will show notification
metadata_warning = True metadata_warning = True
# Fixed gradient stop at 50% (visual appeal, no semantic meaning)
gradient_stops = [50, 100]
# Set fallback values if sensor not used # Set fallback values if sensor not used
if not use_sensor_metadata: if not use_sensor_metadata:
# Build yaxis config (only include min/max if not None) # Build yaxis config (only include min/max if not None)
yaxis_price_config = { yaxis_price_config = {
"id": "price", "id": "price",
"decimals": 2,
"apex_config": { "apex_config": {
"title": {"text": price_unit}, "title": {"text": price_unit},
"decimalsInFloat": 0, "decimalsInFloat": 0 if use_subunit else 1,
"forceNiceScale": True, "forceNiceScale": True,
}, },
} }
@ -850,13 +863,12 @@ async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]: # noqa:
# Build yaxis config with template variables # Build yaxis config with template variables
yaxis_price_config = { yaxis_price_config = {
"id": "price", "id": "price",
"decimals": 2,
"min": "${v_yaxis_min}", "min": "${v_yaxis_min}",
"max": "${v_yaxis_max}", "max": "${v_yaxis_max}",
"apex_config": { "apex_config": {
"title": {"text": price_unit}, "title": {"text": price_unit},
"decimalsInFloat": 0, "decimalsInFloat": 0 if use_subunit else 1,
"forceNiceScale": False, "forceNiceScale": True,
}, },
} }
@ -900,10 +912,9 @@ async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]: # noqa:
"shade": "light", "shade": "light",
"type": "vertical", "type": "vertical",
"shadeIntensity": 0.2, "shadeIntensity": 0.2,
"opacityFrom": 0.7, "opacityFrom": [0.5, 0.7, 0.7, 0.7, 0.7, 0.7],
"opacityTo": 0.25, "opacityTo": 0.25,
"gradientToColors": ["#transparent"], "stops": [50, 100],
"stops": gradient_stops,
}, },
}, },
}, },

View file

@ -45,6 +45,7 @@ from custom_components.tibber_prices.const import (
format_price_unit_base, format_price_unit_base,
format_price_unit_subunit, format_price_unit_subunit,
get_currency_info, get_currency_info,
get_currency_name,
) )
from custom_components.tibber_prices.coordinator.helpers import ( from custom_components.tibber_prices.coordinator.helpers import (
get_intervals_for_day_offsets, get_intervals_for_day_offsets,
@ -97,6 +98,7 @@ def _calculate_metadata( # noqa: PLR0912, PLR0913, PLR0915
currency_obj = { currency_obj = {
"code": currency, "code": currency,
"symbol": base_symbol, "symbol": base_symbol,
"name": get_currency_name(currency), # Full currency name (e.g., "Euro")
"unit": format_price_unit_base(currency), "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 precision: 2 decimals for subunit currency, 4 for base currency
position_decimals = 2 if subunit_currency else 4 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 { return {
"min": round(min_val, 2), "min": round(min_val, price_decimals),
"max": round(max_val, 2), "max": round(max_val, price_decimals),
"avg": round(avg_val, 2), "avg": round(avg_val, price_decimals),
"avg_position": round(avg_position, position_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), "median_position": round(median_position, position_decimals),
} }