From 48d6e2580a2337cc420ce80e1fa37903c7a28a4c Mon Sep 17 00:00:00 2001 From: Julian Pawlowski Date: Sat, 22 Nov 2025 13:01:17 +0000 Subject: [PATCH] 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. --- .../tibber_prices/coordinator/core.py | 35 +++---------------- .../sensor/attributes/lifecycle.py | 4 --- .../sensor/calculators/lifecycle.py | 23 ------------ .../tibber_prices/sensor/core.py | 28 --------------- 4 files changed, 4 insertions(+), 86 deletions(-) diff --git a/custom_components/tibber_prices/coordinator/core.py b/custom_components/tibber_prices/coordinator/core.py index 2aeaa54..ff3d281 100644 --- a/custom_components/tibber_prices/coordinator/core.py +++ b/custom_components/tibber_prices/coordinator/core.py @@ -504,35 +504,6 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): 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: """ 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: self._api_calls_today += 1 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 # 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 self._is_fetching = False 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( err, self._transform_data_for_main_entry, diff --git a/custom_components/tibber_prices/sensor/attributes/lifecycle.py b/custom_components/tibber_prices/sensor/attributes/lifecycle.py index 5a6b947..5379ffd 100644 --- a/custom_components/tibber_prices/sensor/attributes/lifecycle.py +++ b/custom_components/tibber_prices/sensor/attributes/lifecycle.py @@ -72,10 +72,6 @@ def build_lifecycle_attributes( if next_poll: # None means data is complete, no more polls needed 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() attributes["next_midnight_turnover"] = next_midnight.isoformat() diff --git a/custom_components/tibber_prices/sensor/calculators/lifecycle.py b/custom_components/tibber_prices/sensor/calculators/lifecycle.py index 5da1775..27aab46 100644 --- a/custom_components/tibber_prices/sensor/calculators/lifecycle.py +++ b/custom_components/tibber_prices/sensor/calculators/lifecycle.py @@ -181,29 +181,6 @@ class TibberPricesLifecycleCalculator(TibberPricesBaseCalculator): # Fallback: If we don't know timer offset yet, assume 13:00:00 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: """Calculate when the next midnight turnover will occur.""" coordinator = self.coordinator diff --git a/custom_components/tibber_prices/sensor/core.py b/custom_components/tibber_prices/sensor/core.py index 52daabe..896b576 100644 --- a/custom_components/tibber_prices/sensor/core.py +++ b/custom_components/tibber_prices/sensor/core.py @@ -110,23 +110,11 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity): 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 - self._lifecycle_remove_listener: Callable | None = None # 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 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: """When entity is 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 = None - # Remove lifecycle listener if registered - if self._lifecycle_remove_listener: - self._lifecycle_remove_listener() - self._lifecycle_remove_listener = None - @callback def _handle_time_sensitive_update(self, time_service: TibberPricesTimeService) -> None: """ @@ -200,17 +183,6 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity): 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 def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator."""