mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-03-29 21:03:40 +00:00
fix(coordinator): track API calls separately from cached data usage
The lifecycle sensor was always showing "fresh" state because _last_price_update was set on every coordinator update, regardless of whether data came from API or cache. Changes: - interval_pool/manager.py: get_intervals() and get_sensor_data() now return tuple[data, bool] where bool indicates actual API call - coordinator/price_data_manager.py: All fetch methods propagate api_called flag through the call chain - coordinator/core.py: Only update _last_price_update when api_called=True, added debug logging to distinguish API calls from cached data - services/get_price.py: Updated to handle new tuple return type Impact: Lifecycle sensor now correctly shows "cached" during normal 15-minute updates (using pool cache) and only "fresh" within 5 minutes of actual API calls. This fixes the issue where the sensor would never leave the "fresh" state during frequent HA restarts or normal operation.
This commit is contained in:
parent
81ebfb4916
commit
23b4330b9a
4 changed files with 87 additions and 39 deletions
|
|
@ -608,7 +608,7 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||||
# Get current price info to check if tomorrow data already exists
|
# Get current price info to check if tomorrow data already exists
|
||||||
current_price_info = self.data.get("priceInfo", []) if self.data else []
|
current_price_info = self.data.get("priceInfo", []) if self.data else []
|
||||||
|
|
||||||
result = await self._price_data_manager.handle_main_entry_update(
|
result, api_called = await self._price_data_manager.handle_main_entry_update(
|
||||||
current_time,
|
current_time,
|
||||||
self._home_id,
|
self._home_id,
|
||||||
self._transform_data,
|
self._transform_data,
|
||||||
|
|
@ -621,12 +621,22 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||||
# Sync user_data cache (price data is in IntervalPool)
|
# Sync user_data cache (price data is in IntervalPool)
|
||||||
self._cached_user_data = self._price_data_manager.cached_user_data
|
self._cached_user_data = self._price_data_manager.cached_user_data
|
||||||
|
|
||||||
# Update lifecycle tracking - Pool decides if API was called
|
# Update lifecycle tracking - ONLY if API was actually called
|
||||||
# We track based on result having data
|
# (not when returning cached data)
|
||||||
if result and "priceInfo" in result and len(result["priceInfo"]) > 0:
|
if api_called and result and "priceInfo" in result and len(result["priceInfo"]) > 0:
|
||||||
self._last_price_update = current_time # Track when data was fetched
|
self._last_price_update = current_time # Track when data was fetched from API
|
||||||
self._api_calls_today += 1
|
self._api_calls_today += 1
|
||||||
self._lifecycle_state = "fresh" # Data just fetched
|
self._lifecycle_state = "fresh" # Data just fetched
|
||||||
|
_LOGGER.debug(
|
||||||
|
"API call completed: Fetched %d intervals, updating lifecycle to 'fresh'",
|
||||||
|
len(result["priceInfo"]),
|
||||||
|
)
|
||||||
|
elif not api_called:
|
||||||
|
# Using cached data - lifecycle stays as is (cached/searching_tomorrow/etc.)
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Using cached data: %d intervals from pool, no API call made",
|
||||||
|
len(result.get("priceInfo", [])),
|
||||||
|
)
|
||||||
except (
|
except (
|
||||||
TibberPricesApiClientAuthenticationError,
|
TibberPricesApiClientAuthenticationError,
|
||||||
TibberPricesApiClientCommunicationError,
|
TibberPricesApiClientCommunicationError,
|
||||||
|
|
|
||||||
|
|
@ -287,7 +287,7 @@ class TibberPricesPriceDataManager:
|
||||||
current_time: datetime,
|
current_time: datetime,
|
||||||
*,
|
*,
|
||||||
include_tomorrow: bool = True,
|
include_tomorrow: bool = True,
|
||||||
) -> dict[str, Any]:
|
) -> tuple[dict[str, Any], bool]:
|
||||||
"""
|
"""
|
||||||
Fetch data for a single home via pool.
|
Fetch data for a single home via pool.
|
||||||
|
|
||||||
|
|
@ -297,15 +297,23 @@ class TibberPricesPriceDataManager:
|
||||||
include_tomorrow: If True, request tomorrow's data too. If False,
|
include_tomorrow: If True, request tomorrow's data too. If False,
|
||||||
only request up to end of today.
|
only request up to end of today.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (data_dict, api_called):
|
||||||
|
- data_dict: Dictionary with timestamp, home_id, price_info, currency.
|
||||||
|
- api_called: True if API was called to fetch missing data.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
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 (
|
||||||
"timestamp": current_time,
|
{
|
||||||
"home_id": "",
|
"timestamp": current_time,
|
||||||
"price_info": [],
|
"home_id": "",
|
||||||
"currency": "EUR",
|
"price_info": [],
|
||||||
}
|
"currency": "EUR",
|
||||||
|
},
|
||||||
|
False, # No API call made
|
||||||
|
)
|
||||||
|
|
||||||
# Ensure we have user_data before fetching price data
|
# Ensure we have user_data before fetching price data
|
||||||
# This is critical for timezone-aware cursor calculation
|
# This is critical for timezone-aware cursor calculation
|
||||||
|
|
@ -336,26 +344,35 @@ class TibberPricesPriceDataManager:
|
||||||
raise TibberPricesApiClientError(msg)
|
raise TibberPricesApiClientError(msg)
|
||||||
|
|
||||||
# Retrieve price data via IntervalPool (single source of truth)
|
# Retrieve price data via IntervalPool (single source of truth)
|
||||||
price_info = await self._fetch_via_pool(home_id, include_tomorrow=include_tomorrow)
|
price_info, api_called = await self._fetch_via_pool(home_id, include_tomorrow=include_tomorrow)
|
||||||
|
|
||||||
# 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)
|
||||||
|
|
||||||
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, api_called=%s)",
|
||||||
|
home_id,
|
||||||
|
len(price_info),
|
||||||
|
api_called,
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return (
|
||||||
"timestamp": current_time,
|
{
|
||||||
"home_id": home_id,
|
"timestamp": current_time,
|
||||||
"price_info": price_info,
|
"home_id": home_id,
|
||||||
"currency": currency,
|
"price_info": price_info,
|
||||||
}
|
"currency": currency,
|
||||||
|
},
|
||||||
|
api_called,
|
||||||
|
)
|
||||||
|
|
||||||
async def _fetch_via_pool(
|
async def _fetch_via_pool(
|
||||||
self,
|
self,
|
||||||
home_id: str,
|
home_id: str,
|
||||||
*,
|
*,
|
||||||
include_tomorrow: bool = True,
|
include_tomorrow: bool = True,
|
||||||
) -> list[dict[str, Any]]:
|
) -> tuple[list[dict[str, Any]], bool]:
|
||||||
"""
|
"""
|
||||||
Retrieve price data via IntervalPool.
|
Retrieve price data via IntervalPool.
|
||||||
|
|
||||||
|
|
@ -375,12 +392,14 @@ class TibberPricesPriceDataManager:
|
||||||
tomorrow data yet.
|
tomorrow data yet.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of price interval dicts.
|
Tuple of (intervals, api_called):
|
||||||
|
- intervals: List of price interval dicts.
|
||||||
|
- api_called: True if API was called to fetch missing data.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
# user_data is guaranteed by fetch_home_data(), but needed for type narrowing
|
# user_data is guaranteed by fetch_home_data(), but needed for type narrowing
|
||||||
if self._cached_user_data is None:
|
if self._cached_user_data is None:
|
||||||
return []
|
return [], False # No data, no API call
|
||||||
|
|
||||||
self._log(
|
self._log(
|
||||||
"debug",
|
"debug",
|
||||||
|
|
@ -388,12 +407,14 @@ class TibberPricesPriceDataManager:
|
||||||
home_id,
|
home_id,
|
||||||
include_tomorrow,
|
include_tomorrow,
|
||||||
)
|
)
|
||||||
return await self._interval_pool.get_sensor_data(
|
intervals, api_called = await self._interval_pool.get_sensor_data(
|
||||||
api_client=self.api,
|
api_client=self.api,
|
||||||
user_data=self._cached_user_data,
|
user_data=self._cached_user_data,
|
||||||
include_tomorrow=include_tomorrow,
|
include_tomorrow=include_tomorrow,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return intervals, api_called
|
||||||
|
|
||||||
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.
|
||||||
|
|
@ -463,7 +484,7 @@ class TibberPricesPriceDataManager:
|
||||||
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,
|
current_price_info: list[dict[str, Any]] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> tuple[dict[str, Any], bool]:
|
||||||
"""
|
"""
|
||||||
Handle update for main entry - fetch data for this home.
|
Handle update for main entry - fetch data for this home.
|
||||||
|
|
||||||
|
|
@ -485,6 +506,11 @@ class TibberPricesPriceDataManager:
|
||||||
current_price_info: Current price intervals (from coordinator.data["priceInfo"]).
|
current_price_info: Current price intervals (from coordinator.data["priceInfo"]).
|
||||||
Used to check if tomorrow data already exists.
|
Used to check if tomorrow data already exists.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (transformed_data, api_called):
|
||||||
|
- transformed_data: Transformed data dict for coordinator.
|
||||||
|
- api_called: True if API was called to fetch missing data.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
# 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)
|
||||||
|
|
@ -497,7 +523,7 @@ class TibberPricesPriceDataManager:
|
||||||
# Return a special marker in the result that coordinator can check
|
# Return a special marker in the result that coordinator can check
|
||||||
result = transform_fn({})
|
result = transform_fn({})
|
||||||
result["_home_not_found"] = True # Special marker for coordinator
|
result["_home_not_found"] = True # Special marker for coordinator
|
||||||
return result
|
return result, False # No API call made (home doesn't exist)
|
||||||
|
|
||||||
# Determine if we should request tomorrow data
|
# Determine if we should request tomorrow data
|
||||||
include_tomorrow = self.should_fetch_tomorrow_data(current_price_info)
|
include_tomorrow = self.should_fetch_tomorrow_data(current_price_info)
|
||||||
|
|
@ -509,7 +535,7 @@ class TibberPricesPriceDataManager:
|
||||||
home_id,
|
home_id,
|
||||||
include_tomorrow,
|
include_tomorrow,
|
||||||
)
|
)
|
||||||
raw_data = await self.fetch_home_data(home_id, current_time, include_tomorrow=include_tomorrow)
|
raw_data, api_called = await self.fetch_home_data(home_id, current_time, include_tomorrow=include_tomorrow)
|
||||||
|
|
||||||
# Parse timestamps immediately after fetch
|
# Parse timestamps immediately after fetch
|
||||||
raw_data = helpers.parse_all_timestamps(raw_data, time=self.time)
|
raw_data = helpers.parse_all_timestamps(raw_data, time=self.time)
|
||||||
|
|
@ -519,7 +545,7 @@ class TibberPricesPriceDataManager:
|
||||||
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), api_called
|
||||||
|
|
||||||
async def handle_api_error(
|
async def handle_api_error(
|
||||||
self,
|
self,
|
||||||
|
|
|
||||||
|
|
@ -118,7 +118,7 @@ class TibberPricesIntervalPool:
|
||||||
user_data: dict[str, Any],
|
user_data: dict[str, Any],
|
||||||
start_time: datetime,
|
start_time: datetime,
|
||||||
end_time: datetime,
|
end_time: datetime,
|
||||||
) -> list[dict[str, Any]]:
|
) -> tuple[list[dict[str, Any]], bool]:
|
||||||
"""
|
"""
|
||||||
Get price intervals for time range (cached + fetch missing).
|
Get price intervals for time range (cached + fetch missing).
|
||||||
|
|
||||||
|
|
@ -139,8 +139,10 @@ class TibberPricesIntervalPool:
|
||||||
end_time: End of range (exclusive, timezone-aware).
|
end_time: End of range (exclusive, timezone-aware).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of price interval dicts, sorted by startsAt.
|
Tuple of (intervals, api_called):
|
||||||
Contains ALL intervals in requested range (cached + fetched).
|
- intervals: List of price interval dicts, sorted by startsAt.
|
||||||
|
Contains ALL intervals in requested range (cached + fetched).
|
||||||
|
- api_called: True if API was called to fetch missing data, False if all from cache.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
TibberPricesApiClientError: If API calls fail or validation errors.
|
TibberPricesApiClientError: If API calls fail or validation errors.
|
||||||
|
|
@ -200,15 +202,19 @@ class TibberPricesIntervalPool:
|
||||||
# This ensures we return exactly what user requested, filtering out extra intervals
|
# This ensures we return exactly what user requested, filtering out extra intervals
|
||||||
final_result = self._get_cached_intervals(start_time_iso, end_time_iso)
|
final_result = self._get_cached_intervals(start_time_iso, end_time_iso)
|
||||||
|
|
||||||
|
# Track if API was called (True if any missing ranges were fetched)
|
||||||
|
api_called = len(missing_ranges) > 0
|
||||||
|
|
||||||
_LOGGER_DETAILS.debug(
|
_LOGGER_DETAILS.debug(
|
||||||
"Pool returning %d intervals for home %s (from cache: %d, fetched from API: %d ranges)",
|
"Pool returning %d intervals for home %s (from cache: %d, fetched from API: %d ranges, api_called=%s)",
|
||||||
len(final_result),
|
len(final_result),
|
||||||
self._home_id,
|
self._home_id,
|
||||||
len(cached_intervals),
|
len(cached_intervals),
|
||||||
len(missing_ranges),
|
len(missing_ranges),
|
||||||
|
api_called,
|
||||||
)
|
)
|
||||||
|
|
||||||
return final_result
|
return final_result, api_called
|
||||||
|
|
||||||
async def get_sensor_data(
|
async def get_sensor_data(
|
||||||
self,
|
self,
|
||||||
|
|
@ -217,7 +223,7 @@ class TibberPricesIntervalPool:
|
||||||
home_timezone: str | None = None,
|
home_timezone: str | None = None,
|
||||||
*,
|
*,
|
||||||
include_tomorrow: bool = True,
|
include_tomorrow: bool = True,
|
||||||
) -> list[dict[str, Any]]:
|
) -> tuple[list[dict[str, Any]], bool]:
|
||||||
"""
|
"""
|
||||||
Get price intervals for sensor data (day-before-yesterday to end-of-tomorrow).
|
Get price intervals for sensor data (day-before-yesterday to end-of-tomorrow).
|
||||||
|
|
||||||
|
|
@ -247,8 +253,10 @@ class TibberPricesIntervalPool:
|
||||||
DOES NOT affect returned data - always returns full range.
|
DOES NOT affect returned data - always returns full range.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of price interval dicts for the 4-day window (including any cached
|
Tuple of (intervals, api_called):
|
||||||
tomorrow data), sorted by startsAt.
|
- intervals: List of price interval dicts for the 4-day window (including any cached
|
||||||
|
tomorrow data), sorted by startsAt.
|
||||||
|
- api_called: True if API was called to fetch missing data, False if all from cache.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
# Determine timezone
|
# Determine timezone
|
||||||
|
|
@ -286,7 +294,7 @@ class TibberPricesIntervalPool:
|
||||||
)
|
)
|
||||||
|
|
||||||
# Fetch data (may be partial if include_tomorrow=False)
|
# Fetch data (may be partial if include_tomorrow=False)
|
||||||
await self.get_intervals(
|
_intervals, api_called = await self.get_intervals(
|
||||||
api_client=api_client,
|
api_client=api_client,
|
||||||
user_data=user_data,
|
user_data=user_data,
|
||||||
start_time=day_before_yesterday,
|
start_time=day_before_yesterday,
|
||||||
|
|
@ -295,11 +303,13 @@ class TibberPricesIntervalPool:
|
||||||
|
|
||||||
# Return FULL protected range (including any cached tomorrow data)
|
# Return FULL protected range (including any cached tomorrow data)
|
||||||
# This ensures cached tomorrow data is available even when include_tomorrow=False
|
# This ensures cached tomorrow data is available even when include_tomorrow=False
|
||||||
return self._get_cached_intervals(
|
final_intervals = self._get_cached_intervals(
|
||||||
day_before_yesterday.isoformat(),
|
day_before_yesterday.isoformat(),
|
||||||
end_of_tomorrow.isoformat(),
|
end_of_tomorrow.isoformat(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return final_intervals, api_called
|
||||||
|
|
||||||
def get_pool_stats(self) -> dict[str, Any]:
|
def get_pool_stats(self) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Get statistics about the interval pool.
|
Get statistics about the interval pool.
|
||||||
|
|
|
||||||
|
|
@ -145,12 +145,14 @@ async def handle_get_price(call: ServiceCall) -> ServiceResponse:
|
||||||
|
|
||||||
# Call the interval pool to get intervals (with intelligent caching)
|
# Call the interval pool to get intervals (with intelligent caching)
|
||||||
# Single-home architecture: pool knows its home_id, no parameter needed
|
# Single-home architecture: pool knows its home_id, no parameter needed
|
||||||
price_info = await pool.get_intervals(
|
price_info, _api_called = await pool.get_intervals(
|
||||||
api_client=api_client,
|
api_client=api_client,
|
||||||
user_data=user_data,
|
user_data=user_data,
|
||||||
start_time=start_time,
|
start_time=start_time,
|
||||||
end_time=end_time,
|
end_time=end_time,
|
||||||
)
|
)
|
||||||
|
# Note: We ignore api_called flag here - service always returns requested data
|
||||||
|
# regardless of whether it came from cache or was fetched fresh from API
|
||||||
|
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
_LOGGER.exception("Error fetching price data")
|
_LOGGER.exception("Error fetching price data")
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue