diff --git a/custom_components/tibber_prices/__init__.py b/custom_components/tibber_prices/__init__.py index 1854399..5d4cc21 100644 --- a/custom_components/tibber_prices/__init__.py +++ b/custom_components/tibber_prices/__init__.py @@ -21,10 +21,12 @@ from homeassistant.loader import async_get_loaded_integration from .api import TibberPricesApiClient from .const import ( CONF_CURRENCY_DISPLAY_MODE, + CONF_PRICE_ROUND_DECIMALS, CONF_PRICE_TREND_MIN_PRICE_CHANGE, CONF_PRICE_TREND_MIN_PRICE_CHANGE_STRONGLY, DATA_CHART_CONFIG, DATA_CHART_METADATA_CONFIG, + DEFAULT_PRICE_ROUND_DECIMALS, DISPLAY_MODE_SUBUNIT, DOMAIN, LOGGER, @@ -146,6 +148,17 @@ async def _migrate_config_options(hass: HomeAssistant, entry: ConfigEntry) -> No DISPLAY_MODE_SUBUNIT, ) + # Migration: Ensure monetary sensor rounding precision option exists + if CONF_PRICE_ROUND_DECIMALS not in migrated: + migrated[CONF_PRICE_ROUND_DECIMALS] = DEFAULT_PRICE_ROUND_DECIMALS + migration_performed = True + LOGGER.info( + "[%s] Migrated legacy config: Set %s=%s", + entry.title, + CONF_PRICE_ROUND_DECIMALS, + DEFAULT_PRICE_ROUND_DECIMALS, + ) + # Migration: Convert min_price_change from display currency (ct/øre) to base currency (EUR/NOK) # Before this change, values were stored in display units. Now always stored in base currency. # Detection: If either value exceeds its new max, both are in old format and need conversion. diff --git a/custom_components/tibber_prices/const.py b/custom_components/tibber_prices/const.py index 9e6cbfd..eb1ef5c 100644 --- a/custom_components/tibber_prices/const.py +++ b/custom_components/tibber_prices/const.py @@ -323,6 +323,7 @@ def get_currency_name(currency_code: str | None) -> str: # Configuration key for currency display mode CONF_CURRENCY_DISPLAY_MODE = "currency_display_mode" +CONF_PRICE_ROUND_DECIMALS = "price_round_decimals" # Display mode values DISPLAY_MODE_BASE = "base" # Display in base currency units (€, kr) @@ -341,6 +342,11 @@ DEFAULT_CURRENCY_DISPLAY = { "GBP": DISPLAY_MODE_BASE, } +# Display precision (applies to monetary sensor states) +DEFAULT_PRICE_ROUND_DECIMALS = 2 +MIN_PRICE_ROUND_DECIMALS = 0 +MAX_PRICE_ROUND_DECIMALS = 6 + def get_default_currency_display(currency_code: str | None) -> str: """ @@ -384,6 +390,7 @@ def get_default_options(currency_code: str | None) -> dict[str, Any]: CONF_EXTENDED_DESCRIPTIONS: DEFAULT_EXTENDED_DESCRIPTIONS, CONF_AVERAGE_SENSOR_DISPLAY: DEFAULT_AVERAGE_SENSOR_DISPLAY, CONF_CURRENCY_DISPLAY_MODE: get_default_currency_display(currency_code), + CONF_PRICE_ROUND_DECIMALS: DEFAULT_PRICE_ROUND_DECIMALS, CONF_VIRTUAL_TIME_OFFSET_DAYS: DEFAULT_VIRTUAL_TIME_OFFSET_DAYS, CONF_VIRTUAL_TIME_OFFSET_HOURS: DEFAULT_VIRTUAL_TIME_OFFSET_HOURS, CONF_VIRTUAL_TIME_OFFSET_MINUTES: DEFAULT_VIRTUAL_TIME_OFFSET_MINUTES, @@ -470,6 +477,17 @@ def get_display_unit_factor(config_entry: ConfigEntry) -> int: return 100 if display_mode == DISPLAY_MODE_SUBUNIT else 1 +def get_price_round_decimals(config_entry: ConfigEntry) -> int: + """Get configured decimal precision for monetary sensor values.""" + raw_value = config_entry.options.get(CONF_PRICE_ROUND_DECIMALS, DEFAULT_PRICE_ROUND_DECIMALS) + try: + value = int(raw_value) + except (TypeError, ValueError): + return DEFAULT_PRICE_ROUND_DECIMALS + + return max(MIN_PRICE_ROUND_DECIMALS, min(MAX_PRICE_ROUND_DECIMALS, value)) + + 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/custom_translations/en.json b/custom_components/tibber_prices/custom_translations/en.json index d6d50a3..631a36d 100644 --- a/custom_components/tibber_prices/custom_translations/en.json +++ b/custom_components/tibber_prices/custom_translations/en.json @@ -630,6 +630,11 @@ "long_description": "When this entity is enabled, its value overrides the 'Gap Tolerance' setting from the options flow for best price period calculations.", "usage_tips": "Increase to allow longer periods with occasional price spikes. Keep low for stricter continuous cheap periods." }, + "price_round_decimals": { + "description": "Number of decimals used for monetary sensor state values.", + "long_description": "Applies globally to price-related sensor states (current/next/previous prices, daily min/max/average, and rolling/window averages).", + "usage_tips": "Set to 4 for base-currency precision (for example 0.2534)." + }, "peak_price_flex_override": { "description": "Maximum below the daily maximum price that intervals can be and still qualify as 'peak price'. Recommended: -15 to -20 with relaxation enabled (default), or -25 to -35 without relaxation. Maximum: -50 (hard cap for reliable period detection). Note: Negative values indicate distance below maximum.", "long_description": "When this entity is enabled, its value overrides the 'Flexibility' setting from the options flow for peak price period calculations.", diff --git a/custom_components/tibber_prices/custom_translations/nl.json b/custom_components/tibber_prices/custom_translations/nl.json index 25fcf39..2b376d0 100644 --- a/custom_components/tibber_prices/custom_translations/nl.json +++ b/custom_components/tibber_prices/custom_translations/nl.json @@ -630,6 +630,11 @@ "long_description": "Wanneer deze entiteit is ingeschakeld, overschrijft de waarde de 'Gap tolerantie'-instelling uit de opties-dialoog voor beste prijs-periodeberekeningen.", "usage_tips": "Verhoog dit voor apparaten met variabele belasting (bijv. warmtepompen) die korte duurdere intervallen kunnen tolereren. Stel in op 0 voor continu goedkope perioden." }, + "price_round_decimals": { + "description": "Aantal decimalen voor monetaire sensorstatuswaarden.", + "long_description": "Wordt globaal toegepast op prijsgerelateerde sensorstatussen (huidig/volgend/vorig, dag min/max/gemiddelde en rolling/window-gemiddelden).", + "usage_tips": "Zet op 4 voor meer precisie in basisvaluta (bijvoorbeeld 0.2534)." + }, "peak_price_flex_override": { "description": "Maximaal percentage onder de dagelijkse maximumprijs dat intervallen kunnen hebben en nog steeds als 'piekprijs' kwalificeren. Dezelfde aanbevelingen als voor beste prijs-flexibiliteit.", "long_description": "Wanneer deze entiteit is ingeschakeld, overschrijft de waarde de 'Flexibiliteit'-instelling uit de opties-dialoog voor piekprijs-periodeberekeningen.", diff --git a/custom_components/tibber_prices/entity_utils/helpers.py b/custom_components/tibber_prices/entity_utils/helpers.py index bd3e8ba..7584acf 100644 --- a/custom_components/tibber_prices/entity_utils/helpers.py +++ b/custom_components/tibber_prices/entity_utils/helpers.py @@ -14,7 +14,10 @@ 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_unit_factor, + get_price_round_decimals, +) if TYPE_CHECKING: from datetime import datetime @@ -55,7 +58,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) + decimals = get_price_round_decimals(config_entry) + return round(price * factor, decimals) # Fallback: default to subunit currency (backward compatibility) return round(price * 100, 2) diff --git a/custom_components/tibber_prices/number/core.py b/custom_components/tibber_prices/number/core.py index 522741d..a250142 100644 --- a/custom_components/tibber_prices/number/core.py +++ b/custom_components/tibber_prices/number/core.py @@ -136,6 +136,11 @@ class TibberPricesConfigNumber(RestoreNumber, NumberEntity): """Handle entity which was added to Home Assistant.""" await super().async_added_to_hass() + # Option-backed numbers use config entry as source of truth. + if self.entity_description.store_in_options: + self._attr_native_value = self._get_value_from_options() + return + # Try to restore previous state last_number_data = await self.async_get_last_number_data() if last_number_data is not None and last_number_data.native_value is not None: @@ -160,6 +165,10 @@ class TibberPricesConfigNumber(RestoreNumber, NumberEntity): async def async_will_remove_from_hass(self) -> None: """Handle entity removal from Home Assistant.""" + if self.entity_description.store_in_options: + await super().async_will_remove_from_hass() + return + # Remove override when entity is removed self.coordinator.remove_config_override( self.entity_description.config_key, @@ -170,6 +179,14 @@ class TibberPricesConfigNumber(RestoreNumber, NumberEntity): def _get_value_from_options(self) -> float: """Get the current value from options flow or default.""" options = self.coordinator.config_entry.options + + if self.entity_description.store_in_options: + value = options.get( + self.entity_description.config_key, + self.entity_description.default_value, + ) + return float(value) + section = options.get(self.entity_description.config_section, {}) value = section.get( self.entity_description.config_key, @@ -179,6 +196,9 @@ class TibberPricesConfigNumber(RestoreNumber, NumberEntity): async def _sync_override_state(self) -> None: """Sync the override state with the coordinator based on entity enabled state.""" + if self.entity_description.store_in_options: + return + # Check if entity is enabled in registry if self.registry_entry is not None and not self.registry_entry.disabled: # Entity is enabled - register the override @@ -199,6 +219,18 @@ class TibberPricesConfigNumber(RestoreNumber, NumberEntity): """Update the current value and trigger recalculation.""" self._attr_native_value = value + if self.entity_description.store_in_options: + options = dict(self.coordinator.config_entry.options) + options[self.entity_description.config_key] = int(value) + self.hass.config_entries.async_update_entry(self.coordinator.config_entry, options=options) + self.async_write_ha_state() + _LOGGER.debug( + "Updated option-backed %s to %s", + self.entity_description.key, + int(value), + ) + return + # Update the coordinator's runtime override self.coordinator.set_config_override( self.entity_description.config_key, diff --git a/custom_components/tibber_prices/number/definitions.py b/custom_components/tibber_prices/number/definitions.py index a7a12c1..a7e52f5 100644 --- a/custom_components/tibber_prices/number/definitions.py +++ b/custom_components/tibber_prices/number/definitions.py @@ -32,6 +32,8 @@ class TibberPricesNumberEntityDescription(NumberEntityDescription): is_peak_price: bool = False # Default value from const.py default_value: float | int = 0 + # If True, entity writes to flat config_entry.options instead of runtime override sections + store_in_options: bool = False # ============================================================================ @@ -134,6 +136,29 @@ BEST_PRICE_NUMBER_ENTITIES = ( ), ) +# ========================================================================== +# DISPLAY SETTINGS OVERRIDES +# ========================================================================== + +DISPLAY_NUMBER_ENTITIES = ( + TibberPricesNumberEntityDescription( + key="price_round_decimals", + translation_key="price_round_decimals", + icon="mdi:decimal", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=True, + native_min_value=0, + native_max_value=6, + native_step=1, + mode=NumberMode.BOX, + config_key="price_round_decimals", + config_section="", + is_peak_price=False, + default_value=2, + store_in_options=True, + ), +) + # ============================================================================ # PEAK PRICE PERIOD CONFIGURATION OVERRIDES # ============================================================================ @@ -235,4 +260,4 @@ PEAK_PRICE_NUMBER_ENTITIES = ( ) # All number entity descriptions combined -NUMBER_ENTITY_DESCRIPTIONS = BEST_PRICE_NUMBER_ENTITIES + PEAK_PRICE_NUMBER_ENTITIES +NUMBER_ENTITY_DESCRIPTIONS = BEST_PRICE_NUMBER_ENTITIES + PEAK_PRICE_NUMBER_ENTITIES + DISPLAY_NUMBER_ENTITIES diff --git a/custom_components/tibber_prices/sensor/attributes/daily_stat.py b/custom_components/tibber_prices/sensor/attributes/daily_stat.py index 123a73e..75613a0 100644 --- a/custom_components/tibber_prices/sensor/attributes/daily_stat.py +++ b/custom_components/tibber_prices/sensor/attributes/daily_stat.py @@ -7,6 +7,7 @@ from typing import TYPE_CHECKING from custom_components.tibber_prices.const import ( PRICE_RATING_MAPPING, get_display_unit_factor, + get_price_round_decimals, ) from custom_components.tibber_prices.coordinator.helpers import ( get_intervals_for_day_offsets, @@ -30,12 +31,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) + decimals = get_price_round_decimals(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, decimals) tax = interval_data.get("tax") if tax is not None: - attributes["tax"] = round(float(tax) * factor, 2) + attributes["tax"] = round(float(tax) * factor, decimals) def _add_energy_tax_averages_from_cache( @@ -49,14 +51,15 @@ def _add_energy_tax_averages_from_cache( "last_energy_tax_averages", (None, None, None, None) ) factor = get_display_unit_factor(config_entry) + decimals = get_price_round_decimals(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, decimals) 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, decimals) if tax_mean is not None: - attributes["tax_mean"] = round(float(tax_mean) * factor, 2) + attributes["tax_mean"] = round(float(tax_mean) * factor, decimals) if tax_median is not None: - attributes["tax_median"] = round(float(tax_median) * factor, 2) + attributes["tax_median"] = round(float(tax_median) * factor, decimals) 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..e36854b 100644 --- a/custom_components/tibber_prices/sensor/attributes/future.py +++ b/custom_components/tibber_prices/sensor/attributes/future.py @@ -4,7 +4,10 @@ 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_unit_factor, + get_price_round_decimals, +) from custom_components.tibber_prices.coordinator.helpers import get_intervals_for_day_offsets if TYPE_CHECKING: @@ -142,7 +145,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) + decimals = get_price_round_decimals(config_entry) + price_display = round(price_major * factor, decimals) 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..8e69b8a 100644 --- a/custom_components/tibber_prices/sensor/attributes/interval.py +++ b/custom_components/tibber_prices/sensor/attributes/interval.py @@ -9,6 +9,7 @@ from custom_components.tibber_prices.const import ( PRICE_LEVEL_MAPPING, PRICE_RATING_MAPPING, get_display_unit_factor, + get_price_round_decimals, ) 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 @@ -112,14 +113,15 @@ def _add_energy_tax_attributes( return factor = get_display_unit_factor(config_entry) + decimals = get_price_round_decimals(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, decimals) tax = interval_data.get("tax") if tax is not None: - attributes["tax"] = round(float(tax) * factor, 2) + attributes["tax"] = round(float(tax) * factor, decimals) def add_current_interval_price_attributes( # noqa: PLR0913 diff --git a/custom_components/tibber_prices/sensor/calculators/daily_stat.py b/custom_components/tibber_prices/sensor/calculators/daily_stat.py index 91f7e69..09b8031 100644 --- a/custom_components/tibber_prices/sensor/calculators/daily_stat.py +++ b/custom_components/tibber_prices/sensor/calculators/daily_stat.py @@ -115,9 +115,9 @@ 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) + avg_result = get_price_value(value, config_entry=self.coordinator.config_entry) median_result = ( - round(get_price_value(median, config_entry=self.coordinator.config_entry), 2) + get_price_value(median, config_entry=self.coordinator.config_entry) if median is not None else None ) @@ -132,9 +132,8 @@ class TibberPricesDailyStatCalculator(TibberPricesBaseCalculator): self._last_extreme_interval = pi["interval"] break - # Return in configured display currency units with 2 decimals - result = get_price_value(value, config_entry=self.coordinator.config_entry) - return round(result, 2) + # Return in configured display currency units with configured precision + return get_price_value(value, config_entry=self.coordinator.config_entry) 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..3ed7a4d 100644 --- a/custom_components/tibber_prices/sensor/calculators/interval.py +++ b/custom_components/tibber_prices/sensor/calculators/interval.py @@ -4,7 +4,10 @@ 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_unit_factor, + get_price_round_decimals, +) from .base import TibberPricesBaseCalculator @@ -74,7 +77,8 @@ class TibberPricesIntervalCalculator(TibberPricesBaseCalculator): if in_euro: return price factor = get_display_unit_factor(self.config_entry) - return round(price * factor, 2) + decimals = get_price_round_decimals(self.config_entry) + return round(price * factor, decimals) 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..3d5c74e 100644 --- a/custom_components/tibber_prices/sensor/calculators/trend.py +++ b/custom_components/tibber_prices/sensor/calculators/trend.py @@ -15,7 +15,10 @@ 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_unit_factor, + get_price_round_decimals, +) from custom_components.tibber_prices.coordinator.helpers import get_intervals_for_day_offsets from custom_components.tibber_prices.utils.average import calculate_mean, calculate_next_n_hours_mean from custom_components.tibber_prices.utils.price import ( @@ -180,6 +183,7 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator): # Convert prices to display currency unit based on configuration factor = get_display_unit_factor(self.config_entry) + decimals = get_price_round_decimals(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 +192,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, decimals), "interval_count": lookahead_intervals, "threshold_rising_%": round(threshold_rising * vol_factor, 1), "threshold_rising_strongly_%": round(threshold_strongly_rising * vol_factor, 1), @@ -203,7 +207,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, decimals) # 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 @@ -378,6 +382,7 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator): ) factor = get_display_unit_factor(self.config_entry) + decimals = get_price_round_decimals(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 +392,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, decimals), + f"second_half_{hours}h_avg": round(second_half_avg * factor, decimals), f"first_half_{hours}h_diff_from_current_%": round( ((first_half_avg - current_interval_price) / abs(current_interval_price)) * 100, 1 ) @@ -910,16 +915,19 @@ 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) + decimals = get_price_round_decimals(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, decimals), + "price_at_change": round(change_price * factor, decimals), "price_avg_after_change": ( - round(first_change["mean"] * factor, 2) if first_change["mean"] else None + round(first_change["mean"] * factor, decimals) + 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..a3e1a23 100644 --- a/custom_components/tibber_prices/sensor/calculators/volatility.py +++ b/custom_components/tibber_prices/sensor/calculators/volatility.py @@ -13,6 +13,7 @@ from custom_components.tibber_prices.const import ( DEFAULT_VOLATILITY_THRESHOLD_MODERATE, DEFAULT_VOLATILITY_THRESHOLD_VERY_HIGH, get_display_unit_factor, + get_price_round_decimals, ) 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 @@ -103,6 +104,7 @@ class TibberPricesVolatilityCalculator(TibberPricesBaseCalculator): # Convert to display currency unit based on configuration factor = get_display_unit_factor(self.config_entry) + decimals = get_price_round_decimals(self.config_entry) spread_display = spread * factor # Calculate volatility level AND coefficient of variation @@ -116,18 +118,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, decimals), + "price_min": round(price_min * factor, decimals), + "price_max": round(price_max * factor, decimals), + "price_mean": round(price_mean * factor, decimals), } # 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, decimals) + attrs["price_q25"] = round(iqr_stats["q25"] * factor, decimals) + attrs["price_q75"] = round(iqr_stats["q75"] * factor, decimals) + attrs["price_typical_spread"] = round(iqr_stats["iqr"] * factor, decimals) 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 +210,16 @@ class TibberPricesVolatilityCalculator(TibberPricesBaseCalculator): # Convert to display units for attribute storage factor = get_display_unit_factor(self.config_entry) + decimals = get_price_round_decimals(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, decimals), "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, decimals), + "reference_max": round(max(reference_prices) * factor, decimals), + "reference_mean": round(calculate_mean(reference_prices) * factor, decimals), } 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..2306a33 100644 --- a/custom_components/tibber_prices/sensor/calculators/window_24h.py +++ b/custom_components/tibber_prices/sensor/calculators/window_24h.py @@ -52,9 +52,9 @@ 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) + mean_result = get_price_value(value, config_entry=self.coordinator.config_entry) median_result = ( - round(get_price_value(median, config_entry=self.coordinator.config_entry), 2) + get_price_value(median, config_entry=self.coordinator.config_entry) if median is not None else None ) @@ -65,6 +65,5 @@ class TibberPricesWindow24hCalculator(TibberPricesBaseCalculator): if value is None: return None - # Return in configured display currency units with 2 decimals - result = get_price_value(value, config_entry=self.coordinator.config_entry) - return round(result, 2) + # Return in configured display currency units with configured precision + return get_price_value(value, config_entry=self.coordinator.config_entry) diff --git a/custom_components/tibber_prices/sensor/core.py b/custom_components/tibber_prices/sensor/core.py index cc06834..0e98fb2 100644 --- a/custom_components/tibber_prices/sensor/core.py +++ b/custom_components/tibber_prices/sensor/core.py @@ -10,17 +10,16 @@ from custom_components.tibber_prices.binary_sensor.attributes import ( ) 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_unit_factor, get_display_unit_string, + get_price_round_decimals, ) from custom_components.tibber_prices.coordinator import ( MINUTE_UPDATE_ENTITY_KEYS, @@ -581,9 +580,8 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor): self._last_extreme_interval = pi["interval"] break - # Return in configured display currency units with 2 decimals - result = get_price_value(value, config_entry=self.coordinator.config_entry) - return round(result, 2) + # Return in configured display currency units with configured precision + return get_price_value(value, config_entry=self.coordinator.config_entry) def _get_daily_aggregated_value( self, @@ -659,9 +657,8 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor): if value is None: return None - # Return in configured display currency units with 2 decimals - result = get_price_value(value, config_entry=self.coordinator.config_entry) - return round(result, 2) + # Return in configured display currency units with configured precision + return get_price_value(value, config_entry=self.coordinator.config_entry) 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 +707,7 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor): # Get display unit factor (100 for minor, 1 for major) factor = get_display_unit_factor(self.coordinator.config_entry) + decimals = get_price_round_decimals(self.coordinator.config_entry) # Get user preference for display (mean or median) display_pref = self.coordinator.config_entry.options.get( @@ -717,14 +715,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, decimals) 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, decimals) # 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, decimals) + return round(mean_price * factor, decimals) # "mean" def _get_data_timestamp(self) -> datetime | None: """ @@ -1089,39 +1087,15 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor): @property def suggested_display_precision(self) -> int | None: """ - Return suggested display precision based on currency display mode. + Return suggested display precision. - 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) - - For non-MONETARY sensors, use static value from entity description. + Monetary sensors follow the global user-configured rounding precision. + Non-monetary sensors keep their static precision from entity description. """ - # Only apply dynamic precision to MONETARY sensors 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) - 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 - - # 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 + return get_price_round_decimals(self.coordinator.config_entry) @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..d49c35b 100644 --- a/custom_components/tibber_prices/sensor/helpers.py +++ b/custom_components/tibber_prices/sensor/helpers.py @@ -16,7 +16,10 @@ 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_unit_factor, + get_price_round_decimals, +) from custom_components.tibber_prices.utils.average import calculate_mean, calculate_median from custom_components.tibber_prices.utils.price import ( aggregate_price_levels, @@ -51,7 +54,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 + decimals = get_price_round_decimals(config_entry) + return round(mean * factor, decimals), round(median * factor, decimals) if median is not None else None def aggregate_level_data(window_data: list[dict]) -> str | None: diff --git a/custom_components/tibber_prices/translations/de.json b/custom_components/tibber_prices/translations/de.json index 45e4c4e..73f8f01 100644 --- a/custom_components/tibber_prices/translations/de.json +++ b/custom_components/tibber_prices/translations/de.json @@ -1101,6 +1101,9 @@ "best_price_gap_count_override": { "name": "Bestpreis: Lückentoleranz" }, + "price_round_decimals": { + "name": "Preis: Dezimalstellen" + }, "peak_price_flex_override": { "name": "Spitzenpreis: Flexibilität" }, @@ -1208,7 +1211,7 @@ "message": "array_fields kann nur mit output_format: array_of_arrays verwendet werden. Ändere das Ausgabeformat oder entferne array_fields." }, "invalid_array_fields": { - "message": "Der Wert {template} für array_fields ist ungültig. Feldnamen müssen in geschweifte Klammern eingeschlossen sein, z.B. '{start_time}, {price_per_kwh}, {level}'." + "message": "Der Wert '{template}' für array_fields ist ungültig. Feldnamen müssen in geschweifte Klammern eingeschlossen sein, z.B. '{start_time}, {price_per_kwh}, {level}'." }, "min_segment_exceeds_duration": { "message": "min_segment_duration ({min_segment_minutes} Min.) darf die Gesamtdauer ({duration_minutes} Min.) nicht überschreiten. Reduziere min_segment_duration oder erhöhe duration." diff --git a/custom_components/tibber_prices/translations/en.json b/custom_components/tibber_prices/translations/en.json index d234144..890a23e 100644 --- a/custom_components/tibber_prices/translations/en.json +++ b/custom_components/tibber_prices/translations/en.json @@ -1101,6 +1101,9 @@ "best_price_gap_count_override": { "name": "Best Price: Gap Tolerance" }, + "price_round_decimals": { + "name": "Price: Decimal Places" + }, "peak_price_flex_override": { "name": "Peak Price: Flexibility" }, @@ -1208,7 +1211,7 @@ "message": "array_fields can only be used with output_format: array_of_arrays. Change the output format or remove array_fields." }, "invalid_array_fields": { - "message": "The array_fields value {template} is invalid. Field names must be wrapped in curly braces, e.g. '{start_time}, {price_per_kwh}, {level}'." + "message": "The array_fields value '{template}' is invalid. Field names must be wrapped in curly braces, e.g. '{start_time}, {price_per_kwh}, {level}'." }, "min_segment_exceeds_duration": { "message": "min_segment_duration ({min_segment_minutes} min) cannot exceed the total duration ({duration_minutes} min). Reduce min_segment_duration or increase duration." diff --git a/custom_components/tibber_prices/translations/nb.json b/custom_components/tibber_prices/translations/nb.json index f5ea852..3d8d172 100644 --- a/custom_components/tibber_prices/translations/nb.json +++ b/custom_components/tibber_prices/translations/nb.json @@ -1101,6 +1101,9 @@ "best_price_gap_count_override": { "name": "Beste pris: Gaptoleranse" }, + "price_round_decimals": { + "name": "Pris: Desimaler" + }, "peak_price_flex_override": { "name": "Topppris: Fleksibilitet" }, @@ -1208,7 +1211,7 @@ "message": "array_fields kan kun brukes med output_format: array_of_arrays. Endre utdataformatet eller fjern array_fields." }, "invalid_array_fields": { - "message": "Verdien {template} for array_fields er ugyldig. Feltnavn må omsluttes av krøllparenteser, f.eks. '{start_time}, {price_per_kwh}, {level}'." + "message": "Verdien '{template}' for array_fields er ugyldig. Feltnavn må omsluttes av krøllparenteser, f.eks. '{start_time}, {price_per_kwh}, {level}'." }, "min_segment_exceeds_duration": { "message": "min_segment_duration ({min_segment_minutes} min) kan ikke overstige total varighet ({duration_minutes} min). Reduser min_segment_duration eller øk duration." diff --git a/custom_components/tibber_prices/translations/nl.json b/custom_components/tibber_prices/translations/nl.json index 489bbf9..c7b04b5 100644 --- a/custom_components/tibber_prices/translations/nl.json +++ b/custom_components/tibber_prices/translations/nl.json @@ -1101,6 +1101,9 @@ "best_price_gap_count_override": { "name": "Beste prijs: Gap tolerantie" }, + "price_round_decimals": { + "name": "Prijs: Decimalen" + }, "peak_price_flex_override": { "name": "Piekprijs: Flexibiliteit" }, @@ -1208,7 +1211,7 @@ "message": "array_fields kan alleen gebruikt worden met output_format: array_of_arrays. Wijzig het uitvoerformaat of verwijder array_fields." }, "invalid_array_fields": { - "message": "De waarde {template} voor array_fields is ongeldig. Veldnamen moeten tussen accolades staan, bijv. '{start_time}, {price_per_kwh}, {level}'." + "message": "De waarde '{template}' voor array_fields is ongeldig. Veldnamen moeten tussen accolades staan, bijv. '{start_time}, {price_per_kwh}, {level}'." }, "min_segment_exceeds_duration": { "message": "min_segment_duration ({min_segment_minutes} min) mag de totale duur ({duration_minutes} min) niet overschrijden. Verklein min_segment_duration of vergroot duration." diff --git a/custom_components/tibber_prices/translations/sv.json b/custom_components/tibber_prices/translations/sv.json index 64f864e..65704de 100644 --- a/custom_components/tibber_prices/translations/sv.json +++ b/custom_components/tibber_prices/translations/sv.json @@ -1101,6 +1101,9 @@ "best_price_gap_count_override": { "name": "Bästa pris: Glaptolerans" }, + "price_round_decimals": { + "name": "Pris: Decimaler" + }, "peak_price_flex_override": { "name": "Topppris: Flexibilitet" }, @@ -1208,7 +1211,7 @@ "message": "array_fields kan bara användas med output_format: array_of_arrays. Ändra utdataformatet eller ta bort array_fields." }, "invalid_array_fields": { - "message": "Värdet {template} för array_fields är ogiltigt. Fältnamn måste omges av klammerparenteser, t.ex. '{start_time}, {price_per_kwh}, {level}'." + "message": "Värdet '{template}' för array_fields är ogiltigt. Fältnamn måste omges av klammerparenteser, t.ex. '{start_time}, {price_per_kwh}, {level}'." }, "min_segment_exceeds_duration": { "message": "min_segment_duration ({min_segment_minutes} min) får inte överstiga den totala varaktigheten ({duration_minutes} min). Minska min_segment_duration eller öka duration."