refactoring

This commit is contained in:
Julian Pawlowski 2025-04-23 18:51:40 +00:00
parent eacc249ad6
commit c67ec09c9a
7 changed files with 334 additions and 447 deletions

View file

@ -1,6 +1,7 @@
# The contents of this file is based on https://github.com/home-assistant/core/blob/dev/pyproject.toml # The contents of this file is based on https://github.com/home-assistant/core/blob/dev/pyproject.toml
target-version = "py313" target-version = "py313"
line-length = 120
[lint] [lint]
select = [ select = [
@ -19,6 +20,10 @@ ignore = [
[lint.flake8-pytest-style] [lint.flake8-pytest-style]
fixture-parentheses = false fixture-parentheses = false
[lint.isort]
force-single-line = false
known-first-party = ["custom_components", "homeassistant"]
[lint.pyupgrade] [lint.pyupgrade]
keep-runtime-typing = true keep-runtime-typing = true

View file

@ -11,6 +11,7 @@ from typing import Any
import aiohttp import aiohttp
import async_timeout import async_timeout
from homeassistant.const import __version__ as ha_version from homeassistant.const import __version__ as ha_version
from .const import VERSION from .const import VERSION

View file

@ -5,6 +5,9 @@ from __future__ import annotations
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Callable
from homeassistant.components.binary_sensor import ( from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass, BinarySensorDeviceClass,
BinarySensorEntity, BinarySensorEntity,
@ -70,18 +73,42 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity):
"""Initialize the binary_sensor class.""" """Initialize the binary_sensor class."""
super().__init__(coordinator) super().__init__(coordinator)
self.entity_description = entity_description self.entity_description = entity_description
self._attr_unique_id = ( self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{entity_description.key}"
f"{coordinator.config_entry.entry_id}_{entity_description.key}" self._state_getter: Callable | None = self._get_state_getter()
) self._attribute_getter: Callable | None = self._get_attribute_getter()
def _get_state_getter(self) -> Callable | None:
"""Return the appropriate state getter method based on the sensor type."""
key = self.entity_description.key
if key == "peak_hour":
return lambda: self._get_price_threshold_state(threshold_percentage=0.8, high_is_active=True)
if key == "best_price_hour":
return lambda: self._get_price_threshold_state(threshold_percentage=0.2, high_is_active=False)
if key == "connection":
return lambda: True if self.coordinator.data else None
return None
def _get_attribute_getter(self) -> Callable | None:
"""Return the appropriate attribute getter method based on the sensor type."""
key = self.entity_description.key
if key == "peak_hour":
return lambda: self._get_price_hours_attributes(attribute_name="peak_hours", reverse_sort=True)
if key == "best_price_hour":
return lambda: self._get_price_hours_attributes(attribute_name="best_price_hours", reverse_sort=False)
return None
def _get_current_price_data(self) -> tuple[list[float], float] | None: def _get_current_price_data(self) -> tuple[list[float], float] | None:
"""Get current price data if available.""" """Get current price data if available."""
if not ( if not (
self.coordinator.data self.coordinator.data
and ( and (
today_prices := self.coordinator.data["data"]["viewer"]["homes"][0][ today_prices := self.coordinator.data["data"]["viewer"]["homes"][0]["currentSubscription"][
"currentSubscription" "priceInfo"
]["priceInfo"].get("today", []) ].get("today", [])
) )
): ):
return None return None
@ -102,26 +129,59 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity):
prices.sort() prices.sort()
return prices, float(current_hour_data["total"]) return prices, float(current_hour_data["total"])
def _get_price_threshold_state(self, *, threshold_percentage: float, high_is_active: bool) -> bool | None:
"""
Determine if current price is above/below threshold.
Args:
threshold_percentage: The percentage point in the sorted list (0.0-1.0)
high_is_active: If True, value >= threshold is active, otherwise value <= threshold is active
"""
price_data = self._get_current_price_data()
if not price_data:
return None
prices, current_price = price_data
threshold_index = int(len(prices) * threshold_percentage)
if high_is_active:
return current_price >= prices[threshold_index]
return current_price <= prices[threshold_index]
def _get_price_hours_attributes(self, *, attribute_name: str, reverse_sort: bool) -> dict | None:
"""Get price hours attributes."""
if not self.coordinator.data:
return None
price_info = self.coordinator.data["data"]["viewer"]["homes"][0]["currentSubscription"]["priceInfo"]
today_prices = price_info.get("today", [])
if not today_prices:
return None
prices = [
(
datetime.fromisoformat(price["startsAt"]).hour,
float(price["total"]),
)
for price in today_prices
]
# Sort by price (high to low for peak, low to high for best)
sorted_hours = sorted(prices, key=lambda x: x[1], reverse=reverse_sort)[:5]
return {attribute_name: [{"hour": hour, "price": price} for hour, price in sorted_hours]}
@property @property
def is_on(self) -> bool | None: def is_on(self) -> bool | None:
"""Return true if the binary_sensor is on.""" """Return true if the binary_sensor is on."""
try: try:
price_data = self._get_current_price_data() if not self.coordinator.data or not self._state_getter:
if not price_data:
return None return None
prices, current_price = price_data return self._state_getter()
match self.entity_description.key:
case "peak_hour":
threshold_index = int(len(prices) * 0.8)
return current_price >= prices[threshold_index]
case "best_price_hour":
threshold_index = int(len(prices) * 0.2)
return current_price <= prices[threshold_index]
case "connection":
return True
case _:
return None
except (KeyError, ValueError, TypeError) as ex: except (KeyError, ValueError, TypeError) as ex:
self.coordinator.logger.exception( self.coordinator.logger.exception(
@ -137,43 +197,10 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity):
def extra_state_attributes(self) -> dict | None: def extra_state_attributes(self) -> dict | None:
"""Return additional state attributes.""" """Return additional state attributes."""
try: try:
if not self.coordinator.data: if not self.coordinator.data or not self._attribute_getter:
return None return None
subscription = self.coordinator.data["data"]["viewer"]["homes"][0][ return self._attribute_getter()
"currentSubscription"
]
price_info = subscription["priceInfo"]
attributes = {}
if self.entity_description.key in ["peak_hour", "best_price_hour"]:
today_prices = price_info.get("today", [])
if today_prices:
prices = [
(
datetime.fromisoformat(price["startsAt"]).hour,
float(price["total"]),
)
for price in today_prices
]
if self.entity_description.key == "peak_hour":
# Get top 5 peak hours
peak_hours = sorted(prices, key=lambda x: x[1], reverse=True)[
:5
]
attributes["peak_hours"] = [
{"hour": hour, "price": price} for hour, price in peak_hours
]
else:
# Get top 5 best price hours
best_hours = sorted(prices, key=lambda x: x[1])[:5]
attributes["best_price_hours"] = [
{"hour": hour, "price": price} for hour, price in best_hours
]
return attributes
else:
return None
except (KeyError, ValueError, TypeError) as ex: except (KeyError, ValueError, TypeError) as ex:
self.coordinator.logger.exception( self.coordinator.logger.exception(

View file

@ -3,11 +3,12 @@
from __future__ import annotations from __future__ import annotations
import voluptuous as vol import voluptuous as vol
from slugify import slugify
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.helpers import selector from homeassistant.helpers import selector
from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.helpers.aiohttp_client import async_create_clientsession
from slugify import slugify
from .api import ( from .api import (
TibberPricesApiClient, TibberPricesApiClient,

View file

@ -91,9 +91,7 @@ def _get_latest_timestamp_from_prices(
for price in today_prices: for price in today_prices:
if starts_at := price.get("startsAt"): if starts_at := price.get("startsAt"):
timestamp = dt_util.parse_datetime(starts_at) timestamp = dt_util.parse_datetime(starts_at)
if timestamp and ( if timestamp and (not latest_timestamp or timestamp > latest_timestamp):
not latest_timestamp or timestamp > latest_timestamp
):
latest_timestamp = timestamp latest_timestamp = timestamp
# Check tomorrow's prices # Check tomorrow's prices
@ -101,9 +99,7 @@ def _get_latest_timestamp_from_prices(
for price in tomorrow_prices: for price in tomorrow_prices:
if starts_at := price.get("startsAt"): if starts_at := price.get("startsAt"):
timestamp = dt_util.parse_datetime(starts_at) timestamp = dt_util.parse_datetime(starts_at)
if timestamp and ( if timestamp and (not latest_timestamp or timestamp > latest_timestamp):
not latest_timestamp or timestamp > latest_timestamp
):
latest_timestamp = timestamp latest_timestamp = timestamp
except (KeyError, IndexError, TypeError): except (KeyError, IndexError, TypeError):
@ -131,9 +127,7 @@ def _get_latest_timestamp_from_rating(
for entry in rating_entries: for entry in rating_entries:
if time := entry.get("time"): if time := entry.get("time"):
timestamp = dt_util.parse_datetime(time) timestamp = dt_util.parse_datetime(time)
if timestamp and ( if timestamp and (not latest_timestamp or timestamp > latest_timestamp):
not latest_timestamp or timestamp > latest_timestamp
):
latest_timestamp = timestamp latest_timestamp = timestamp
except (KeyError, IndexError, TypeError): except (KeyError, IndexError, TypeError):
return None return None
@ -171,16 +165,12 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[TibberPricesData])
# 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(
async_track_time_change( async_track_time_change(hass, self._async_refresh_hourly, minute=0, second=0)
hass, self._async_refresh_hourly, minute=0, second=0
)
) )
# Schedule data rotation at midnight # Schedule data rotation at midnight
self._remove_update_listeners.append( self._remove_update_listeners.append(
async_track_time_change( async_track_time_change(hass, self._async_handle_midnight_rotation, hour=0, minute=0, second=0)
hass, self._async_handle_midnight_rotation, hour=0, minute=0, second=0
)
) )
async def async_shutdown(self) -> None: async def async_shutdown(self) -> None:
@ -189,18 +179,14 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[TibberPricesData])
for listener in self._remove_update_listeners: for listener in self._remove_update_listeners:
listener() listener()
async def _async_handle_midnight_rotation( async def _async_handle_midnight_rotation(self, _now: datetime | None = None) -> None:
self, _now: datetime | None = None
) -> None:
"""Handle data rotation at midnight.""" """Handle data rotation at midnight."""
if not self._cached_price_data: if not self._cached_price_data:
return return
try: try:
LOGGER.debug("Starting midnight data rotation") LOGGER.debug("Starting midnight data rotation")
subscription = self._cached_price_data["data"]["viewer"]["homes"][0][ subscription = self._cached_price_data["data"]["viewer"]["homes"][0]["currentSubscription"]
"currentSubscription"
]
price_info = subscription["priceInfo"] price_info = subscription["priceInfo"]
# Move today's data to yesterday # Move today's data to yesterday
@ -262,20 +248,12 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[TibberPricesData])
if stored: if stored:
# Load cached data # Load cached data
self._cached_price_data = cast("TibberPricesData", stored.get("price_data")) self._cached_price_data = cast("TibberPricesData", stored.get("price_data"))
self._cached_rating_data_hourly = cast( self._cached_rating_data_hourly = cast("TibberPricesData", stored.get("rating_data_hourly"))
"TibberPricesData", stored.get("rating_data_hourly") self._cached_rating_data_daily = cast("TibberPricesData", stored.get("rating_data_daily"))
) self._cached_rating_data_monthly = cast("TibberPricesData", stored.get("rating_data_monthly"))
self._cached_rating_data_daily = cast(
"TibberPricesData", stored.get("rating_data_daily")
)
self._cached_rating_data_monthly = cast(
"TibberPricesData", stored.get("rating_data_monthly")
)
# Recover timestamps # Recover timestamps
self._last_price_update = self._recover_timestamp( self._last_price_update = self._recover_timestamp(self._cached_price_data, stored.get("last_price_update"))
self._cached_price_data, stored.get("last_price_update")
)
self._last_rating_update_hourly = self._recover_timestamp( self._last_rating_update_hourly = self._recover_timestamp(
self._cached_rating_data_hourly, self._cached_rating_data_hourly,
stored.get("last_rating_update_hourly"), stored.get("last_rating_update_hourly"),
@ -293,8 +271,7 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[TibberPricesData])
) )
LOGGER.debug( LOGGER.debug(
"Loaded stored cache data - " "Loaded stored cache data - Price update: %s, Rating hourly: %s, daily: %s, monthly: %s",
"Price update: %s, Rating hourly: %s, daily: %s, monthly: %s",
self._last_price_update, self._last_price_update,
self._last_rating_update_hourly, self._last_rating_update_hourly,
self._last_rating_update_daily, self._last_rating_update_daily,
@ -379,46 +356,15 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[TibberPricesData])
else: else:
return result return result
async def _handle_conditional_update( async def _handle_conditional_update(self, current_time: datetime) -> TibberPricesData:
self, current_time: datetime
) -> TibberPricesData:
"""Handle conditional update based on update conditions.""" """Handle conditional update based on update conditions."""
should_update_price = self._should_update_price_data(current_time) # Simplified conditional update checking
should_update_hourly = self._should_update_rating_type( update_conditions = self._check_update_conditions(current_time)
current_time,
self._cached_rating_data_hourly,
self._last_rating_update_hourly,
"hourly",
)
should_update_daily = self._should_update_rating_type(
current_time,
self._cached_rating_data_daily,
self._last_rating_update_daily,
"daily",
)
should_update_monthly = self._should_update_rating_type(
current_time,
self._cached_rating_data_monthly,
self._last_rating_update_monthly,
"monthly",
)
if any( if any(update_conditions.values()):
[
should_update_price,
should_update_hourly,
should_update_daily,
should_update_monthly,
]
):
LOGGER.debug( LOGGER.debug(
"Updating data based on conditions", "Updating data based on conditions",
extra={ extra=update_conditions,
"update_price": should_update_price,
"update_hourly": should_update_hourly,
"update_daily": should_update_daily,
"update_monthly": should_update_monthly,
},
) )
return await self._fetch_all_data() return await self._fetch_all_data()
@ -429,6 +375,31 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[TibberPricesData])
LOGGER.debug("No cached data available, fetching new data") LOGGER.debug("No cached data available, fetching new data")
return await self._fetch_all_data() return await self._fetch_all_data()
@callback
def _check_update_conditions(self, current_time: datetime) -> dict[str, bool]:
"""Check all update conditions and return results as a dictionary."""
return {
"update_price": self._should_update_price_data(current_time),
"update_hourly": self._should_update_rating_type(
current_time,
self._cached_rating_data_hourly,
self._last_rating_update_hourly,
"hourly",
),
"update_daily": self._should_update_rating_type(
current_time,
self._cached_rating_data_daily,
self._last_rating_update_daily,
"daily",
),
"update_monthly": self._should_update_rating_type(
current_time,
self._cached_rating_data_monthly,
self._last_rating_update_monthly,
"monthly",
),
}
async def _fetch_all_data(self) -> TibberPricesData: async def _fetch_all_data(self) -> TibberPricesData:
""" """
Fetch all data from the API without checking update conditions. Fetch all data from the API without checking update conditions.
@ -462,9 +433,7 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[TibberPricesData])
except TibberPricesApiClientError as ex: except TibberPricesApiClientError as ex:
LOGGER.error("Failed to fetch price data: %s", ex) LOGGER.error("Failed to fetch price data: %s", ex)
if self._cached_price_data is not None: if self._cached_price_data is not None:
LOGGER.info( LOGGER.info("Using cached data as fallback after price data fetch failure")
"Using cached data as fallback after price data fetch failure"
)
return self._merge_all_cached_data() return self._merge_all_cached_data()
raise raise
@ -483,24 +452,7 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[TibberPricesData])
# Update rating data cache only for types that were successfully fetched # Update rating data cache only for types that were successfully fetched
for rating_type, rating_data in new_data["rating_data"].items(): for rating_type, rating_data in new_data["rating_data"].items():
if rating_data is not None: if rating_data is not None:
if rating_type == "hourly": self._update_rating_cache(rating_type, rating_data, current_time)
self._cached_rating_data_hourly = cast(
"TibberPricesData", rating_data
)
self._last_rating_update_hourly = current_time
elif rating_type == "daily":
self._cached_rating_data_daily = cast(
"TibberPricesData", rating_data
)
self._last_rating_update_daily = current_time
else: # monthly
self._cached_rating_data_monthly = cast(
"TibberPricesData", rating_data
)
self._last_rating_update_monthly = current_time
LOGGER.debug(
"Updated %s rating data cache at %s", rating_type, current_time
)
# Store the updated cache # Store the updated cache
await self._store_cache() await self._store_cache()
@ -509,13 +461,25 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[TibberPricesData])
# Return merged data # Return merged data
return self._merge_all_cached_data() return self._merge_all_cached_data()
@callback
def _update_rating_cache(self, rating_type: str, rating_data: TibberPricesData, current_time: datetime) -> None:
"""Update the rating cache for a specific rating type."""
if rating_type == "hourly":
self._cached_rating_data_hourly = cast("TibberPricesData", rating_data)
self._last_rating_update_hourly = current_time
elif rating_type == "daily":
self._cached_rating_data_daily = cast("TibberPricesData", rating_data)
self._last_rating_update_daily = current_time
else: # monthly
self._cached_rating_data_monthly = cast("TibberPricesData", rating_data)
self._last_rating_update_monthly = current_time
LOGGER.debug("Updated %s rating data cache at %s", rating_type, current_time)
async def _store_cache(self) -> None: async def _store_cache(self) -> None:
"""Store cache data.""" """Store cache data."""
# Recover any missing timestamps from the data # Recover any missing timestamps from the data
if self._cached_price_data and not self._last_price_update: if self._cached_price_data and not self._last_price_update:
latest_timestamp = _get_latest_timestamp_from_prices( latest_timestamp = _get_latest_timestamp_from_prices(self._cached_price_data)
self._cached_price_data
)
if latest_timestamp: if latest_timestamp:
self._last_price_update = latest_timestamp self._last_price_update = latest_timestamp
LOGGER.debug( LOGGER.debug(
@ -537,9 +501,7 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[TibberPricesData])
for rating_type, (cached_data, last_update) in rating_types.items(): for rating_type, (cached_data, last_update) in rating_types.items():
if cached_data and not last_update: if cached_data and not last_update:
latest_timestamp = self._get_latest_timestamp_from_rating_type( latest_timestamp = self._get_latest_timestamp_from_rating_type(cached_data, rating_type)
cached_data, rating_type
)
if latest_timestamp: if latest_timestamp:
if rating_type == "hourly": if rating_type == "hourly":
self._last_rating_update_hourly = latest_timestamp self._last_rating_update_hourly = latest_timestamp
@ -558,9 +520,7 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[TibberPricesData])
"rating_data_hourly": self._cached_rating_data_hourly, "rating_data_hourly": self._cached_rating_data_hourly,
"rating_data_daily": self._cached_rating_data_daily, "rating_data_daily": self._cached_rating_data_daily,
"rating_data_monthly": self._cached_rating_data_monthly, "rating_data_monthly": self._cached_rating_data_monthly,
"last_price_update": self._last_price_update.isoformat() "last_price_update": self._last_price_update.isoformat() if self._last_price_update else None,
if self._last_price_update
else None,
"last_rating_update_hourly": self._last_rating_update_hourly.isoformat() "last_rating_update_hourly": self._last_rating_update_hourly.isoformat()
if self._last_rating_update_hourly if self._last_rating_update_hourly
else None, else None,
@ -586,9 +546,7 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[TibberPricesData])
return True return True
# Get the latest timestamp from our price data # Get the latest timestamp from our price data
latest_price_timestamp = _get_latest_timestamp_from_prices( latest_price_timestamp = _get_latest_timestamp_from_prices(self._cached_price_data)
self._cached_price_data
)
if not latest_price_timestamp: if not latest_price_timestamp:
LOGGER.debug("No valid timestamp found in price data, update needed") LOGGER.debug("No valid timestamp found in price data, update needed")
return True return True
@ -603,30 +561,22 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[TibberPricesData])
# Check if we're in the update window (13:00-15:00) # 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 = ( in_update_window = PRICE_UPDATE_RANDOM_MIN_HOUR <= current_hour <= PRICE_UPDATE_RANDOM_MAX_HOUR
PRICE_UPDATE_RANDOM_MIN_HOUR <= current_hour <= PRICE_UPDATE_RANDOM_MAX_HOUR
)
# Get tomorrow's date at midnight # Get tomorrow's date at midnight
tomorrow = (current_time + timedelta(days=1)).replace( tomorrow = (current_time + timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0)
hour=0, minute=0, second=0, microsecond=0
)
# If we're in the update window and don't have tomorrow's complete data # If we're in the update window and don't have tomorrow's complete data
if in_update_window and latest_price_timestamp < tomorrow: if in_update_window and latest_price_timestamp < tomorrow:
LOGGER.debug( LOGGER.debug(
"In update window (%d:00) and latest price timestamp (%s) " "In update window (%d:00) and latest price timestamp (%s) is before tomorrow, update needed",
"is before tomorrow, update needed",
current_hour, current_hour,
latest_price_timestamp, latest_price_timestamp,
) )
return True return True
# If it's been more than 24 hours since our last update # If it's been more than 24 hours since our last update
if ( if self._last_price_update and current_time - self._last_price_update >= UPDATE_INTERVAL:
self._last_price_update
and current_time - self._last_price_update >= UPDATE_INTERVAL
):
LOGGER.debug( LOGGER.debug(
"More than 24 hours since last price update (%s), update needed", "More than 24 hours since last price update (%s), update needed",
self._last_price_update, self._last_price_update,
@ -651,19 +601,13 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[TibberPricesData])
"""Check if specific rating type should be updated.""" """Check if specific rating type should be updated."""
# If no cached data, we definitely need an update # If no cached data, we definitely need an update
if cached_data is None: if cached_data is None:
LOGGER.debug( LOGGER.debug("No cached %s rating data available, update needed", rating_type)
"No cached %s rating data available, update needed", rating_type
)
return True return True
# Get the latest timestamp from our rating data # Get the latest timestamp from our rating data
latest_timestamp = self._get_latest_timestamp_from_rating_type( latest_timestamp = self._get_latest_timestamp_from_rating_type(cached_data, rating_type)
cached_data, rating_type
)
if not latest_timestamp: if not latest_timestamp:
LOGGER.debug( LOGGER.debug("No valid timestamp found in %s rating data, update needed", rating_type)
"No valid timestamp found in %s rating data, update needed", rating_type
)
return True return True
# If we have rating data but no last_update timestamp, set it # If we have rating data but no last_update timestamp, set it
@ -682,22 +626,16 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[TibberPricesData])
last_update = latest_timestamp last_update = latest_timestamp
current_hour = current_time.hour current_hour = current_time.hour
in_update_window = ( in_update_window = PRICE_UPDATE_RANDOM_MIN_HOUR <= current_hour <= PRICE_UPDATE_RANDOM_MAX_HOUR
PRICE_UPDATE_RANDOM_MIN_HOUR <= current_hour <= PRICE_UPDATE_RANDOM_MAX_HOUR
)
should_update = False should_update = False
if rating_type == "monthly": if rating_type == "monthly":
current_month_start = current_time.replace( current_month_start = current_time.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
day=1, hour=0, minute=0, second=0, microsecond=0
)
should_update = latest_timestamp < current_month_start or ( should_update = latest_timestamp < current_month_start or (
last_update and current_time - last_update >= timedelta(days=1) last_update and current_time - last_update >= timedelta(days=1)
) )
else: else:
tomorrow = (current_time + timedelta(days=1)).replace( tomorrow = (current_time + timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0)
hour=0, minute=0, second=0, microsecond=0
)
should_update = ( should_update = (
in_update_window and latest_timestamp < tomorrow in_update_window and latest_timestamp < tomorrow
) or current_time - last_update >= UPDATE_INTERVAL ) or current_time - last_update >= UPDATE_INTERVAL
@ -722,9 +660,7 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[TibberPricesData])
@callback @callback
def _is_price_update_window(self, current_hour: int) -> bool: def _is_price_update_window(self, current_hour: int) -> bool:
"""Check if current hour is within price update window.""" """Check if current hour is within price update window."""
return ( return PRICE_UPDATE_RANDOM_MIN_HOUR <= current_hour <= PRICE_UPDATE_RANDOM_MAX_HOUR
PRICE_UPDATE_RANDOM_MIN_HOUR <= current_hour <= PRICE_UPDATE_RANDOM_MAX_HOUR
)
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."""
@ -737,14 +673,10 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[TibberPricesData])
try: try:
# Try to access data in the transformed structure first # Try to access data in the transformed structure first
try: try:
price_info = data["viewer"]["homes"][0]["currentSubscription"][ price_info = data["viewer"]["homes"][0]["currentSubscription"]["priceInfo"]
"priceInfo"
]
except KeyError: except KeyError:
# If that fails, try the raw data structure # If that fails, try the raw data structure
price_info = data["data"]["viewer"]["homes"][0]["currentSubscription"][ price_info = data["data"]["viewer"]["homes"][0]["currentSubscription"]["priceInfo"]
"priceInfo"
]
# Ensure we have all required fields # Ensure we have all required fields
extracted_price_info = { extracted_price_info = {
@ -771,15 +703,7 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[TibberPricesData])
} }
} }
} }
return { return {"data": {"viewer": {"homes": [{"currentSubscription": {"priceInfo": extracted_price_info}}]}}}
"data": {
"viewer": {
"homes": [
{"currentSubscription": {"priceInfo": extracted_price_info}}
]
}
}
}
@callback @callback
def _get_latest_timestamp_from_rating_type( def _get_latest_timestamp_from_rating_type(
@ -790,9 +714,7 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[TibberPricesData])
return None return None
try: try:
subscription = rating_data["data"]["viewer"]["homes"][0][ subscription = rating_data["data"]["viewer"]["homes"][0]["currentSubscription"]
"currentSubscription"
]
price_rating = subscription["priceRating"] price_rating = subscription["priceRating"]
result = None result = None
@ -823,15 +745,11 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[TibberPricesData])
except KeyError: except KeyError:
try: try:
# If that fails, try the raw data structure # If that fails, try the raw data structure
rating = data["data"]["viewer"]["homes"][0]["currentSubscription"][ rating = data["data"]["viewer"]["homes"][0]["currentSubscription"]["priceRating"]
"priceRating"
]
except KeyError as ex: except KeyError as ex:
LOGGER.error("Failed to extract rating data: %s", ex) LOGGER.error("Failed to extract rating data: %s", ex)
raise TibberPricesApiClientError( raise TibberPricesApiClientError(
TibberPricesApiClientError.EMPTY_DATA_ERROR.format( TibberPricesApiClientError.EMPTY_DATA_ERROR.format(query_type=rating_type)
query_type=rating_type
)
) from ex ) from ex
else: else:
return { return {
@ -841,9 +759,7 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[TibberPricesData])
{ {
"currentSubscription": { "currentSubscription": {
"priceRating": { "priceRating": {
"thresholdPercentages": rating[ "thresholdPercentages": rating["thresholdPercentages"],
"thresholdPercentages"
],
rating_type: rating[rating_type], rating_type: rating[rating_type],
} }
} }
@ -860,9 +776,7 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[TibberPricesData])
{ {
"currentSubscription": { "currentSubscription": {
"priceRating": { "priceRating": {
"thresholdPercentages": rating[ "thresholdPercentages": rating["thresholdPercentages"],
"thresholdPercentages"
],
rating_type: rating[rating_type], rating_type: rating[rating_type],
} }
} }
@ -880,9 +794,7 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[TibberPricesData])
# Start with price info # Start with price info
subscription = { subscription = {
"priceInfo": self._cached_price_data["data"]["viewer"]["homes"][0][ "priceInfo": self._cached_price_data["data"]["viewer"]["homes"][0]["currentSubscription"]["priceInfo"],
"currentSubscription"
]["priceInfo"],
"priceRating": { "priceRating": {
"thresholdPercentages": None, "thresholdPercentages": None,
}, },
@ -897,15 +809,11 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[TibberPricesData])
for rating_type, data in rating_data.items(): for rating_type, data in rating_data.items():
if data and "data" in data: if data and "data" in data:
rating = data["data"]["viewer"]["homes"][0]["currentSubscription"][ rating = data["data"]["viewer"]["homes"][0]["currentSubscription"]["priceRating"]
"priceRating"
]
# Set thresholdPercentages from any available rating data # Set thresholdPercentages from any available rating data
if not subscription["priceRating"]["thresholdPercentages"]: if not subscription["priceRating"]["thresholdPercentages"]:
subscription["priceRating"]["thresholdPercentages"] = rating[ subscription["priceRating"]["thresholdPercentages"] = rating["thresholdPercentages"]
"thresholdPercentages"
]
# Add the specific rating type data # Add the specific rating type data
subscription["priceRating"][rating_type] = rating[rating_type] subscription["priceRating"][rating_type] = rating[rating_type]

View file

@ -3,7 +3,10 @@
from __future__ import annotations from __future__ import annotations
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from collections.abc import Callable
from homeassistant.components.sensor import ( from homeassistant.components.sensor import (
SensorDeviceClass, SensorDeviceClass,
@ -16,6 +19,8 @@ from homeassistant.util import dt as dt_util
from .entity import TibberPricesEntity from .entity import TibberPricesEntity
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Callable
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
@ -203,192 +208,164 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
"""Initialize the sensor class.""" """Initialize the sensor class."""
super().__init__(coordinator) super().__init__(coordinator)
self.entity_description = entity_description self.entity_description = entity_description
self._attr_unique_id = ( self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{entity_description.key}"
f"{coordinator.config_entry.entry_id}_{entity_description.key}"
)
self._attr_has_entity_name = True self._attr_has_entity_name = True
self._value_getter: Callable | None = self._get_value_getter()
def _get_value_getter(self) -> Callable | None:
"""Return the appropriate value getter method based on the sensor type."""
key = self.entity_description.key
# Map sensor keys to their handler methods
handlers = {
# Price level
"price_level": self._get_price_level_value,
# Price sensors
"current_price": lambda: self._get_hourly_price_value(hour_offset=0, in_euro=False),
"current_price_eur": lambda: self._get_hourly_price_value(hour_offset=0, in_euro=True),
"next_hour_price": lambda: self._get_hourly_price_value(hour_offset=1, in_euro=False),
"next_hour_price_eur": lambda: self._get_hourly_price_value(hour_offset=1, in_euro=True),
# Statistics sensors
"lowest_price_today": lambda: self._get_statistics_value(stat_func=min, in_euro=False),
"lowest_price_today_eur": lambda: self._get_statistics_value(stat_func=min, in_euro=True),
"highest_price_today": lambda: self._get_statistics_value(stat_func=max, in_euro=False),
"highest_price_today_eur": lambda: self._get_statistics_value(stat_func=max, in_euro=True),
"average_price_today": lambda: self._get_statistics_value(
stat_func=lambda prices: sum(prices) / len(prices), in_euro=False
),
"average_price_today_eur": lambda: self._get_statistics_value(
stat_func=lambda prices: sum(prices) / len(prices), in_euro=True
),
# Rating sensors
"hourly_rating": lambda: self._get_rating_value(rating_type="hourly"),
"daily_rating": lambda: self._get_rating_value(rating_type="daily"),
"monthly_rating": lambda: self._get_rating_value(rating_type="monthly"),
# Diagnostic sensors
"data_timestamp": self._get_data_timestamp,
"tomorrow_data_available": self._get_tomorrow_data_status,
}
return handlers.get(key)
def _get_current_hour_data(self) -> dict | None: def _get_current_hour_data(self) -> dict | None:
"""Get the price data for the current hour.""" """Get the price data for the current hour."""
if not self.coordinator.data: if not self.coordinator.data:
return None return None
now = datetime.now(tz=UTC).astimezone() now = datetime.now(tz=UTC).astimezone()
price_info = self.coordinator.data["data"]["viewer"]["homes"][0][ price_info = self.coordinator.data["data"]["viewer"]["homes"][0]["currentSubscription"]["priceInfo"]
"currentSubscription"
]["priceInfo"]
for price_data in price_info.get("today", []): for price_data in price_info.get("today", []):
starts_at = datetime.fromisoformat(price_data["startsAt"]) starts_at = datetime.fromisoformat(price_data["startsAt"])
if starts_at.hour == now.hour: if starts_at.hour == now.hour:
return price_data return price_data
return None return None
def _get_price_value(self, price: float) -> float: def _get_price_level_value(self) -> str | None:
"""Convert price based on unit.""" """Get the current price level value."""
return ( current_hour_data = self._get_current_hour_data()
price * 100 return current_hour_data["level"] if current_hour_data else None
if self.entity_description.native_unit_of_measurement == "ct/kWh"
else price
)
def _get_price_sensor_value(self) -> float | None: def _get_price_value(self, price: float, *, in_euro: bool) -> float:
"""Handle price sensor values.""" """Convert price based on unit."""
return price if in_euro else price * 100
def _get_hourly_price_value(self, *, hour_offset: int, in_euro: bool) -> float | None:
"""Get price for current hour or with offset."""
if not self.coordinator.data: if not self.coordinator.data:
return None return None
subscription = self.coordinator.data["data"]["viewer"]["homes"][0][ price_info = self.coordinator.data["data"]["viewer"]["homes"][0]["currentSubscription"]["priceInfo"]
"currentSubscription"
]
price_info = subscription["priceInfo"]
now = datetime.now(tz=UTC).astimezone() now = datetime.now(tz=UTC).astimezone()
current_hour_data = self._get_current_hour_data() target_hour = (now.hour + hour_offset) % 24
key = self.entity_description.key for price_data in price_info.get("today", []):
if key in ["current_price", "current_price_eur"]: starts_at = datetime.fromisoformat(price_data["startsAt"])
if not current_hour_data: if starts_at.hour == target_hour:
return None return self._get_price_value(float(price_data["total"]), in_euro=in_euro)
return (
self._get_price_value(float(current_hour_data["total"]))
if key == "current_price"
else float(current_hour_data["total"])
)
if key in ["next_hour_price", "next_hour_price_eur"]:
next_hour = (now.hour + 1) % 24
for price_data in price_info.get("today", []):
starts_at = datetime.fromisoformat(price_data["startsAt"])
if starts_at.hour == next_hour:
return (
self._get_price_value(float(price_data["total"]))
if key == "next_hour_price"
else float(price_data["total"])
)
return None
return None return None
def _get_statistics_value(self) -> float | None: def _get_statistics_value(self, *, stat_func: Callable[[list[float]], float], in_euro: bool) -> float | None:
"""Handle statistics sensor values.""" """Handle statistics sensor values using the provided statistical function."""
if not self.coordinator.data: if not self.coordinator.data:
return None return None
price_info = self.coordinator.data["data"]["viewer"]["homes"][0][ price_info = self.coordinator.data["data"]["viewer"]["homes"][0]["currentSubscription"]["priceInfo"]
"currentSubscription"
]["priceInfo"]
today_prices = price_info.get("today", []) today_prices = price_info.get("today", [])
if not today_prices: if not today_prices:
return None return None
key = self.entity_description.key
prices = [float(price["total"]) for price in today_prices] prices = [float(price["total"]) for price in today_prices]
if not prices:
if key in ["lowest_price_today", "lowest_price_today_eur"]:
value = min(prices)
elif key in ["highest_price_today", "highest_price_today_eur"]:
value = max(prices)
elif key in ["average_price_today", "average_price_today_eur"]:
value = sum(prices) / len(prices)
else:
return None return None
return self._get_price_value(value) if key.endswith("today") else value value = stat_func(prices)
return self._get_price_value(value, in_euro=in_euro)
def _get_rating_value(self) -> float | None: def _get_rating_value(self, *, rating_type: str) -> float | None:
"""Handle rating sensor values.""" """Handle rating sensor values."""
if not self.coordinator.data: if not self.coordinator.data:
return None return None
def check_hourly(entry: dict) -> bool: subscription = self.coordinator.data["data"]["viewer"]["homes"][0]["currentSubscription"]
return datetime.fromisoformat(entry["time"]).hour == now.hour
def check_daily(entry: dict) -> bool:
return datetime.fromisoformat(entry["time"]).date() == now.date()
def check_monthly(entry: dict) -> bool:
dt = datetime.fromisoformat(entry["time"])
return dt.month == now.month and dt.year == now.year
subscription = self.coordinator.data["data"]["viewer"]["homes"][0][
"currentSubscription"
]
price_rating = subscription.get("priceRating", {}) or {} price_rating = subscription.get("priceRating", {}) or {}
now = datetime.now(tz=UTC).astimezone() now = datetime.now(tz=UTC).astimezone()
key = self.entity_description.key rating_data = price_rating.get(rating_type, {})
if key == "hourly_rating": entries = rating_data.get("entries", []) if rating_data else []
rating_data = price_rating.get("hourly", {})
entries = rating_data.get("entries", []) if rating_data else [] if rating_type == "hourly":
time_match = check_hourly for entry in entries:
elif key == "daily_rating": entry_time = datetime.fromisoformat(entry["time"])
rating_data = price_rating.get("daily", {}) if entry_time.hour == now.hour:
entries = rating_data.get("entries", []) if rating_data else [] return round(float(entry["difference"]) * 100, 1)
time_match = check_daily elif rating_type == "daily":
elif key == "monthly_rating": for entry in entries:
rating_data = price_rating.get("monthly", {}) entry_time = datetime.fromisoformat(entry["time"])
entries = rating_data.get("entries", []) if rating_data else [] if entry_time.date() == now.date():
time_match = check_monthly return round(float(entry["difference"]) * 100, 1)
else: elif rating_type == "monthly":
return None for entry in entries:
entry_time = datetime.fromisoformat(entry["time"])
if entry_time.month == now.month and entry_time.year == now.year:
return round(float(entry["difference"]) * 100, 1)
for entry in entries:
if time_match(entry):
return round(float(entry["difference"]) * 100, 1)
return None return None
def _get_diagnostic_value(self) -> datetime | str | None: def _get_data_timestamp(self) -> datetime | None:
"""Handle diagnostic sensor values.""" """Get the latest data timestamp."""
if not self.coordinator.data: if not self.coordinator.data:
return None return None
price_info = self.coordinator.data["data"]["viewer"]["homes"][0][ price_info = self.coordinator.data["data"]["viewer"]["homes"][0]["currentSubscription"]["priceInfo"]
"currentSubscription" latest_timestamp = None
]["priceInfo"]
key = self.entity_description.key
if key == "data_timestamp": for day in ["today", "tomorrow"]:
latest_timestamp = None for price_data in price_info.get(day, []):
for day in ["today", "tomorrow"]: timestamp = datetime.fromisoformat(price_data["startsAt"])
for price_data in price_info.get(day, []): if not latest_timestamp or timestamp > latest_timestamp:
timestamp = datetime.fromisoformat(price_data["startsAt"]) latest_timestamp = timestamp
if not latest_timestamp or timestamp > latest_timestamp:
latest_timestamp = timestamp
return dt_util.as_utc(latest_timestamp) if latest_timestamp else None
if key == "tomorrow_data_available": return dt_util.as_utc(latest_timestamp) if latest_timestamp else None
tomorrow_prices = price_info.get("tomorrow", [])
if not tomorrow_prices:
return "No"
return "Yes" if len(tomorrow_prices) == HOURS_IN_DAY else "Partial"
return None def _get_tomorrow_data_status(self) -> str | None:
"""Get tomorrow's data availability status."""
if not self.coordinator.data:
return None
price_info = self.coordinator.data["data"]["viewer"]["homes"][0]["currentSubscription"]["priceInfo"]
tomorrow_prices = price_info.get("tomorrow", [])
if not tomorrow_prices:
return "No"
return "Yes" if len(tomorrow_prices) == HOURS_IN_DAY else "Partial"
@property @property
def native_value(self) -> float | str | datetime | None: def native_value(self) -> float | str | datetime | None:
"""Return the native value of the sensor.""" """Return the native value of the sensor."""
result = None
try: try:
if self.coordinator.data: if not self.coordinator.data or not self._value_getter:
key = self.entity_description.key return None
current_hour_data = self._get_current_hour_data() return self._value_getter()
if key == "price_level":
result = current_hour_data["level"] if current_hour_data else None
elif key in [
"current_price",
"current_price_eur",
"next_hour_price",
"next_hour_price_eur",
]:
result = self._get_price_sensor_value()
elif "price_today" in key:
result = self._get_statistics_value()
elif "rating" in key:
result = self._get_rating_value()
elif key in ["data_timestamp", "tomorrow_data_available"]:
result = self._get_diagnostic_value()
else:
result = None
else:
result = None
except (KeyError, ValueError, TypeError) as ex: except (KeyError, ValueError, TypeError) as ex:
self.coordinator.logger.exception( self.coordinator.logger.exception(
"Error getting sensor value", "Error getting sensor value",
@ -397,101 +374,57 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
"entity": self.entity_description.key, "entity": self.entity_description.key,
}, },
) )
result = None return None
return result
@property @property
def extra_state_attributes(self) -> dict | None: # noqa: PLR0912 def extra_state_attributes(self) -> dict | None:
"""Return additional state attributes.""" """Return additional state attributes."""
if not self.coordinator.data:
return None
attributes = self._get_sensor_attributes()
# Add translated description
if attributes and self.hass is not None:
base_key = "entity.sensor"
key = f"{base_key}.{self.entity_description.translation_key}.description"
language_config = getattr(self.hass.config, "language", None)
if isinstance(language_config, dict):
description = language_config.get(key)
if description is not None:
attributes = dict(attributes) # Make a copy before modifying
attributes["description"] = description
return attributes
def _get_sensor_attributes(self) -> dict | None:
"""Get attributes based on sensor type."""
try: try:
if not self.coordinator.data: key = self.entity_description.key
return None attributes: dict[str, Any] = {}
subscription = self.coordinator.data["data"]["viewer"]["homes"][0][ # Get the timestamp attribute for different sensor types
"currentSubscription" price_info = self.coordinator.data["data"]["viewer"]["homes"][0]["currentSubscription"]["priceInfo"]
]
price_info = subscription["priceInfo"]
attributes = {}
# Get current hour's data for timestamp
now = datetime.now(tz=UTC).astimezone()
current_hour_data = self._get_current_hour_data() current_hour_data = self._get_current_hour_data()
now = datetime.now(tz=UTC).astimezone()
if self.entity_description.key in ["current_price", "current_price_eur"]: # Price sensors timestamps
attributes["timestamp"] = ( if key in ["current_price", "current_price_eur", "price_level"]:
current_hour_data["startsAt"] if current_hour_data else None attributes["timestamp"] = current_hour_data["startsAt"] if current_hour_data else None
) elif key in ["next_hour_price", "next_hour_price_eur"]:
if self.entity_description.key in [
"next_hour_price",
"next_hour_price_eur",
]:
next_hour = (now.hour + 1) % 24 next_hour = (now.hour + 1) % 24
for price_data in price_info.get("today", []): for price_data in price_info.get("today", []):
starts_at = datetime.fromisoformat(price_data["startsAt"]) starts_at = datetime.fromisoformat(price_data["startsAt"])
if starts_at.hour == next_hour: if starts_at.hour == next_hour:
attributes["timestamp"] = price_data["startsAt"] attributes["timestamp"] = price_data["startsAt"]
break break
# Statistics, rating, and diagnostic sensors
if self.entity_description.key == "price_level": elif any(
attributes["timestamp"] = ( pattern in key for pattern in ["_price_today", "rating", "data_timestamp", "tomorrow_data_available"]
current_hour_data["startsAt"] if current_hour_data else None ):
) first_timestamp = price_info.get("today", [{}])[0].get("startsAt")
attributes["timestamp"] = first_timestamp
if self.entity_description.key == "lowest_price_today":
attributes["timestamp"] = price_info.get("today", [{}])[0].get(
"startsAt"
)
if self.entity_description.key == "highest_price_today":
attributes["timestamp"] = price_info.get("today", [{}])[0].get(
"startsAt"
)
if self.entity_description.key == "average_price_today":
attributes["timestamp"] = price_info.get("today", [{}])[0].get(
"startsAt"
)
if self.entity_description.key == "hourly_rating":
attributes["timestamp"] = (
current_hour_data["startsAt"] if current_hour_data else None
)
if self.entity_description.key == "daily_rating":
attributes["timestamp"] = price_info.get("today", [{}])[0].get(
"startsAt"
)
if self.entity_description.key == "monthly_rating":
attributes["timestamp"] = price_info.get("today", [{}])[0].get(
"startsAt"
)
if self.entity_description.key == "data_timestamp":
attributes["timestamp"] = price_info.get("today", [{}])[0].get(
"startsAt"
)
if self.entity_description.key == "tomorrow_data_available":
attributes["timestamp"] = price_info.get("today", [{}])[0].get(
"startsAt"
)
# Add translated description
if self.hass is not None:
base_key = "entity.sensor"
key = (
f"{base_key}.{self.entity_description.translation_key}.description"
)
language_config = getattr(self.hass.config, "language", None)
if isinstance(language_config, dict):
description = language_config.get(key)
if description is not None:
attributes["description"] = description
return attributes if attributes else None # noqa: TRY300
except (KeyError, ValueError, TypeError) as ex: except (KeyError, ValueError, TypeError) as ex:
self.coordinator.logger.exception( self.coordinator.logger.exception(
@ -502,3 +435,5 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
}, },
) )
return None return None
else:
return attributes if attributes else None

10
pyproject.toml Normal file
View file

@ -0,0 +1,10 @@
# pyproject.toml
[tool.black]
line-length = 120
target-version = ['py313']
skip-string-normalization = false
[tool.isort]
profile = "black"
line_length = 120