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
3d33d8d6bc
commit
02a226819a
10 changed files with 424 additions and 173 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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__)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
89
custom_components/tibber_prices/translations/de.json
Normal file
89
custom_components/tibber_prices/translations/de.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue