diff --git a/custom_components/tibber_prices/const.py b/custom_components/tibber_prices/const.py index 9e6cbfd..8413180 100644 --- a/custom_components/tibber_prices/const.py +++ b/custom_components/tibber_prices/const.py @@ -9,12 +9,7 @@ from typing import TYPE_CHECKING, Any import aiofiles -from homeassistant.const import ( - CURRENCY_DOLLAR, - CURRENCY_EURO, - UnitOfPower, - UnitOfTime, -) +from homeassistant.const import CURRENCY_DOLLAR, CURRENCY_EURO, UnitOfPower, UnitOfTime if TYPE_CHECKING: from collections.abc import Sequence @@ -29,6 +24,11 @@ LOGGER = logging.getLogger(__package__) DATA_CHART_CONFIG = "chart_config" # Key for chart export config in hass.data DATA_CHART_METADATA_CONFIG = "chart_metadata_config" # Key for chart metadata config in hass.data +# Config entry data flag: set when user switches currency display mode. +# Triggers a fresh (un-dismissed) repair issue on every setup/reload until +# the user explicitly re-saves the currency settings to acknowledge. +DATA_STATISTICS_REVIEW_REQUIRED = "statistics_review_required" + # Configuration keys CONF_EXTENDED_DESCRIPTIONS = "extended_descriptions" CONF_VIRTUAL_TIME_OFFSET_DAYS = ( @@ -462,14 +462,42 @@ def get_display_unit_factor(config_entry: ConfigEntry) -> int: Example: price_base = 0.2534 # Internal: 0.2534 €/kWh factor = get_display_unit_factor(config_entry) - display_value = round(price_base * factor, 2) - # → 25.34 ct/kWh (subunit) or 0.25 €/kWh (base) + precision = get_display_precision(config_entry) + display_value = round(price_base * factor, precision) + # → 25.34 ct/kWh (subunit, 2 decimals) or 0.2534 €/kWh (base, 4 decimals) """ display_mode = config_entry.options.get(CONF_CURRENCY_DISPLAY_MODE, DISPLAY_MODE_SUBUNIT) return 100 if display_mode == DISPLAY_MODE_SUBUNIT else 1 +# Rounding precision constants for display currency +DISPLAY_PRECISION_SUBUNIT = 2 # Decimal places for subunit currency (ct, øre) +DISPLAY_PRECISION_BASE = 4 # Decimal places for base currency (€, kr) + + +def get_display_precision(config_entry: ConfigEntry) -> int: + """ + Get decimal precision for rounding prices in the configured display currency. + + Subunit currencies (ct, øre) use 2 decimal places (e.g., 25.34 ct/kWh). + Base currencies (€, kr) use 4 decimal places (e.g., 0.2534 €/kWh). + + This ensures sufficient precision for all currency modes: + - Subunit: 2 decimals (the sub-cent level is rarely meaningful) + - Base: 4 decimals (preserves full API precision for EUR/NOK/SEK prices) + + Args: + config_entry: ConfigEntry with currency_display_mode option + + Returns: + 2 for subunit currency, 4 for base currency + + """ + display_mode = config_entry.options.get(CONF_CURRENCY_DISPLAY_MODE, DISPLAY_MODE_SUBUNIT) + return DISPLAY_PRECISION_SUBUNIT if display_mode == DISPLAY_MODE_SUBUNIT else DISPLAY_PRECISION_BASE + + def get_display_unit_string(config_entry: ConfigEntry, currency_code: str | None) -> str: """ Get unit string for display based on configuration. diff --git a/custom_components/tibber_prices/entity_utils/helpers.py b/custom_components/tibber_prices/entity_utils/helpers.py index bd3e8ba..957af2b 100644 --- a/custom_components/tibber_prices/entity_utils/helpers.py +++ b/custom_components/tibber_prices/entity_utils/helpers.py @@ -14,7 +14,7 @@ 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.const import get_display_precision, get_display_unit_factor if TYPE_CHECKING: from datetime import datetime @@ -55,7 +55,8 @@ def get_price_value( # New mode: use config_entry if config_entry is not None: factor = get_display_unit_factor(config_entry) - return round(price * factor, 2) + precision = get_display_precision(config_entry) + return round(price * factor, precision) # Fallback: default to subunit currency (backward compatibility) return round(price * 100, 2) diff --git a/custom_components/tibber_prices/entity_utils/icons.py b/custom_components/tibber_prices/entity_utils/icons.py index a04390c..3b875a1 100644 --- a/custom_components/tibber_prices/entity_utils/icons.py +++ b/custom_components/tibber_prices/entity_utils/icons.py @@ -35,6 +35,7 @@ class TibberPricesIconContext: has_future_periods_callback: Callable[[], bool] | None = None period_is_active_callback: Callable[[], bool] | None = None time: TibberPricesTimeService | None = None + trend_change_direction: str | None = None # For next_price_trend_change icon lookup if TYPE_CHECKING: @@ -74,7 +75,7 @@ def get_dynamic_icon( # Try various icon sources in order return ( - get_trend_icon(key, value) + get_trend_icon(key, value, context=ctx) or get_timing_sensor_icon(key, value, period_is_active_callback=ctx.period_is_active_callback) or get_price_sensor_icon(key, ctx.coordinator_data, time=ctx.time) or get_level_sensor_icon(key, value) @@ -84,12 +85,24 @@ def get_dynamic_icon( ) -def get_trend_icon(key: str, value: Any) -> str | None: +# 5-level trend icons: strongly uses double arrows, normal uses single +_TREND_ICONS = { + "strongly_rising": "mdi:chevron-double-up", + "rising": "mdi:trending-up", + "stable": "mdi:trending-neutral", + "falling": "mdi:trending-down", + "strongly_falling": "mdi:chevron-double-down", +} + + +def get_trend_icon(key: str, value: Any, *, context: TibberPricesIconContext | None = None) -> str | None: """Get icon for trend sensors using 5-level trend scale.""" - # Handle next_price_trend_change TIMESTAMP sensor differently - # (icon based on attributes, not value which is a timestamp) + # next_price_trend_change is a TIMESTAMP sensor — icon comes from direction attribute if key == "next_price_trend_change": - return None # Will be handled by sensor's icon property using attributes + direction = context.trend_change_direction if context else None + if isinstance(direction, str): + return _TREND_ICONS.get(direction, "mdi:help-circle-outline") + return "mdi:help-circle-outline" if not key.startswith(("price_trend_", "price_outlook_", "price_trajectory_")) and key != "current_price_trend": return None @@ -97,15 +110,7 @@ def get_trend_icon(key: str, value: Any) -> str | None: if not isinstance(value, str): return None - # 5-level trend icons: strongly uses double arrows, normal uses single - trend_icons = { - "strongly_rising": "mdi:chevron-double-up", # Strong upward movement - "rising": "mdi:trending-up", # Normal upward trend - "stable": "mdi:trending-neutral", # No significant change - "falling": "mdi:trending-down", # Normal downward trend - "strongly_falling": "mdi:chevron-double-down", # Strong downward movement - } - return trend_icons.get(value) + return _TREND_ICONS.get(value, "mdi:help-circle-outline") def get_timing_sensor_icon( diff --git a/custom_components/tibber_prices/sensor/attributes/daily_stat.py b/custom_components/tibber_prices/sensor/attributes/daily_stat.py index 123a73e..66ad181 100644 --- a/custom_components/tibber_prices/sensor/attributes/daily_stat.py +++ b/custom_components/tibber_prices/sensor/attributes/daily_stat.py @@ -4,13 +4,8 @@ from __future__ import annotations from typing import TYPE_CHECKING -from custom_components.tibber_prices.const import ( - PRICE_RATING_MAPPING, - get_display_unit_factor, -) -from custom_components.tibber_prices.coordinator.helpers import ( - get_intervals_for_day_offsets, -) +from custom_components.tibber_prices.const import PRICE_RATING_MAPPING, get_display_precision, get_display_unit_factor +from custom_components.tibber_prices.coordinator.helpers import get_intervals_for_day_offsets from homeassistant.const import PERCENTAGE if TYPE_CHECKING: @@ -30,12 +25,13 @@ def _add_energy_tax_from_interval( ) -> None: """Add energy_price and tax from a single interval dict.""" factor = get_display_unit_factor(config_entry) + precision = get_display_precision(config_entry) energy = interval_data.get("energy") if energy is not None: - attributes["energy_price"] = round(float(energy) * factor, 2) + attributes["energy_price"] = round(float(energy) * factor, precision) tax = interval_data.get("tax") if tax is not None: - attributes["tax"] = round(float(tax) * factor, 2) + attributes["tax"] = round(float(tax) * factor, precision) def _add_energy_tax_averages_from_cache( @@ -49,14 +45,15 @@ def _add_energy_tax_averages_from_cache( "last_energy_tax_averages", (None, None, None, None) ) factor = get_display_unit_factor(config_entry) + precision = get_display_precision(config_entry) if energy_mean is not None: - attributes["energy_price_mean"] = round(float(energy_mean) * factor, 2) + attributes["energy_price_mean"] = round(float(energy_mean) * factor, precision) if energy_median is not None: - attributes["energy_price_median"] = round(float(energy_median) * factor, 2) + attributes["energy_price_median"] = round(float(energy_median) * factor, precision) if tax_mean is not None: - attributes["tax_mean"] = round(float(tax_mean) * factor, 2) + attributes["tax_mean"] = round(float(tax_mean) * factor, precision) if tax_median is not None: - attributes["tax_median"] = round(float(tax_median) * factor, 2) + attributes["tax_median"] = round(float(tax_median) * factor, precision) def _get_day_midnight_timestamp(key: str, *, time: TibberPricesTimeService) -> datetime: diff --git a/custom_components/tibber_prices/sensor/attributes/future.py b/custom_components/tibber_prices/sensor/attributes/future.py index 338e692..68eedf2 100644 --- a/custom_components/tibber_prices/sensor/attributes/future.py +++ b/custom_components/tibber_prices/sensor/attributes/future.py @@ -4,13 +4,11 @@ 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.const import get_display_precision, get_display_unit_factor from custom_components.tibber_prices.coordinator.helpers import get_intervals_for_day_offsets if TYPE_CHECKING: - from custom_components.tibber_prices.coordinator.core import ( - TibberPricesDataUpdateCoordinator, - ) + from custom_components.tibber_prices.coordinator.core import TibberPricesDataUpdateCoordinator from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService from custom_components.tibber_prices.data import TibberPricesConfigEntry @@ -20,7 +18,7 @@ from .helpers import add_alternate_average_attribute MAX_FORECAST_INTERVALS = 8 # Show up to 8 future intervals (2 hours with 15-min intervals) -def add_next_avg_attributes( # noqa: PLR0913 +def add_next_avg_attributes( attributes: dict, key: str, coordinator: TibberPricesDataUpdateCoordinator, @@ -142,7 +140,8 @@ def get_future_prices( # Convert to display currency unit based on configuration price_major = float(price_data["total"]) factor = get_display_unit_factor(config_entry) - price_display = round(price_major * factor, 2) + precision = get_display_precision(config_entry) + price_display = round(price_major * factor, precision) future_prices.append( { diff --git a/custom_components/tibber_prices/sensor/attributes/interval.py b/custom_components/tibber_prices/sensor/attributes/interval.py index 6f289ea..cccc053 100644 --- a/custom_components/tibber_prices/sensor/attributes/interval.py +++ b/custom_components/tibber_prices/sensor/attributes/interval.py @@ -8,15 +8,14 @@ from typing import TYPE_CHECKING, Any from custom_components.tibber_prices.const import ( PRICE_LEVEL_MAPPING, PRICE_RATING_MAPPING, + get_display_precision, get_display_unit_factor, ) from custom_components.tibber_prices.entity_utils import add_icon_color_attribute from custom_components.tibber_prices.utils.price import find_price_data_for_interval if TYPE_CHECKING: - from custom_components.tibber_prices.coordinator.core import ( - TibberPricesDataUpdateCoordinator, - ) + from custom_components.tibber_prices.coordinator.core import TibberPricesDataUpdateCoordinator from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService from custom_components.tibber_prices.data import TibberPricesConfigEntry @@ -112,17 +111,18 @@ def _add_energy_tax_attributes( return factor = get_display_unit_factor(config_entry) + precision = get_display_precision(config_entry) energy = interval_data.get("energy") if energy is not None: - attributes["energy_price"] = round(float(energy) * factor, 2) + attributes["energy_price"] = round(float(energy) * factor, precision) tax = interval_data.get("tax") if tax is not None: - attributes["tax"] = round(float(tax) * factor, 2) + attributes["tax"] = round(float(tax) * factor, precision) -def add_current_interval_price_attributes( # noqa: PLR0913 +def add_current_interval_price_attributes( attributes: dict, key: str, coordinator: TibberPricesDataUpdateCoordinator, @@ -198,7 +198,7 @@ def add_current_interval_price_attributes( # noqa: PLR0913 ) -def add_level_attributes_for_sensor( # noqa: PLR0913 +def add_level_attributes_for_sensor( attributes: dict, key: str, interval_data: dict | None, @@ -252,7 +252,7 @@ def add_price_level_attributes(attributes: dict, level: str) -> None: add_icon_color_attribute(attributes, key="price_level", state_value=level) -def add_rating_attributes_for_sensor( # noqa: PLR0913 +def add_rating_attributes_for_sensor( attributes: dict, key: str, interval_data: dict | None, diff --git a/custom_components/tibber_prices/sensor/calculators/daily_stat.py b/custom_components/tibber_prices/sensor/calculators/daily_stat.py index 91f7e69..a357340 100644 --- a/custom_components/tibber_prices/sensor/calculators/daily_stat.py +++ b/custom_components/tibber_prices/sensor/calculators/daily_stat.py @@ -9,12 +9,10 @@ from custom_components.tibber_prices.const import ( CONF_PRICE_RATING_THRESHOLD_LOW, DEFAULT_PRICE_RATING_THRESHOLD_HIGH, DEFAULT_PRICE_RATING_THRESHOLD_LOW, + get_display_precision, ) from custom_components.tibber_prices.entity_utils import get_price_value -from custom_components.tibber_prices.sensor.helpers import ( - aggregate_level_data, - aggregate_rating_data, -) +from custom_components.tibber_prices.sensor.helpers import aggregate_level_data, aggregate_rating_data from custom_components.tibber_prices.utils.average import calculate_median from .base import TibberPricesBaseCalculator @@ -22,9 +20,7 @@ from .base import TibberPricesBaseCalculator if TYPE_CHECKING: from collections.abc import Callable - from custom_components.tibber_prices.coordinator import ( - TibberPricesDataUpdateCoordinator, - ) + from custom_components.tibber_prices.coordinator import TibberPricesDataUpdateCoordinator class TibberPricesDailyStatCalculator(TibberPricesBaseCalculator): @@ -115,9 +111,10 @@ class TibberPricesDailyStatCalculator(TibberPricesBaseCalculator): # Compute and cache energy/tax averages for attribute builders self._cache_energy_tax_averages(price_intervals) # Convert to display currency units based on config - avg_result = round(get_price_value(value, config_entry=self.coordinator.config_entry), 2) + precision = get_display_precision(self.coordinator.config_entry) + avg_result = round(get_price_value(value, config_entry=self.coordinator.config_entry), precision) median_result = ( - round(get_price_value(median, config_entry=self.coordinator.config_entry), 2) + round(get_price_value(median, config_entry=self.coordinator.config_entry), precision) if median is not None else None ) @@ -132,9 +129,10 @@ class TibberPricesDailyStatCalculator(TibberPricesBaseCalculator): self._last_extreme_interval = pi["interval"] break - # Return in configured display currency units with 2 decimals + # Return in configured display currency units + precision = get_display_precision(self.coordinator.config_entry) result = get_price_value(value, config_entry=self.coordinator.config_entry) - return round(result, 2) + return round(result, precision) def get_daily_aggregated_value( self, diff --git a/custom_components/tibber_prices/sensor/calculators/interval.py b/custom_components/tibber_prices/sensor/calculators/interval.py index 73c0784..593a44f 100644 --- a/custom_components/tibber_prices/sensor/calculators/interval.py +++ b/custom_components/tibber_prices/sensor/calculators/interval.py @@ -4,14 +4,12 @@ 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.const import get_display_precision, get_display_unit_factor from .base import TibberPricesBaseCalculator if TYPE_CHECKING: - from custom_components.tibber_prices.coordinator import ( - TibberPricesDataUpdateCoordinator, - ) + from custom_components.tibber_prices.coordinator import TibberPricesDataUpdateCoordinator class TibberPricesIntervalCalculator(TibberPricesBaseCalculator): @@ -36,7 +34,7 @@ class TibberPricesIntervalCalculator(TibberPricesBaseCalculator): self._last_rating_level: str | None = None self._last_rating_difference: float | None = None - def get_interval_value( # noqa: PLR0911 + def get_interval_value( self, *, interval_offset: int, @@ -74,7 +72,8 @@ class TibberPricesIntervalCalculator(TibberPricesBaseCalculator): if in_euro: return price factor = get_display_unit_factor(self.config_entry) - return round(price * factor, 2) + precision = get_display_precision(self.config_entry) + return round(price * factor, precision) if value_type == "level": level = self.safe_get_from_interval(interval_data, "level") diff --git a/custom_components/tibber_prices/sensor/calculators/trend.py b/custom_components/tibber_prices/sensor/calculators/trend.py index a21609c..9b73ef6 100644 --- a/custom_components/tibber_prices/sensor/calculators/trend.py +++ b/custom_components/tibber_prices/sensor/calculators/trend.py @@ -15,22 +15,18 @@ Caching strategy: from typing import TYPE_CHECKING, Any, ClassVar -from custom_components.tibber_prices.const import get_display_unit_factor +from custom_components.tibber_prices.const import get_display_precision, get_display_unit_factor from custom_components.tibber_prices.coordinator.helpers import get_intervals_for_day_offsets +from custom_components.tibber_prices.entity_utils.colors import get_icon_color from custom_components.tibber_prices.utils.average import calculate_mean, calculate_next_n_hours_mean -from custom_components.tibber_prices.utils.price import ( - calculate_price_trend, - find_price_data_for_interval, -) +from custom_components.tibber_prices.utils.price import calculate_price_trend, find_price_data_for_interval from .base import TibberPricesBaseCalculator if TYPE_CHECKING: from datetime import datetime - from custom_components.tibber_prices.coordinator import ( - TibberPricesDataUpdateCoordinator, - ) + from custom_components.tibber_prices.coordinator import TibberPricesDataUpdateCoordinator # Constants MIN_HOURS_FOR_LATER_HALF = 1 # Minimum hours needed to calculate half-window averages (activates at 2h+) @@ -62,7 +58,7 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator): "strongly_rising": "rising", } - def __init__(self, coordinator: "TibberPricesDataUpdateCoordinator") -> None: + def __init__(self, coordinator: TibberPricesDataUpdateCoordinator) -> None: """Initialize trend calculator with caching state.""" super().__init__(coordinator) # Per-sensor caches (for price_outlook_Xh and price_trajectory_Xh sensors) @@ -168,18 +164,12 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator): volatility_threshold_high=volatility_threshold_high, ) - # Determine icon color based on trend state (5-level scale) - # Strongly rising/falling uses more intense colors - icon_color = { - "strongly_rising": "var(--error-color)", # Red for strongly rising (very expensive) - "rising": "var(--warning-color)", # Orange/Yellow for rising prices - "stable": "var(--state-icon-color)", # Default gray for stable prices - "falling": "var(--success-color)", # Green for falling prices (cheaper) - "strongly_falling": "var(--success-color)", # Green for strongly falling (great deal) - }.get(trend_state, "var(--state-icon-color)") + # Determine icon color via centralized mapping (same as colors.py) + icon_color = get_icon_color(f"price_outlook_{hours}h", trend_state) or "var(--state-icon-color)" # Convert prices to display currency unit based on configuration factor = get_display_unit_factor(self.config_entry) + precision = get_display_precision(self.config_entry) # Store attributes in sensor-specific dictionary AND cache the trend value # Show effective thresholds (after volatility adjustment) so users can understand @@ -188,7 +178,7 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator): "timestamp": next_interval_start, "trend_value": trend_value, f"trend_{hours}h_%": round(diff_pct, 1), - f"next_{hours}h_avg": round(future_mean * factor, 2), + f"next_{hours}h_avg": round(future_mean * factor, precision), "interval_count": lookahead_intervals, "threshold_rising_%": round(threshold_rising * vol_factor, 1), "threshold_rising_strongly_%": round(threshold_strongly_rising * vol_factor, 1), @@ -203,7 +193,7 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator): # Get second half average for longer periods later_half_avg = self._calculate_later_half_average(hours, next_interval_start) if later_half_avg is not None: - self._trend_attributes[f"second_half_{hours}h_avg"] = round(later_half_avg * factor, 2) + self._trend_attributes[f"second_half_{hours}h_avg"] = round(later_half_avg * factor, precision) # Calculate incremental change: how much does the later half differ from current? # CRITICAL: Use abs() for negative prices and allow calculation for all non-zero prices @@ -237,15 +227,17 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator): # price direction BEFORE the current trend (binary: rising/falling), # not the trend classification. next_price_trend_change uses "from_direction" # for the current 5-level trend state. + current_trend_state = trend_info["current_trend_state"] self._current_trend_attributes = { "previous_direction": trend_info["from_direction"], "price_direction_duration_minutes": trend_info["trend_duration_minutes"], "price_direction_since": ( trend_info["trend_start_time"].isoformat() if trend_info["trend_start_time"] else None ), + "icon_color": get_icon_color("current_price_trend", current_trend_state), } - return trend_info["current_trend_state"] + return current_trend_state def get_next_trend_change_value(self) -> datetime | None: """ @@ -308,7 +300,7 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator): Trend state: "rising" | "falling" | "stable", or None if unavailable """ - if hours < 2: # noqa: PLR2004 + if hours < 2: return None if not self.has_data(): @@ -378,6 +370,7 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator): ) factor = get_display_unit_factor(self.config_entry) + precision = get_display_precision(self.config_entry) time_obj = self.coordinator.time total_intervals = time_obj.minutes_to_intervals(hours * 60) first_half_count = total_intervals // 2 @@ -387,8 +380,8 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator): "timestamp": next_interval_start, "trend_value": trend_value, f"trajectory_{hours}h_%": round(diff_pct, 1), - f"first_half_{hours}h_avg": round(first_half_avg * factor, 2), - f"second_half_{hours}h_avg": round(second_half_avg * factor, 2), + f"first_half_{hours}h_avg": round(first_half_avg * factor, precision), + f"second_half_{hours}h_avg": round(second_half_avg * factor, precision), f"first_half_{hours}h_diff_from_current_%": round( ((first_half_avg - current_interval_price) / abs(current_interval_price)) * 100, 1 ) @@ -402,6 +395,7 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator): "first_half_interval_count": first_half_count, "second_half_interval_count": second_half_count, "volatility_factor": vol_factor, + "icon_color": get_icon_color(f"price_trajectory_{hours}h", trajectory_state), } return trajectory_state @@ -910,16 +904,17 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator): change_price = float(change_interval["total"]) minutes_until = time.minutes_until_rounded(change_time) factor = get_display_unit_factor(self.config_entry) + precision = get_display_precision(self.config_entry) vf = first_change["vol_factor"] self._trend_change_attributes = { "direction": first_change["trend"], "from_direction": current_trend_state, "minutes_until_change": minutes_until, - "price_now": round(float(current_interval["total"]) * factor, 2), - "price_at_change": round(change_price * factor, 2), + "price_now": round(float(current_interval["total"]) * factor, precision), + "price_at_change": round(change_price * factor, precision), "price_avg_after_change": ( - round(first_change["mean"] * factor, 2) if first_change["mean"] else None + round(first_change["mean"] * factor, precision) if first_change["mean"] else None ), "trend_diff_%": round(first_change["diff"], 1), "threshold_rising_%": round(thresholds["rising"] * vf, 1), diff --git a/custom_components/tibber_prices/sensor/calculators/volatility.py b/custom_components/tibber_prices/sensor/calculators/volatility.py index 732311e..af93b4d 100644 --- a/custom_components/tibber_prices/sensor/calculators/volatility.py +++ b/custom_components/tibber_prices/sensor/calculators/volatility.py @@ -12,14 +12,12 @@ from custom_components.tibber_prices.const import ( DEFAULT_VOLATILITY_THRESHOLD_HIGH, DEFAULT_VOLATILITY_THRESHOLD_MODERATE, DEFAULT_VOLATILITY_THRESHOLD_VERY_HIGH, + get_display_precision, 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, find_rolling_hour_center_index -from custom_components.tibber_prices.sensor.attributes import ( - add_volatility_type_attributes, - get_prices_for_volatility, -) +from custom_components.tibber_prices.sensor.attributes import add_volatility_type_attributes, get_prices_for_volatility from custom_components.tibber_prices.utils.average import calculate_mean from custom_components.tibber_prices.utils.price import ( calculate_iqr_stats, @@ -103,6 +101,7 @@ class TibberPricesVolatilityCalculator(TibberPricesBaseCalculator): # Convert to display currency unit based on configuration factor = get_display_unit_factor(self.config_entry) + precision = get_display_precision(self.config_entry) spread_display = spread * factor # Calculate volatility level AND coefficient of variation @@ -116,18 +115,18 @@ class TibberPricesVolatilityCalculator(TibberPricesBaseCalculator): attrs: dict[str, Any] = { "price_volatility": volatility.lower(), "price_coefficient_variation_%": round(cv, 2) if cv is not None else None, - "price_spread": round(spread_display, 2), - "price_min": round(price_min * factor, 2), - "price_max": round(price_max * factor, 2), - "price_mean": round(price_mean * factor, 2), + "price_spread": round(spread_display, precision), + "price_min": round(price_min * factor, precision), + "price_max": round(price_max * factor, precision), + "price_mean": round(price_mean * factor, precision), } # Add IQR attributes when enough data is available (stay in price_* group) if iqr_stats is not None: - attrs["price_median"] = round(iqr_stats["median"] * factor, 2) - attrs["price_q25"] = round(iqr_stats["q25"] * factor, 2) - attrs["price_q75"] = round(iqr_stats["q75"] * factor, 2) - attrs["price_typical_spread"] = round(iqr_stats["iqr"] * factor, 2) + attrs["price_median"] = round(iqr_stats["median"] * factor, precision) + attrs["price_q25"] = round(iqr_stats["q25"] * factor, precision) + attrs["price_q75"] = round(iqr_stats["q75"] * factor, precision) + attrs["price_typical_spread"] = round(iqr_stats["iqr"] * factor, precision) if iqr_stats["iqr_pct"] is not None: attrs["price_typical_spread_%"] = round(iqr_stats["iqr_pct"], 2) attrs["price_spike_count"] = iqr_stats["outlier_count"] @@ -208,15 +207,16 @@ class TibberPricesVolatilityCalculator(TibberPricesBaseCalculator): # Convert to display units for attribute storage factor = get_display_unit_factor(self.config_entry) + precision = get_display_precision(self.config_entry) price_attr_key = self._get_subject_price_attr_key(subject) self._last_percentile_rank_attributes = { - price_attr_key: round(subject_price * factor, 2), + price_attr_key: round(subject_price * factor, precision), "prices_below_count": bisect.bisect_left(sorted(reference_prices), subject_price), "interval_count": len(reference_prices), - "reference_min": round(min(reference_prices) * factor, 2), - "reference_max": round(max(reference_prices) * factor, 2), - "reference_mean": round(calculate_mean(reference_prices) * factor, 2), + "reference_min": round(min(reference_prices) * factor, precision), + "reference_max": round(max(reference_prices) * factor, precision), + "reference_mean": round(calculate_mean(reference_prices) * factor, precision), } return rank diff --git a/custom_components/tibber_prices/sensor/calculators/window_24h.py b/custom_components/tibber_prices/sensor/calculators/window_24h.py index 73c61b3..bf9f66d 100644 --- a/custom_components/tibber_prices/sensor/calculators/window_24h.py +++ b/custom_components/tibber_prices/sensor/calculators/window_24h.py @@ -4,6 +4,7 @@ from __future__ import annotations from typing import TYPE_CHECKING +from custom_components.tibber_prices.const import get_display_precision from custom_components.tibber_prices.entity_utils import get_price_value from .base import TibberPricesBaseCalculator @@ -52,9 +53,10 @@ class TibberPricesWindow24hCalculator(TibberPricesBaseCalculator): if value is None: return None # Convert to display currency units based on config - mean_result = round(get_price_value(value, config_entry=self.coordinator.config_entry), 2) + precision = get_display_precision(self.coordinator.config_entry) + mean_result = round(get_price_value(value, config_entry=self.coordinator.config_entry), precision) median_result = ( - round(get_price_value(median, config_entry=self.coordinator.config_entry), 2) + round(get_price_value(median, config_entry=self.coordinator.config_entry), precision) if median is not None else None ) @@ -65,6 +67,7 @@ class TibberPricesWindow24hCalculator(TibberPricesBaseCalculator): if value is None: return None - # Return in configured display currency units with 2 decimals + # Return in configured display currency units + precision = get_display_precision(self.coordinator.config_entry) result = get_price_value(value, config_entry=self.coordinator.config_entry) - return round(result, 2) + return round(result, precision) diff --git a/custom_components/tibber_prices/sensor/core.py b/custom_components/tibber_prices/sensor/core.py index cc06834..194193b 100644 --- a/custom_components/tibber_prices/sensor/core.py +++ b/custom_components/tibber_prices/sensor/core.py @@ -2,54 +2,35 @@ from __future__ import annotations -from datetime import datetime # noqa: TC003 - Used at runtime for _get_data_timestamp() +from datetime import datetime from typing import TYPE_CHECKING, Any -from custom_components.tibber_prices.binary_sensor.attributes import ( - get_price_intervals_attributes, -) +from custom_components.tibber_prices.binary_sensor.attributes import get_price_intervals_attributes from custom_components.tibber_prices.const import ( CONF_AVERAGE_SENSOR_DISPLAY, - CONF_CURRENCY_DISPLAY_MODE, CONF_PRICE_RATING_THRESHOLD_HIGH, CONF_PRICE_RATING_THRESHOLD_LOW, DEFAULT_AVERAGE_SENSOR_DISPLAY, DEFAULT_PRICE_RATING_THRESHOLD_HIGH, DEFAULT_PRICE_RATING_THRESHOLD_LOW, - DISPLAY_MODE_BASE, DOMAIN, format_price_unit_base, + get_display_precision, get_display_unit_factor, get_display_unit_string, ) -from custom_components.tibber_prices.coordinator import ( - MINUTE_UPDATE_ENTITY_KEYS, - TIME_SENSITIVE_ENTITY_KEYS, -) -from custom_components.tibber_prices.coordinator.helpers import ( - get_intervals_for_day_offsets, -) +from custom_components.tibber_prices.coordinator import MINUTE_UPDATE_ENTITY_KEYS, TIME_SENSITIVE_ENTITY_KEYS +from custom_components.tibber_prices.coordinator.helpers import get_intervals_for_day_offsets from custom_components.tibber_prices.entity import TibberPricesEntity from custom_components.tibber_prices.entity_utils import ( add_icon_color_attribute, find_rolling_hour_center_index, get_price_value, ) -from custom_components.tibber_prices.entity_utils.icons import ( - TibberPricesIconContext, - get_dynamic_icon, -) -from custom_components.tibber_prices.utils.average import ( - calculate_next_n_hours_mean, -) -from custom_components.tibber_prices.utils.price import ( - calculate_volatility_level, -) -from homeassistant.components.sensor import ( - RestoreSensor, - SensorDeviceClass, - SensorEntityDescription, -) +from custom_components.tibber_prices.entity_utils.icons import TibberPricesIconContext, get_dynamic_icon +from custom_components.tibber_prices.utils.average import calculate_next_n_hours_mean +from custom_components.tibber_prices.utils.price import calculate_volatility_level +from homeassistant.components.sensor import RestoreSensor, SensorDeviceClass, SensorEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import callback @@ -70,11 +51,7 @@ from .calculators import ( TibberPricesVolatilityCalculator, TibberPricesWindow24hCalculator, ) -from .chart_data import ( - build_chart_data_attributes, - call_chartdata_service_async, - get_chart_data_state, -) +from .chart_data import build_chart_data_attributes, call_chartdata_service_async, get_chart_data_state from .chart_metadata import ( build_chart_metadata_attributes, call_chartdata_service_for_metadata_async, @@ -86,9 +63,7 @@ from .value_getters import get_value_getter_mapping if TYPE_CHECKING: from collections.abc import Callable - from custom_components.tibber_prices.coordinator import ( - TibberPricesDataUpdateCoordinator, - ) + from custom_components.tibber_prices.coordinator import TibberPricesDataUpdateCoordinator from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService HOURS_IN_DAY = 24 @@ -426,6 +401,15 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor): # Coordinator updates bring new API data — always write to ensure fresh state. # Reset _last_written_value so timer-based handlers also write next cycle. self._last_written_value = _SENTINEL + + # Sync suggested display precision with entity registry. HA only updates + # this on entity add and registry entry changes, not on state writes. When + # the user switches currency display mode via options flow, we must push + # the new precision to the registry explicitly. The call is cheap (property + # read + dict comparison) and returns early when values already match. + if self.registry_entry: + self._update_suggested_precision() + super()._handle_coordinator_update() def _get_value_getter(self) -> Callable | None: @@ -581,9 +565,10 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor): self._last_extreme_interval = pi["interval"] break - # Return in configured display currency units with 2 decimals + # Return in configured display currency units + precision = get_display_precision(self.coordinator.config_entry) result = get_price_value(value, config_entry=self.coordinator.config_entry) - return round(result, 2) + return round(result, precision) def _get_daily_aggregated_value( self, @@ -659,9 +644,10 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor): if value is None: return None - # Return in configured display currency units with 2 decimals + # Return in configured display currency units + precision = get_display_precision(self.coordinator.config_entry) result = get_price_value(value, config_entry=self.coordinator.config_entry) - return round(result, 2) + return round(result, precision) def _translate_rating_level(self, level: str) -> str: """Translate the rating level using custom translations, falling back to English or the raw value.""" @@ -710,6 +696,7 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor): # Get display unit factor (100 for minor, 1 for major) factor = get_display_unit_factor(self.coordinator.config_entry) + precision = get_display_precision(self.coordinator.config_entry) # Get user preference for display (mean or median) display_pref = self.coordinator.config_entry.options.get( @@ -717,14 +704,14 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor): ) # Store both values for attributes - self.cached_data[f"next_avg_{hours}h_mean"] = round(mean_price * factor, 2) + self.cached_data[f"next_avg_{hours}h_mean"] = round(mean_price * factor, precision) if median_price is not None: - self.cached_data[f"next_avg_{hours}h_median"] = round(median_price * factor, 2) + self.cached_data[f"next_avg_{hours}h_median"] = round(median_price * factor, precision) # Return the value chosen for state display if display_pref == "median" and median_price is not None: - return round(median_price * factor, 2) - return round(mean_price * factor, 2) # "mean" + return round(median_price * factor, precision) + return round(mean_price * factor, precision) # "mean" def _get_data_timestamp(self) -> datetime | None: """ @@ -916,7 +903,7 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor): return True @property - def native_value(self) -> float | str | datetime | None: # noqa: PLR0912 + def native_value(self) -> float | str | datetime | None: """Return the native value of the sensor.""" try: if not self.coordinator.data or not self._value_getter: @@ -1043,28 +1030,6 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor): key = self.entity_description.key value = self.native_value - # Icon mapping for trend directions (5-level scale) - trend_icons = { - "strongly_rising": "mdi:chevron-double-up", - "rising": "mdi:trending-up", - "stable": "mdi:trending-neutral", - "falling": "mdi:trending-down", - "strongly_falling": "mdi:chevron-double-down", - } - - # Special handling for next_price_trend_change: Icon based on direction attribute - if key == "next_price_trend_change": - trend_change_attrs = self._trend_calculator.get_trend_change_attributes() - if trend_change_attrs: - direction = trend_change_attrs.get("direction") - if isinstance(direction, str): - return trend_icons.get(direction, "mdi:help-circle-outline") - return "mdi:help-circle-outline" - - # Special handling for current_price_trend: Icon based on current state value - if key == "current_price_trend" and isinstance(value, str): - return trend_icons.get(value, "mdi:help-circle-outline") - # Create callback for period active state check (used by timing sensors) period_is_active_callback = None if key.startswith("best_price_"): @@ -1072,6 +1037,13 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor): elif key.startswith("peak_price_"): period_is_active_callback = self._is_peak_price_period_active + # For next_price_trend_change, pass direction from cached attributes via context + trend_change_direction = None + if key == "next_price_trend_change": + trend_change_attrs = self._trend_calculator.get_trend_change_attributes() + if trend_change_attrs: + trend_change_direction = trend_change_attrs.get("direction") + # Use centralized icon logic with context icon = get_dynamic_icon( key=key, @@ -1080,24 +1052,37 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor): coordinator_data=self.coordinator.data, period_is_active_callback=period_is_active_callback, time=self.coordinator.time, + trend_change_direction=trend_change_direction, ), ) # Fall back to static icon from entity description return icon or self.entity_description.icon + # Interval price sensors that show individual quarter-hour prices. + # These get full data precision (subunit→2, base→4) as display default + # because users rely on exact values for automations and dashboards. + _INTERVAL_PRICE_KEYS = frozenset( + { + "current_interval_price", + "current_interval_price_base", + "next_interval_price", + "previous_interval_price", + } + ) + @property def suggested_display_precision(self) -> int | None: """ Return suggested display precision based on currency display mode. For MONETARY sensors: - - Current/Next Interval Price: Show exact price with higher precision - - Base currency (€/kr): 4 decimals (e.g., 0.1234 €) - - Subunit currency (ct/øre): 2 decimals (e.g., 12.34 ct) - - All other price sensors: - - Base currency (€/kr): 2 decimals (e.g., 0.12 €) - - Subunit currency (ct/øre): 1 decimal (e.g., 12.5 ct) + - Interval price sensors: Full precision (subunit→2, base→4) + - Energy Dashboard sensor (current_interval_price_base): Always 4 + - All other price sensors: Reduced precision (subunit→1, base→2) + + The actual state value retains full rounded precision (2 or 4 decimals), + so users can increase display precision in the HA UI to see more detail. For non-MONETARY sensors, use static value from entity description. """ @@ -1105,23 +1090,16 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor): if self.entity_description.device_class != SensorDeviceClass.MONETARY: return self.entity_description.suggested_display_precision - # Check display mode configuration - display_mode = self.coordinator.config_entry.options.get(CONF_CURRENCY_DISPLAY_MODE, DISPLAY_MODE_BASE) - - # Special case: Energy Dashboard sensor always shows base currency with 4 decimals - # regardless of display mode (it's always in base currency by design) + # Energy Dashboard sensor: always base currency, always 4 decimals if self.entity_description.key == "current_interval_price_base": return 4 - # Special case: Current and Next interval price sensors get higher precision - # to show exact prices as received from API - if self.entity_description.key in ("current_interval_price", "next_interval_price"): - # Major: 4 decimals (0.1234 €), Minor: 2 decimals (12.34 ct) - return 4 if display_mode == DISPLAY_MODE_BASE else 2 + # Interval price sensors: full data precision (subunit→2, base→4) + if self.entity_description.key in self._INTERVAL_PRICE_KEYS: + return get_display_precision(self.coordinator.config_entry) - # All other sensors: Standard precision - # Major: 2 decimals (0.12 €), Minor: 1 decimal (12.5 ct) - return 2 if display_mode == DISPLAY_MODE_BASE else 1 + # All other MONETARY sensors: reduced precision (subunit→1, base→2) + return get_display_precision(self.coordinator.config_entry) // 2 @property def extra_state_attributes(self) -> dict[str, Any] | None: diff --git a/custom_components/tibber_prices/sensor/helpers.py b/custom_components/tibber_prices/sensor/helpers.py index 497b6aa..f493cdc 100644 --- a/custom_components/tibber_prices/sensor/helpers.py +++ b/custom_components/tibber_prices/sensor/helpers.py @@ -16,12 +16,9 @@ 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.const import get_display_precision, get_display_unit_factor from custom_components.tibber_prices.utils.average import calculate_mean, calculate_median -from custom_components.tibber_prices.utils.price import ( - aggregate_price_levels, - aggregate_price_rating, -) +from custom_components.tibber_prices.utils.price import aggregate_price_levels, aggregate_price_rating if TYPE_CHECKING: from homeassistant.config_entries import ConfigEntry @@ -51,7 +48,8 @@ def aggregate_average_data( median = calculate_median(prices) # Convert to display currency unit based on configuration factor = get_display_unit_factor(config_entry) - return round(mean * factor, 2), round(median * factor, 2) if median is not None else None + precision = get_display_precision(config_entry) + return round(mean * factor, precision), round(median * factor, precision) if median is not None else None def aggregate_level_data(window_data: list[dict]) -> str | None: