refactor(coordinator): remove redundant lifecycle callback system

Removed custom lifecycle callback push-update mechanism after confirming
it was redundant with Home Assistant's built-in DataUpdateCoordinator
pattern.

Root cause analysis showed HA's async_update_listeners() is called
synchronously (no await) immediately after _async_update_data() returns,
making separate lifecycle callbacks unnecessary.

Changes:
- coordinator/core.py: Removed lifecycle callback methods and notifications
- sensor/core.py: Removed lifecycle callback registration and cleanup
- sensor/attributes/lifecycle.py: Removed next_tomorrow_check attribute
- sensor/calculators/lifecycle.py: Removed get_next_tomorrow_check_time()

Impact: Simplified coordinator pattern, no user-visible changes. Standard
HA coordinator mechanism provides same immediate update guarantee without
custom callback complexity.
This commit is contained in:
Julian Pawlowski 2025-11-22 13:01:17 +00:00
parent f2627a5292
commit 48d6e2580a
4 changed files with 4 additions and 86 deletions

View file

@ -504,35 +504,6 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
return True return True
def register_lifecycle_callback(self, callback: Callable[[], None]) -> Callable[[], None]:
"""
Register callback for lifecycle state changes (push updates).
This allows sensors to receive immediate updates when the coordinator's
lifecycle state changes, instead of waiting for the next polling cycle.
Args:
callback: Function to call when lifecycle state changes (typically async_write_ha_state)
Returns:
Callable that unregisters the callback when called
"""
if callback not in self._lifecycle_callbacks:
self._lifecycle_callbacks.append(callback)
def unregister() -> None:
"""Unregister the lifecycle callback."""
if callback in self._lifecycle_callbacks:
self._lifecycle_callbacks.remove(callback)
return unregister
def _notify_lifecycle_change(self) -> None:
"""Notify registered callbacks about lifecycle state change (push update)."""
for lifecycle_callback in self._lifecycle_callbacks:
lifecycle_callback()
async def async_shutdown(self) -> None: async def async_shutdown(self) -> None:
""" """
Shut down the coordinator and clean up timers. Shut down the coordinator and clean up timers.
@ -661,7 +632,8 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
if self._last_price_update != old_price_update: if self._last_price_update != old_price_update:
self._api_calls_today += 1 self._api_calls_today += 1
self._lifecycle_state = "fresh" # Data just fetched self._lifecycle_state = "fresh" # Data just fetched
self._notify_lifecycle_change() # Push update: fresh data available # No separate lifecycle notification needed - normal async_update_listeners()
# will trigger all entities (including lifecycle sensor) after this return
return result return result
# Subentries get data from main coordinator (no lifecycle tracking - they don't fetch) # Subentries get data from main coordinator (no lifecycle tracking - they don't fetch)
@ -675,7 +647,8 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# Reset lifecycle state on error # Reset lifecycle state on error
self._is_fetching = False self._is_fetching = False
self._lifecycle_state = "error" self._lifecycle_state = "error"
self._notify_lifecycle_change() # Push update: error occurred # No separate lifecycle notification needed - error case returns data
# which triggers normal async_update_listeners()
return await self._data_fetcher.handle_api_error( return await self._data_fetcher.handle_api_error(
err, err,
self._transform_data_for_main_entry, self._transform_data_for_main_entry,

View file

@ -72,10 +72,6 @@ def build_lifecycle_attributes(
if next_poll: # None means data is complete, no more polls needed if next_poll: # None means data is complete, no more polls needed
attributes["next_api_poll"] = next_poll.isoformat() attributes["next_api_poll"] = next_poll.isoformat()
next_tomorrow_check = lifecycle_calculator.get_next_tomorrow_check_time()
if next_tomorrow_check:
attributes["next_tomorrow_check"] = next_tomorrow_check.isoformat()
next_midnight = lifecycle_calculator.get_next_midnight_turnover_time() next_midnight = lifecycle_calculator.get_next_midnight_turnover_time()
attributes["next_midnight_turnover"] = next_midnight.isoformat() attributes["next_midnight_turnover"] = next_midnight.isoformat()

View file

@ -181,29 +181,6 @@ class TibberPricesLifecycleCalculator(TibberPricesBaseCalculator):
# Fallback: If we don't know timer offset yet, assume 13:00:00 # Fallback: If we don't know timer offset yet, assume 13:00:00
return tomorrow_13 return tomorrow_13
def get_next_tomorrow_check_time(self) -> datetime | None:
"""
Calculate when the next tomorrow data check will occur.
Returns None if not applicable (before 13:00 or tomorrow already available).
"""
coordinator = self.coordinator
current_time = coordinator.time.now()
now_local = coordinator.time.as_local(current_time)
# Only relevant after 13:00
if now_local.hour < TOMORROW_CHECK_HOUR:
return None
# Only relevant if tomorrow data is missing
_, tomorrow_midnight = coordinator.time.get_day_boundaries("today")
tomorrow_date = tomorrow_midnight.date()
if not coordinator._needs_tomorrow_data(tomorrow_date): # noqa: SLF001 - Internal state access
return None
# Next check = next regular API poll (same as get_next_api_poll_time)
return self.get_next_api_poll_time()
def get_next_midnight_turnover_time(self) -> datetime: def get_next_midnight_turnover_time(self) -> datetime:
"""Calculate when the next midnight turnover will occur.""" """Calculate when the next midnight turnover will occur."""
coordinator = self.coordinator coordinator = self.coordinator

View file

@ -110,23 +110,11 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
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
self._lifecycle_remove_listener: Callable | None = None
# 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
self._chart_data_response = None # Store service response for attributes self._chart_data_response = None # Store service response for attributes
# Register for push updates if this is the lifecycle sensor
if entity_description.key == "data_lifecycle_status":
self._lifecycle_remove_listener = coordinator.register_lifecycle_callback(self.async_write_ha_state)
# Register for push updates if this is the chart_data_export sensor
# This ensures chart data is refreshed immediately when new price data arrives
if entity_description.key == "chart_data_export":
self._lifecycle_remove_listener = coordinator.register_lifecycle_callback(
self._handle_lifecycle_update_for_chart
)
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."""
await super().async_added_to_hass() await super().async_added_to_hass()
@ -161,11 +149,6 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
self._minute_update_remove_listener() self._minute_update_remove_listener()
self._minute_update_remove_listener = None self._minute_update_remove_listener = None
# Remove lifecycle listener if registered
if self._lifecycle_remove_listener:
self._lifecycle_remove_listener()
self._lifecycle_remove_listener = None
@callback @callback
def _handle_time_sensitive_update(self, time_service: TibberPricesTimeService) -> None: def _handle_time_sensitive_update(self, time_service: TibberPricesTimeService) -> None:
""" """
@ -200,17 +183,6 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
self.async_write_ha_state() self.async_write_ha_state()
@callback
def _handle_lifecycle_update_for_chart(self) -> None:
"""
Handle lifecycle state change for chart_data_export sensor.
When lifecycle state changes (especially to "fresh" after new API data),
refresh chart data immediately to ensure charts show latest prices.
"""
# Schedule async refresh as a task (we're in a callback)
self.hass.async_create_task(self._refresh_chart_data())
@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."""