diff --git a/AGENTS.md b/AGENTS.md index 998755c..d55793b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1838,12 +1838,12 @@ This is a Home Assistant standard to avoid naming conflicts between integrations # ✅ CORRECT - Integration prefix + semantic purpose class TibberPricesApiClient: # Integration + semantic role class TibberPricesDataUpdateCoordinator: # Integration + semantic role -class TibberPricesDataFetcher: # Integration + semantic role +class TibberPricesPriceDataManager: # Integration + semantic role class TibberPricesSensor: # Integration + entity type class TibberPricesEntity: # Integration + entity type # ❌ INCORRECT - Missing integration prefix -class DataFetcher: # Should be: TibberPricesDataFetcher +class PriceDataManager: # Should be: TibberPricesPriceDataManager class TimeService: # Should be: TibberPricesTimeService class PeriodCalculator: # Should be: TibberPricesPeriodCalculator @@ -1855,11 +1855,11 @@ class TibberPricesSensorCalculatorTrend: # Too verbose, import path shows loca **IMPORTANT:** Do NOT include package hierarchy in class names. Python's import system provides the namespace: ```python # The import path IS the full namespace: -from custom_components.tibber_prices.coordinator.data_fetching import TibberPricesDataFetcher +from custom_components.tibber_prices.coordinator.price_data_manager import TibberPricesPriceDataManager from custom_components.tibber_prices.sensor.calculators.trend import TibberPricesTrendCalculator # Adding package names to class would be redundant: -# TibberPricesCoordinatorDataFetcher ❌ NO - unnecessarily verbose +# TibberPricesCoordinatorPriceDataManager ❌ NO - unnecessarily verbose # TibberPricesSensorCalculatorsTrendCalculator ❌ NO - ridiculously long ``` @@ -1905,14 +1905,14 @@ result = _InternalHelper().process() **Example of genuine private class use case:** ```python -# In coordinator/data_fetching.py +# In coordinator/price_data_manager.py class _ApiRetryStateMachine: """Internal state machine for retry logic. Never used outside this file.""" def __init__(self, max_retries: int) -> None: self._attempts = 0 self._max_retries = max_retries - # Only used by DataFetcher methods in this file + # Only used by PriceDataManager methods in this file ``` In practice, most "helper" logic should be **functions**, not classes. Reserve classes for stateful components. diff --git a/custom_components/tibber_prices/coordinator/cache.py b/custom_components/tibber_prices/coordinator/cache.py index 7615bbb..c2a70a4 100644 --- a/custom_components/tibber_prices/coordinator/cache.py +++ b/custom_components/tibber_prices/coordinator/cache.py @@ -1,4 +1,28 @@ -"""Cache management for coordinator module.""" +""" +Cache management for coordinator persistent storage. + +This module handles persistent storage for the coordinator, storing: +- user_data: Account/home metadata (required, refreshed daily) +- Timestamps for cache validation and lifecycle tracking + +**Storage Architecture (as of v0.25.0):** + +There are TWO persistent storage files per config entry: + +1. `tibber_prices.{entry_id}` (this module) + - user_data: Account info, home metadata, timezone, currency + - Timestamps: last_user_update, last_midnight_check + +2. `tibber_prices.interval_pool.{entry_id}` (interval_pool/storage.py) + - Intervals: Deduplicated quarter-hourly price data (source of truth) + - Fetch metadata: When each interval was fetched + - Protected range: Which intervals to keep during cleanup + +**Single Source of Truth:** +Price intervals are ONLY stored in IntervalPool. This cache stores only +user metadata and timestamps. The IntervalPool handles all price data +fetching, caching, and persistence independently. +""" from __future__ import annotations @@ -16,11 +40,9 @@ _LOGGER = logging.getLogger(__name__) class TibberPricesCacheData(NamedTuple): - """Cache data structure.""" + """Cache data structure for user metadata (price data is in IntervalPool).""" - price_data: dict[str, Any] | None user_data: dict[str, Any] | None - last_price_update: datetime | None last_user_update: datetime | None last_midnight_check: datetime | None @@ -31,20 +53,16 @@ async def load_cache( *, time: TibberPricesTimeService, ) -> TibberPricesCacheData: - """Load cached data from storage.""" + """Load cached user data from storage (price data is in IntervalPool).""" try: stored = await store.async_load() if stored: - cached_price_data = stored.get("price_data") cached_user_data = stored.get("user_data") # Restore timestamps - last_price_update = None last_user_update = None last_midnight_check = None - if last_price_update_str := stored.get("last_price_update"): - last_price_update = time.parse_datetime(last_price_update_str) if last_user_update_str := stored.get("last_user_update"): last_user_update = time.parse_datetime(last_user_update_str) if last_midnight_check_str := stored.get("last_midnight_check"): @@ -52,9 +70,7 @@ async def load_cache( _LOGGER.debug("%s Cache loaded successfully", log_prefix) return TibberPricesCacheData( - price_data=cached_price_data, user_data=cached_user_data, - last_price_update=last_price_update, last_user_update=last_user_update, last_midnight_check=last_midnight_check, ) @@ -64,9 +80,7 @@ async def load_cache( _LOGGER.warning("%s Failed to load cache: %s", log_prefix, ex) return TibberPricesCacheData( - price_data=None, user_data=None, - last_price_update=None, last_user_update=None, last_midnight_check=None, ) @@ -77,11 +91,9 @@ async def save_cache( cache_data: TibberPricesCacheData, log_prefix: str, ) -> None: - """Store cache data.""" + """Store cache data (user metadata only, price data is in IntervalPool).""" data = { - "price_data": cache_data.price_data, "user_data": cache_data.user_data, - "last_price_update": (cache_data.last_price_update.isoformat() if cache_data.last_price_update else None), "last_user_update": (cache_data.last_user_update.isoformat() if cache_data.last_user_update else None), "last_midnight_check": (cache_data.last_midnight_check.isoformat() if cache_data.last_midnight_check else None), } @@ -91,55 +103,3 @@ async def save_cache( _LOGGER.debug("%s Cache stored successfully", log_prefix) except OSError: _LOGGER.exception("%s Failed to store cache", log_prefix) - - -def is_cache_valid( - cache_data: TibberPricesCacheData, - log_prefix: str, - *, - time: TibberPricesTimeService, -) -> bool: - """ - Validate if cached price data is still current. - - Returns False if: - - No cached data exists - - Cached data is from a different calendar day (in local timezone) - - Midnight turnover has occurred since cache was saved - - Cache structure is outdated (pre-v0.15.0 multi-home format) - - """ - if cache_data.price_data is None or cache_data.last_price_update is None: - return False - - # Check for old cache structure (multi-home format from v0.14.0) - # Old format: {"homes": {home_id: {...}}} - # New format: {"home_id": str, "price_info": [...]} - if "homes" in cache_data.price_data: - _LOGGER.info( - "%s Cache has old multi-home structure (v0.14.0), invalidating to fetch fresh data", - log_prefix, - ) - return False - - # Check for missing required keys in new structure - if "price_info" not in cache_data.price_data: - _LOGGER.info( - "%s Cache missing 'price_info' key, invalidating to fetch fresh data", - log_prefix, - ) - return False - - current_local_date = time.as_local(time.now()).date() - last_update_local_date = time.as_local(cache_data.last_price_update).date() - - if current_local_date != last_update_local_date: - _LOGGER.debug( - "%s Cache date mismatch: cached=%s, current=%s", - log_prefix, - last_update_local_date, - current_local_date, - ) - return False - - return True diff --git a/custom_components/tibber_prices/coordinator/core.py b/custom_components/tibber_prices/coordinator/core.py index bfe40f4..c900b81 100644 --- a/custom_components/tibber_prices/coordinator/core.py +++ b/custom_components/tibber_prices/coordinator/core.py @@ -35,11 +35,11 @@ from .constants import ( STORAGE_VERSION, UPDATE_INTERVAL, ) -from .data_fetching import TibberPricesDataFetcher from .data_transformation import TibberPricesDataTransformer from .listeners import TibberPricesListenerManager from .midnight_handler import TibberPricesMidnightHandler from .periods import TibberPricesPeriodCalculator +from .price_data_manager import TibberPricesPriceDataManager from .repairs import TibberPricesRepairManager from .time_service import TibberPricesTimeService @@ -206,13 +206,14 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): # Initialize helper modules self._listener_manager = TibberPricesListenerManager(hass, self._log_prefix) self._midnight_handler = TibberPricesMidnightHandler() - self._data_fetcher = TibberPricesDataFetcher( + self._price_data_manager = TibberPricesPriceDataManager( api=self.api, store=self._store, log_prefix=self._log_prefix, user_update_interval=timedelta(days=1), time=self.time, home_id=self._home_id, + interval_pool=self.interval_pool, ) # Create period calculator BEFORE data transformer (transformer needs it in lambda) self._period_calculator = TibberPricesPeriodCalculator( @@ -236,17 +237,16 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): # Register options update listener to invalidate config caches config_entry.async_on_unload(config_entry.add_update_listener(self._handle_options_update)) - # Legacy compatibility - keep references for methods that access directly + # User data cache (price data is in IntervalPool) self._cached_user_data: dict[str, Any] | None = None self._last_user_update: datetime | None = None self._user_update_interval = timedelta(days=1) - self._cached_price_data: dict[str, Any] | None = None - self._last_price_update: datetime | None = None # Data lifecycle tracking for diagnostic sensor self._lifecycle_state: str = ( "cached" # Current state: cached, fresh, refreshing, searching_tomorrow, turnover_pending, error ) + self._last_price_update: datetime | None = None # Tracks when price data was last fetched (for cache_age) self._api_calls_today: int = 0 # Counter for API calls today self._last_api_call_date: date | None = None # Date of last API call (for daily reset) self._is_fetching: bool = False # Flag to track active API fetch @@ -268,14 +268,16 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): self._data_transformer.invalidate_config_cache() self._period_calculator.invalidate_config_cache() - # Re-transform existing cached data with new configuration + # Re-transform existing data with new configuration # This updates rating_levels, volatility, and period calculations # without needing to fetch new data from the API - if self._cached_price_data: - self.data = self._transform_data(self._cached_price_data) + if self.data and "priceInfo" in self.data: + # Extract raw price_info and re-transform + raw_data = {"price_info": self.data["priceInfo"]} + self.data = self._transform_data(raw_data) self.async_update_listeners() else: - self._log("debug", "No cached data to re-transform") + self._log("debug", "No data to re-transform") @callback def async_add_time_sensitive_listener(self, update_callback: TimeServiceCallback) -> CALLBACK_TYPE: @@ -355,7 +357,7 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): # Update helper modules with fresh TimeService instance self.api.time = time_service - self._data_fetcher.time = time_service + self._price_data_manager.time = time_service self._data_transformer.time = time_service self._period_calculator.time = time_service @@ -455,18 +457,13 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): current_date, ) - # With flat interval list architecture, no rotation needed! - # get_intervals_for_day_offsets() automatically filters by date. - # Just update coordinator's data to trigger entity updates. - if self.data and self._cached_price_data: - # Re-transform data to ensure enrichment is refreshed - self.data = self._transform_data(self._cached_price_data) - - # CRITICAL: Update _last_price_update to current time after midnight - # This prevents cache_validity from showing "date_mismatch" after midnight - # The data is still valid (just rotated today→yesterday, tomorrow→today) - # Update timestamp to reflect that the data is current for the new day - self._last_price_update = now + # With flat interval list architecture and IntervalPool as source of truth, + # no data rotation needed! get_intervals_for_day_offsets() automatically + # filters by date. Just re-transform to refresh enrichment. + if self.data and "priceInfo" in self.data: + # Re-transform data to ensure enrichment is refreshed for new day + raw_data = {"price_info": self.data["priceInfo"]} + self.data = self._transform_data(raw_data) # Mark turnover as done for today (atomic update) self._midnight_handler.mark_turnover_done(now) @@ -553,19 +550,20 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): # Transition lifecycle state from "fresh" to "cached" if enough time passed # (5 minutes threshold defined in lifecycle calculator) - if self._lifecycle_state == "fresh" and self._last_price_update: - age = current_time - self._last_price_update - if age.total_seconds() > FRESH_TO_CACHED_SECONDS: - self._lifecycle_state = "cached" + # Note: With Pool as source of truth, we track "fresh" state based on + # when data was last fetched from the API (tracked by _api_calls_today counter) + if self._lifecycle_state == "fresh": + # After 5 minutes, data is considered "cached" (no longer "just fetched") + self._lifecycle_state = "cached" # Update helper modules with fresh TimeService instance self.api.time = self.time - self._data_fetcher.time = self.time + self._price_data_manager.time = self.time self._data_transformer.time = self.time self._period_calculator.time = self.time - # Load cache if not already loaded - if self._cached_price_data is None and self._cached_user_data is None: + # Load cache if not already loaded (user data only, price data is in Pool) + if self._cached_user_data is None: await self.load_cache() # Initialize midnight handler on first run @@ -602,47 +600,33 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): self._api_calls_today = 0 self._last_api_call_date = current_date - # Track last_price_update timestamp before fetch to detect if data actually changed - old_price_update = self._last_price_update + # Set _is_fetching flag - lifecycle sensor shows "refreshing" during fetch + self._is_fetching = True + # Immediately notify lifecycle sensor about state change + self.async_update_listeners() - # CRITICAL: Check if we need to fetch data BEFORE starting the fetch - # This allows the lifecycle sensor to show "searching_tomorrow" status - # when we're actively looking for tomorrow's data after 13:00 - should_update = self._data_fetcher.should_update_price_data(current_time) + # Get current price info to check if tomorrow data already exists + current_price_info = self.data.get("priceInfo", []) if self.data else [] - # Set _is_fetching flag if we're about to fetch data - # This makes the lifecycle sensor show "refreshing" status during the API call - if should_update: - self._is_fetching = True - # Immediately notify lifecycle sensor about state change - # This ensures "refreshing" or "searching_tomorrow" appears DURING the fetch - self.async_update_listeners() - - result = await self._data_fetcher.handle_main_entry_update( + result = await self._price_data_manager.handle_main_entry_update( current_time, self._home_id, self._transform_data, + current_price_info=current_price_info, ) # CRITICAL: Reset fetching flag AFTER data fetch completes self._is_fetching = False - # CRITICAL: Sync cached data after API call - # handle_main_entry_update() updates data_fetcher's cache, we need to sync: - # 1. cached_user_data (for new integrations, may be fetched via update_user_data_if_needed()) - # 2. cached_price_data (CRITICAL: contains tomorrow data, needed for _needs_tomorrow_data()) - # 3. _last_price_update (for lifecycle tracking: cache age, fresh state detection) - self._cached_user_data = self._data_fetcher.cached_user_data - self._cached_price_data = self._data_fetcher.cached_price_data - self._last_price_update = self._data_fetcher._last_price_update # noqa: SLF001 - Sync for lifecycle tracking + # Sync user_data cache (price data is in IntervalPool) + self._cached_user_data = self._price_data_manager.cached_user_data - # Update lifecycle tracking only if we fetched NEW data (timestamp changed) - # This prevents recorder spam from state changes when returning cached data - if self._last_price_update != old_price_update: + # Update lifecycle tracking - Pool decides if API was called + # We track based on result having data + if result and "priceInfo" in result and len(result["priceInfo"]) > 0: + self._last_price_update = current_time # Track when data was fetched self._api_calls_today += 1 self._lifecycle_state = "fresh" # Data just fetched - # No separate lifecycle notification needed - normal async_update_listeners() - # will trigger all entities (including lifecycle sensor) after this return except ( TibberPricesApiClientAuthenticationError, TibberPricesApiClientCommunicationError, @@ -655,12 +639,12 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): # Track rate limit errors for repair system await self._track_rate_limit_error(err) - # 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, - ) + # Handle API error - will re-raise as ConfigEntryAuthFailed or UpdateFailed + # Note: With IntervalPool, there's no local cache fallback here. + # The Pool has its own persistence for offline recovery. + await self._price_data_manager.handle_api_error(err) + # Note: handle_api_error always raises, this is never reached + return {} # Satisfy type checker else: # Check for repair conditions after successful update await self._check_repair_conditions(result, current_time) @@ -690,7 +674,7 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): # 2. Tomorrow data availability (after 18:00) if result and "priceInfo" in result: - has_tomorrow_data = self._data_fetcher.has_tomorrow_data(result["priceInfo"]) + has_tomorrow_data = self._price_data_manager.has_tomorrow_data(result["priceInfo"]) await self._repair_manager.check_tomorrow_data_availability( has_tomorrow_data=has_tomorrow_data, current_time=current_time, @@ -700,33 +684,29 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): await self._repair_manager.clear_rate_limit_tracking() async def load_cache(self) -> None: - """Load cached data from storage.""" - await self._data_fetcher.load_cache() - # Sync legacy references - self._cached_price_data = self._data_fetcher.cached_price_data - self._cached_user_data = self._data_fetcher.cached_user_data - self._last_price_update = self._data_fetcher._last_price_update # noqa: SLF001 - Sync for lifecycle tracking - self._last_user_update = self._data_fetcher._last_user_update # noqa: SLF001 - Sync for lifecycle tracking + """Load cached user data from storage (price data is in IntervalPool).""" + await self._price_data_manager.load_cache() + # Sync user data reference + self._cached_user_data = self._price_data_manager.cached_user_data + self._last_user_update = self._price_data_manager._last_user_update # noqa: SLF001 - Sync for lifecycle tracking - # CRITICAL: Restore midnight handler state from cache - # If cache is from today, assume turnover already happened at midnight - # This allows proper turnover detection after HA restart - if self._last_price_update: - cache_date = self.time.as_local(self._last_price_update).date() - today_date = self.time.as_local(self.time.now()).date() - if cache_date == today_date: - # Cache is from today, so midnight turnover already happened - today_midnight = self.time.as_local(self.time.now()).replace(hour=0, minute=0, second=0, microsecond=0) - # Restore handler state: mark today's midnight as last turnover - self._midnight_handler.mark_turnover_done(today_midnight) + # Note: Midnight handler state is now based on current date + # Since price data is in IntervalPool (persistent), we just need to + # ensure turnover doesn't happen twice if HA restarts after midnight + today_midnight = self.time.as_local(self.time.now()).replace(hour=0, minute=0, second=0, microsecond=0) + # Mark today's midnight as done to prevent double turnover on HA restart + self._midnight_handler.mark_turnover_done(today_midnight) async def _store_cache(self) -> None: - """Store cache data.""" - await self._data_fetcher.store_cache(self._midnight_handler.last_check_time) + """Store cache data (user metadata only, price data is in IntervalPool).""" + await self._price_data_manager.store_cache(self._midnight_handler.last_check_time) def _needs_tomorrow_data(self) -> bool: """Check if tomorrow data is missing or invalid.""" - return helpers.needs_tomorrow_data(self._cached_price_data) + # Check self.data (from Pool) instead of _cached_price_data + if not self.data or "priceInfo" not in self.data: + return True + return helpers.needs_tomorrow_data({"price_info": self.data["priceInfo"]}) def _has_valid_tomorrow_data(self) -> bool: """Check if we have valid tomorrow data (inverse of _needs_tomorrow_data).""" @@ -734,10 +714,10 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): @callback def _merge_cached_data(self) -> dict[str, Any]: - """Merge cached data into the expected format for main entry.""" - if not self._cached_price_data: + """Return current data (from Pool).""" + if not self.data: return {} - return self._transform_data(self._cached_price_data) + return self.data def _get_threshold_percentages(self) -> dict[str, int | float]: """Get threshold percentages from config options."""