refactoring

This commit is contained in:
Julian Pawlowski 2025-04-23 23:07:30 +00:00
parent 3d33d8d6bc
commit 02a226819a
10 changed files with 424 additions and 173 deletions

View file

@ -16,7 +16,7 @@ from homeassistant.helpers.storage import Store
from homeassistant.loader import async_get_loaded_integration
from .api import TibberPricesApiClient
from .const import DOMAIN, LOGGER, async_load_translations
from .const import DOMAIN, LOGGER, SCAN_INTERVAL, async_load_translations
from .coordinator import STORAGE_VERSION, TibberPricesDataUpdateCoordinator
from .data import TibberPricesData
@ -44,12 +44,13 @@ async def async_setup_entry(
if hass.config.language and hass.config.language != "en":
await async_load_translations(hass, hass.config.language)
# Use the defined SCAN_INTERVAL constant for consistent polling
coordinator = TibberPricesDataUpdateCoordinator(
hass=hass,
entry=entry,
logger=LOGGER,
name=DOMAIN,
update_interval=timedelta(minutes=5),
update_interval=timedelta(seconds=SCAN_INTERVAL),
)
entry.runtime_data = TibberPricesData(
client=TibberPricesApiClient(

View file

@ -5,7 +5,7 @@ from __future__ import annotations
import asyncio
import logging
import socket
from datetime import UTC, datetime, timedelta
from datetime import timedelta
from enum import Enum, auto
from typing import Any
@ -13,6 +13,7 @@ import aiohttp
import async_timeout
from homeassistant.const import __version__ as ha_version
from homeassistant.util import dt as dt_util
from .const import VERSION
@ -67,9 +68,7 @@ class TibberPricesApiClientAuthenticationError(TibberPricesApiClientError):
def _verify_response_or_raise(response: aiohttp.ClientResponse) -> None:
"""Verify that the response is valid."""
if response.status in (HTTP_UNAUTHORIZED, HTTP_FORBIDDEN):
raise TibberPricesApiClientAuthenticationError(
TibberPricesApiClientAuthenticationError.INVALID_CREDENTIALS
)
raise TibberPricesApiClientAuthenticationError(TibberPricesApiClientAuthenticationError.INVALID_CREDENTIALS)
if response.status == HTTP_TOO_MANY_REQUESTS:
raise TibberPricesApiClientError(TibberPricesApiClientError.RATE_LIMIT_ERROR)
response.raise_for_status()
@ -84,27 +83,19 @@ async def _verify_graphql_response(response_json: dict) -> None:
error = errors[0] # Take first error
if not isinstance(error, dict):
raise TibberPricesApiClientError(
TibberPricesApiClientError.MALFORMED_ERROR.format(error=error)
)
raise TibberPricesApiClientError(TibberPricesApiClientError.MALFORMED_ERROR.format(error=error))
message = error.get("message", "Unknown error")
extensions = error.get("extensions", {})
if extensions.get("code") == "UNAUTHENTICATED":
raise TibberPricesApiClientAuthenticationError(
TibberPricesApiClientAuthenticationError.INVALID_CREDENTIALS
)
raise TibberPricesApiClientAuthenticationError(TibberPricesApiClientAuthenticationError.INVALID_CREDENTIALS)
raise TibberPricesApiClientError(
TibberPricesApiClientError.GRAPHQL_ERROR.format(message=message)
)
raise TibberPricesApiClientError(TibberPricesApiClientError.GRAPHQL_ERROR.format(message=message))
if "data" not in response_json or response_json["data"] is None:
raise TibberPricesApiClientError(
TibberPricesApiClientError.GRAPHQL_ERROR.format(
message="Response missing data object"
)
TibberPricesApiClientError.GRAPHQL_ERROR.format(message="Response missing data object")
)
@ -139,25 +130,18 @@ def _is_data_empty(data: dict, query_type: str) -> bool:
and price_info["range"]["edges"]
)
has_yesterday = (
"yesterday" in price_info
and price_info["yesterday"] is not None
and len(price_info["yesterday"]) > 0
"yesterday" in price_info and price_info["yesterday"] is not None and len(price_info["yesterday"]) > 0
)
has_historical = has_range or has_yesterday
# Check today's data
has_today = (
"today" in price_info
and price_info["today"] is not None
and len(price_info["today"]) > 0
)
has_today = "today" in price_info and price_info["today"] is not None and len(price_info["today"]) > 0
# Data is empty if we don't have historical data or today's data
is_empty = not has_historical or not has_today
_LOGGER.debug(
"Price info check - historical data "
"(range: %s, yesterday: %s), today: %s, is_empty: %s",
"Price info check - historical data (range: %s, yesterday: %s), today: %s, is_empty: %s",
bool(has_range),
bool(has_yesterday),
bool(has_today),
@ -176,9 +160,7 @@ def _is_data_empty(data: dict, query_type: str) -> bool:
and "high" in rating["thresholdPercentages"]
)
if not has_thresholds:
_LOGGER.debug(
"Missing or invalid threshold percentages for %s rating", query_type
)
_LOGGER.debug("Missing or invalid threshold percentages for %s rating", query_type)
return True
# Check rating entries
@ -249,9 +231,8 @@ def _transform_price_info(data: dict) -> dict:
_LOGGER.debug("Starting price info transformation")
price_info = data["viewer"]["homes"][0]["currentSubscription"]["priceInfo"]
# Get yesterday's date in UTC first, then convert to local for comparison
today_utc = datetime.now(tz=UTC)
today_local = today_utc.astimezone().date()
# Get today and yesterday dates using Home Assistant's dt_util
today_local = dt_util.now().date()
yesterday_local = today_local - timedelta(days=1)
_LOGGER.debug("Processing data for yesterday's date: %s", yesterday_local)
@ -266,16 +247,14 @@ def _transform_price_info(data: dict) -> dict:
continue
price_data = edge["node"]
# First parse startsAt time, then handle timezone conversion
starts_at = datetime.fromisoformat(price_data["startsAt"])
if starts_at.tzinfo is None:
_LOGGER.debug(
"Found naive timestamp, treating as local time: %s", starts_at
)
starts_at = starts_at.astimezone()
else:
starts_at = starts_at.astimezone()
# Parse timestamp using dt_util for proper timezone handling
starts_at = dt_util.parse_datetime(price_data["startsAt"])
if starts_at is None:
_LOGGER.debug("Could not parse timestamp: %s", price_data["startsAt"])
continue
# Convert to local timezone
starts_at = dt_util.as_local(starts_at)
price_date = starts_at.date()
# Only include prices from yesterday
@ -302,7 +281,7 @@ class TibberPricesApiClient:
self._access_token = access_token
self._session = session
self._request_semaphore = asyncio.Semaphore(2)
self._last_request_time = datetime.now(tz=UTC)
self._last_request_time = dt_util.now()
self._min_request_interval = timedelta(seconds=1)
self._max_retries = 3
self._retry_delay = 2
@ -408,14 +387,10 @@ class TibberPricesApiClient:
"currentSubscription": {
"priceInfo": price_info_data,
"priceRating": {
"thresholdPercentages": get_rating_data(
daily_rating
)["thresholdPercentages"],
"thresholdPercentages": get_rating_data(daily_rating)["thresholdPercentages"],
"daily": get_rating_data(daily_rating)["daily"],
"hourly": get_rating_data(hourly_rating)["hourly"],
"monthly": get_rating_data(monthly_rating)[
"monthly"
],
"monthly": get_rating_data(monthly_rating)["monthly"],
},
}
}
@ -462,12 +437,10 @@ class TibberPricesApiClient:
) -> Any:
"""Handle a single API request with rate limiting."""
async with self._request_semaphore:
now = datetime.now(tz=UTC)
now = dt_util.now()
time_since_last_request = now - self._last_request_time
if time_since_last_request < self._min_request_interval:
sleep_time = (
self._min_request_interval - time_since_last_request
).total_seconds()
sleep_time = (self._min_request_interval - time_since_last_request).total_seconds()
_LOGGER.debug(
"Rate limiting: waiting %s seconds before next request",
sleep_time,
@ -475,21 +448,17 @@ class TibberPricesApiClient:
await asyncio.sleep(sleep_time)
async with async_timeout.timeout(10):
self._last_request_time = datetime.now(tz=UTC)
self._last_request_time = dt_util.now()
response_data = await self._make_request(
headers,
data or {},
query_type,
)
if query_type != QueryType.TEST and _is_data_empty(
response_data, query_type.value
):
if query_type != QueryType.TEST and _is_data_empty(response_data, query_type.value):
_LOGGER.debug("Empty data detected for query_type: %s", query_type)
raise TibberPricesApiClientError(
TibberPricesApiClientError.EMPTY_DATA_ERROR.format(
query_type=query_type.value
)
TibberPricesApiClientError.EMPTY_DATA_ERROR.format(query_type=query_type.value)
)
return response_data
@ -524,9 +493,7 @@ class TibberPricesApiClient:
error
if isinstance(error, TibberPricesApiClientError)
else TibberPricesApiClientError(
TibberPricesApiClientError.GENERIC_ERROR.format(
exception=str(error)
)
TibberPricesApiClientError.GENERIC_ERROR.format(exception=str(error))
)
)
@ -545,17 +512,11 @@ class TibberPricesApiClient:
# Handle final error state
if isinstance(last_error, TimeoutError):
raise TibberPricesApiClientCommunicationError(
TibberPricesApiClientCommunicationError.TIMEOUT_ERROR.format(
exception=last_error
)
TibberPricesApiClientCommunicationError.TIMEOUT_ERROR.format(exception=last_error)
) from last_error
if isinstance(last_error, (aiohttp.ClientError, socket.gaierror)):
raise TibberPricesApiClientCommunicationError(
TibberPricesApiClientCommunicationError.CONNECTION_ERROR.format(
exception=last_error
)
TibberPricesApiClientCommunicationError.CONNECTION_ERROR.format(exception=last_error)
) from last_error
raise last_error or TibberPricesApiClientError(
TibberPricesApiClientError.UNKNOWN_ERROR
)
raise last_error or TibberPricesApiClientError(TibberPricesApiClientError.UNKNOWN_ERROR)

