diff --git a/custom_components/tibber_prices/__init__.py b/custom_components/tibber_prices/__init__.py index 2f214fe..b2333c2 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 +from .const import DOMAIN, LOGGER, async_load_translations from .coordinator import STORAGE_VERSION, TibberPricesDataUpdateCoordinator from .data import TibberPricesData @@ -37,6 +37,13 @@ async def async_setup_entry( entry: TibberPricesConfigEntry, ) -> bool: """Set up this integration using UI.""" + # Preload translations to populate the cache + await async_load_translations(hass, "en") + + # Try to load translations for the user's configured language if not English + if hass.config.language and hass.config.language != "en": + await async_load_translations(hass, hass.config.language) + coordinator = TibberPricesDataUpdateCoordinator( hass=hass, entry=entry, diff --git a/custom_components/tibber_prices/binary_sensor.py b/custom_components/tibber_prices/binary_sensor.py index bf1c3c8..3d0ed5c 100644 --- a/custom_components/tibber_prices/binary_sensor.py +++ b/custom_components/tibber_prices/binary_sensor.py @@ -5,9 +5,6 @@ from __future__ import annotations from datetime import UTC, datetime from typing import TYPE_CHECKING -if TYPE_CHECKING: - from collections.abc import Callable - from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, @@ -18,6 +15,8 @@ from homeassistant.const import EntityCategory from .entity import TibberPricesEntity if TYPE_CHECKING: + from collections.abc import Callable + from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -194,13 +193,63 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity): return None @property - def extra_state_attributes(self) -> dict | None: - """Return additional state attributes.""" + async def async_extra_state_attributes(self) -> dict | None: + """Return additional state attributes asynchronously.""" try: - if not self.coordinator.data or not self._attribute_getter: + # Get the dynamic attributes if the getter is available + if not self.coordinator.data: return None - return self._attribute_getter() + attributes = {} + if self._attribute_getter: + dynamic_attrs = self._attribute_getter() + if dynamic_attrs: + attributes.update(dynamic_attrs) + + # Add descriptions from the custom translations file + if self.entity_description.translation_key and self.hass is not None: + # Get user's language preference + language = self.hass.config.language if self.hass.config.language else "en" + + # Import async function to get descriptions + from .const import ( + CONF_EXTENDED_DESCRIPTIONS, + DEFAULT_EXTENDED_DESCRIPTIONS, + async_get_entity_description, + ) + + # Add basic description + description = await async_get_entity_description( + self.hass, "binary_sensor", self.entity_description.translation_key, language, "description" + ) + if description: + attributes["description"] = description + + # Check if extended descriptions are enabled in the config + extended_descriptions = self.coordinator.config_entry.options.get( + CONF_EXTENDED_DESCRIPTIONS, + self.coordinator.config_entry.data.get(CONF_EXTENDED_DESCRIPTIONS, DEFAULT_EXTENDED_DESCRIPTIONS), + ) + + # Add extended descriptions if enabled + if extended_descriptions: + # Add long description if available + long_desc = await async_get_entity_description( + self.hass, + "binary_sensor", + self.entity_description.translation_key, + language, + "long_description", + ) + if long_desc: + attributes["long_description"] = long_desc + + # Add usage tips if available + usage_tips = await async_get_entity_description( + self.hass, "binary_sensor", self.entity_description.translation_key, language, "usage_tips" + ) + if usage_tips: + attributes["usage_tips"] = usage_tips except (KeyError, ValueError, TypeError) as ex: self.coordinator.logger.exception( @@ -211,3 +260,72 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity): }, ) return None + else: + return attributes if attributes else None + + @property + def extra_state_attributes(self) -> dict | None: + """Return additional state attributes synchronously.""" + try: + # Start with dynamic attributes if available + if not self.coordinator.data: + return None + + attributes = {} + if self._attribute_getter: + dynamic_attrs = self._attribute_getter() + if dynamic_attrs: + attributes.update(dynamic_attrs) + + # Add descriptions from the cache (non-blocking) + if self.entity_description.translation_key and self.hass is not None: + # Get user's language preference + language = self.hass.config.language if self.hass.config.language else "en" + + # Import synchronous function to get cached descriptions + from .const import ( + CONF_EXTENDED_DESCRIPTIONS, + DEFAULT_EXTENDED_DESCRIPTIONS, + get_entity_description, + ) + + # Add basic description from cache + description = get_entity_description( + "binary_sensor", self.entity_description.translation_key, language, "description" + ) + if description: + attributes["description"] = description + + # Check if extended descriptions are enabled in the config + extended_descriptions = self.coordinator.config_entry.options.get( + CONF_EXTENDED_DESCRIPTIONS, + self.coordinator.config_entry.data.get(CONF_EXTENDED_DESCRIPTIONS, DEFAULT_EXTENDED_DESCRIPTIONS), + ) + + # Add extended descriptions if enabled (from cache only) + if extended_descriptions: + # Add long description if available in cache + long_desc = get_entity_description( + "binary_sensor", self.entity_description.translation_key, language, "long_description" + ) + if long_desc: + attributes["long_description"] = long_desc + + # Add usage tips if available in cache + usage_tips = get_entity_description( + "binary_sensor", self.entity_description.translation_key, language, "usage_tips" + ) + if usage_tips: + attributes["usage_tips"] = usage_tips + + except (KeyError, ValueError, TypeError) as ex: + self.coordinator.logger.exception( + "Error getting binary sensor attributes", + extra={ + "error": str(ex), + "entity": self.entity_description.key, + }, + ) + return None + else: + return attributes if attributes else None diff --git a/custom_components/tibber_prices/config_flow.py b/custom_components/tibber_prices/config_flow.py index 8529e27..da33005 100644 --- a/custom_components/tibber_prices/config_flow.py +++ b/custom_components/tibber_prices/config_flow.py @@ -16,7 +16,12 @@ from .api import ( TibberPricesApiClientCommunicationError, TibberPricesApiClientError, ) -from .const import DOMAIN, LOGGER +from .const import ( + CONF_EXTENDED_DESCRIPTIONS, + DEFAULT_EXTENDED_DESCRIPTIONS, + DOMAIN, + LOGGER, +) class TibberPricesFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): @@ -48,9 +53,7 @@ class TibberPricesFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): _errors = {} if user_input is not None: try: - name = await self._test_credentials( - access_token=user_input[CONF_ACCESS_TOKEN] - ) + name = await self._test_credentials(access_token=user_input[CONF_ACCESS_TOKEN]) except TibberPricesApiClientAuthenticationError as exception: LOGGER.warning(exception) _errors["base"] = "auth" @@ -74,14 +77,16 @@ class TibberPricesFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): { vol.Required( CONF_ACCESS_TOKEN, - default=(user_input or {}).get( - CONF_ACCESS_TOKEN, vol.UNDEFINED - ), + default=(user_input or {}).get(CONF_ACCESS_TOKEN, vol.UNDEFINED), ): selector.TextSelector( selector.TextSelectorConfig( type=selector.TextSelectorType.TEXT, ), ), + vol.Optional( + CONF_EXTENDED_DESCRIPTIONS, + default=(user_input or {}).get(CONF_EXTENDED_DESCRIPTIONS, DEFAULT_EXTENDED_DESCRIPTIONS), + ): selector.BooleanSelector(), }, ), errors=_errors, @@ -103,12 +108,9 @@ class TibberPricesOptionsFlowHandler(config_entries.OptionsFlow): def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize options flow.""" super().__init__() - # Store the entry_id instead of the whole config_entry - self._entry_id = config_entry.entry_id + self.config_entry = config_entry - async def async_step_init( - self, user_input: dict | None = None - ) -> config_entries.ConfigFlowResult: + async def async_step_init(self, user_input: dict | None = None) -> config_entries.ConfigFlowResult: """Manage the options.""" errors: dict[str, str] = {} @@ -122,20 +124,15 @@ class TibberPricesOptionsFlowHandler(config_entries.OptionsFlow): result = await client.async_test_connection() new_account_name = result["viewer"]["name"] - # Get the config entry using the entry_id - config_entry = self.hass.config_entries.async_get_entry(self._entry_id) - if not config_entry: - return self.async_abort(reason="entry_not_found") - # Check if this token is for the same account - current_unique_id = config_entry.unique_id + current_unique_id = self.config_entry.unique_id new_unique_id = slugify(new_account_name) if current_unique_id != new_unique_id: # Token is for a different account errors["base"] = "different_account" else: - # Update the config entry with the new access token + # Update the config entry with the new access token and options return self.async_create_entry(title="", data=user_input) except TibberPricesApiClientAuthenticationError as exception: @@ -148,25 +145,27 @@ class TibberPricesOptionsFlowHandler(config_entries.OptionsFlow): LOGGER.exception(exception) errors["base"] = "unknown" - # Get current config entry to get the current access token - config_entry = self.hass.config_entries.async_get_entry(self._entry_id) - if not config_entry: - return self.async_abort(reason="entry_not_found") - - # If there's no user input or if there were errors, show the form - schema = { + # Build options schema + options = { vol.Required( CONF_ACCESS_TOKEN, - default=config_entry.data.get(CONF_ACCESS_TOKEN, ""), + default=self.config_entry.data.get(CONF_ACCESS_TOKEN, ""), ): selector.TextSelector( selector.TextSelectorConfig( type=selector.TextSelectorType.TEXT, ), ), + vol.Optional( + CONF_EXTENDED_DESCRIPTIONS, + default=self.config_entry.options.get( + CONF_EXTENDED_DESCRIPTIONS, + self.config_entry.data.get(CONF_EXTENDED_DESCRIPTIONS, DEFAULT_EXTENDED_DESCRIPTIONS), + ), + ): selector.BooleanSelector(), } return self.async_show_form( step_id="init", - data_schema=vol.Schema(schema), + data_schema=vol.Schema(options), errors=errors, ) diff --git a/custom_components/tibber_prices/const.py b/custom_components/tibber_prices/const.py index 230fb64..df015c9 100644 --- a/custom_components/tibber_prices/const.py +++ b/custom_components/tibber_prices/const.py @@ -1,10 +1,181 @@ -"""Constants for tibber_prices.""" +"""Constants for the Tibber Price Analytics integration.""" -from logging import Logger, getLogger +import json +import logging +from pathlib import Path -LOGGER: Logger = getLogger(__package__) +import aiofiles + +from homeassistant.core import HomeAssistant + +# Version of the integration +VERSION = "1.0.0" -NAME = "Tibber Price Information & Ratings" -VERSION = "0.1.0" # Must match version in manifest.json DOMAIN = "tibber_prices" -ATTRIBUTION = "Data provided by https://tibber.com/" +CONF_ACCESS_TOKEN = "access_token" # noqa: S105 +CONF_EXTENDED_DESCRIPTIONS = "extended_descriptions" + +ATTRIBUTION = "Data provided by Tibber" + +SCAN_INTERVAL = 60 * 5 # 5 minutes + +DEFAULT_NAME = "Tibber Price Analytics" +DEFAULT_EXTENDED_DESCRIPTIONS = False + +PRICE_LEVEL_NORMAL = "NORMAL" +PRICE_LEVEL_CHEAP = "CHEAP" +PRICE_LEVEL_VERY_CHEAP = "VERY_CHEAP" +PRICE_LEVEL_EXPENSIVE = "EXPENSIVE" +PRICE_LEVEL_VERY_EXPENSIVE = "VERY_EXPENSIVE" + +PRICE_LEVEL_MAPPING = { + PRICE_LEVEL_VERY_CHEAP: -2, + PRICE_LEVEL_CHEAP: -1, + PRICE_LEVEL_NORMAL: 0, + PRICE_LEVEL_EXPENSIVE: 1, + PRICE_LEVEL_VERY_EXPENSIVE: 2, +} + +SENSOR_TYPE_PRICE_LEVEL = "price_level" + +LOGGER = logging.getLogger(__package__) + +# Path to custom translations directory +CUSTOM_TRANSLATIONS_DIR = Path(__file__).parent / "custom_translations" + +# Cache for translations to avoid repeated file reads +_TRANSLATIONS_CACHE: dict[str, dict] = {} + + +async def async_load_translations(hass: HomeAssistant, language: str) -> dict: + """ + Load translations from file asynchronously. + + Args: + hass: HomeAssistant instance + language: The language code to load + + Returns: + The loaded translations as a dictionary + + """ + # Use a key that includes the language parameter + cache_key = f"{DOMAIN}_translations_{language}" + + # Check if we have an instance in hass.data + if cache_key in hass.data: + return hass.data[cache_key] + + # Check the module-level cache + if language in _TRANSLATIONS_CACHE: + return _TRANSLATIONS_CACHE[language] + + # Determine the file path + file_path = CUSTOM_TRANSLATIONS_DIR / f"{language}.json" + if not file_path.exists(): + # Fall back to English if requested language not found + file_path = CUSTOM_TRANSLATIONS_DIR / "en.json" + if not file_path.exists(): + LOGGER.debug("No custom translations found at %s", file_path) + empty_cache = {} + _TRANSLATIONS_CACHE[language] = empty_cache + hass.data[cache_key] = empty_cache + return empty_cache + + try: + # Read the file asynchronously + async with aiofiles.open(file_path, encoding="utf-8") as f: + content = await f.read() + translations = json.loads(content) + + # Store in both caches for future calls + _TRANSLATIONS_CACHE[language] = translations + hass.data[cache_key] = translations + + return translations + + except (OSError, json.JSONDecodeError) as err: + LOGGER.warning("Error loading custom translations file: %s", err) + empty_cache = {} + _TRANSLATIONS_CACHE[language] = empty_cache + hass.data[cache_key] = empty_cache + return empty_cache + + except Exception: # pylint: disable=broad-except + LOGGER.exception("Unexpected error loading custom translations") + empty_cache = {} + _TRANSLATIONS_CACHE[language] = empty_cache + hass.data[cache_key] = empty_cache + return empty_cache + + +async def async_get_entity_description( + hass: HomeAssistant, + entity_type: str, + entity_key: str, + language: str = "en", + field: str = "description", +) -> str | None: + """ + Get a specific field from the entity's custom translations asynchronously. + + Args: + hass: HomeAssistant instance + entity_type: The type of entity (sensor, binary_sensor, etc.) + entity_key: The key of the entity + language: The language code (defaults to English) + field: The field to retrieve (description, long_description, usage_tips) + + Returns: + The requested field's value if found, None otherwise + + """ + translations = await async_load_translations(hass, language) + + # Check if entity exists in translations + if entity_type in translations and entity_key in translations[entity_type]: + # Get the entity data + entity_data = translations[entity_type][entity_key] + + # If entity_data is a string, return it only for description field + if isinstance(entity_data, str) and field == "description": + return entity_data + + # If entity_data is a dict, look for the requested field + if isinstance(entity_data, dict) and field in entity_data: + return entity_data[field] + + return None + + +def get_entity_description( + entity_type: str, entity_key: str, language: str = "en", field: str = "description" +) -> str | None: + """ + Get entity description synchronously from the cache. + + This function only accesses the cached translations to avoid blocking I/O. + + Args: + entity_type: The type of entity + entity_key: The key of the entity + language: The language code + field: The field to retrieve + + Returns: + The requested field's value if found in cache, None otherwise + + """ + # Only return from cache to avoid blocking I/O + if language in _TRANSLATIONS_CACHE: + translations = _TRANSLATIONS_CACHE[language] + if entity_type in translations and entity_key in translations[entity_type]: + entity_data = translations[entity_type][entity_key] + + if isinstance(entity_data, str) and field == "description": + return entity_data + + if isinstance(entity_data, dict) and field in entity_data: + return entity_data[field] + + return None diff --git a/custom_components/tibber_prices/custom_translations/de.json b/custom_components/tibber_prices/custom_translations/de.json new file mode 100644 index 0000000..c05eab0 --- /dev/null +++ b/custom_components/tibber_prices/custom_translations/de.json @@ -0,0 +1,81 @@ +{ + "sensor": { + "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" + }, + "next_hour_price": { + "description": "Der Strompreis für die nächste Stunde inklusive Steuern", + "long_description": "Zeigt den Strompreis für die kommende Stunde, einschließlich aller Steuern und Gebühren", + "usage_tips": "Perfekt für die Planung von Geräten, die in der nächsten Stunde basierend auf dem Preis laufen sollen" + }, + "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" + }, + "lowest_price_today": { + "description": "Der niedrigste Strompreis für den aktuellen Tag", + "long_description": "Zeigt den niedrigsten Preis des aktuellen Tages an", + "usage_tips": "Nützlich, um die optimale Zeit für den Betrieb energieintensiver Geräte zu finden" + }, + "highest_price_today": { + "description": "Der höchste Strompreis für den aktuellen Tag", + "long_description": "Zeigt den höchsten Preis während des aktuellen Tages an", + "usage_tips": "Hilfreich, um Spitzenpreiszeiten zu vermeiden" + }, + "average_price_today": { + "description": "Der durchschnittliche Strompreis für den aktuellen Tag", + "long_description": "Berechnet den durchschnittlichen Preis über alle Stunden des aktuellen Tages", + "usage_tips": "Als Grundlage für Preisvergleiche verwenden" + }, + "hourly_rating": { + "description": "Preisvergleich mit historischen Daten für die aktuelle Stunde", + "long_description": "Zeigt, wie der Preis der aktuellen Stunde im Vergleich zu historischen Daten als prozentuale Differenz abschneidet", + "usage_tips": "Hilft zu verstehen, ob die aktuellen Preise höher oder niedriger als üblich für diese Zeit sind" + }, + "daily_rating": { + "description": "Preisvergleich mit historischen Daten für den aktuellen Tag", + "long_description": "Zeigt, wie der heutige Durchschnittspreis im Vergleich zu historischen Daten als prozentuale Differenz abschneidet", + "usage_tips": "Nützlich, um zu verstehen, ob heute generell teuer oder günstig ist" + }, + "monthly_rating": { + "description": "Preisvergleich mit historischen Daten für den aktuellen Monat", + "long_description": "Zeigt, wie der durchschnittliche Preis dieses Monats im Vergleich zu historischen Daten als prozentuale Differenz abschneidet", + "usage_tips": "Hilfreich für die langfristige Energiebudgetplanung" + }, + "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" + }, + "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" + } + }, + "binary_sensor": { + "peak_hour": { + "description": "Zeigt an, ob die aktuelle Stunde den höchsten Preis des Tages hat", + "long_description": "Wird während Stunden aktiv, die zu den teuersten des Tages gehören", + "usage_tips": "In Automatisierungen verwenden, um den Betrieb von Geräten mit hohem Verbrauch während Spitzenzeiten zu vermeiden" + }, + "best_price_hour": { + "description": "Zeigt an, ob die aktuelle Stunde den niedrigsten Preis des Tages hat", + "long_description": "Wird während Stunden aktiv, die zu den günstigsten des Tages gehören", + "usage_tips": "Perfekt, um energieintensive Geräte zu optimalen Zeiten zu aktivieren" + }, + "connection": { + "description": "Zeigt den Verbindungsstatus zur Tibber API an", + "long_description": "Zeigt an, ob die Komponente erfolgreich eine Verbindung zur Tibber API herstellt", + "usage_tips": "Überwachen Sie dies, um sicherzustellen, dass Ihre Preisdaten korrekt aktualisiert werden" + } + }, + "metadata": { + "author": "Julian Pawlowski", + "version": "1.0.0", + "last_updated": "2025-04-23" + } +} diff --git a/custom_components/tibber_prices/custom_translations/en.json b/custom_components/tibber_prices/custom_translations/en.json new file mode 100644 index 0000000..8c3d838 --- /dev/null +++ b/custom_components/tibber_prices/custom_translations/en.json @@ -0,0 +1,81 @@ +{ + "sensor": { + "current_price": { + "description": "The current hour's electricity price including taxes", + "long_description": "Shows the electricity price for the current hour, including all taxes and fees", + "usage_tips": "Use this sensor for automations that should react to the current price" + }, + "next_hour_price": { + "description": "The next hour's electricity price including taxes", + "long_description": "Shows the electricity price for the upcoming hour, including all taxes and fees", + "usage_tips": "Perfect for scheduling devices to run in the next hour based on price" + }, + "price_level": { + "description": "Current price level indicator (VERY_CHEAP to VERY_EXPENSIVE)", + "long_description": "Indicates the current price level on a scale from very cheap to very expensive", + "usage_tips": "Use this for visual indicators or simple automations without needing to calculate thresholds" + }, + "lowest_price_today": { + "description": "The lowest electricity price for the current day", + "long_description": "Shows the lowest price point available during the current day", + "usage_tips": "Useful to find the optimal time to run energy-intensive appliances" + }, + "highest_price_today": { + "description": "The highest electricity price for the current day", + "long_description": "Shows the highest price point during the current day", + "usage_tips": "Helpful for avoiding peak price periods" + }, + "average_price_today": { + "description": "The average electricity price for the current day", + "long_description": "Calculates the average price across all hours of the current day", + "usage_tips": "Use as a baseline for price comparison" + }, + "hourly_rating": { + "description": "Price comparison with historical data for the current hour", + "long_description": "Shows how the current hour's price compares to historical data as a percentage difference", + "usage_tips": "Helps understand if current prices are higher or lower than usual for this time" + }, + "daily_rating": { + "description": "Price comparison with historical data for the current day", + "long_description": "Shows how today's average price compares to historical data as a percentage difference", + "usage_tips": "Useful to understand if today is generally expensive or cheap" + }, + "monthly_rating": { + "description": "Price comparison with historical data for the current month", + "long_description": "Shows how this month's average price compares to historical data as a percentage difference", + "usage_tips": "Helpful for long-term energy budget planning" + }, + "data_timestamp": { + "description": "Timestamp of the most recent price data received from Tibber", + "long_description": "Shows when the price data was last updated from the Tibber API", + "usage_tips": "Monitor this to ensure your price data is current" + }, + "tomorrow_data_available": { + "description": "Indicates if price data for tomorrow is available", + "long_description": "Shows whether complete, partial, or no price data is available for tomorrow", + "usage_tips": "Use this to check if you can schedule appliances for tomorrow reliably" + } + }, + "binary_sensor": { + "peak_hour": { + "description": "Indicates whether the current hour has the highest price of the day", + "long_description": "Becomes active during hours that are among the most expensive of the day", + "usage_tips": "Use in automations to avoid running high-consumption devices during peak hours" + }, + "best_price_hour": { + "description": "Indicates whether the current hour has the lowest price of the day", + "long_description": "Becomes active during hours that are among the cheapest of the day", + "usage_tips": "Perfect for triggering energy-intensive appliances during optimal times" + }, + "connection": { + "description": "Shows connection status 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" + } + }, + "metadata": { + "author": "Julian Pawlowski", + "version": "1.0.0", + "last_updated": "2025-04-23" + } +} diff --git a/custom_components/tibber_prices/manifest.json b/custom_components/tibber_prices/manifest.json index 2844276..86f0c92 100644 --- a/custom_components/tibber_prices/manifest.json +++ b/custom_components/tibber_prices/manifest.json @@ -1,12 +1,15 @@ { - "domain": "tibber_prices", - "name": "Tibber Price Information & Ratings", - "codeowners": [ - "@jpawlowski" - ], - "config_flow": true, - "documentation": "https://github.com/jpawlowski/hass.tibber_prices", - "iot_class": "cloud_polling", - "issue_tracker": "https://github.com/jpawlowski/hass.tibber_prices/issues", - "version": "0.1.0" -} \ No newline at end of file + "domain": "tibber_prices", + "name": "Tibber Price Information & Ratings", + "codeowners": [ + "@jpawlowski" + ], + "config_flow": true, + "documentation": "https://github.com/jpawlowski/hass.tibber_prices", + "iot_class": "cloud_polling", + "issue_tracker": "https://github.com/jpawlowski/hass.tibber_prices/issues", + "version": "0.1.0", + "requirements": [ + "aiofiles>=23.2.1" + ] +} diff --git a/custom_components/tibber_prices/sensor.py b/custom_components/tibber_prices/sensor.py index 2a8b8a4..2525c3b 100644 --- a/custom_components/tibber_prices/sensor.py +++ b/custom_components/tibber_prices/sensor.py @@ -393,25 +393,111 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity): return None @property - def extra_state_attributes(self) -> dict | None: - """Return additional state attributes.""" + async def async_extra_state_attributes(self) -> dict | None: + """Return additional state attributes asynchronously.""" if not self.coordinator.data: return None - attributes = self._get_sensor_attributes() + attributes = self._get_sensor_attributes() or {} - # Add translated description - if attributes and self.hass is not None: - base_key = "entity.sensor" - key = f"{base_key}.{self.entity_description.translation_key}.description" - language_config = getattr(self.hass.config, "language", None) - if isinstance(language_config, dict): - description = language_config.get(key) - if description is not None: - attributes = dict(attributes) # Make a copy before modifying - attributes["description"] = description + # Add description from the custom translations file + if self.entity_description.translation_key and self.hass is not None: + # Extract the base key (without _cents suffix if present) + base_key = self.entity_description.translation_key + base_key = base_key.removesuffix("_cents") - return attributes + # Get user's language preference + language = self.hass.config.language if self.hass.config.language else "en" + + # Import only within the method to avoid circular imports + from .const import ( + CONF_EXTENDED_DESCRIPTIONS, + DEFAULT_EXTENDED_DESCRIPTIONS, + async_get_entity_description, + ) + + # Add basic description + description = await async_get_entity_description(self.hass, "sensor", base_key, language, "description") + if description: + attributes["description"] = description + + # Check if extended descriptions are enabled in the config + extended_descriptions = self.coordinator.config_entry.options.get( + CONF_EXTENDED_DESCRIPTIONS, + self.coordinator.config_entry.data.get(CONF_EXTENDED_DESCRIPTIONS, DEFAULT_EXTENDED_DESCRIPTIONS), + ) + + # Add extended descriptions if enabled + if extended_descriptions: + # Add long description if available + long_desc = await async_get_entity_description( + self.hass, "sensor", base_key, language, "long_description" + ) + if long_desc: + attributes["long_description"] = long_desc + + # Add usage tips if available + usage_tips = await async_get_entity_description(self.hass, "sensor", base_key, language, "usage_tips") + if usage_tips: + attributes["usage_tips"] = usage_tips + + return attributes if attributes else None + + @property + def extra_state_attributes(self) -> dict | None: + """ + Return additional state attributes (synchronous version). + + This synchronous method is required by Home Assistant and will + first return basic attributes, then add cached descriptions + without any blocking I/O operations. + """ + if not self.coordinator.data: + return None + + # Start with the basic attributes + attributes = self._get_sensor_attributes() or {} + + # Add descriptions from the cache if available (non-blocking) + if self.entity_description.translation_key and self.hass is not None: + # Extract the base key (without _cents suffix if present) + base_key = self.entity_description.translation_key + base_key = base_key.removesuffix("_cents") + + # Get user's language preference + language = self.hass.config.language if self.hass.config.language else "en" + + # Import synchronous function to get cached descriptions + from .const import ( + CONF_EXTENDED_DESCRIPTIONS, + DEFAULT_EXTENDED_DESCRIPTIONS, + get_entity_description, + ) + + # Add basic description from cache + description = get_entity_description("sensor", base_key, language, "description") + if description: + attributes["description"] = description + + # Check if extended descriptions are enabled in the config + extended_descriptions = self.coordinator.config_entry.options.get( + CONF_EXTENDED_DESCRIPTIONS, + self.coordinator.config_entry.data.get(CONF_EXTENDED_DESCRIPTIONS, DEFAULT_EXTENDED_DESCRIPTIONS), + ) + + # Add extended descriptions if enabled (from cache only) + if extended_descriptions: + # Add long description if available in cache + long_desc = get_entity_description("sensor", base_key, language, "long_description") + if long_desc: + attributes["long_description"] = long_desc + + # Add usage tips if available in cache + usage_tips = get_entity_description("sensor", base_key, language, "usage_tips") + if usage_tips: + attributes["usage_tips"] = usage_tips + + return attributes if attributes else None def _get_sensor_attributes(self) -> dict | None: """Get attributes based on sensor type.""" diff --git a/custom_components/tibber_prices/translations/en.json b/custom_components/tibber_prices/translations/en.json index 8fe383a..3e5afb2 100644 --- a/custom_components/tibber_prices/translations/en.json +++ b/custom_components/tibber_prices/translations/en.json @@ -41,48 +41,37 @@ "entity": { "sensor": { "current_price": { - "name": "Current Price", - "description": "The current hour's electricity price including taxes" + "name": "Current Price" }, "next_hour_price": { - "name": "Next Hour Price", - "description": "The next hour's electricity price including taxes" + "name": "Next Hour Price" }, "price_level": { - "name": "Price Level", - "description": "Current price level indicator (VERY_CHEAP, CHEAP, NORMAL, EXPENSIVE, VERY_EXPENSIVE)" + "name": "Price Level" }, "lowest_price_today": { - "name": "Lowest Price Today", - "description": "The lowest electricity price for the current day" + "name": "Lowest Price Today" }, "highest_price_today": { - "name": "Highest Price Today", - "description": "The highest electricity price for the current day" + "name": "Highest Price Today" }, "average_price_today": { - "name": "Average Price Today", - "description": "The average electricity price for the current day" + "name": "Average Price Today" }, "hourly_rating": { - "name": "Hourly Price Rating", - "description": "Price comparison with historical data for the current hour (percentage difference)" + "name": "Hourly Price Rating" }, "daily_rating": { - "name": "Daily Price Rating", - "description": "Price comparison with historical data for the current day (percentage difference)" + "name": "Daily Price Rating" }, "monthly_rating": { - "name": "Monthly Price Rating", - "description": "Price comparison with historical data for the current month (percentage difference)" + "name": "Monthly Price Rating" }, "data_timestamp": { - "name": "Last Data Available", - "description": "Timestamp of the most recent price data received from Tibber" + "name": "Last Data Available" }, "tomorrow_data_available": { - "name": "Tomorrow's Data Available", - "description": "Indicates if price data for tomorrow is available (Yes/No/Partial)" + "name": "Tomorrow's Data Available" } }, "binary_sensor": { diff --git a/hacs.json b/hacs.json index a9e1104..2975773 100644 --- a/hacs.json +++ b/hacs.json @@ -1,10 +1,6 @@ { - "name": "Tibber Price Information & Ratings", - "homeassistant": "2025.4.2", - "hacs": "2.0.1", - "render_readme": true, - "domains": [ - "sensor", - "binary_sensor" - ] -} \ No newline at end of file + "name": "Tibber Price Information & Ratings", + "homeassistant": "2025.4.2", + "hacs": "2.0.1", + "render_readme": true +}