mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-03-30 05:13: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 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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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__)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
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"
|
"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"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue