diff --git a/custom_components/tibber_prices/config_flow.py b/custom_components/tibber_prices/config_flow.py index a141bad..1235b46 100644 --- a/custom_components/tibber_prices/config_flow.py +++ b/custom_components/tibber_prices/config_flow.py @@ -48,6 +48,8 @@ from .const import ( CONF_PEAK_PRICE_MIN_PERIOD_LENGTH, CONF_PRICE_RATING_THRESHOLD_HIGH, CONF_PRICE_RATING_THRESHOLD_LOW, + CONF_PRICE_TREND_THRESHOLD_FALLING, + CONF_PRICE_TREND_THRESHOLD_RISING, DEFAULT_BEST_PRICE_FLEX, DEFAULT_BEST_PRICE_MIN_DISTANCE_FROM_AVG, DEFAULT_BEST_PRICE_MIN_PERIOD_LENGTH, @@ -57,6 +59,8 @@ from .const import ( DEFAULT_PEAK_PRICE_MIN_PERIOD_LENGTH, DEFAULT_PRICE_RATING_THRESHOLD_HIGH, DEFAULT_PRICE_RATING_THRESHOLD_LOW, + DEFAULT_PRICE_TREND_THRESHOLD_FALLING, + DEFAULT_PRICE_TREND_THRESHOLD_RISING, DOMAIN, LOGGER, ) @@ -590,7 +594,7 @@ class TibberPricesOptionsFlowHandler(OptionsFlow): """Configure peak price period settings.""" if user_input is not None: self._options.update(user_input) - return self.async_create_entry(title="", data=self._options) + return await self.async_step_price_trend() return self.async_show_form( step_id="peak_price", @@ -648,3 +652,49 @@ class TibberPricesOptionsFlowHandler(OptionsFlow): } ), ) + + async def async_step_price_trend(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: + """Configure price trend thresholds.""" + if user_input is not None: + self._options.update(user_input) + return self.async_create_entry(title="", data=self._options) + + return self.async_show_form( + step_id="price_trend", + data_schema=vol.Schema( + { + vol.Optional( + CONF_PRICE_TREND_THRESHOLD_RISING, + default=int( + self.config_entry.options.get( + CONF_PRICE_TREND_THRESHOLD_RISING, + DEFAULT_PRICE_TREND_THRESHOLD_RISING, + ) + ), + ): NumberSelector( + NumberSelectorConfig( + min=1, + max=50, + step=1, + mode=NumberSelectorMode.SLIDER, + ), + ), + vol.Optional( + CONF_PRICE_TREND_THRESHOLD_FALLING, + default=int( + self.config_entry.options.get( + CONF_PRICE_TREND_THRESHOLD_FALLING, + DEFAULT_PRICE_TREND_THRESHOLD_FALLING, + ) + ), + ): NumberSelector( + NumberSelectorConfig( + min=-50, + max=-1, + step=1, + mode=NumberSelectorMode.SLIDER, + ), + ), + } + ), + ) diff --git a/custom_components/tibber_prices/const.py b/custom_components/tibber_prices/const.py index 0d5c454..4f14dee 100644 --- a/custom_components/tibber_prices/const.py +++ b/custom_components/tibber_prices/const.py @@ -26,6 +26,8 @@ CONF_BEST_PRICE_MIN_PERIOD_LENGTH = "best_price_min_period_length" CONF_PEAK_PRICE_MIN_PERIOD_LENGTH = "peak_price_min_period_length" CONF_PRICE_RATING_THRESHOLD_LOW = "price_rating_threshold_low" CONF_PRICE_RATING_THRESHOLD_HIGH = "price_rating_threshold_high" +CONF_PRICE_TREND_THRESHOLD_RISING = "price_trend_threshold_rising" +CONF_PRICE_TREND_THRESHOLD_FALLING = "price_trend_threshold_falling" ATTRIBUTION = "Data provided by Tibber" @@ -40,6 +42,8 @@ DEFAULT_BEST_PRICE_MIN_PERIOD_LENGTH = 60 # 60 minutes minimum period length fo DEFAULT_PEAK_PRICE_MIN_PERIOD_LENGTH = 60 # 60 minutes minimum period length for peak price (user-facing, minutes) DEFAULT_PRICE_RATING_THRESHOLD_LOW = -10 # Default rating threshold low percentage DEFAULT_PRICE_RATING_THRESHOLD_HIGH = 10 # Default rating threshold high percentage +DEFAULT_PRICE_TREND_THRESHOLD_RISING = 5 # Default trend threshold for rising prices (%) +DEFAULT_PRICE_TREND_THRESHOLD_FALLING = -5 # Default trend threshold for falling prices (%, negative value) # Home types HOME_TYPE_APARTMENT = "APARTMENT" diff --git a/custom_components/tibber_prices/price_utils.py b/custom_components/tibber_prices/price_utils.py index 76fcd6e..4801fab 100644 --- a/custom_components/tibber_prices/price_utils.py +++ b/custom_components/tibber_prices/price_utils.py @@ -413,7 +413,8 @@ def aggregate_period_ratings( def calculate_price_trend( current_price: float, future_average: float, - threshold_pct: float = 5.0, + threshold_rising: float = 5.0, + threshold_falling: float = -5.0, ) -> tuple[str, float]: """ Calculate price trend by comparing current price with future average. @@ -421,7 +422,8 @@ def calculate_price_trend( Args: current_price: Current interval price future_average: Average price of future intervals - threshold_pct: Percentage threshold for stable vs rising/falling (default 5%) + threshold_rising: Percentage threshold for rising trend (positive, default 5%) + threshold_falling: Percentage threshold for falling trend (negative, default -5%) Returns: Tuple of (trend_state, difference_percentage) @@ -436,10 +438,11 @@ def calculate_price_trend( # Calculate percentage difference from current to future diff_pct = ((future_average - current_price) / current_price) * 100 - # Determine trend based on threshold - if diff_pct > threshold_pct: + # Determine trend based on thresholds + # threshold_falling is negative, so we compare with it directly + if diff_pct > threshold_rising: trend = "rising" - elif diff_pct < -threshold_pct: + elif diff_pct < threshold_falling: trend = "falling" else: trend = "stable" diff --git a/custom_components/tibber_prices/sensor.py b/custom_components/tibber_prices/sensor.py index 22c4d32..5ac9395 100644 --- a/custom_components/tibber_prices/sensor.py +++ b/custom_components/tibber_prices/sensor.py @@ -29,9 +29,13 @@ from .const import ( CONF_EXTENDED_DESCRIPTIONS, CONF_PRICE_RATING_THRESHOLD_HIGH, CONF_PRICE_RATING_THRESHOLD_LOW, + CONF_PRICE_TREND_THRESHOLD_FALLING, + CONF_PRICE_TREND_THRESHOLD_RISING, DEFAULT_EXTENDED_DESCRIPTIONS, DEFAULT_PRICE_RATING_THRESHOLD_HIGH, DEFAULT_PRICE_RATING_THRESHOLD_LOW, + DEFAULT_PRICE_TREND_THRESHOLD_FALLING, + DEFAULT_PRICE_TREND_THRESHOLD_RISING, DOMAIN, PRICE_LEVEL_MAPPING, PRICE_RATING_MAPPING, @@ -522,6 +526,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity): self._value_getter: Callable | None = self._get_value_getter() self._time_sensitive_remove_listener: Callable | None = None self._trend_attributes: dict[str, Any] = {} # Sensor-specific trend attributes + self._cached_trend_value: str | None = None # Cache for trend state async def async_added_to_hass(self) -> None: """When entity is added to hass.""" @@ -545,8 +550,21 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity): @callback def _handle_time_sensitive_update(self) -> None: """Handle time-sensitive update from coordinator.""" + # Clear cached trend values on time-sensitive updates + if self.entity_description.key.startswith("price_trend_"): + self._cached_trend_value = None + self._trend_attributes = {} self.async_write_ha_state() + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + # Clear cached trend values when coordinator data changes + if self.entity_description.key.startswith("price_trend_"): + self._cached_trend_value = None + self._trend_attributes = {} + super()._handle_coordinator_update() + def _get_value_getter(self) -> Callable | None: """Return the appropriate value getter method based on the sensor type.""" key = self.entity_description.key @@ -1201,6 +1219,11 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity): Trend state: "rising" | "falling" | "stable", or None if unavailable """ + # Return cached value if available to ensure consistency between + # native_value and extra_state_attributes + if self._cached_trend_value is not None and self._trend_attributes: + return self._cached_trend_value + if not self.coordinator.data: return None @@ -1223,15 +1246,29 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity): if future_avg is None: return None - # Calculate trend with 5% threshold - trend_state, diff_pct = calculate_price_trend(current_price, future_avg, threshold_pct=5.0) + # Get configured thresholds from options + threshold_rising = self.coordinator.config_entry.options.get( + CONF_PRICE_TREND_THRESHOLD_RISING, + DEFAULT_PRICE_TREND_THRESHOLD_RISING, + ) + threshold_falling = self.coordinator.config_entry.options.get( + CONF_PRICE_TREND_THRESHOLD_FALLING, + DEFAULT_PRICE_TREND_THRESHOLD_FALLING, + ) - # Store attributes in sensor-specific dictionary (not shared _attr_extra_state_attributes) + # Calculate trend with configured thresholds + trend_state, diff_pct = calculate_price_trend( + current_price, future_avg, threshold_rising=threshold_rising, threshold_falling=threshold_falling + ) + + # Store attributes in sensor-specific dictionary AND cache the trend value self._trend_attributes = { "timestamp": next_interval_start.isoformat(), f"trend_{hours}h_%": round(diff_pct, 1), f"future_avg_{hours}h": round(future_avg * 100, 2), - "intervals_analyzed": hours * 4, + "interval_count": hours * 4, + "threshold_rising": threshold_rising, + "threshold_falling": threshold_falling, } # Calculate additional attributes for better granularity @@ -1243,8 +1280,11 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity): # Calculate incremental change: how much does the later half differ from current? if current_price > 0: - incremental_diff = ((later_half_avg - current_price) / current_price) * 100 - self._trend_attributes["incremental_change"] = round(incremental_diff, 1) + later_half_diff = ((later_half_avg - current_price) / current_price) * 100 + self._trend_attributes[f"later_half_diff_{hours}h_%"] = round(later_half_diff, 1) + + # Cache the trend value for consistency + self._cached_trend_value = trend_state return trend_state @@ -1588,7 +1628,8 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity): key = self.entity_description.key attributes = {} - # For trend sensors, merge _trend_attributes (sensor-specific) + # For trend sensors, use the cached _trend_attributes + # These are populated when native_value is calculated if key.startswith("price_trend_") and hasattr(self, "_trend_attributes") and self._trend_attributes: attributes.update(self._trend_attributes) diff --git a/custom_components/tibber_prices/translations/de.json b/custom_components/tibber_prices/translations/de.json index 5944ee7..09d0483 100644 --- a/custom_components/tibber_prices/translations/de.json +++ b/custom_components/tibber_prices/translations/de.json @@ -109,6 +109,14 @@ "peak_price_flex": "Flexibilität: Maximale % unter dem Höchstpreis (negativer Wert)", "peak_price_min_distance_from_avg": "Mindestabstand: Erforderliche % über dem Tagesdurchschnitt" } + }, + "price_trend": { + "title": "Preistrend-Schwellenwerte", + "description": "Konfiguration der Schwellenwerte für Preistrend-Sensoren. Diese Sensoren vergleichen den aktuellen Preis mit dem Durchschnitt der nächsten N Stunden, um festzustellen, ob die Preise steigen, fallen oder stabil sind.", + "data": { + "price_trend_threshold_rising": "Schwellenwert für Steigen (% über aktuellem Preis)", + "price_trend_threshold_falling": "Schwellenwert für Fallen (% unter aktuellem Preis, negativer Wert)" + } } }, "error": { diff --git a/custom_components/tibber_prices/translations/en.json b/custom_components/tibber_prices/translations/en.json index f2793f0..4de89b9 100644 --- a/custom_components/tibber_prices/translations/en.json +++ b/custom_components/tibber_prices/translations/en.json @@ -109,6 +109,14 @@ "peak_price_flex": "Flexibility: Maximum % below maximum price (negative value)", "peak_price_min_distance_from_avg": "Minimum Distance: Required % above daily average" } + }, + "price_trend": { + "title": "Price Trend Thresholds", + "description": "Configure thresholds for price trend sensors. These sensors compare the current price with the average of the next N hours to determine if prices are rising, falling, or stable.", + "data": { + "price_trend_threshold_rising": "Rising Threshold (% above current price)", + "price_trend_threshold_falling": "Falling Threshold (% below current price, negative value)" + } } }, "error": { diff --git a/custom_components/tibber_prices/translations/nb.json b/custom_components/tibber_prices/translations/nb.json index e562138..d14234c 100644 --- a/custom_components/tibber_prices/translations/nb.json +++ b/custom_components/tibber_prices/translations/nb.json @@ -109,6 +109,14 @@ "peak_price_flex": "Fleksibilitet: Maksimum % under maksimumspris (negativ verdi)", "peak_price_min_distance_from_avg": "Minimumsavstand: Påkrevd % over daglig gjennomsnitt" } + }, + "price_trend": { + "title": "Terskelverdier for pristrend", + "description": "Konfigurer terskelverdier for pristrendsensorer. Disse sensorene sammenligner gjeldende pris med gjennomsnittet av de neste N timene for å avgjøre om prisene stiger, faller eller er stabile.", + "data": { + "price_trend_threshold_rising": "Stigende terskelverdi (% over gjeldende pris)", + "price_trend_threshold_falling": "Fallende terskelverdi (% under gjeldende pris, negativ verdi)" + } } }, "error": { diff --git a/custom_components/tibber_prices/translations/nl.json b/custom_components/tibber_prices/translations/nl.json index 03cda2a..d346c8c 100644 --- a/custom_components/tibber_prices/translations/nl.json +++ b/custom_components/tibber_prices/translations/nl.json @@ -109,6 +109,14 @@ "peak_price_flex": "Flexibiliteit: Maximaal % onder maximumprijs (negatieve waarde)", "peak_price_min_distance_from_avg": "Minimale afstand: Vereist % boven dagelijks gemiddelde" } + }, + "price_trend": { + "title": "Prijstrend drempelwaarden", + "description": "Configureer drempelwaarden voor prijstrendsensoren. Deze sensoren vergelijken de huidige prijs met het gemiddelde van de volgende N uur om te bepalen of de prijzen stijgen, dalen of stabiel zijn.", + "data": { + "price_trend_threshold_rising": "Stijgende drempelwaarde (% boven huidige prijs)", + "price_trend_threshold_falling": "Dalende drempelwaarde (% onder huidige prijs, negatieve waarde)" + } } }, "error": { diff --git a/custom_components/tibber_prices/translations/sv.json b/custom_components/tibber_prices/translations/sv.json index 66a8df3..05908e4 100644 --- a/custom_components/tibber_prices/translations/sv.json +++ b/custom_components/tibber_prices/translations/sv.json @@ -109,6 +109,14 @@ "peak_price_flex": "Flexibilitet: Maximalt % under maximumpris (negativt värde)", "peak_price_min_distance_from_avg": "Minimiavstånd: Krävd % över dagligt genomsnitt" } + }, + "price_trend": { + "title": "Pristrend tröskelvärden", + "description": "Konfigurera tröskelvärden för pristrendsensorer. Dessa sensorer jämför det aktuella priset med genomsnittet av de nästa N timmarna för att avgöra om priserna stiger, faller eller är stabila.", + "data": { + "price_trend_threshold_rising": "Stigande tröskelvärde (% över aktuellt pris)", + "price_trend_threshold_falling": "Fallande tröskelvärde (% under aktuellt pris, negativt värde)" + } } }, "error": {