View file

@ -2,7 +2,7 @@
from __future__ import annotations
from datetime import UTC, datetime
from datetime import datetime
from typing import TYPE_CHECKING
from homeassistant.components.binary_sensor import (
@ -11,6 +11,7 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntityDescription,
)
from homeassistant.const import EntityCategory
from homeassistant.util import dt as dt_util
from .entity import TibberPricesEntity
@ -112,15 +113,20 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity):
):
return None
now = datetime.now(tz=UTC).astimezone()
current_hour_data = next(
(
price_data
for price_data in today_prices
if datetime.fromisoformat(price_data["startsAt"]).hour == now.hour
),
None,
)
now = dt_util.now()
# Find price data for current hour
current_hour_data = None
for price_data in today_prices:
starts_at = dt_util.parse_datetime(price_data["startsAt"])
if starts_at is None:
continue
starts_at = dt_util.as_local(starts_at)
if starts_at.hour == now.hour and starts_at.date() == now.date():
current_hour_data = price_data
break
if not current_hour_data:
return None

View file

@ -8,8 +8,8 @@ import aiofiles
from homeassistant.core import HomeAssistant
# Version of the integration
VERSION = "1.0.0"
# Version should match manifest.json
VERSION = "0.1.0"
DOMAIN = "tibber_prices"
CONF_ACCESS_TOKEN = "access_token" # noqa: S105
@ -17,17 +17,21 @@ CONF_EXTENDED_DESCRIPTIONS = "extended_descriptions"
ATTRIBUTION = "Data provided by Tibber"
# Update interval in seconds
SCAN_INTERVAL = 60 * 5 # 5 minutes
DEFAULT_NAME = "Tibber Price Analytics"
# Integration name should match manifest.json
DEFAULT_NAME = "Tibber Price Information & Ratings"
DEFAULT_EXTENDED_DESCRIPTIONS = False
# Price level constants
PRICE_LEVEL_NORMAL = "NORMAL"
PRICE_LEVEL_CHEAP = "CHEAP"
PRICE_LEVEL_VERY_CHEAP = "VERY_CHEAP"
PRICE_LEVEL_EXPENSIVE = "EXPENSIVE"
PRICE_LEVEL_VERY_EXPENSIVE = "VERY_EXPENSIVE"
# Mapping for comparing price levels (used for sorting)
PRICE_LEVEL_MAPPING = {
PRICE_LEVEL_VERY_CHEAP: -2,
PRICE_LEVEL_CHEAP: -1,
@ -36,6 +40,7 @@ PRICE_LEVEL_MAPPING = {
PRICE_LEVEL_VERY_EXPENSIVE: 2,
}
# Sensor type constants
SENSOR_TYPE_PRICE_LEVEL = "price_level"
LOGGER = logging.getLogger(__package__)

View file

@ -2,6 +2,7 @@
from __future__ import annotations
import asyncio
import logging
from datetime import datetime, timedelta
from typing import TYPE_CHECKING, Any, Final, TypedDict, cast
@ -21,8 +22,6 @@ from .api import (
from .const import DOMAIN, LOGGER
if TYPE_CHECKING:
import asyncio
from .data import TibberPricesConfigEntry
_LOGGER = logging.getLogger(__name__)
@ -162,6 +161,7 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[TibberPricesData])
self._scheduled_price_update: asyncio.Task | None = None
self._remove_update_listeners: list[Any] = []
self._force_update = False
self._rotation_lock = asyncio.Lock() # Add lock for data rotation operations
# Schedule updates at the start of every hour
self._remove_update_listeners.append(
@ -182,8 +182,10 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[TibberPricesData])
async def _async_handle_midnight_rotation(self, _now: datetime | None = None) -> None:
"""Handle data rotation at midnight."""
if not self._cached_price_data:
LOGGER.debug("No cached price data available for midnight rotation")
return
async with self._rotation_lock: # Ensure rotation operations are protected
try:
LOGGER.debug("Starting midnight data rotation")
subscription = self._cached_price_data["data"]["viewer"]["homes"][0]["currentSubscription"]
@ -191,21 +193,26 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[TibberPricesData])
# Move today's data to yesterday
if today_data := price_info.get("today"):
LOGGER.debug("Moving today's data (%d entries) to yesterday", len(today_data))
price_info["yesterday"] = today_data
else:
LOGGER.debug("No today's data available to move to yesterday")
# Move tomorrow's data to today
if tomorrow_data := price_info.get("tomorrow"):
LOGGER.debug("Moving tomorrow's data (%d entries) to today", len(tomorrow_data))
price_info["today"] = tomorrow_data
price_info["tomorrow"] = []
else:
LOGGER.warning("No tomorrow's data available to move to today, clearing today's data")
price_info["today"] = []
# Store the rotated data
await self._store_cache()
LOGGER.debug("Completed midnight data rotation")
# Trigger an update to refresh the entities
await self.async_request_refresh()
# No need to schedule immediate refresh - tomorrow's data won't be available yet
# We'll wait for the regular update cycle between 13:00-15:00
except (KeyError, TypeError, ValueError) as ex:
LOGGER.error("Error during midnight data rotation: %s", ex)
@ -278,8 +285,19 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[TibberPricesData])
self._last_rating_update_monthly,
)
async def _async_refresh_hourly(self, *_: Any) -> None:
"""Handle the hourly refresh - don't force update."""
async def _async_refresh_hourly(self, now: datetime | None = None) -> None:
"""
Handle the hourly refresh.
This will:
1. Check if it's midnight and handle rotation if needed
2. Then perform a regular refresh
"""
# If this is a midnight update (hour=0), handle data rotation first
if now and now.hour == 0 and now.minute == 0:
await self._perform_midnight_rotation()
# Then do a regular refresh
await self.async_refresh()
async def _async_update_data(self) -> TibberPricesData:
@ -839,3 +857,66 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[TibberPricesData])
def _transform_api_response(self, data: dict[str, Any]) -> TibberPricesData:
"""Transform API response to coordinator data format."""
return cast("TibberPricesData", data)
async def _perform_midnight_rotation(self) -> None:
"""
Perform the data rotation at midnight within the hourly update process.
This ensures that data rotation completes before any regular updates run,
avoiding race conditions between midnight rotation and regular updates.
"""
LOGGER.info("Performing midnight data rotation as part of hourly update cycle")
if not self._cached_price_data:
LOGGER.debug("No cached price data available for midnight rotation")
return
async with self._rotation_lock:
try:
subscription = self._cached_price_data["data"]["viewer"]["homes"][0]["currentSubscription"]
price_info = subscription["priceInfo"]
# Save current data state for logging
today_count = len(price_info.get("today", []))
tomorrow_count = len(price_info.get("tomorrow", []))
yesterday_count = len(price_info.get("yesterday", []))
LOGGER.debug(
"Before rotation - Yesterday: %d, Today: %d, Tomorrow: %d items",
yesterday_count,
today_count,
tomorrow_count,
)
# Move today's data to yesterday
if today_data := price_info.get("today"):
price_info["yesterday"] = today_data
else:
LOGGER.warning("No today's data available to move to yesterday")
# Move tomorrow's data to today
if tomorrow_data := price_info.get("tomorrow"):
price_info["today"] = tomorrow_data
price_info["tomorrow"] = []
else:
LOGGER.warning("No tomorrow's data available to move to today")
# We don't clear today's data here to avoid potential data loss
# If somehow tomorrow's data isn't available, keep today's data
# This is different from the separate midnight rotation handler
# Store the rotated data
await self._store_cache()
# Log the new state
LOGGER.info(
"Completed midnight rotation - Yesterday: %d, Today: %d, Tomorrow: %d items",
len(price_info.get("yesterday", [])),
len(price_info.get("today", [])),
len(price_info.get("tomorrow", [])),
)
# Flag that we need to fetch new tomorrow's data
self._force_update = True
except (KeyError, TypeError, ValueError) as ex:
LOGGER.error("Error during midnight data rotation in hourly update: %s", ex)

