mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-03-29 21:03:40 +00:00
refactoring
This commit is contained in:
parent
eacc249ad6
commit
c67ec09c9a
7 changed files with 334 additions and 447 deletions
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,27 +129,60 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity):
|
||||||
prices.sort()
|
prices.sort()
|
||||||
return prices, float(current_hour_data["total"])
|
return prices, float(current_hour_data["total"])
|
||||||
|
|
||||||
@property
|
def _get_price_threshold_state(self, *, threshold_percentage: float, high_is_active: bool) -> bool | None:
|
||||||
def is_on(self) -> bool | None:
|
"""
|
||||||
"""Return true if the binary_sensor is on."""
|
Determine if current price is above/below threshold.
|
||||||
try:
|
|
||||||
|
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()
|
price_data = self._get_current_price_data()
|
||||||
if not price_data:
|
if not price_data:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
prices, current_price = price_data
|
prices, current_price = price_data
|
||||||
match self.entity_description.key:
|
threshold_index = int(len(prices) * threshold_percentage)
|
||||||
case "peak_hour":
|
|
||||||
threshold_index = int(len(prices) * 0.8)
|
if high_is_active:
|
||||||
return current_price >= prices[threshold_index]
|
return current_price >= prices[threshold_index]
|
||||||
case "best_price_hour":
|
|
||||||
threshold_index = int(len(prices) * 0.2)
|
|
||||||
return current_price <= prices[threshold_index]
|
return current_price <= prices[threshold_index]
|
||||||
case "connection":
|
|
||||||
return True
|
def _get_price_hours_attributes(self, *, attribute_name: str, reverse_sort: bool) -> dict | None:
|
||||||
case _:
|
"""Get price hours attributes."""
|
||||||
|
if not self.coordinator.data:
|
||||||
return None
|
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
|
||||||
|
def is_on(self) -> bool | None:
|
||||||
|
"""Return true if the binary_sensor is on."""
|
||||||
|
try:
|
||||||
|
if not self.coordinator.data or not self._state_getter:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return self._state_getter()
|
||||||
|
|
||||||
except (KeyError, ValueError, TypeError) as ex:
|
except (KeyError, ValueError, TypeError) as ex:
|
||||||
self.coordinator.logger.exception(
|
self.coordinator.logger.exception(
|
||||||
"Error getting binary sensor state",
|
"Error getting binary sensor state",
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
if key in ["current_price", "current_price_eur"]:
|
|
||||||
if not current_hour_data:
|
|
||||||
return None
|
|
||||||
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", []):
|
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 == target_hour:
|
||||||
return (
|
return self._get_price_value(float(price_data["total"]), in_euro=in_euro)
|
||||||
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":
|
|
||||||
rating_data = price_rating.get("hourly", {})
|
|
||||||
entries = rating_data.get("entries", []) if rating_data else []
|
entries = rating_data.get("entries", []) if rating_data else []
|
||||||
time_match = check_hourly
|
|
||||||
elif key == "daily_rating":
|
|
||||||
rating_data = price_rating.get("daily", {})
|
|
||||||
entries = rating_data.get("entries", []) if rating_data else []
|
|
||||||
time_match = check_daily
|
|
||||||
elif key == "monthly_rating":
|
|
||||||
rating_data = price_rating.get("monthly", {})
|
|
||||||
entries = rating_data.get("entries", []) if rating_data else []
|
|
||||||
time_match = check_monthly
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
if rating_type == "hourly":
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
if time_match(entry):
|
entry_time = datetime.fromisoformat(entry["time"])
|
||||||
|
if entry_time.hour == now.hour:
|
||||||
return round(float(entry["difference"]) * 100, 1)
|
return round(float(entry["difference"]) * 100, 1)
|
||||||
|
elif rating_type == "daily":
|
||||||
|
for entry in entries:
|
||||||
|
entry_time = datetime.fromisoformat(entry["time"])
|
||||||
|
if entry_time.date() == now.date():
|
||||||
|
return round(float(entry["difference"]) * 100, 1)
|
||||||
|
elif rating_type == "monthly":
|
||||||
|
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)
|
||||||
|
|
||||||
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"
|
|
||||||
]["priceInfo"]
|
|
||||||
key = self.entity_description.key
|
|
||||||
|
|
||||||
if key == "data_timestamp":
|
|
||||||
latest_timestamp = None
|
latest_timestamp = None
|
||||||
|
|
||||||
for day in ["today", "tomorrow"]:
|
for day in ["today", "tomorrow"]:
|
||||||
for price_data in price_info.get(day, []):
|
for price_data in price_info.get(day, []):
|
||||||
timestamp = datetime.fromisoformat(price_data["startsAt"])
|
timestamp = datetime.fromisoformat(price_data["startsAt"])
|
||||||
if not latest_timestamp or timestamp > latest_timestamp:
|
if not latest_timestamp or timestamp > latest_timestamp:
|
||||||
latest_timestamp = timestamp
|
latest_timestamp = timestamp
|
||||||
|
|
||||||
return dt_util.as_utc(latest_timestamp) if latest_timestamp else None
|
return dt_util.as_utc(latest_timestamp) if latest_timestamp else None
|
||||||
|
|
||||||
if key == "tomorrow_data_available":
|
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", [])
|
tomorrow_prices = price_info.get("tomorrow", [])
|
||||||
|
|
||||||
if not tomorrow_prices:
|
if not tomorrow_prices:
|
||||||
return "No"
|
return "No"
|
||||||
return "Yes" if len(tomorrow_prices) == HOURS_IN_DAY else "Partial"
|
return "Yes" if len(tomorrow_prices) == HOURS_IN_DAY else "Partial"
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
@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."""
|
||||||
try:
|
|
||||||
if not self.coordinator.data:
|
if not self.coordinator.data:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
subscription = self.coordinator.data["data"]["viewer"]["homes"][0][
|
attributes = self._get_sensor_attributes()
|
||||||
"currentSubscription"
|
|
||||||
]
|
|
||||||
price_info = subscription["priceInfo"]
|
|
||||||
|
|
||||||
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:
|
||||||
|
key = self.entity_description.key
|
||||||
|
attributes: dict[str, Any] = {}
|
||||||
|
|
||||||
|
# Get the timestamp attribute for different sensor types
|
||||||
|
price_info = self.coordinator.data["data"]["viewer"]["homes"][0]["currentSubscription"]["priceInfo"]
|
||||||
|
|
||||||
# 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
10
pyproject.toml
Normal 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
|
||||||
Loading…
Reference in a new issue