hass.tibber_prices/custom_components/tibber_prices/interval_pool/cache.py
Julian Pawlowski 7adc56bf79 fix(interval_pool): prevent external mutation of cached intervals
Return shallow copies from _get_cached_intervals() to prevent external
code (e.g., parse_all_timestamps()) from mutating Pool internal cache.
This fixes TypeError in check_coverage() caused by datetime objects in
cached interval dicts.

Additional improvements:
- Add TimeService support for time-travel testing in cache/manager
- Normalize startsAt to consistent format (handles datetime vs string)
- Rename detect_gaps() → check_coverage() for clarity
- Add get_sensor_data() for sensor data fetching with fetch/return separation
- Add get_pool_stats() for lifecycle sensor metrics

Impact: Fixes critical cache mutation bug, enables time-travel testing,
improves pool API for sensor integration.
2025-12-23 14:13:24 +00:00

206 lines
6.6 KiB
Python

"""Fetch group cache for price intervals."""
from __future__ import annotations
import logging
from datetime import datetime, timedelta
from typing import TYPE_CHECKING, Any
from homeassistant.util import dt as dt_utils
if TYPE_CHECKING:
from custom_components.tibber_prices.coordinator.time_service import (
TibberPricesTimeService,
)
_LOGGER = logging.getLogger(__name__)
_LOGGER_DETAILS = logging.getLogger(__name__ + ".details")
# Protected date range: day-before-yesterday to tomorrow (4 days total)
PROTECTED_DAYS_BEFORE = 2 # day-before-yesterday + yesterday
PROTECTED_DAYS_AFTER = 1 # tomorrow
class TibberPricesIntervalPoolFetchGroupCache:
"""
Storage for fetch groups with protected range management.
A fetch group is a collection of intervals fetched at the same time,
stored together with their fetch timestamp for GC purposes.
Structure:
{
"fetched_at": datetime, # When this group was fetched
"intervals": [dict, ...] # List of interval dicts
}
Protected Range:
Intervals within day-before-yesterday to tomorrow are protected
and never evicted from cache. This range shifts daily automatically.
Example (today = 2025-11-25):
Protected: 2025-11-23 00:00 to 2025-11-27 00:00
"""
def __init__(self, *, time_service: TibberPricesTimeService | None = None) -> None:
"""Initialize empty fetch group cache with optional TimeService."""
self._fetch_groups: list[dict[str, Any]] = []
self._time_service = time_service
# Protected range cache (invalidated daily)
self._protected_range_cache: tuple[str, str] | None = None
self._protected_range_cache_date: str | None = None
def add_fetch_group(
self,
intervals: list[dict[str, Any]],
fetched_at: datetime,
) -> int:
"""
Add new fetch group to cache.
Args:
intervals: List of interval dicts (sorted by startsAt).
fetched_at: Timestamp when intervals were fetched.
Returns:
Index of the newly added fetch group.
"""
fetch_group = {
"fetched_at": fetched_at,
"intervals": intervals,
}
fetch_group_index = len(self._fetch_groups)
self._fetch_groups.append(fetch_group)
_LOGGER_DETAILS.debug(
"Added fetch group %d: %d intervals (fetched at %s)",
fetch_group_index,
len(intervals),
fetched_at.isoformat(),
)
return fetch_group_index
def get_fetch_groups(self) -> list[dict[str, Any]]:
"""Get all fetch groups (read-only access)."""
return self._fetch_groups
def set_fetch_groups(self, fetch_groups: list[dict[str, Any]]) -> None:
"""Replace all fetch groups (used during GC)."""
self._fetch_groups = fetch_groups
def get_protected_range(self) -> tuple[str, str]:
"""
Get protected date range as ISO strings.
Protected range: day-before-yesterday 00:00 to day-after-tomorrow 00:00.
This range shifts daily automatically.
Time Machine Support:
If time_service was provided at init, uses time_service.now() for
"today" calculation. This protects the correct date range when
simulating a different date.
Returns:
Tuple of (start_iso, end_iso) for protected range.
Start is inclusive, end is exclusive.
Example (today = 2025-11-25):
Returns: ("2025-11-23T00:00:00+01:00", "2025-11-27T00:00:00+01:00")
Protected days: 2025-11-23, 2025-11-24, 2025-11-25, 2025-11-26
"""
# Use TimeService if available (Time Machine support), else real time
now = self._time_service.now() if self._time_service else dt_utils.now()
today_date_str = now.date().isoformat()
# Check cache validity (invalidate daily)
if self._protected_range_cache_date == today_date_str and self._protected_range_cache:
return self._protected_range_cache
# Calculate new protected range
today_midnight = now.replace(hour=0, minute=0, second=0, microsecond=0)
# Start: day-before-yesterday at 00:00
start_dt = today_midnight - timedelta(days=PROTECTED_DAYS_BEFORE)
# End: day after tomorrow at 00:00 (exclusive, so tomorrow is included)
end_dt = today_midnight + timedelta(days=PROTECTED_DAYS_AFTER + 1)
# Convert to ISO strings and cache
start_iso = start_dt.isoformat()
end_iso = end_dt.isoformat()
self._protected_range_cache = (start_iso, end_iso)
self._protected_range_cache_date = today_date_str
return start_iso, end_iso
def is_interval_protected(self, interval: dict[str, Any]) -> bool:
"""
Check if interval is within protected date range.
Protected intervals are never evicted from cache.
Args:
interval: Interval dict with "startsAt" ISO timestamp.
Returns:
True if interval is protected (within protected range).
"""
starts_at_iso = interval["startsAt"]
start_protected_iso, end_protected_iso = self.get_protected_range()
# Fast string comparison (ISO timestamps are lexicographically sortable)
return start_protected_iso <= starts_at_iso < end_protected_iso
def count_total_intervals(self) -> int:
"""Count total intervals across all fetch groups."""
return sum(len(group["intervals"]) for group in self._fetch_groups)
def to_dict(self) -> dict[str, Any]:
"""
Serialize fetch groups for storage.
Returns:
Dict with serializable fetch groups.
"""
return {
"fetch_groups": [
{
"fetched_at": group["fetched_at"].isoformat(),
"intervals": group["intervals"],
}
for group in self._fetch_groups
],
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> TibberPricesIntervalPoolFetchGroupCache:
"""
Restore fetch groups from storage.
Args:
data: Dict with "fetch_groups" list.
Returns:
TibberPricesIntervalPoolFetchGroupCache instance with restored data.
"""
cache = cls()
fetch_groups_data = data.get("fetch_groups", [])
cache._fetch_groups = [
{
"fetched_at": datetime.fromisoformat(group["fetched_at"]),
"intervals": group["intervals"],
}
for group in fetch_groups_data
]
return cache