View file

@ -3,7 +3,7 @@
"current_price": {
"description": "Der aktuelle Strompreis inklusive Steuern",
"long_description": "Zeigt den Strompreis für die aktuelle Stunde, einschließlich aller Steuern und Gebühren",
"usage_tips": "Verwenden Sie diesen Sensor für Automatisierungen, die auf den aktuellen Preis reagieren sollen"
"usage_tips": "Verwende diesen Sensor für Automatisierungen, die auf den aktuellen Preis reagieren sollen"
},
"next_hour_price": {
"description": "Der Strompreis für die nächste Stunde inklusive Steuern",
@ -13,7 +13,7 @@
"price_level": {
"description": "Aktueller Preisstandanzeige (SEHR_GÜNSTIG bis SEHR_TEUER)",
"long_description": "Zeigt das aktuelle Preisniveau auf einer Skala von sehr günstig bis sehr teuer an",
"usage_tips": "Verwenden Sie dies für visuelle Anzeigen oder einfache Automatisierungen ohne Schwellenwertberechnung"
"usage_tips": "Verwende dies für visuelle Anzeigen oder einfache Automatisierungen ohne Schwellenwertberechnung"
},
"lowest_price_today": {
"description": "Der niedrigste Strompreis für den aktuellen Tag",
@ -48,12 +48,12 @@
"data_timestamp": {
"description": "Zeitstempel der neuesten Preisdaten von Tibber",
"long_description": "Zeigt an, wann die Preisdaten zuletzt von der Tibber API aktualisiert wurden",
"usage_tips": "Überwachen Sie dies, um sicherzustellen, dass Ihre Preisdaten aktuell sind"
"usage_tips": "Überwache dies, um sicherzustellen, dass Ihre Preisdaten aktuell sind"
},
"tomorrow_data_available": {
"description": "Zeigt an, ob Preisdaten für morgen verfügbar sind",
"long_description": "Zeigt an, ob vollständige, teilweise oder keine Preisdaten für morgen verfügbar sind",
"usage_tips": "Verwenden Sie dies, um zu prüfen, ob Sie Geräte für morgen zuverlässig planen können"
"usage_tips": "Verwende dies, um zu prüfen, ob Geräte für morgen zuverlässig geplant werden können"
}
},
"binary_sensor": {
@ -70,12 +70,7 @@
"connection": {
"description": "Zeigt den Verbindungsstatus zur Tibber API an",
"long_description": "Zeigt an, ob die Komponente erfolgreich eine Verbindung zur Tibber API herstellt",
"usage_tips": "Überwachen Sie dies, um sicherzustellen, dass Ihre Preisdaten korrekt aktualisiert werden"
}
},
"metadata": {
"author": "Julian Pawlowski",
"version": "1.0.0",
"last_updated": "2025-04-23"
"usage_tips": "Überwache dies, um sicherzustellen, dass die Preisdaten korrekt aktualisiert werden"
}
}
}

