diff --git a/custom_components/tibber_prices/binary_sensor/core.py b/custom_components/tibber_prices/binary_sensor/core.py index d50adb0..0233bb8 100644 --- a/custom_components/tibber_prices/binary_sensor/core.py +++ b/custom_components/tibber_prices/binary_sensor/core.py @@ -33,6 +33,10 @@ if TYPE_CHECKING: from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService +# Sentinel for _last_written_state: forces first write after init or coordinator update +_SENTINEL = object() + + class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity, RestoreEntity): """tibber_prices binary_sensor class with state restoration.""" @@ -85,6 +89,8 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity, RestoreEn self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{entity_description.key}" self._state_getter: Callable | None = self._get_value_getter() self._time_sensitive_remove_listener: Callable | None = None + # State change detection for call-avoidance optimization (see sensor/core.py for rationale) + self._last_written_state: bool | None | object = _SENTINEL async def async_added_to_hass(self) -> None: """When entity is added to hass.""" @@ -122,7 +128,12 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity, RestoreEn # Store TimeService from Timer #2 for calculations during this update cycle self.coordinator.time = time_service - self.async_write_ha_state() + # Call-avoidance: period binary sensors only change at period boundaries, + # not every 15 minutes. Skip expensive async_write_ha_state() when unchanged. + current_state = self.is_on + if current_state != self._last_written_state: + self._last_written_state = current_state + self.async_write_ha_state() def _get_value_getter(self) -> Callable | None: """Return the appropriate value getter method based on the sensor type.""" @@ -306,12 +317,9 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity, RestoreEn @callback def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" - # All binary sensors get push updates when coordinator has new data: - # - tomorrow_data_available: Reflects new data availability immediately after API fetch - # - connection: Reflects connection state changes immediately - # - chart_data_export: Updates chart data when price data changes - # - peak_price_period, best_price_period: Update when periods change (also get Timer #2 updates) - # - data_lifecycle_status: Gets both push and Timer #2 updates + # Coordinator updates bring new API data — always write to ensure fresh state. + # Reset _last_written_state so timer-based handlers also write next cycle. + self._last_written_state = _SENTINEL self.async_write_ha_state() @property diff --git a/custom_components/tibber_prices/sensor/core.py b/custom_components/tibber_prices/sensor/core.py index f585b55..ed65d19 100644 --- a/custom_components/tibber_prices/sensor/core.py +++ b/custom_components/tibber_prices/sensor/core.py @@ -96,6 +96,9 @@ LAST_HOUR_OF_DAY = 23 MAX_FORECAST_INTERVALS = 8 # Show up to 8 future intervals (2 hours with 15-min intervals) MIN_HOURS_FOR_LATER_HALF = 3 # Minimum hours needed to calculate later half average +# Sentinel for _last_written_value: forces first write after init or coordinator update +_SENTINEL = object() + class TibberPricesSensor(TibberPricesEntity, RestoreSensor): """tibber_prices Sensor class with state restoration.""" @@ -199,9 +202,12 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor): self._value_getter: Callable | None = self._get_value_getter() self._time_sensitive_remove_listener: Callable | None = None self._minute_update_remove_listener: Callable | None = None - # Lifecycle sensor state change detection (for recorder optimization) - # Store as Any because native_value can be str/float/datetime depending on sensor type - self._last_lifecycle_state: Any = None + # State change detection for call-avoidance optimization. + # Skips expensive async_write_ha_state() (property eval + attribute building) + # when native_value hasn't changed. HA's state machine has its own change detection, + # but it only runs AFTER all properties and attributes are evaluated. + # Store as Any because native_value can be str/float/datetime depending on sensor type. + self._last_written_value: Any = _SENTINEL # Chart data export (for chart_data_export sensor) - from binary_sensor self._chart_data_last_update = None # Track last service call timestamp self._chart_data_error = None # Track last service call error @@ -342,17 +348,10 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor): ): self._trend_calculator.clear_calculation_cache() - # For lifecycle sensor: Only write state if it actually changed (state-change filter) - # This enables precise detection at quarter-hour boundaries (23:45 turnover_pending, - # 13:00 searching_tomorrow, 00:00 turnover complete) without recorder spam - if self.entity_description.key == "data_lifecycle_status": - current_state = self.native_value - if current_state != self._last_lifecycle_state: - self._last_lifecycle_state = current_state - self.async_write_ha_state() - # If state didn't change, skip write to recorder - else: - self.async_write_ha_state() + # Call-avoidance: Skip expensive async_write_ha_state() when value unchanged. + # This runs 4x/hour for ~45 entities. Many (enum levels, ratings, trends) stay + # constant across multiple quarter-hour intervals. + self._write_if_changed() @callback def _handle_minute_update(self, time_service: TibberPricesTimeService) -> None: @@ -366,7 +365,28 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor): # Store TimeService from Timer #3 for calculations during this update cycle self.coordinator.time = time_service - self.async_write_ha_state() + # Call-avoidance: Skip expensive async_write_ha_state() when value unchanged. + # This runs 120x/hour for 7 countdown/progress entities. Many calls are redundant: + # - Progress (precision=0) changes only every ~72s for a 2h period + # - When no period is active, remaining_minutes/progress stay at 0 indefinitely + self._write_if_changed() + + @callback + def _write_if_changed(self) -> None: + """ + Write state only if native_value changed since last write. + + Call-avoidance optimization: evaluates native_value (cheap) once, + then skips the expensive async_write_ha_state() call if the value + is unchanged. async_write_ha_state() evaluates ALL properties + (native_value, extra_state_attributes, icon, available, etc.) and + builds the full attribute dict every time — even when HA's own + state machine would ultimately discard the identical update. + """ + current_value = self.native_value + if current_value != self._last_written_value: + self._last_written_value = current_value + self.async_write_ha_state() @callback def _handle_coordinator_update(self) -> None: @@ -387,16 +407,10 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor): # Schedule async refresh as a task (we're in a callback) self.hass.async_create_task(self._refresh_chart_metadata()) - # For lifecycle sensor: Only write state if it actually changed (event-based filter) - # Prevents excessive recorder entries while keeping quarter-hour update capability - if self.entity_description.key == "data_lifecycle_status": - current_state = self.native_value - if current_state != self._last_lifecycle_state: - self._last_lifecycle_state = current_state - super()._handle_coordinator_update() - # If state didn't change, skip write to recorder - else: - super()._handle_coordinator_update() + # 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 + super()._handle_coordinator_update() def _get_value_getter(self) -> Callable | None: """Return the appropriate value getter method based on the sensor type."""