refactoring

This commit is contained in:
Julian Pawlowski 2025-05-20 12:43:12 +00:00
parent adc11b0e4d
commit 3d37ace85e

View file

@ -4,7 +4,8 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
from datetime import datetime, timedelta import secrets
from datetime import date, datetime, timedelta
from typing import TYPE_CHECKING, Any, Final, cast from typing import TYPE_CHECKING, Any, Final, cast
import homeassistant.util.dt as dt_util import homeassistant.util.dt as dt_util
@ -14,6 +15,11 @@ from homeassistant.helpers.event import async_track_time_change
from homeassistant.helpers.storage import Store from homeassistant.helpers.storage import Store
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
if TYPE_CHECKING:
from collections.abc import Callable
from .data import TibberPricesConfigEntry
from .api import ( from .api import (
TibberPricesApiClientAuthenticationError, TibberPricesApiClientAuthenticationError,
TibberPricesApiClientCommunicationError, TibberPricesApiClientCommunicationError,
@ -21,9 +27,6 @@ from .api import (
) )
from .const import DOMAIN, LOGGER from .const import DOMAIN, LOGGER
if TYPE_CHECKING:
from .data import TibberPricesConfigEntry
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
PRICE_UPDATE_RANDOM_MIN_HOUR: Final = 13 # Don't check before 13:00 PRICE_UPDATE_RANDOM_MIN_HOUR: Final = 13 # Don't check before 13:00
@ -34,6 +37,8 @@ STORAGE_VERSION: Final = 1
UPDATE_INTERVAL: Final = timedelta(days=1) # Both price and rating data update daily UPDATE_INTERVAL: Final = timedelta(days=1) # Both price and rating data update daily
UPDATE_FAILED_MSG: Final = "Update failed" UPDATE_FAILED_MSG: Final = "Update failed"
AUTH_FAILED_MSG: Final = "Authentication failed" AUTH_FAILED_MSG: Final = "Authentication failed"
MIN_RETRY_INTERVAL: Final = timedelta(minutes=10)
END_OF_DAY_HOUR: Final = 24 # End of day hour for logic clarity
@callback @callback
@ -130,6 +135,9 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict]):
self._remove_update_listeners: list[Any] = [] self._remove_update_listeners: list[Any] = []
self._force_update = False self._force_update = False
self._rotation_lock = asyncio.Lock() # Add lock for data rotation operations self._rotation_lock = asyncio.Lock() # Add lock for data rotation operations
self._last_attempted_price_update: datetime | None = None
self._random_update_minute: int | None = None
self._random_update_date: date | None = None
# Schedule updates at the start of every hour # Schedule updates at the start of every hour
self._remove_update_listeners.append( self._remove_update_listeners.append(
@ -295,7 +303,7 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict]):
try: try:
# Fetch price data # Fetch price data
price_data = await self._fetch_price_data() price_data = await self._fetch_price_data()
new_data["price_data"] = self._extract_price_data(price_data) new_data["price_data"] = self._extract_data(price_data, "priceInfo", ("yesterday", "today", "tomorrow"))
# Fetch all rating data # Fetch all rating data
for rating_type in ["hourly", "daily", "monthly"]: for rating_type in ["hourly", "daily", "monthly"]:
@ -384,56 +392,61 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict]):
@callback @callback
def _should_update_price_data(self, current_time: datetime) -> bool: def _should_update_price_data(self, current_time: datetime) -> bool:
"""Check if price data should be updated.""" """
# If no cached data, we definitely need an update Decide if price data should be updated.
if self._cached_price_data is None:
LOGGER.debug("No cached price data available, update needed")
return True
# Get the latest timestamp from our price data - No fetch before 13:00.
latest_price_timestamp = _get_latest_timestamp_from_prices(self._cached_price_data) - Randomized fetch minute in update window (13:00-15:00).
if not latest_price_timestamp: - Always fetch after 15:00 if tomorrow's data is missing.
LOGGER.debug("No valid timestamp found in price data, update needed") - No fetch after midnight until 13:00.
return True """
# If we have price data but no last_update timestamp, set it
if not self._last_price_update:
self._last_price_update = latest_price_timestamp
LOGGER.debug(
"Setting missing price update timestamp in check: %s",
self._last_price_update,
)
# Check if we're in the update window (13:00-15:00)
current_hour = current_time.hour current_hour = current_time.hour
in_update_window = PRICE_UPDATE_RANDOM_MIN_HOUR <= current_hour <= PRICE_UPDATE_RANDOM_MAX_HOUR # Check if tomorrow's data is available
tomorrow_prices = []
if self._cached_price_data and "priceInfo" in self._cached_price_data:
tomorrow_prices = self._cached_price_data["priceInfo"].get("tomorrow", [])
interval_count = len(tomorrow_prices)
min_tomorrow_intervals_hourly = 24
min_tomorrow_intervals_15min = 96
tomorrow_data_complete = interval_count in {min_tomorrow_intervals_hourly, min_tomorrow_intervals_15min}
# Get tomorrow's date at midnight should_update = False
tomorrow = (current_time + timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0) # 1. Before 13:00: never fetch
if current_hour < PRICE_UPDATE_RANDOM_MIN_HOUR:
# If we're in the update window and don't have tomorrow's complete data should_update = False
if in_update_window and latest_price_timestamp < tomorrow: # 2. In update window (13:00-15:00): fetch at random minute, with min retry interval
elif PRICE_UPDATE_RANDOM_MIN_HOUR <= current_hour < PRICE_UPDATE_RANDOM_MAX_HOUR:
today = current_time.date()
if self._random_update_date != today or self._random_update_minute is None:
self._random_update_date = today
self._random_update_minute = secrets.randbelow(RANDOM_DELAY_MAX_MINUTES)
# Only fetch at the random minute
if current_time.minute == self._random_update_minute:
# Enforce minimum retry interval
if self._last_attempted_price_update:
since_last = current_time - self._last_attempted_price_update
if since_last < MIN_RETRY_INTERVAL:
LOGGER.debug( LOGGER.debug(
"In update window (%d:00) and latest price timestamp (%s) is before tomorrow, update needed", "Skipping price update: last attempt was %s ago (<%s)",
current_hour, since_last,
latest_price_timestamp, MIN_RETRY_INTERVAL,
) )
return True should_update = False
else:
# If it's been more than 24 hours since our last update self._last_attempted_price_update = current_time
if self._last_price_update and current_time - self._last_price_update >= UPDATE_INTERVAL: should_update = not tomorrow_data_complete
LOGGER.debug( else:
"More than 24 hours since last price update (%s), update needed", self._last_attempted_price_update = current_time
self._last_price_update, should_update = not tomorrow_data_complete
) else:
return True should_update = False
# 3. After update window (15:00-00:00): always fetch if tomorrow's data is missing
LOGGER.debug( elif PRICE_UPDATE_RANDOM_MAX_HOUR <= current_hour < END_OF_DAY_HOUR:
"No price update needed - Last update: %s, Latest data: %s", should_update = not tomorrow_data_complete
self._last_price_update, # 4. After midnight until 13:00: never fetch
latest_price_timestamp, else:
) should_update = False
return False return should_update
@callback @callback
def _should_update_rating_type( def _should_update_rating_type(
@ -443,69 +456,62 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict]):
last_update: datetime | None, last_update: datetime | None,
rating_type: str, rating_type: str,
) -> bool: ) -> bool:
"""Check if specific rating type should be updated.""" def extra_check_monthly(now: datetime, latest: datetime) -> bool:
# If no cached data, we definitely need an update current_month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
if cached_data is None: return latest < current_month_start
LOGGER.debug("No cached %s rating data available, update needed", rating_type)
return True
# Get the latest timestamp from our rating data
latest_timestamp = self._get_latest_timestamp_from_rating_type(cached_data, rating_type)
if not latest_timestamp:
LOGGER.debug("No valid timestamp found in %s rating data, update needed", rating_type)
return True
# If we have rating data but no last_update timestamp, set it
if not last_update:
if rating_type == "hourly":
self._last_rating_update_hourly = latest_timestamp
elif rating_type == "daily":
self._last_rating_update_daily = latest_timestamp
else: # monthly
self._last_rating_update_monthly = latest_timestamp
LOGGER.debug(
"Setting missing %s rating timestamp in check: %s",
rating_type,
latest_timestamp,
)
last_update = latest_timestamp
current_hour = current_time.hour
in_update_window = PRICE_UPDATE_RANDOM_MIN_HOUR <= current_hour <= PRICE_UPDATE_RANDOM_MAX_HOUR
should_update = False
if rating_type == "monthly": if rating_type == "monthly":
current_month_start = current_time.replace(day=1, hour=0, minute=0, second=0, microsecond=0) return self._should_update_data(
should_update = latest_timestamp < current_month_start or ( current_time,
last_update and current_time - last_update >= timedelta(days=1) cached_data,
)
else:
tomorrow = (current_time + timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0)
should_update = (
in_update_window and latest_timestamp < tomorrow
) or current_time - last_update >= UPDATE_INTERVAL
if should_update:
LOGGER.debug(
"Update needed for %s rating data - Last update: %s, Latest data: %s",
rating_type,
last_update, last_update,
latest_timestamp, lambda d: self._get_latest_rating_timestamp(d, rating_type),
config={
"interval": timedelta(days=1),
"extra_check": extra_check_monthly,
},
) )
else: return self._should_update_data(
LOGGER.debug( current_time,
"No %s rating update needed - Last update: %s, Latest data: %s", cached_data,
rating_type,
last_update, last_update,
latest_timestamp, lambda d: self._get_latest_rating_timestamp(d, rating_type),
config={
"update_window": (PRICE_UPDATE_RANDOM_MIN_HOUR, PRICE_UPDATE_RANDOM_MAX_HOUR),
"interval": UPDATE_INTERVAL,
},
) )
return should_update
@callback @callback
def _is_price_update_window(self, current_hour: int) -> bool: def _should_update_data(
"""Check if current hour is within price update window.""" self,
return PRICE_UPDATE_RANDOM_MIN_HOUR <= current_hour <= PRICE_UPDATE_RANDOM_MAX_HOUR current_time: datetime,
cached_data: dict | None,
last_update: datetime | None,
timestamp_func: Callable[[dict | None], datetime | None],
config: dict | None = None,
) -> bool:
"""Generalized update check for any data type."""
config = config or {}
update_window = config.get("update_window")
interval = config.get("interval", UPDATE_INTERVAL)
extra_check = config.get("extra_check")
if cached_data is None:
return True
latest_timestamp = timestamp_func(cached_data)
if not latest_timestamp:
return True
if not last_update:
last_update = latest_timestamp
if update_window:
current_hour = current_time.hour
if update_window[0] <= current_hour <= update_window[1]:
tomorrow = (current_time + timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0)
if latest_timestamp < tomorrow:
return True
if last_update and current_time - last_update >= interval:
return True
return extra_check(current_time, latest_timestamp) if extra_check else False
async def _fetch_price_data(self) -> dict: async def _fetch_price_data(self) -> dict:
"""Fetch fresh price data from API.""" """Fetch fresh price data from API."""
@ -513,19 +519,15 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict]):
return await client.async_get_price_info() return await client.async_get_price_info()
@callback @callback
def _extract_price_data(self, data: dict) -> dict: def _extract_data(self, data: dict, container_key: str, keys: tuple[str, ...]) -> dict:
"""Extract price data for caching in flat format.""" """Extract and harmonize data for caching in flat format."""
try: try:
price_info = data["priceInfo"] container = data[container_key]
extracted_price_info = { extracted = {key: list(container.get(key, [])) for key in keys}
"yesterday": price_info.get("yesterday", []),
"today": price_info.get("today", []),
"tomorrow": price_info.get("tomorrow", []),
}
except (KeyError, IndexError, TypeError) as ex: except (KeyError, IndexError, TypeError) as ex:
LOGGER.error("Error extracting price data: %s", ex) LOGGER.error("Error extracting %s data: %s", container_key, ex)
extracted_price_info = {"yesterday": [], "today": [], "tomorrow": []} extracted = {key: [] for key in keys}
return extracted_price_info return extracted
@callback @callback
def _get_latest_timestamp_from_rating_type(self, rating_data: dict | None, rating_type: str) -> datetime | None: def _get_latest_timestamp_from_rating_type(self, rating_data: dict | None, rating_type: str) -> datetime | None:
@ -550,14 +552,16 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict]):
async def _get_rating_data_for_type(self, rating_type: str) -> dict: async def _get_rating_data_for_type(self, rating_type: str) -> dict:
"""Get fresh rating data for a specific type in flat format.""" """Get fresh rating data for a specific type in flat format."""
client = self.config_entry.runtime_data.client client = self.config_entry.runtime_data.client
if rating_type == "hourly": method_map = {
data = await client.async_get_hourly_price_rating() "hourly": client.async_get_hourly_price_rating,
elif rating_type == "daily": "daily": client.async_get_daily_price_rating,
data = await client.async_get_daily_price_rating() "monthly": client.async_get_monthly_price_rating,
else: }
data = await client.async_get_monthly_price_rating() fetch_method = method_map.get(rating_type)
if not fetch_method:
# Accept both {"priceRating": {...}} and flat {rating_type: [...], ...} dicts msg = f"Unknown rating type: {rating_type}"
raise ValueError(msg)
data = await fetch_method()
try: try:
price_rating = data.get("priceRating", data) price_rating = data.get("priceRating", data)
threshold = price_rating.get("thresholdPercentages") threshold = price_rating.get("thresholdPercentages")
@ -772,9 +776,9 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict]):
return None return None
if rating_type: if rating_type:
timestamp = self._get_latest_timestamp_from_rating_type(data, rating_type) timestamp = self._get_latest_rating_timestamp(data, rating_type)
else: else:
timestamp = _get_latest_timestamp_from_prices(data) timestamp = self._get_latest_price_timestamp(data)
if timestamp: if timestamp:
LOGGER.debug( LOGGER.debug(
@ -786,3 +790,52 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict]):
return None return None
return timestamp return timestamp
@callback
def _get_latest_timestamp(
self,
data: dict | None,
container_key: str,
entry_key: str | None = None,
time_field: str = "startsAt",
) -> datetime | None:
"""Get the latest timestamp from a container in data, optionally for a subkey and time field."""
if not data or container_key not in data:
return None
try:
container = data[container_key]
if entry_key:
container = container.get(entry_key, [])
latest = None
for entry in container:
time_str = entry.get(time_field)
if time_str:
timestamp = dt_util.parse_datetime(time_str)
if timestamp and (not latest or timestamp > latest):
latest = timestamp
except (KeyError, IndexError, TypeError):
return None
return latest
@callback
def _get_latest_price_timestamp(self, price_data: dict | None) -> datetime | None:
"""Get the latest timestamp from price data (today and tomorrow)."""
# Check both today and tomorrow, return the latest
today = self._get_latest_timestamp(price_data, "priceInfo", "today", "startsAt")
tomorrow = self._get_latest_timestamp(price_data, "priceInfo", "tomorrow", "startsAt")
if today and tomorrow:
return max(today, tomorrow)
return today or tomorrow
@callback
def _get_latest_rating_timestamp(self, rating_data: dict | None, rating_type: str | None = None) -> datetime | None:
"""Get the latest timestamp from rating data, optionally for a specific type."""
if not rating_type:
# Check all types and return the latest
latest = None
for rtype in ("hourly", "daily", "monthly"):
ts = self._get_latest_timestamp(rating_data, "priceRating", rtype, "time")
if ts and (not latest or ts > latest):
latest = ts
return latest
return self._get_latest_timestamp(rating_data, "priceRating", rating_type, "time")