perf(sensors): add call-avoidance for timer-based state updates

Skip expensive async_write_ha_state() when native_value hasn't changed
since last write. HA's state machine has built-in change detection, but
it only runs AFTER all properties and attributes are evaluated — the
expensive part we now avoid entirely.

Sensor platform (Timer #2 + #3):
- New _write_if_changed() method compares native_value before writing
- Timer #3 (30s, 7 entities): Skips all writes when no period active
- Timer #2 (15min, ~45 entities): Skips enum levels/ratings that stay
  constant across quarter-hour intervals
- Replaces data_lifecycle_status-only pattern with unified approach

Binary sensor platform (Timer #2):
- Period sensors only write at actual period boundaries, not every 15min

Coordinator push updates always write (sentinel reset ensures freshness).

Impact: Eliminates asyncio "Executing TimerHandle took 1.4s" warnings
caused by redundant property evaluation in Timer #3 callbacks. Reduces
event loop blocking from ~1.4s to microseconds when values unchanged.
This commit is contained in:
Julian Pawlowski 2026-04-09 19:04:04 +00:00
parent ac09e5f235
commit 459d6762c7
2 changed files with 54 additions and 32 deletions

View file

@ -33,6 +33,10 @@ if TYPE_CHECKING:
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService 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): class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity, RestoreEntity):
"""tibber_prices binary_sensor class with state restoration.""" """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._attr_unique_id = f"{coordinator.config_entry.entry_id}_{entity_description.key}"
self._state_getter: Callable | None = self._get_value_getter() self._state_getter: Callable | None = self._get_value_getter()
self._time_sensitive_remove_listener: Callable | None = None 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: async def async_added_to_hass(self) -> None:
"""When entity is added to hass.""" """When entity is added to hass."""
@ -122,6 +128,11 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity, RestoreEn
# Store TimeService from Timer #2 for calculations during this update cycle # Store TimeService from Timer #2 for calculations during this update cycle
self.coordinator.time = time_service self.coordinator.time = time_service
# 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() self.async_write_ha_state()
def _get_value_getter(self) -> Callable | None: def _get_value_getter(self) -> Callable | None:
@ -306,12 +317,9 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity, RestoreEn
@callback @callback
def _handle_coordinator_update(self) -> None: def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator.""" """Handle updated data from the coordinator."""
# All binary sensors get push updates when coordinator has new data: # Coordinator updates bring new API data — always write to ensure fresh state.
# - tomorrow_data_available: Reflects new data availability immediately after API fetch # Reset _last_written_state so timer-based handlers also write next cycle.
# - connection: Reflects connection state changes immediately self._last_written_state = _SENTINEL
# - 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
self.async_write_ha_state() self.async_write_ha_state()
@property @property

View file

@ -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) 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 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): class TibberPricesSensor(TibberPricesEntity, RestoreSensor):
"""tibber_prices Sensor class with state restoration.""" """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._value_getter: Callable | None = self._get_value_getter()
self._time_sensitive_remove_listener: Callable | None = None self._time_sensitive_remove_listener: Callable | None = None
self._minute_update_remove_listener: Callable | None = None self._minute_update_remove_listener: Callable | None = None
# Lifecycle sensor state change detection (for recorder optimization) # State change detection for call-avoidance optimization.
# Store as Any because native_value can be str/float/datetime depending on sensor type # Skips expensive async_write_ha_state() (property eval + attribute building)
self._last_lifecycle_state: Any = None # 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 # 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_last_update = None # Track last service call timestamp
self._chart_data_error = None # Track last service call error self._chart_data_error = None # Track last service call error
@ -342,17 +348,10 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor):
): ):
self._trend_calculator.clear_calculation_cache() self._trend_calculator.clear_calculation_cache()
# For lifecycle sensor: Only write state if it actually changed (state-change filter) # Call-avoidance: Skip expensive async_write_ha_state() when value unchanged.
# This enables precise detection at quarter-hour boundaries (23:45 turnover_pending, # This runs 4x/hour for ~45 entities. Many (enum levels, ratings, trends) stay
# 13:00 searching_tomorrow, 00:00 turnover complete) without recorder spam # constant across multiple quarter-hour intervals.
if self.entity_description.key == "data_lifecycle_status": self._write_if_changed()
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()
@callback @callback
def _handle_minute_update(self, time_service: TibberPricesTimeService) -> None: def _handle_minute_update(self, time_service: TibberPricesTimeService) -> None:
@ -366,6 +365,27 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor):
# Store TimeService from Timer #3 for calculations during this update cycle # Store TimeService from Timer #3 for calculations during this update cycle
self.coordinator.time = time_service self.coordinator.time = time_service
# 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() self.async_write_ha_state()
@callback @callback
@ -387,15 +407,9 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor):
# Schedule async refresh as a task (we're in a callback) # Schedule async refresh as a task (we're in a callback)
self.hass.async_create_task(self._refresh_chart_metadata()) self.hass.async_create_task(self._refresh_chart_metadata())
# For lifecycle sensor: Only write state if it actually changed (event-based filter) # Coordinator updates bring new API data — always write to ensure fresh state.
# Prevents excessive recorder entries while keeping quarter-hour update capability # Reset _last_written_value so timer-based handlers also write next cycle.
if self.entity_description.key == "data_lifecycle_status": self._last_written_value = _SENTINEL
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() super()._handle_coordinator_update()
def _get_value_getter(self) -> Callable | None: def _get_value_getter(self) -> Callable | None: