mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-03-30 05:13:40 +00:00
refactoring
This commit is contained in:
parent
adc11b0e4d
commit
3d37ace85e
1 changed files with 183 additions and 130 deletions
|
|
@ -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
|
||||||
LOGGER.debug(
|
elif PRICE_UPDATE_RANDOM_MIN_HOUR <= current_hour < PRICE_UPDATE_RANDOM_MAX_HOUR:
|
||||||
"In update window (%d:00) and latest price timestamp (%s) is before tomorrow, update needed",
|
today = current_time.date()
|
||||||
current_hour,
|
if self._random_update_date != today or self._random_update_minute is None:
|
||||||
latest_price_timestamp,
|
self._random_update_date = today
|
||||||
)
|
self._random_update_minute = secrets.randbelow(RANDOM_DELAY_MAX_MINUTES)
|
||||||
return True
|
# Only fetch at the random minute
|
||||||
|
if current_time.minute == self._random_update_minute:
|
||||||
# If it's been more than 24 hours since our last update
|
# Enforce minimum retry interval
|
||||||
if self._last_price_update and current_time - self._last_price_update >= UPDATE_INTERVAL:
|
if self._last_attempted_price_update:
|
||||||
LOGGER.debug(
|
since_last = current_time - self._last_attempted_price_update
|
||||||
"More than 24 hours since last price update (%s), update needed",
|
if since_last < MIN_RETRY_INTERVAL:
|
||||||
self._last_price_update,
|
LOGGER.debug(
|
||||||
)
|
"Skipping price update: last attempt was %s ago (<%s)",
|
||||||
return True
|
since_last,
|
||||||
|
MIN_RETRY_INTERVAL,
|
||||||
LOGGER.debug(
|
)
|
||||||
"No price update needed - Last update: %s, Latest data: %s",
|
should_update = False
|
||||||
self._last_price_update,
|
else:
|
||||||
latest_price_timestamp,
|
self._last_attempted_price_update = current_time
|
||||||
)
|
should_update = not tomorrow_data_complete
|
||||||
return False
|
else:
|
||||||
|
self._last_attempted_price_update = current_time
|
||||||
|
should_update = not tomorrow_data_complete
|
||||||
|
else:
|
||||||
|
should_update = False
|
||||||
|
# 3. After update window (15:00-00:00): always fetch if tomorrow's data is missing
|
||||||
|
elif PRICE_UPDATE_RANDOM_MAX_HOUR <= current_hour < END_OF_DAY_HOUR:
|
||||||
|
should_update = not tomorrow_data_complete
|
||||||
|
# 4. After midnight until 13:00: never fetch
|
||||||
|
else:
|
||||||
|
should_update = 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,
|
lambda d: self._get_latest_rating_timestamp(d, rating_type),
|
||||||
latest_timestamp,
|
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")
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue