refactor(coordinator): remove price_data from cache, delegate to Pool

Cache now stores only user metadata and timestamps. Price data is
managed exclusively by IntervalPool (single source of truth).

Changes:
- cache.py: Remove price_data and last_price_update fields
- core.py: Remove _cached_price_data, update references to use Pool
- core.py: Rename _data_fetcher to _price_data_manager
- AGENTS.md: Update class naming examples (DataFetcher → PriceDataManager)

This completes the Pool integration architecture where IntervalPool
handles all price data persistence and coordinator cache handles
only user account metadata.
This commit is contained in:
Julian Pawlowski 2025-12-23 14:15:26 +00:00
parent 9b34d416bc
commit 9eea984d1f
3 changed files with 102 additions and 162 deletions

View file

@ -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.

View file

@ -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

View file

@ -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."""