From 02a226819ac26a8886eccd2839b600bba1b19c7e Mon Sep 17 00:00:00 2001 From: Julian Pawlowski Date: Wed, 23 Apr 2025 23:07:30 +0000 Subject: [PATCH] refactoring --- custom_components/tibber_prices/__init__.py | 5 +- custom_components/tibber_prices/api.py | 103 +++------ .../tibber_prices/binary_sensor.py | 26 ++- custom_components/tibber_prices/const.py | 11 +- .../tibber_prices/coordinator.py | 129 +++++++++-- .../tibber_prices/custom_translations/de.json | 15 +- .../tibber_prices/custom_translations/en.json | 5 - custom_components/tibber_prices/sensor.py | 212 ++++++++++++++---- .../tibber_prices/translations/de.json | 89 ++++++++ .../tibber_prices/translations/en.json | 2 +- 10 files changed, 424 insertions(+), 173 deletions(-) create mode 100644 custom_components/tibber_prices/translations/de.json diff --git a/custom_components/tibber_prices/__init__.py b/custom_components/tibber_prices/__init__.py index b2333c2..2deb864 100644 --- a/custom_components/tibber_prices/__init__.py +++ b/custom_components/tibber_prices/__init__.py @@ -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( diff --git a/custom_components/tibber_prices/api.py b/custom_components/tibber_prices/api.py index 951c926..2709e84 100644 --- a/custom_components/tibber_prices/api.py +++ b/custom_components/tibber_prices/api.py @@ -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) diff --git a/custom_components/tibber_prices/binary_sensor.py b/custom_components/tibber_prices/binary_sensor.py index 3d0ed5c..7058cfc 100644 --- a/custom_components/tibber_prices/binary_sensor.py +++ b/custom_components/tibber_prices/binary_sensor.py @@ -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 diff --git a/custom_components/tibber_prices/const.py b/custom_components/tibber_prices/const.py index df015c9..d10a1be 100644 --- a/custom_components/tibber_prices/const.py +++ b/custom_components/tibber_prices/const.py @@ -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__) diff --git a/custom_components/tibber_prices/coordinator.py b/custom_components/tibber_prices/coordinator.py index ce55376..d04fa82 100644 --- a/custom_components/tibber_prices/coordinator.py +++ b/custom_components/tibber_prices/coordinator.py @@ -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,33 +182,40 @@ 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 - try: - LOGGER.debug("Starting midnight data rotation") - subscription = self._cached_price_data["data"]["viewer"]["homes"][0]["currentSubscription"] - price_info = subscription["priceInfo"] + 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"] + price_info = subscription["priceInfo"] - # Move today's data to yesterday - if today_data := price_info.get("today"): - price_info["yesterday"] = today_data + # 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"): - price_info["today"] = tomorrow_data - price_info["tomorrow"] = [] - else: - price_info["today"] = [] + # 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") + # 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) + except (KeyError, TypeError, ValueError) as ex: + LOGGER.error("Error during midnight data rotation: %s", ex) @callback def _recover_timestamp( @@ -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) diff --git a/custom_components/tibber_prices/custom_translations/de.json b/custom_components/tibber_prices/custom_translations/de.json index c05eab0..17e1bd6 100644 --- a/custom_components/tibber_prices/custom_translations/de.json +++ b/custom_components/tibber_prices/custom_translations/de.json @@ -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" } } diff --git a/custom_components/tibber_prices/custom_translations/en.json b/custom_components/tibber_prices/custom_translations/en.json index 8c3d838..5de89ba 100644 --- a/custom_components/tibber_prices/custom_translations/en.json +++ b/custom_components/tibber_prices/custom_translations/en.json @@ -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" } } diff --git a/custom_components/tibber_prices/sensor.py b/custom_components/tibber_prices/sensor.py index 2525c3b..2491143 100644 --- a/custom_components/tibber_prices/sensor.py +++ b/custom_components/tibber_prices/sensor.py @@ -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": - 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) + 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 = 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 diff --git a/custom_components/tibber_prices/translations/de.json b/custom_components/tibber_prices/translations/de.json new file mode 100644 index 0000000..34e0f56 --- /dev/null +++ b/custom_components/tibber_prices/translations/de.json @@ -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" + } + } + } +} diff --git a/custom_components/tibber_prices/translations/en.json b/custom_components/tibber_prices/translations/en.json index 3e5afb2..69d58b6 100644 --- a/custom_components/tibber_prices/translations/en.json +++ b/custom_components/tibber_prices/translations/en.json @@ -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"