View file

@ -72,10 +72,5 @@
"long_description": "Indicates whether the component is successfully connecting to the Tibber API",
"usage_tips": "Monitor this to ensure your price data is being updated correctly"
}
},
"metadata": {
"author": "Julian Pawlowski",
"version": "1.0.0",
"last_updated": "2025-04-23"
}
}

View file

@ -2,7 +2,7 @@
from __future__ import annotations
from datetime import UTC, datetime
from datetime import date, datetime
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
@ -16,6 +16,15 @@ from homeassistant.components.sensor import (
from homeassistant.const import CURRENCY_EURO, EntityCategory
from homeassistant.util import dt as dt_util
from .const import (
PRICE_LEVEL_CHEAP,
PRICE_LEVEL_EXPENSIVE,
PRICE_LEVEL_MAPPING,
PRICE_LEVEL_NORMAL,
PRICE_LEVEL_VERY_CHEAP,
PRICE_LEVEL_VERY_EXPENSIVE,
SENSOR_TYPE_PRICE_LEVEL,
)
from .entity import TibberPricesEntity
if TYPE_CHECKING:
@ -29,6 +38,7 @@ if TYPE_CHECKING:
PRICE_UNIT = "ct/kWh"
HOURS_IN_DAY = 24
LAST_HOUR_OF_DAY = 23
# Main price sensors that users will typically use in automations
PRICE_SENSORS = (
@ -71,7 +81,7 @@ PRICE_SENSORS = (
suggested_display_precision=2,
),
SensorEntityDescription(
key="price_level",
key=SENSOR_TYPE_PRICE_LEVEL,
translation_key="price_level",
name="Current Price Level",
icon="mdi:meter-electric",
@ -147,6 +157,7 @@ RATING_SENSORS = (
name="Hourly Price Rating",
icon="mdi:clock-outline",
native_unit_of_measurement="%",
suggested_display_precision=1,
),
SensorEntityDescription(
key="daily_rating",
@ -154,6 +165,7 @@ RATING_SENSORS = (
name="Daily Price Rating",
icon="mdi:calendar-today",
native_unit_of_measurement="%",
suggested_display_precision=1,
),
SensorEntityDescription(
key="monthly_rating",
@ -161,6 +173,7 @@ RATING_SENSORS = (
name="Monthly Price Rating",
icon="mdi:calendar-month",
native_unit_of_measurement="%",
suggested_display_precision=1,
),
)
@ -229,7 +242,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
# Map sensor keys to their handler methods
handlers = {
# Price level
"price_level": self._get_price_level_value,
SENSOR_TYPE_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),
@ -261,12 +274,23 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
"""Get the price data for the current hour."""
if not self.coordinator.data:
return None
now = datetime.now(tz=UTC).astimezone()
# Use HomeAssistant's dt_util to get the current time in the user's timezone
now = dt_util.now()
price_info = self.coordinator.data["data"]["viewer"]["homes"][0]["currentSubscription"]["priceInfo"]
for price_data in price_info.get("today", []):
starts_at = datetime.fromisoformat(price_data["startsAt"])
if starts_at.hour == now.hour:
# Parse the timestamp and convert to local time
starts_at = dt_util.parse_datetime(price_data["startsAt"])
if starts_at is None:
continue
# Make sure it's in the local timezone for proper comparison
starts_at = dt_util.as_local(starts_at)
if starts_at.hour == now.hour and starts_at.date() == now.date():
return price_data
return None
def _get_price_level_value(self) -> str | None:
@ -284,12 +308,44 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
return None
price_info = self.coordinator.data["data"]["viewer"]["homes"][0]["currentSubscription"]["priceInfo"]
now = datetime.now(tz=UTC).astimezone()
target_hour = (now.hour + hour_offset) % 24
for price_data in price_info.get("today", []):
starts_at = datetime.fromisoformat(price_data["startsAt"])
if starts_at.hour == target_hour:
# Use HomeAssistant's dt_util to get the current time in the user's timezone
now = dt_util.now()
# Calculate the exact target datetime (not just the hour)
# This properly handles day boundaries
from datetime import timedelta
target_datetime = now.replace(microsecond=0) + timedelta(hours=hour_offset)
target_hour = target_datetime.hour
target_date = target_datetime.date()
# Determine which day's data we need
day_key = "tomorrow" if target_date > now.date() else "today"
for price_data in price_info.get(day_key, []):
# Parse the timestamp and convert to local time
starts_at = dt_util.parse_datetime(price_data["startsAt"])
if starts_at is None:
continue
# Make sure it's in the local timezone for proper comparison
starts_at = dt_util.as_local(starts_at)
# Compare using both hour and date for accuracy
if starts_at.hour == target_hour and starts_at.date() == target_date:
return self._get_price_value(float(price_data["total"]), in_euro=in_euro)
# If we didn't find the price in the expected day's data, check the other day
# This is a fallback for potential edge cases
other_day_key = "today" if day_key == "tomorrow" else "tomorrow"
for price_data in price_info.get(other_day_key, []):
starts_at = dt_util.parse_datetime(price_data["startsAt"])
if starts_at is None:
continue
starts_at = dt_util.as_local(starts_at)
if starts_at.hour == target_hour and starts_at.date() == target_date:
return self._get_price_value(float(price_data["total"]), in_euro=in_euro)
return None
@ -324,26 +380,29 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
subscription = self.coordinator.data["data"]["viewer"]["homes"][0]["currentSubscription"]
price_rating = subscription.get("priceRating", {}) or {}
now = datetime.now(tz=UTC).astimezone()
now = dt_util.now()
rating_data = price_rating.get(rating_type, {})
entries = rating_data.get("entries", []) if rating_data else []
if rating_type == "hourly":
match_conditions = {
"hourly": lambda et: et.hour == now.hour and et.date() == now.date(),
"daily": lambda et: et.date() == now.date(),
"monthly": lambda et: et.month == now.month and et.year == now.year,
}
match_func = match_conditions.get(rating_type)
if not match_func:
return None
for entry in entries:
entry_time = datetime.fromisoformat(entry["time"])
if entry_time.hour == now.hour:
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)
entry_time = dt_util.parse_datetime(entry["time"])
if entry_time is None:
continue
entry_time = dt_util.as_local(entry_time)
if match_func(entry_time):
return float(entry["difference"])
return None
@ -502,32 +561,21 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
def _get_sensor_attributes(self) -> dict | None:
"""Get attributes based on sensor type."""
try:
if not self.coordinator.data:
return None
key = self.entity_description.key
attributes: dict[str, Any] = {}
attributes = {}
# Get the timestamp attribute for different sensor types
price_info = self.coordinator.data["data"]["viewer"]["homes"][0]["currentSubscription"]["priceInfo"]
current_hour_data = self._get_current_hour_data()
now = datetime.now(tz=UTC).astimezone()
# Price sensors timestamps
if key in ["current_price", "current_price_eur", "price_level"]:
attributes["timestamp"] = current_hour_data["startsAt"] if current_hour_data else None
# Group sensors by type and delegate to specific handlers
if key in ["current_price", "current_price_eur", SENSOR_TYPE_PRICE_LEVEL]:
self._add_current_price_attributes(attributes)
elif 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:
attributes["timestamp"] = price_data["startsAt"]
break
# Statistics, rating, and diagnostic sensors
self._add_next_hour_attributes(attributes)
elif any(
pattern in key for pattern in ["_price_today", "rating", "data_timestamp", "tomorrow_data_available"]
):
first_timestamp = price_info.get("today", [{}])[0].get("startsAt")
attributes["timestamp"] = first_timestamp
self._add_statistics_attributes(attributes)
except (KeyError, ValueError, TypeError) as ex:
self.coordinator.logger.exception(
"Error getting sensor attributes",
@ -539,3 +587,73 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
return None
else:
return attributes if attributes else None
def _add_current_price_attributes(self, attributes: dict) -> None:
"""Add attributes for current price sensors."""
current_hour_data = self._get_current_hour_data()
attributes["timestamp"] = current_hour_data["startsAt"] if current_hour_data else None
# Add price level info for the price level sensor
if (
self.entity_description.key == SENSOR_TYPE_PRICE_LEVEL
and current_hour_data
and "level" in current_hour_data
):
self._add_price_level_attributes(attributes, current_hour_data["level"])
def _add_price_level_attributes(self, attributes: dict, level: str) -> None:
"""Add price level specific attributes."""
if level in PRICE_LEVEL_MAPPING:
attributes["level_value"] = PRICE_LEVEL_MAPPING[level]
# Add human-readable level descriptions
level_descriptions = {
PRICE_LEVEL_VERY_CHEAP: "Very low price compared to average",
PRICE_LEVEL_CHEAP: "Lower than average price",
PRICE_LEVEL_NORMAL: "Average price level",
PRICE_LEVEL_EXPENSIVE: "Higher than average price",
PRICE_LEVEL_VERY_EXPENSIVE: "Very high price compared to average",
}
if level in level_descriptions:
attributes["description"] = level_descriptions[level]
def _add_next_hour_attributes(self, attributes: dict) -> None:
"""Add attributes for next hour price sensors."""
from datetime import timedelta
price_info = self.coordinator.data["data"]["viewer"]["homes"][0]["currentSubscription"]["priceInfo"]
now = dt_util.now()
target_datetime = now.replace(microsecond=0) + timedelta(hours=1)
target_hour = target_datetime.hour
target_date = target_datetime.date()
# Determine which day's data we need
day_key = "tomorrow" if target_date > now.date() else "today"
# Try to find the timestamp in either day's data
self._find_price_timestamp(attributes, price_info, day_key, target_hour, target_date)
if "timestamp" not in attributes:
other_day_key = "today" if day_key == "tomorrow" else "tomorrow"
self._find_price_timestamp(attributes, price_info, other_day_key, target_hour, target_date)
def _find_price_timestamp(
self, attributes: dict, price_info: Any, day_key: str, target_hour: int, target_date: date
) -> None:
"""Find a price timestamp for a specific hour and date."""
for price_data in price_info.get(day_key, []):
starts_at = dt_util.parse_datetime(price_data["startsAt"])
if starts_at is None:
continue
starts_at = dt_util.as_local(starts_at)
if starts_at.hour == target_hour and starts_at.date() == target_date:
attributes["timestamp"] = price_data["startsAt"]
break
def _add_statistics_attributes(self, attributes: dict) -> None:
"""Add attributes for statistics, rating, and diagnostic sensors."""
price_info = self.coordinator.data["data"]["viewer"]["homes"][0]["currentSubscription"]["priceInfo"]
first_timestamp = price_info.get("today", [{}])[0].get("startsAt")
attributes["timestamp"] = first_timestamp

View file

@ -0,0 +1,89 @@
{
"config": {
"step": {
"user": {
"description": "Wenn du Hilfe bei der Konfiguration benötigst, schau hier nach: https://github.com/jpawlowski/hass.tibber_prices",
"data": {
"access_token": "Tibber Zugangstoken"
}
}
},
"error": {
"auth": "Der Tibber Zugangstoken ist ungültig.",
"connection": "Verbindung zu Tibber nicht möglich. Bitte überprüfe deine Internetverbindung.",
"unknown": "Ein unerwarteter Fehler ist aufgetreten. Bitte überprüfe die Logs für Details."
},
"abort": {
"already_configured": "Dieser Eintrag ist bereits konfiguriert.",
"entry_not_found": "Tibber Konfigurationseintrag nicht gefunden."
}
},
"options": {
"step": {
"init": {
"title": "Tibber Konfiguration aktualisieren",
"description": "Aktualisiere deinen Tibber API Zugangstoken. Wenn du einen neuen Token benötigst, kannst du einen unter https://developer.tibber.com/settings/access-token generieren",
"data": {
"access_token": "Tibber Zugangstoken"
}
}
},
"error": {
"auth": "Der Tibber Zugangstoken ist ungültig.",
"connection": "Verbindung zu Tibber nicht möglich. Bitte überprüfe deine Internetverbindung.",
"unknown": "Ein unerwarteter Fehler ist aufgetreten. Bitte überprüfe die Logs für Details.",
"different_account": "Der neue Zugangstoken gehört zu einem anderen Tibber-Konto. Bitte verwende einen Token vom selben Konto oder erstelle eine neue Konfiguration für das andere Konto."
},
"abort": {
"entry_not_found": "Tibber Konfigurationseintrag nicht gefunden."
}
},
"entity": {
"sensor": {
"current_price": {
"name": "Aktueller Preis"
},
"next_hour_price": {
"name": "Preis nächste Stunde"
},
"price_level": {
"name": "Preisniveau"
},
"lowest_price_today": {
"name": "Niedrigster Preis heute"
},
"highest_price_today": {
"name": "Höchster Preis heute"
},
"average_price_today": {
"name": "Durchschnittspreis heute"
},
"hourly_rating": {
"name": "Stündliche Preisbewertung"
},
"daily_rating": {
"name": "Tägliche Preisbewertung"
},
"monthly_rating": {
"name": "Monatliche Preisbewertung"
},
"data_timestamp": {
"name": "Preisprognose-Horizont"
},
"tomorrow_data_available": {
"name": "Daten für morgen verfügbar"
}
},
"binary_sensor": {
"peak_hour": {
"name": "Spitzenstunde"
},
"best_price_hour": {
"name": "Beste Preisstunde"
},
"connection": {
"name": "Verbindungsstatus"
}
}
}
}

View file

@ -68,7 +68,7 @@
"name": "Monthly Price Rating"
},
"data_timestamp": {
"name": "Last Data Available"
"name": "Price Forecast Horizon"
},
"tomorrow_data_available": {
"name": "Tomorrow's Data Available"