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

View file

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

View file

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

View file

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

View file

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import logging import logging
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import TYPE_CHECKING, Any, Final, TypedDict, cast from typing import TYPE_CHECKING, Any, Final, TypedDict, cast
@ -21,8 +22,6 @@ from .api import (
from .const import DOMAIN, LOGGER from .const import DOMAIN, LOGGER
if TYPE_CHECKING: if TYPE_CHECKING:
import asyncio
from .data import TibberPricesConfigEntry from .data import TibberPricesConfigEntry
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -162,6 +161,7 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[TibberPricesData])
self._scheduled_price_update: asyncio.Task | None = None self._scheduled_price_update: asyncio.Task | None = None
self._remove_update_listeners: list[Any] = [] self._remove_update_listeners: list[Any] = []
self._force_update = False self._force_update = False
self._rotation_lock = asyncio.Lock() # Add lock for data rotation operations
# 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(
@ -182,33 +182,40 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[TibberPricesData])
async def _async_handle_midnight_rotation(self, _now: datetime | None = None) -> None: async def _async_handle_midnight_rotation(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:
LOGGER.debug("No cached price data available for midnight rotation")
return return
try: async with self._rotation_lock: # Ensure rotation operations are protected
LOGGER.debug("Starting midnight data rotation") try:
subscription = self._cached_price_data["data"]["viewer"]["homes"][0]["currentSubscription"] LOGGER.debug("Starting midnight data rotation")
price_info = subscription["priceInfo"] subscription = self._cached_price_data["data"]["viewer"]["homes"][0]["currentSubscription"]
price_info = subscription["priceInfo"]
# Move today's data to yesterday # Move today's data to yesterday
if today_data := price_info.get("today"): if today_data := price_info.get("today"):
price_info["yesterday"] = today_data 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 # Move tomorrow's data to today
if tomorrow_data := price_info.get("tomorrow"): if tomorrow_data := price_info.get("tomorrow"):
price_info["today"] = tomorrow_data LOGGER.debug("Moving tomorrow's data (%d entries) to today", len(tomorrow_data))
price_info["tomorrow"] = [] price_info["today"] = tomorrow_data
else: price_info["tomorrow"] = []
price_info["today"] = [] else:
LOGGER.warning("No tomorrow's data available to move to today, clearing today's data")
price_info["today"] = []
# Store the rotated data # Store the rotated data
await self._store_cache() await self._store_cache()
LOGGER.debug("Completed midnight data rotation") LOGGER.debug("Completed midnight data rotation")
# Trigger an update to refresh the entities # No need to schedule immediate refresh - tomorrow's data won't be available yet
await self.async_request_refresh() # We'll wait for the regular update cycle between 13:00-15:00
except (KeyError, TypeError, ValueError) as ex: except (KeyError, TypeError, ValueError) as ex:
LOGGER.error("Error during midnight data rotation: %s", ex) LOGGER.error("Error during midnight data rotation: %s", ex)
@callback @callback
def _recover_timestamp( def _recover_timestamp(
@ -278,8 +285,19 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[TibberPricesData])
self._last_rating_update_monthly, self._last_rating_update_monthly,
) )
async def _async_refresh_hourly(self, *_: Any) -> None: async def _async_refresh_hourly(self, now: datetime | None = None) -> None:
"""Handle the hourly refresh - don't force update.""" """
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() await self.async_refresh()
async def _async_update_data(self) -> TibberPricesData: 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: def _transform_api_response(self, data: dict[str, Any]) -> TibberPricesData:
"""Transform API response to coordinator data format.""" """Transform API response to coordinator data format."""
return cast("TibberPricesData", data) 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": { "current_price": {
"description": "Der aktuelle Strompreis inklusive Steuern", "description": "Der aktuelle Strompreis inklusive Steuern",
"long_description": "Zeigt den Strompreis für die aktuelle Stunde, einschließlich aller Steuern und Gebühren", "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": { "next_hour_price": {
"description": "Der Strompreis für die nächste Stunde inklusive Steuern", "description": "Der Strompreis für die nächste Stunde inklusive Steuern",
@ -13,7 +13,7 @@
"price_level": { "price_level": {
"description": "Aktueller Preisstandanzeige (SEHR_GÜNSTIG bis SEHR_TEUER)", "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", "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": { "lowest_price_today": {
"description": "Der niedrigste Strompreis für den aktuellen Tag", "description": "Der niedrigste Strompreis für den aktuellen Tag",
@ -48,12 +48,12 @@
"data_timestamp": { "data_timestamp": {
"description": "Zeitstempel der neuesten Preisdaten von Tibber", "description": "Zeitstempel der neuesten Preisdaten von Tibber",
"long_description": "Zeigt an, wann die Preisdaten zuletzt von der Tibber API aktualisiert wurden", "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": { "tomorrow_data_available": {
"description": "Zeigt an, ob Preisdaten für morgen verfügbar sind", "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", "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": { "binary_sensor": {
@ -70,12 +70,7 @@
"connection": { "connection": {
"description": "Zeigt den Verbindungsstatus zur Tibber API an", "description": "Zeigt den Verbindungsstatus zur Tibber API an",
"long_description": "Zeigt an, ob die Komponente erfolgreich eine Verbindung zur Tibber API herstellt", "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" "usage_tips": "Überwache dies, um sicherzustellen, dass die Preisdaten korrekt aktualisiert werden"
} }
},
"metadata": {
"author": "Julian Pawlowski",
"version": "1.0.0",
"last_updated": "2025-04-23"
} }
} }

View file

@ -72,10 +72,5 @@
"long_description": "Indicates whether the component is successfully connecting to the Tibber API", "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" "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 __future__ import annotations
from datetime import UTC, datetime from datetime import date, datetime
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
if TYPE_CHECKING: if TYPE_CHECKING:
@ -16,6 +16,15 @@ from homeassistant.components.sensor import (
from homeassistant.const import CURRENCY_EURO, EntityCategory from homeassistant.const import CURRENCY_EURO, EntityCategory
from homeassistant.util import dt as dt_util 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 from .entity import TibberPricesEntity
if TYPE_CHECKING: if TYPE_CHECKING:
@ -29,6 +38,7 @@ if TYPE_CHECKING:
PRICE_UNIT = "ct/kWh" PRICE_UNIT = "ct/kWh"
HOURS_IN_DAY = 24 HOURS_IN_DAY = 24
LAST_HOUR_OF_DAY = 23
# Main price sensors that users will typically use in automations # Main price sensors that users will typically use in automations
PRICE_SENSORS = ( PRICE_SENSORS = (
@ -71,7 +81,7 @@ PRICE_SENSORS = (
suggested_display_precision=2, suggested_display_precision=2,
), ),
SensorEntityDescription( SensorEntityDescription(
key="price_level", key=SENSOR_TYPE_PRICE_LEVEL,
translation_key="price_level", translation_key="price_level",
name="Current Price Level", name="Current Price Level",
icon="mdi:meter-electric", icon="mdi:meter-electric",
@ -147,6 +157,7 @@ RATING_SENSORS = (
name="Hourly Price Rating", name="Hourly Price Rating",
icon="mdi:clock-outline", icon="mdi:clock-outline",
native_unit_of_measurement="%", native_unit_of_measurement="%",
suggested_display_precision=1,
), ),
SensorEntityDescription( SensorEntityDescription(
key="daily_rating", key="daily_rating",
@ -154,6 +165,7 @@ RATING_SENSORS = (
name="Daily Price Rating", name="Daily Price Rating",
icon="mdi:calendar-today", icon="mdi:calendar-today",
native_unit_of_measurement="%", native_unit_of_measurement="%",
suggested_display_precision=1,
), ),
SensorEntityDescription( SensorEntityDescription(
key="monthly_rating", key="monthly_rating",
@ -161,6 +173,7 @@ RATING_SENSORS = (
name="Monthly Price Rating", name="Monthly Price Rating",
icon="mdi:calendar-month", icon="mdi:calendar-month",
native_unit_of_measurement="%", native_unit_of_measurement="%",
suggested_display_precision=1,
), ),
) )
@ -229,7 +242,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
# Map sensor keys to their handler methods # Map sensor keys to their handler methods
handlers = { handlers = {
# Price level # Price level
"price_level": self._get_price_level_value, SENSOR_TYPE_PRICE_LEVEL: self._get_price_level_value,
# Price sensors # Price sensors
"current_price": lambda: self._get_hourly_price_value(hour_offset=0, in_euro=False), "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), "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.""" """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()
# 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"] price_info = self.coordinator.data["data"]["viewer"]["homes"][0]["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"]) # Parse the timestamp and convert to local time
if starts_at.hour == now.hour: 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 price_data
return None return None
def _get_price_level_value(self) -> str | None: def _get_price_level_value(self) -> str | None:
@ -284,12 +308,44 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
return None return None
price_info = self.coordinator.data["data"]["viewer"]["homes"][0]["currentSubscription"]["priceInfo"] 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", []): # Use HomeAssistant's dt_util to get the current time in the user's timezone
starts_at = datetime.fromisoformat(price_data["startsAt"]) now = dt_util.now()
if starts_at.hour == target_hour:
# 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 self._get_price_value(float(price_data["total"]), in_euro=in_euro)
return None return None
@ -324,26 +380,29 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
subscription = self.coordinator.data["data"]["viewer"]["homes"][0]["currentSubscription"] 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 = dt_util.now()
rating_data = price_rating.get(rating_type, {}) rating_data = price_rating.get(rating_type, {})
entries = rating_data.get("entries", []) if rating_data else [] entries = rating_data.get("entries", []) if rating_data else []
if rating_type == "hourly": match_conditions = {
for entry in entries: "hourly": lambda et: et.hour == now.hour and et.date() == now.date(),
entry_time = datetime.fromisoformat(entry["time"]) "daily": lambda et: et.date() == now.date(),
if entry_time.hour == now.hour: "monthly": lambda et: et.month == now.month and et.year == now.year,
return round(float(entry["difference"]) * 100, 1) }
elif rating_type == "daily":
for entry in entries: match_func = match_conditions.get(rating_type)
entry_time = datetime.fromisoformat(entry["time"]) if not match_func:
if entry_time.date() == now.date(): return None
return round(float(entry["difference"]) * 100, 1)
elif rating_type == "monthly": for entry in entries:
for entry in entries: entry_time = dt_util.parse_datetime(entry["time"])
entry_time = datetime.fromisoformat(entry["time"]) if entry_time is None:
if entry_time.month == now.month and entry_time.year == now.year: continue
return round(float(entry["difference"]) * 100, 1)
entry_time = dt_util.as_local(entry_time)
if match_func(entry_time):
return float(entry["difference"])
return None return None
@ -502,32 +561,21 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
def _get_sensor_attributes(self) -> dict | None: def _get_sensor_attributes(self) -> dict | None:
"""Get attributes based on sensor type.""" """Get attributes based on sensor type."""
try: try:
if not self.coordinator.data:
return None
key = self.entity_description.key key = self.entity_description.key
attributes: dict[str, Any] = {} attributes = {}
# Get the timestamp attribute for different sensor types # Group sensors by type and delegate to specific handlers
price_info = self.coordinator.data["data"]["viewer"]["homes"][0]["currentSubscription"]["priceInfo"] if key in ["current_price", "current_price_eur", SENSOR_TYPE_PRICE_LEVEL]:
self._add_current_price_attributes(attributes)
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
elif key in ["next_hour_price", "next_hour_price_eur"]: elif key in ["next_hour_price", "next_hour_price_eur"]:
next_hour = (now.hour + 1) % 24 self._add_next_hour_attributes(attributes)
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
elif any( elif any(
pattern in key for pattern in ["_price_today", "rating", "data_timestamp", "tomorrow_data_available"] pattern in key for pattern in ["_price_today", "rating", "data_timestamp", "tomorrow_data_available"]
): ):
first_timestamp = price_info.get("today", [{}])[0].get("startsAt") self._add_statistics_attributes(attributes)
attributes["timestamp"] = first_timestamp
except (KeyError, ValueError, TypeError) as ex: except (KeyError, ValueError, TypeError) as ex:
self.coordinator.logger.exception( self.coordinator.logger.exception(
"Error getting sensor attributes", "Error getting sensor attributes",
@ -539,3 +587,73 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
return None return None
else: else:
return attributes if attributes else None 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" "name": "Monthly Price Rating"
}, },
"data_timestamp": { "data_timestamp": {
"name": "Last Data Available" "name": "Price Forecast Horizon"
}, },
"tomorrow_data_available": { "tomorrow_data_available": {
"name": "Tomorrow's Data Available" "name": "Tomorrow's Data Available"