mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-03-30 05:13:40 +00:00
refactor(coordinator): replace DataFetcher with PriceDataManager
Rename and refactor data_fetching.py → price_data_manager.py to reflect actual responsibilities: - User data: Fetches directly via API, validates, caches - Price data: Delegates to IntervalPool (single source of truth) Key changes: - Add should_fetch_tomorrow_data() for intelligent API call decisions - Add include_tomorrow parameter to prevent API spam before 13:00 - Remove cached_price_data property (Pool is source of truth) - Update tests to use new class name Impact: Clearer separation of concerns, reduced API calls through intelligent tomorrow data fetching logic.
This commit is contained in:
parent
78df8a4b17
commit
cfc7cf6abc
2 changed files with 274 additions and 171 deletions
|
|
@ -1,10 +1,32 @@
|
||||||
"""Data fetching logic for the coordinator."""
|
"""
|
||||||
|
Price data management for the coordinator.
|
||||||
|
|
||||||
|
This module manages all price-related data for the Tibber Prices integration:
|
||||||
|
|
||||||
|
**User Data** (fetched directly via API):
|
||||||
|
- Home metadata (name, address, timezone)
|
||||||
|
- Account info (subscription status)
|
||||||
|
- Currency settings
|
||||||
|
- Refreshed daily (24h interval)
|
||||||
|
|
||||||
|
**Price Data** (fetched via IntervalPool):
|
||||||
|
- Quarter-hourly price intervals
|
||||||
|
- Yesterday/today/tomorrow coverage
|
||||||
|
- The IntervalPool handles actual API fetching, deduplication, and caching
|
||||||
|
- This manager coordinates the data flow and user data refresh
|
||||||
|
|
||||||
|
Data flow:
|
||||||
|
Tibber API → IntervalPool → PriceDataManager → Coordinator → Sensors
|
||||||
|
↑ ↓
|
||||||
|
(actual fetching) (orchestration + user data)
|
||||||
|
|
||||||
|
Note: Price data is NOT cached in this module - IntervalPool is the single
|
||||||
|
source of truth. This module only caches user_data for daily refresh cycle.
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
import logging
|
||||||
import secrets
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
|
@ -13,26 +35,41 @@ from custom_components.tibber_prices.api import (
|
||||||
TibberPricesApiClientCommunicationError,
|
TibberPricesApiClientCommunicationError,
|
||||||
TibberPricesApiClientError,
|
TibberPricesApiClientError,
|
||||||
)
|
)
|
||||||
from homeassistant.core import callback
|
|
||||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||||
from homeassistant.helpers.update_coordinator import UpdateFailed
|
from homeassistant.helpers.update_coordinator import UpdateFailed
|
||||||
|
|
||||||
from . import cache, helpers
|
from . import cache, helpers
|
||||||
from .constants import TOMORROW_DATA_CHECK_HOUR, TOMORROW_DATA_RANDOM_DELAY_MAX
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from custom_components.tibber_prices.api import TibberPricesApiClient
|
from custom_components.tibber_prices.api import TibberPricesApiClient
|
||||||
|
from custom_components.tibber_prices.interval_pool import TibberPricesIntervalPool
|
||||||
|
|
||||||
from .time_service import TibberPricesTimeService
|
from .time_service import TibberPricesTimeService
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Hour when Tibber publishes tomorrow's prices (around 13:00 local time)
|
||||||
|
# Before this hour, requesting tomorrow data will always fail → wasted API call
|
||||||
|
TOMORROW_DATA_AVAILABLE_HOUR = 13
|
||||||
|
|
||||||
class TibberPricesDataFetcher:
|
|
||||||
"""Handles data fetching, caching, and main/subentry coordination."""
|
class TibberPricesPriceDataManager:
|
||||||
|
"""
|
||||||
|
Manages price and user data for the coordinator.
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
- User data: Fetches directly via API, validates, caches with persistence
|
||||||
|
- Price data: Coordinates with IntervalPool (which does actual API fetching)
|
||||||
|
- Cache management: Loads/stores both data types to HA persistent storage
|
||||||
|
- Update decisions: Determines when fresh data is needed
|
||||||
|
|
||||||
|
Note: Despite the name, this class does NOT do the actual price fetching.
|
||||||
|
The IntervalPool handles API calls, deduplication, and interval management.
|
||||||
|
This class orchestrates WHEN to fetch and processes the results.
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__( # noqa: PLR0913
|
def __init__( # noqa: PLR0913
|
||||||
self,
|
self,
|
||||||
|
|
@ -42,19 +79,31 @@ class TibberPricesDataFetcher:
|
||||||
user_update_interval: timedelta,
|
user_update_interval: timedelta,
|
||||||
time: TibberPricesTimeService,
|
time: TibberPricesTimeService,
|
||||||
home_id: str,
|
home_id: str,
|
||||||
|
interval_pool: TibberPricesIntervalPool,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize the data fetcher."""
|
"""
|
||||||
|
Initialize the price data manager.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
api: API client for direct requests (user data only).
|
||||||
|
store: Home Assistant storage for persistence.
|
||||||
|
log_prefix: Prefix for log messages (e.g., "[Home Name]").
|
||||||
|
user_update_interval: How often to refresh user data (default: 1 day).
|
||||||
|
time: TimeService for time operations.
|
||||||
|
home_id: Home ID this manager is responsible for.
|
||||||
|
interval_pool: IntervalPool for price data (handles actual fetching).
|
||||||
|
|
||||||
|
"""
|
||||||
self.api = api
|
self.api = api
|
||||||
self._store = store
|
self._store = store
|
||||||
self._log_prefix = log_prefix
|
self._log_prefix = log_prefix
|
||||||
self._user_update_interval = user_update_interval
|
self._user_update_interval = user_update_interval
|
||||||
self.time: TibberPricesTimeService = time
|
self.time: TibberPricesTimeService = time
|
||||||
self.home_id = home_id
|
self.home_id = home_id
|
||||||
|
self._interval_pool = interval_pool
|
||||||
|
|
||||||
# Cached data
|
# Cached data (user data only - price data is in IntervalPool)
|
||||||
self._cached_price_data: dict[str, Any] | None = None
|
|
||||||
self._cached_user_data: dict[str, Any] | None = None
|
self._cached_user_data: dict[str, Any] | None = None
|
||||||
self._last_price_update: datetime | None = None
|
|
||||||
self._last_user_update: datetime | None = None
|
self._last_user_update: datetime | None = None
|
||||||
|
|
||||||
def _log(self, level: str, message: str, *args: object, **kwargs: object) -> None:
|
def _log(self, level: str, message: str, *args: object, **kwargs: object) -> None:
|
||||||
|
|
@ -63,31 +112,67 @@ class TibberPricesDataFetcher:
|
||||||
getattr(_LOGGER, level)(prefixed_message, *args, **kwargs)
|
getattr(_LOGGER, level)(prefixed_message, *args, **kwargs)
|
||||||
|
|
||||||
async def load_cache(self) -> None:
|
async def load_cache(self) -> None:
|
||||||
"""Load cached data from storage."""
|
"""Load cached user data from storage (price data is in IntervalPool)."""
|
||||||
cache_data = await cache.load_cache(self._store, self._log_prefix, time=self.time)
|
cache_data = await cache.load_cache(self._store, self._log_prefix, time=self.time)
|
||||||
|
|
||||||
self._cached_price_data = cache_data.price_data
|
|
||||||
self._cached_user_data = cache_data.user_data
|
self._cached_user_data = cache_data.user_data
|
||||||
self._last_price_update = cache_data.last_price_update
|
|
||||||
self._last_user_update = cache_data.last_user_update
|
self._last_user_update = cache_data.last_user_update
|
||||||
|
|
||||||
# Parse timestamps if we loaded price data from cache
|
def should_fetch_tomorrow_data(
|
||||||
if self._cached_price_data:
|
self,
|
||||||
self._cached_price_data = helpers.parse_all_timestamps(self._cached_price_data, time=self.time)
|
current_price_info: list[dict[str, Any]] | None,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Determine if tomorrow's data should be requested from the API.
|
||||||
|
|
||||||
# Validate cache: check if price data is from a previous day
|
This is the key intelligence that prevents API spam:
|
||||||
if not cache.is_cache_valid(cache_data, self._log_prefix, time=self.time):
|
- Tibber publishes tomorrow's prices around 13:00 each day
|
||||||
self._log("info", "Cached price data is from a previous day, clearing cache to fetch fresh data")
|
- Before 13:00, requesting tomorrow data will always fail → wasted API call
|
||||||
self._cached_price_data = None
|
- If we already have tomorrow data, no need to request it again
|
||||||
self._last_price_update = None
|
|
||||||
await self.store_cache()
|
The decision logic:
|
||||||
|
1. Before 13:00 local time → Don't fetch (data not available yet)
|
||||||
|
2. After 13:00 AND tomorrow data already present → Don't fetch (already have it)
|
||||||
|
3. After 13:00 AND tomorrow data missing → Fetch (data should be available)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
current_price_info: List of price intervals from current coordinator data.
|
||||||
|
Used to check if tomorrow data already exists.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if tomorrow data should be requested, False otherwise.
|
||||||
|
|
||||||
|
"""
|
||||||
|
now = self.time.now()
|
||||||
|
now_local = self.time.as_local(now)
|
||||||
|
current_hour = now_local.hour
|
||||||
|
|
||||||
|
# Before TOMORROW_DATA_AVAILABLE_HOUR - tomorrow data not available yet
|
||||||
|
if current_hour < TOMORROW_DATA_AVAILABLE_HOUR:
|
||||||
|
self._log("debug", "Before %d:00 - not requesting tomorrow data", TOMORROW_DATA_AVAILABLE_HOUR)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# After TOMORROW_DATA_AVAILABLE_HOUR - check if we already have tomorrow data
|
||||||
|
if current_price_info:
|
||||||
|
has_tomorrow = self.has_tomorrow_data(current_price_info)
|
||||||
|
if has_tomorrow:
|
||||||
|
self._log(
|
||||||
|
"debug", "After %d:00 but already have tomorrow data - not requesting", TOMORROW_DATA_AVAILABLE_HOUR
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
self._log("debug", "After %d:00 and tomorrow data missing - will request", TOMORROW_DATA_AVAILABLE_HOUR)
|
||||||
|
return True
|
||||||
|
|
||||||
|
# No current data - request tomorrow data if after TOMORROW_DATA_AVAILABLE_HOUR
|
||||||
|
self._log(
|
||||||
|
"debug", "After %d:00 with no current data - will request tomorrow data", TOMORROW_DATA_AVAILABLE_HOUR
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
async def store_cache(self, last_midnight_check: datetime | None = None) -> None:
|
async def store_cache(self, last_midnight_check: datetime | None = None) -> None:
|
||||||
"""Store cache data."""
|
"""Store cache data (user metadata only, price data is in IntervalPool)."""
|
||||||
cache_data = cache.TibberPricesCacheData(
|
cache_data = cache.TibberPricesCacheData(
|
||||||
price_data=self._cached_price_data,
|
|
||||||
user_data=self._cached_user_data,
|
user_data=self._cached_user_data,
|
||||||
last_price_update=self._last_price_update,
|
|
||||||
last_user_update=self._last_user_update,
|
last_user_update=self._last_user_update,
|
||||||
last_midnight_check=last_midnight_check,
|
last_midnight_check=last_midnight_check,
|
||||||
)
|
)
|
||||||
|
|
@ -196,57 +281,23 @@ class TibberPricesDataFetcher:
|
||||||
return True # User data was updated
|
return True # User data was updated
|
||||||
return False # No update needed
|
return False # No update needed
|
||||||
|
|
||||||
@callback
|
async def fetch_home_data(
|
||||||
def should_update_price_data(self, current_time: datetime) -> bool | str:
|
self,
|
||||||
|
home_id: str,
|
||||||
|
current_time: datetime,
|
||||||
|
*,
|
||||||
|
include_tomorrow: bool = True,
|
||||||
|
) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Check if price data should be updated from the API.
|
Fetch data for a single home via pool.
|
||||||
|
|
||||||
API calls only happen when truly needed:
|
Args:
|
||||||
1. No cached data exists
|
home_id: Home ID to fetch data for.
|
||||||
2. Cache is invalid (from previous day - detected by _is_cache_valid)
|
current_time: Current time for timestamp in result.
|
||||||
3. After 13:00 local time and tomorrow's data is missing or invalid
|
include_tomorrow: If True, request tomorrow's data too. If False,
|
||||||
|
only request up to end of today.
|
||||||
Cache validity is ensured by:
|
|
||||||
- _is_cache_valid() checks date mismatch on load
|
|
||||||
- Midnight turnover clears cache (Timer #2)
|
|
||||||
- Tomorrow data validation after 13:00
|
|
||||||
|
|
||||||
No periodic "safety" updates - trust the cache validation!
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool or str: True for immediate update, "tomorrow_check" for tomorrow
|
|
||||||
data check (needs random delay), False for no update
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
if self._cached_price_data is None:
|
|
||||||
self._log("debug", "API update needed: No cached price data")
|
|
||||||
return True
|
|
||||||
if self._last_price_update is None:
|
|
||||||
self._log("debug", "API update needed: No last price update timestamp")
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Check if after 13:00 and tomorrow data is missing or invalid
|
|
||||||
now_local = self.time.as_local(current_time)
|
|
||||||
if now_local.hour >= TOMORROW_DATA_CHECK_HOUR and self._cached_price_data and self.needs_tomorrow_data():
|
|
||||||
self._log(
|
|
||||||
"info",
|
|
||||||
"API update needed: After %s:00 and tomorrow's data missing/invalid",
|
|
||||||
TOMORROW_DATA_CHECK_HOUR,
|
|
||||||
)
|
|
||||||
# Return special marker to indicate this is a tomorrow data check
|
|
||||||
# Caller should add random delay to spread load
|
|
||||||
return "tomorrow_check"
|
|
||||||
|
|
||||||
# No update needed - cache is valid and complete
|
|
||||||
self._log("debug", "No API update needed: Cache is valid and complete")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def needs_tomorrow_data(self) -> bool:
|
|
||||||
"""Check if tomorrow data is missing or invalid."""
|
|
||||||
return helpers.needs_tomorrow_data(self._cached_price_data)
|
|
||||||
|
|
||||||
async def fetch_home_data(self, home_id: str, current_time: datetime) -> dict[str, Any]:
|
|
||||||
"""Fetch data for a single home."""
|
|
||||||
if not home_id:
|
if not home_id:
|
||||||
self._log("warning", "No home ID provided - cannot fetch price data")
|
self._log("warning", "No home ID provided - cannot fetch price data")
|
||||||
return {
|
return {
|
||||||
|
|
@ -279,24 +330,17 @@ class TibberPricesDataFetcher:
|
||||||
self._log("error", msg)
|
self._log("error", msg)
|
||||||
raise TibberPricesApiClientError(msg) from ex
|
raise TibberPricesApiClientError(msg) from ex
|
||||||
|
|
||||||
# Get price data for this home
|
|
||||||
# Pass user_data for timezone-aware cursor calculation
|
|
||||||
# At this point, _cached_user_data is guaranteed to be not None (checked above)
|
# At this point, _cached_user_data is guaranteed to be not None (checked above)
|
||||||
if not self._cached_user_data:
|
if not self._cached_user_data:
|
||||||
msg = "User data unexpectedly None after fetch attempt"
|
msg = "User data unexpectedly None after fetch attempt"
|
||||||
raise TibberPricesApiClientError(msg)
|
raise TibberPricesApiClientError(msg)
|
||||||
|
|
||||||
self._log("debug", "Fetching price data for home %s", home_id)
|
# Retrieve price data via IntervalPool (single source of truth)
|
||||||
home_data = await self.api.async_get_price_info(
|
price_info = await self._fetch_via_pool(home_id, include_tomorrow=include_tomorrow)
|
||||||
home_id=home_id,
|
|
||||||
user_data=self._cached_user_data,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Extract currency for this home from user_data
|
# Extract currency for this home from user_data
|
||||||
currency = self._get_currency_for_home(home_id)
|
currency = self._get_currency_for_home(home_id)
|
||||||
|
|
||||||
price_info = home_data.get("price_info", [])
|
|
||||||
|
|
||||||
self._log("debug", "Successfully fetched data for home %s (%d intervals)", home_id, len(price_info))
|
self._log("debug", "Successfully fetched data for home %s (%d intervals)", home_id, len(price_info))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -306,6 +350,50 @@ class TibberPricesDataFetcher:
|
||||||
"currency": currency,
|
"currency": currency,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async def _fetch_via_pool(
|
||||||
|
self,
|
||||||
|
home_id: str,
|
||||||
|
*,
|
||||||
|
include_tomorrow: bool = True,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Retrieve price data via IntervalPool.
|
||||||
|
|
||||||
|
The IntervalPool is the single source of truth for price data:
|
||||||
|
- Handles actual API calls to Tibber
|
||||||
|
- Manages deduplication and caching
|
||||||
|
- Provides intervals from day-before-yesterday to end-of-today/tomorrow
|
||||||
|
|
||||||
|
This method delegates to the Pool's get_sensor_data() which returns
|
||||||
|
all relevant intervals for sensor display.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
home_id: Home ID (currently unused, Pool knows its home).
|
||||||
|
include_tomorrow: If True, request tomorrow's data too. If False,
|
||||||
|
only request up to end of today. This prevents
|
||||||
|
API spam before 13:00 when Tibber doesn't have
|
||||||
|
tomorrow data yet.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of price interval dicts.
|
||||||
|
|
||||||
|
"""
|
||||||
|
# user_data is guaranteed by fetch_home_data(), but needed for type narrowing
|
||||||
|
if self._cached_user_data is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
self._log(
|
||||||
|
"debug",
|
||||||
|
"Retrieving price data for home %s via interval pool (include_tomorrow=%s)",
|
||||||
|
home_id,
|
||||||
|
include_tomorrow,
|
||||||
|
)
|
||||||
|
return await self._interval_pool.get_sensor_data(
|
||||||
|
api_client=self.api,
|
||||||
|
user_data=self._cached_user_data,
|
||||||
|
include_tomorrow=include_tomorrow,
|
||||||
|
)
|
||||||
|
|
||||||
def _get_currency_for_home(self, home_id: str) -> str:
|
def _get_currency_for_home(self, home_id: str) -> str:
|
||||||
"""
|
"""
|
||||||
Get currency for a specific home from cached user_data.
|
Get currency for a specific home from cached user_data.
|
||||||
|
|
@ -373,8 +461,31 @@ class TibberPricesDataFetcher:
|
||||||
current_time: datetime,
|
current_time: datetime,
|
||||||
home_id: str,
|
home_id: str,
|
||||||
transform_fn: Callable[[dict[str, Any]], dict[str, Any]],
|
transform_fn: Callable[[dict[str, Any]], dict[str, Any]],
|
||||||
|
*,
|
||||||
|
current_price_info: list[dict[str, Any]] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Handle update for main entry - fetch data for this home."""
|
"""
|
||||||
|
Handle update for main entry - fetch data for this home.
|
||||||
|
|
||||||
|
The IntervalPool is the single source of truth for price data:
|
||||||
|
- It handles API fetching, deduplication, and caching internally
|
||||||
|
- We decide WHEN to fetch tomorrow data (after 13:00, if not already present)
|
||||||
|
- This prevents API spam before 13:00 when Tibber doesn't have tomorrow data
|
||||||
|
|
||||||
|
This method:
|
||||||
|
1. Updates user data if needed (daily)
|
||||||
|
2. Determines if tomorrow data should be requested
|
||||||
|
3. Fetches price data via IntervalPool
|
||||||
|
4. Transforms result for coordinator
|
||||||
|
|
||||||
|
Args:
|
||||||
|
current_time: Current time for update decisions.
|
||||||
|
home_id: Home ID to fetch data for.
|
||||||
|
transform_fn: Function to transform raw data for coordinator.
|
||||||
|
current_price_info: Current price intervals (from coordinator.data["priceInfo"]).
|
||||||
|
Used to check if tomorrow data already exists.
|
||||||
|
|
||||||
|
"""
|
||||||
# Update user data if needed (daily check)
|
# Update user data if needed (daily check)
|
||||||
user_data_updated = await self.update_user_data_if_needed(current_time)
|
user_data_updated = await self.update_user_data_if_needed(current_time)
|
||||||
|
|
||||||
|
|
@ -384,84 +495,50 @@ class TibberPricesDataFetcher:
|
||||||
if not home_exists:
|
if not home_exists:
|
||||||
self._log("warning", "Home ID %s not found in Tibber account", home_id)
|
self._log("warning", "Home ID %s not found in Tibber account", home_id)
|
||||||
# Return a special marker in the result that coordinator can check
|
# Return a special marker in the result that coordinator can check
|
||||||
# We still need to return valid data to avoid coordinator errors
|
result = transform_fn({})
|
||||||
result = transform_fn(self._cached_price_data or {})
|
|
||||||
result["_home_not_found"] = True # Special marker for coordinator
|
result["_home_not_found"] = True # Special marker for coordinator
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# Check if we need to update price data
|
# Determine if we should request tomorrow data
|
||||||
should_update = self.should_update_price_data(current_time)
|
include_tomorrow = self.should_fetch_tomorrow_data(current_price_info)
|
||||||
|
|
||||||
if should_update:
|
# Fetch price data via IntervalPool
|
||||||
# If this is a tomorrow data check, add random delay to spread API load
|
|
||||||
if should_update == "tomorrow_check":
|
|
||||||
# Use secrets for better randomness distribution
|
|
||||||
delay = secrets.randbelow(TOMORROW_DATA_RANDOM_DELAY_MAX + 1)
|
|
||||||
self._log(
|
self._log(
|
||||||
"debug",
|
"debug",
|
||||||
"Tomorrow data check - adding random delay of %d seconds to spread load",
|
"Fetching price data for home %s via interval pool (include_tomorrow=%s)",
|
||||||
delay,
|
home_id,
|
||||||
|
include_tomorrow,
|
||||||
)
|
)
|
||||||
await asyncio.sleep(delay)
|
raw_data = await self.fetch_home_data(home_id, current_time, include_tomorrow=include_tomorrow)
|
||||||
|
|
||||||
self._log("debug", "Fetching fresh price data from API")
|
# Parse timestamps immediately after fetch
|
||||||
raw_data = await self.fetch_home_data(home_id, current_time)
|
|
||||||
# Parse timestamps immediately after API fetch
|
|
||||||
raw_data = helpers.parse_all_timestamps(raw_data, time=self.time)
|
raw_data = helpers.parse_all_timestamps(raw_data, time=self.time)
|
||||||
# Cache the data (now with datetime objects)
|
|
||||||
self._cached_price_data = raw_data
|
# Store user data cache (price data persisted by IntervalPool)
|
||||||
self._last_price_update = current_time
|
if user_data_updated:
|
||||||
await self.store_cache()
|
await self.store_cache()
|
||||||
|
|
||||||
# Transform for main entry
|
# Transform for main entry
|
||||||
return transform_fn(raw_data)
|
return transform_fn(raw_data)
|
||||||
|
|
||||||
# Use cached data if available
|
|
||||||
if self._cached_price_data is not None:
|
|
||||||
# If user data was updated, we need to return transformed data to trigger entity updates
|
|
||||||
# This ensures diagnostic sensors (home_type, grid_company, etc.) get refreshed
|
|
||||||
if user_data_updated:
|
|
||||||
self._log("debug", "User data updated - returning transformed data to update diagnostic sensors")
|
|
||||||
else:
|
|
||||||
self._log("debug", "Using cached price data (no API call needed)")
|
|
||||||
return transform_fn(self._cached_price_data)
|
|
||||||
|
|
||||||
# Fallback: no cache and no update needed (shouldn't happen)
|
|
||||||
self._log("warning", "No cached data available and update not triggered - returning empty data")
|
|
||||||
return {
|
|
||||||
"timestamp": current_time,
|
|
||||||
"home_id": home_id,
|
|
||||||
"priceInfo": [],
|
|
||||||
"currency": "",
|
|
||||||
}
|
|
||||||
|
|
||||||
async def handle_api_error(
|
async def handle_api_error(
|
||||||
self,
|
self,
|
||||||
error: Exception,
|
error: Exception,
|
||||||
transform_fn: Callable[[dict[str, Any]], dict[str, Any]],
|
) -> None:
|
||||||
) -> dict[str, Any]:
|
"""
|
||||||
"""Handle API errors with fallback to cached data."""
|
Handle API errors - re-raise appropriate exceptions.
|
||||||
|
|
||||||
|
Note: With IntervalPool as source of truth, there's no local price cache
|
||||||
|
to fall back to. The Pool has its own persistence, so on next update
|
||||||
|
it will use its cached intervals if API is unavailable.
|
||||||
|
"""
|
||||||
if isinstance(error, TibberPricesApiClientAuthenticationError):
|
if isinstance(error, TibberPricesApiClientAuthenticationError):
|
||||||
msg = "Invalid access token"
|
msg = "Invalid access token"
|
||||||
raise ConfigEntryAuthFailed(msg) from error
|
raise ConfigEntryAuthFailed(msg) from error
|
||||||
|
|
||||||
# Use cached data as fallback if available
|
|
||||||
if self._cached_price_data is not None:
|
|
||||||
self._log("warning", "API error, using cached data: %s", error)
|
|
||||||
return transform_fn(self._cached_price_data)
|
|
||||||
|
|
||||||
msg = f"Error communicating with API: {error}"
|
msg = f"Error communicating with API: {error}"
|
||||||
raise UpdateFailed(msg) from error
|
raise UpdateFailed(msg) from error
|
||||||
|
|
||||||
@property
|
|
||||||
def cached_price_data(self) -> dict[str, Any] | None:
|
|
||||||
"""Get cached price data."""
|
|
||||||
return self._cached_price_data
|
|
||||||
|
|
||||||
@cached_price_data.setter
|
|
||||||
def cached_price_data(self, value: dict[str, Any] | None) -> None:
|
|
||||||
"""Set cached price data."""
|
|
||||||
self._cached_price_data = value
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def cached_user_data(self) -> dict[str, Any] | None:
|
def cached_user_data(self) -> dict[str, Any] | None:
|
||||||
"""Get cached user data."""
|
"""Get cached user data."""
|
||||||
|
|
@ -26,8 +26,8 @@ import pytest
|
||||||
|
|
||||||
from custom_components.tibber_prices.api.exceptions import TibberPricesApiClientError
|
from custom_components.tibber_prices.api.exceptions import TibberPricesApiClientError
|
||||||
from custom_components.tibber_prices.api.helpers import flatten_price_info
|
from custom_components.tibber_prices.api.helpers import flatten_price_info
|
||||||
from custom_components.tibber_prices.coordinator.data_fetching import (
|
from custom_components.tibber_prices.coordinator.price_data_manager import (
|
||||||
TibberPricesDataFetcher,
|
TibberPricesPriceDataManager,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -52,16 +52,23 @@ def mock_store() -> Mock:
|
||||||
return Mock()
|
return Mock()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_interval_pool() -> Mock:
|
||||||
|
"""Create a mock interval pool."""
|
||||||
|
return Mock()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
def test_validate_user_data_complete(mock_api_client, mock_time_service, mock_store) -> None: # noqa: ANN001
|
def test_validate_user_data_complete(mock_api_client, mock_time_service, mock_store, mock_interval_pool) -> None: # noqa: ANN001
|
||||||
"""Test that complete user data passes validation."""
|
"""Test that complete user data passes validation."""
|
||||||
fetcher = TibberPricesDataFetcher(
|
price_data_manager = TibberPricesPriceDataManager(
|
||||||
api=mock_api_client,
|
api=mock_api_client,
|
||||||
store=mock_store,
|
store=mock_store,
|
||||||
log_prefix="[Test]",
|
log_prefix="[Test]",
|
||||||
user_update_interval=timedelta(days=1),
|
user_update_interval=timedelta(days=1),
|
||||||
time=mock_time_service,
|
time=mock_time_service,
|
||||||
home_id="home-123",
|
home_id="home-123",
|
||||||
|
interval_pool=mock_interval_pool,
|
||||||
)
|
)
|
||||||
|
|
||||||
user_data = {
|
user_data = {
|
||||||
|
|
@ -82,19 +89,22 @@ def test_validate_user_data_complete(mock_api_client, mock_time_service, mock_st
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
assert fetcher._validate_user_data(user_data, "home-123") is True # noqa: SLF001 # noqa: SLF001
|
assert price_data_manager._validate_user_data(user_data, "home-123") is True # noqa: SLF001 # noqa: SLF001
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
def test_validate_user_data_none_subscription(mock_api_client, mock_time_service, mock_store) -> None: # noqa: ANN001
|
def test_validate_user_data_none_subscription(
|
||||||
|
mock_api_client: Mock, mock_time_service: Mock, mock_store: Mock, mock_interval_pool: Mock
|
||||||
|
) -> None:
|
||||||
"""Test that user data without subscription (but with timezone) passes validation."""
|
"""Test that user data without subscription (but with timezone) passes validation."""
|
||||||
fetcher = TibberPricesDataFetcher(
|
price_data_manager = TibberPricesPriceDataManager(
|
||||||
api=mock_api_client,
|
api=mock_api_client,
|
||||||
store=mock_store,
|
store=mock_store,
|
||||||
log_prefix="[Test]",
|
log_prefix="[Test]",
|
||||||
user_update_interval=timedelta(days=1),
|
user_update_interval=timedelta(days=1),
|
||||||
time=mock_time_service,
|
time=mock_time_service,
|
||||||
home_id="home-123",
|
home_id="home-123",
|
||||||
|
interval_pool=mock_interval_pool,
|
||||||
)
|
)
|
||||||
|
|
||||||
user_data = {
|
user_data = {
|
||||||
|
|
@ -110,19 +120,22 @@ def test_validate_user_data_none_subscription(mock_api_client, mock_time_service
|
||||||
}
|
}
|
||||||
|
|
||||||
# Should pass validation - timezone is present, subscription being None is valid
|
# Should pass validation - timezone is present, subscription being None is valid
|
||||||
assert fetcher._validate_user_data(user_data, "home-123") is True # noqa: SLF001 # noqa: SLF001
|
assert price_data_manager._validate_user_data(user_data, "home-123") is True # noqa: SLF001 # noqa: SLF001
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
def test_validate_user_data_missing_timezone(mock_api_client, mock_time_service, mock_store) -> None: # noqa: ANN001
|
def test_validate_user_data_missing_timezone(
|
||||||
|
mock_api_client: Mock, mock_time_service: Mock, mock_store: Mock, mock_interval_pool: Mock
|
||||||
|
) -> None:
|
||||||
"""Test that user data without timezone fails validation."""
|
"""Test that user data without timezone fails validation."""
|
||||||
fetcher = TibberPricesDataFetcher(
|
price_data_manager = TibberPricesPriceDataManager(
|
||||||
api=mock_api_client,
|
api=mock_api_client,
|
||||||
store=mock_store,
|
store=mock_store,
|
||||||
log_prefix="[Test]",
|
log_prefix="[Test]",
|
||||||
user_update_interval=timedelta(days=1),
|
user_update_interval=timedelta(days=1),
|
||||||
time=mock_time_service,
|
time=mock_time_service,
|
||||||
home_id="home-123",
|
home_id="home-123",
|
||||||
|
interval_pool=mock_interval_pool,
|
||||||
)
|
)
|
||||||
|
|
||||||
user_data = {
|
user_data = {
|
||||||
|
|
@ -143,19 +156,22 @@ def test_validate_user_data_missing_timezone(mock_api_client, mock_time_service,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
assert fetcher._validate_user_data(user_data, "home-123") is False # noqa: SLF001 # noqa: SLF001
|
assert price_data_manager._validate_user_data(user_data, "home-123") is False # noqa: SLF001 # noqa: SLF001
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
def test_validate_user_data_subscription_without_currency(mock_api_client, mock_time_service, mock_store) -> None: # noqa: ANN001
|
def test_validate_user_data_subscription_without_currency(
|
||||||
|
mock_api_client: Mock, mock_time_service: Mock, mock_store: Mock, mock_interval_pool: Mock
|
||||||
|
) -> None:
|
||||||
"""Test that user data with subscription but no currency fails validation."""
|
"""Test that user data with subscription but no currency fails validation."""
|
||||||
fetcher = TibberPricesDataFetcher(
|
price_data_manager = TibberPricesPriceDataManager(
|
||||||
api=mock_api_client,
|
api=mock_api_client,
|
||||||
store=mock_store,
|
store=mock_store,
|
||||||
log_prefix="[Test]",
|
log_prefix="[Test]",
|
||||||
user_update_interval=timedelta(days=1),
|
user_update_interval=timedelta(days=1),
|
||||||
time=mock_time_service,
|
time=mock_time_service,
|
||||||
home_id="home-123",
|
home_id="home-123",
|
||||||
|
interval_pool=mock_interval_pool,
|
||||||
)
|
)
|
||||||
|
|
||||||
user_data = {
|
user_data = {
|
||||||
|
|
@ -174,19 +190,20 @@ def test_validate_user_data_subscription_without_currency(mock_api_client, mock_
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
assert fetcher._validate_user_data(user_data, "home-123") is False # noqa: SLF001 # noqa: SLF001
|
assert price_data_manager._validate_user_data(user_data, "home-123") is False # noqa: SLF001 # noqa: SLF001
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
def test_validate_user_data_home_not_found(mock_api_client, mock_time_service, mock_store) -> None: # noqa: ANN001
|
def test_validate_user_data_home_not_found(mock_api_client, mock_time_service, mock_store, mock_interval_pool) -> None: # noqa: ANN001
|
||||||
"""Test that user data without the requested home fails validation."""
|
"""Test that user data without the requested home fails validation."""
|
||||||
fetcher = TibberPricesDataFetcher(
|
price_data_manager = TibberPricesPriceDataManager(
|
||||||
api=mock_api_client,
|
api=mock_api_client,
|
||||||
store=mock_store,
|
store=mock_store,
|
||||||
log_prefix="[Test]",
|
log_prefix="[Test]",
|
||||||
user_update_interval=timedelta(days=1),
|
user_update_interval=timedelta(days=1),
|
||||||
time=mock_time_service,
|
time=mock_time_service,
|
||||||
home_id="home-123",
|
home_id="home-123",
|
||||||
|
interval_pool=mock_interval_pool,
|
||||||
)
|
)
|
||||||
|
|
||||||
user_data = {
|
user_data = {
|
||||||
|
|
@ -200,39 +217,45 @@ def test_validate_user_data_home_not_found(mock_api_client, mock_time_service, m
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
assert fetcher._validate_user_data(user_data, "home-123") is False # noqa: SLF001 # noqa: SLF001
|
assert price_data_manager._validate_user_data(user_data, "home-123") is False # noqa: SLF001 # noqa: SLF001
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
def test_get_currency_raises_on_no_cached_data(mock_api_client, mock_time_service, mock_store) -> None: # noqa: ANN001
|
def test_get_currency_raises_on_no_cached_data(
|
||||||
|
mock_api_client: Mock, mock_time_service: Mock, mock_store: Mock, mock_interval_pool: Mock
|
||||||
|
) -> None:
|
||||||
"""Test that _get_currency_for_home raises exception when no data cached."""
|
"""Test that _get_currency_for_home raises exception when no data cached."""
|
||||||
fetcher = TibberPricesDataFetcher(
|
price_data_manager = TibberPricesPriceDataManager(
|
||||||
api=mock_api_client,
|
api=mock_api_client,
|
||||||
store=mock_store,
|
store=mock_store,
|
||||||
log_prefix="[Test]",
|
log_prefix="[Test]",
|
||||||
user_update_interval=timedelta(days=1),
|
user_update_interval=timedelta(days=1),
|
||||||
time=mock_time_service,
|
time=mock_time_service,
|
||||||
home_id="home-123",
|
home_id="home-123",
|
||||||
|
interval_pool=mock_interval_pool,
|
||||||
)
|
)
|
||||||
|
|
||||||
# No cached data
|
# No cached data
|
||||||
with pytest.raises(TibberPricesApiClientError, match="No user data cached"):
|
with pytest.raises(TibberPricesApiClientError, match="No user data cached"):
|
||||||
fetcher._get_currency_for_home("home-123") # noqa: SLF001 # noqa: SLF001
|
price_data_manager._get_currency_for_home("home-123") # noqa: SLF001 # noqa: SLF001
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
def test_get_currency_raises_on_no_subscription(mock_api_client, mock_time_service, mock_store) -> None: # noqa: ANN001
|
def test_get_currency_raises_on_no_subscription(
|
||||||
|
mock_api_client: Mock, mock_time_service: Mock, mock_store: Mock, mock_interval_pool: Mock
|
||||||
|
) -> None:
|
||||||
"""Test that _get_currency_for_home raises exception when home has no subscription."""
|
"""Test that _get_currency_for_home raises exception when home has no subscription."""
|
||||||
fetcher = TibberPricesDataFetcher(
|
price_data_manager = TibberPricesPriceDataManager(
|
||||||
api=mock_api_client,
|
api=mock_api_client,
|
||||||
store=mock_store,
|
store=mock_store,
|
||||||
log_prefix="[Test]",
|
log_prefix="[Test]",
|
||||||
user_update_interval=timedelta(days=1),
|
user_update_interval=timedelta(days=1),
|
||||||
time=mock_time_service,
|
time=mock_time_service,
|
||||||
home_id="home-123",
|
home_id="home-123",
|
||||||
|
interval_pool=mock_interval_pool,
|
||||||
)
|
)
|
||||||
|
|
||||||
fetcher._cached_user_data = { # noqa: SLF001 # noqa: SLF001
|
price_data_manager._cached_user_data = { # noqa: SLF001 # noqa: SLF001
|
||||||
"viewer": {
|
"viewer": {
|
||||||
"homes": [
|
"homes": [
|
||||||
{
|
{
|
||||||
|
|
@ -244,22 +267,25 @@ def test_get_currency_raises_on_no_subscription(mock_api_client, mock_time_servi
|
||||||
}
|
}
|
||||||
|
|
||||||
with pytest.raises(TibberPricesApiClientError, match="has no active subscription"):
|
with pytest.raises(TibberPricesApiClientError, match="has no active subscription"):
|
||||||
fetcher._get_currency_for_home("home-123") # noqa: SLF001 # noqa: SLF001
|
price_data_manager._get_currency_for_home("home-123") # noqa: SLF001 # noqa: SLF001
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
def test_get_currency_extracts_valid_currency(mock_api_client, mock_time_service, mock_store) -> None: # noqa: ANN001
|
def test_get_currency_extracts_valid_currency(
|
||||||
|
mock_api_client: Mock, mock_time_service: Mock, mock_store: Mock, mock_interval_pool: Mock
|
||||||
|
) -> None:
|
||||||
"""Test that _get_currency_for_home successfully extracts currency."""
|
"""Test that _get_currency_for_home successfully extracts currency."""
|
||||||
fetcher = TibberPricesDataFetcher(
|
price_data_manager = TibberPricesPriceDataManager(
|
||||||
api=mock_api_client,
|
api=mock_api_client,
|
||||||
store=mock_store,
|
store=mock_store,
|
||||||
log_prefix="[Test]",
|
log_prefix="[Test]",
|
||||||
user_update_interval=timedelta(days=1),
|
user_update_interval=timedelta(days=1),
|
||||||
time=mock_time_service,
|
time=mock_time_service,
|
||||||
home_id="home-123",
|
home_id="home-123",
|
||||||
|
interval_pool=mock_interval_pool,
|
||||||
)
|
)
|
||||||
|
|
||||||
fetcher._cached_user_data = { # noqa: SLF001 # noqa: SLF001
|
price_data_manager._cached_user_data = { # noqa: SLF001 # noqa: SLF001
|
||||||
"viewer": {
|
"viewer": {
|
||||||
"homes": [
|
"homes": [
|
||||||
{
|
{
|
||||||
|
|
@ -276,7 +302,7 @@ def test_get_currency_extracts_valid_currency(mock_api_client, mock_time_service
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
assert fetcher._get_currency_for_home("home-123") == "NOK" # noqa: SLF001 # noqa: SLF001
|
assert price_data_manager._get_currency_for_home("home-123") == "NOK" # noqa: SLF001 # noqa: SLF001
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue