mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-05-28 18:43:40 +00:00
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:
parent
ac09e5f235
commit
459d6762c7
2 changed files with 54 additions and 32 deletions
|
|
@ -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,6 +128,11 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity, RestoreEn
|
|||
# Store TimeService from Timer #2 for calculations during this update cycle
|
||||
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()
|
||||
|
||||
def _get_value_getter(self) -> Callable | None:
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,6 +365,27 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor):
|
|||
# Store TimeService from Timer #3 for calculations during this update cycle
|
||||
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()
|
||||
|
||||
@callback
|
||||
|
|
@ -387,15 +407,9 @@ 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:
|
||||
# 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:
|
||||
|
|
|
|||
Loading…
Reference in a new issue