add descriptions

This commit is contained in:
Julian Pawlowski 2025-04-23 21:13:57 +00:00
parent 51b028e9b7
commit 3d33d8d6bc
10 changed files with 629 additions and 98 deletions

View file

@ -16,7 +16,7 @@ from homeassistant.helpers.storage import Store
from homeassistant.loader import async_get_loaded_integration from homeassistant.loader import async_get_loaded_integration
from .api import TibberPricesApiClient from .api import TibberPricesApiClient
from .const import DOMAIN, LOGGER from .const import DOMAIN, LOGGER, async_load_translations
from .coordinator import STORAGE_VERSION, TibberPricesDataUpdateCoordinator from .coordinator import STORAGE_VERSION, TibberPricesDataUpdateCoordinator
from .data import TibberPricesData from .data import TibberPricesData
@ -37,6 +37,13 @@ async def async_setup_entry(
entry: TibberPricesConfigEntry, entry: TibberPricesConfigEntry,
) -> bool: ) -> bool:
"""Set up this integration using UI.""" """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( coordinator = TibberPricesDataUpdateCoordinator(
hass=hass, hass=hass,
entry=entry, entry=entry,

View file

@ -5,9 +5,6 @@ from __future__ import annotations
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Callable
from homeassistant.components.binary_sensor import ( from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass, BinarySensorDeviceClass,
BinarySensorEntity, BinarySensorEntity,
@ -18,6 +15,8 @@ from homeassistant.const import EntityCategory
from .entity import TibberPricesEntity from .entity import TibberPricesEntity
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Callable
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
@ -194,13 +193,63 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity):
return None return None
@property @property
def extra_state_attributes(self) -> dict | None: async def async_extra_state_attributes(self) -> dict | None:
"""Return additional state attributes.""" """Return additional state attributes asynchronously."""
try: 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 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: except (KeyError, ValueError, TypeError) as ex:
self.coordinator.logger.exception( self.coordinator.logger.exception(
@ -211,3 +260,72 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity):
}, },
) )
return None 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

View file

@ -16,7 +16,12 @@ from .api import (
TibberPricesApiClientCommunicationError, TibberPricesApiClientCommunicationError,
TibberPricesApiClientError, TibberPricesApiClientError,
) )
from .const import DOMAIN, LOGGER from .const import (
CONF_EXTENDED_DESCRIPTIONS,
DEFAULT_EXTENDED_DESCRIPTIONS,
DOMAIN,
LOGGER,
)
class TibberPricesFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): class TibberPricesFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
@ -48,9 +53,7 @@ class TibberPricesFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
_errors = {} _errors = {}
if user_input is not None: if user_input is not None:
try: try:
name = await self._test_credentials( name = await self._test_credentials(access_token=user_input[CONF_ACCESS_TOKEN])
access_token=user_input[CONF_ACCESS_TOKEN]
)
except TibberPricesApiClientAuthenticationError as exception: except TibberPricesApiClientAuthenticationError as exception:
LOGGER.warning(exception) LOGGER.warning(exception)
_errors["base"] = "auth" _errors["base"] = "auth"
@ -74,14 +77,16 @@ class TibberPricesFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
{ {
vol.Required( vol.Required(
CONF_ACCESS_TOKEN, CONF_ACCESS_TOKEN,
default=(user_input or {}).get( default=(user_input or {}).get(CONF_ACCESS_TOKEN, vol.UNDEFINED),
CONF_ACCESS_TOKEN, vol.UNDEFINED
),
): selector.TextSelector( ): selector.TextSelector(
selector.TextSelectorConfig( selector.TextSelectorConfig(
type=selector.TextSelectorType.TEXT, type=selector.TextSelectorType.TEXT,
), ),
), ),
vol.Optional(
CONF_EXTENDED_DESCRIPTIONS,
default=(user_input or {}).get(CONF_EXTENDED_DESCRIPTIONS, DEFAULT_EXTENDED_DESCRIPTIONS),
): selector.BooleanSelector(),
}, },
), ),
errors=_errors, errors=_errors,
@ -103,12 +108,9 @@ class TibberPricesOptionsFlowHandler(config_entries.OptionsFlow):
def __init__(self, config_entry: config_entries.ConfigEntry) -> None: def __init__(self, config_entry: config_entries.ConfigEntry) -> None:
"""Initialize options flow.""" """Initialize options flow."""
super().__init__() super().__init__()
# Store the entry_id instead of the whole config_entry self.config_entry = config_entry
self._entry_id = config_entry.entry_id
async def async_step_init( async def async_step_init(self, user_input: dict | None = None) -> config_entries.ConfigFlowResult:
self, user_input: dict | None = None
) -> config_entries.ConfigFlowResult:
"""Manage the options.""" """Manage the options."""
errors: dict[str, str] = {} errors: dict[str, str] = {}
@ -122,20 +124,15 @@ class TibberPricesOptionsFlowHandler(config_entries.OptionsFlow):
result = await client.async_test_connection() result = await client.async_test_connection()
new_account_name = result["viewer"]["name"] 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 # 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) new_unique_id = slugify(new_account_name)
if current_unique_id != new_unique_id: if current_unique_id != new_unique_id:
# Token is for a different account # Token is for a different account
errors["base"] = "different_account" errors["base"] = "different_account"
else: 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) return self.async_create_entry(title="", data=user_input)
except TibberPricesApiClientAuthenticationError as exception: except TibberPricesApiClientAuthenticationError as exception:
@ -148,25 +145,27 @@ class TibberPricesOptionsFlowHandler(config_entries.OptionsFlow):
LOGGER.exception(exception) LOGGER.exception(exception)
errors["base"] = "unknown" errors["base"] = "unknown"
# Get current config entry to get the current access token # Build options schema
config_entry = self.hass.config_entries.async_get_entry(self._entry_id) options = {
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 = {
vol.Required( vol.Required(
CONF_ACCESS_TOKEN, CONF_ACCESS_TOKEN,
default=config_entry.data.get(CONF_ACCESS_TOKEN, ""), default=self.config_entry.data.get(CONF_ACCESS_TOKEN, ""),
): selector.TextSelector( ): selector.TextSelector(
selector.TextSelectorConfig( selector.TextSelectorConfig(
type=selector.TextSelectorType.TEXT, 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( return self.async_show_form(
step_id="init", step_id="init",
data_schema=vol.Schema(schema), data_schema=vol.Schema(options),
errors=errors, errors=errors,
) )

View file

@ -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" 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

View file

@ -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"
}
}

View file

@ -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"
}
}

View file

@ -1,12 +1,15 @@
{ {
"domain": "tibber_prices", "domain": "tibber_prices",
"name": "Tibber Price Information & Ratings", "name": "Tibber Price Information & Ratings",
"codeowners": [ "codeowners": [
"@jpawlowski" "@jpawlowski"
], ],
"config_flow": true, "config_flow": true,
"documentation": "https://github.com/jpawlowski/hass.tibber_prices", "documentation": "https://github.com/jpawlowski/hass.tibber_prices",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"issue_tracker": "https://github.com/jpawlowski/hass.tibber_prices/issues", "issue_tracker": "https://github.com/jpawlowski/hass.tibber_prices/issues",
"version": "0.1.0" "version": "0.1.0",
} "requirements": [
"aiofiles>=23.2.1"
]
}

View file

@ -393,25 +393,111 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
return None return None
@property @property
def extra_state_attributes(self) -> dict | None: async def async_extra_state_attributes(self) -> dict | None:
"""Return additional state attributes.""" """Return additional state attributes asynchronously."""
if not self.coordinator.data: if not self.coordinator.data:
return None return None
attributes = self._get_sensor_attributes() attributes = self._get_sensor_attributes() or {}
# Add translated description # Add description from the custom translations file
if attributes and self.hass is not None: if self.entity_description.translation_key and self.hass is not None:
base_key = "entity.sensor" # Extract the base key (without _cents suffix if present)
key = f"{base_key}.{self.entity_description.translation_key}.description" base_key = self.entity_description.translation_key
language_config = getattr(self.hass.config, "language", None) base_key = base_key.removesuffix("_cents")
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
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: def _get_sensor_attributes(self) -> dict | None:
"""Get attributes based on sensor type.""" """Get attributes based on sensor type."""

View file

@ -41,48 +41,37 @@
"entity": { "entity": {
"sensor": { "sensor": {
"current_price": { "current_price": {
"name": "Current Price", "name": "Current Price"
"description": "The current hour's electricity price including taxes"
}, },
"next_hour_price": { "next_hour_price": {
"name": "Next Hour Price", "name": "Next Hour Price"
"description": "The next hour's electricity price including taxes"
}, },
"price_level": { "price_level": {
"name": "Price Level", "name": "Price Level"
"description": "Current price level indicator (VERY_CHEAP, CHEAP, NORMAL, EXPENSIVE, VERY_EXPENSIVE)"
}, },
"lowest_price_today": { "lowest_price_today": {
"name": "Lowest Price Today", "name": "Lowest Price Today"
"description": "The lowest electricity price for the current day"
}, },
"highest_price_today": { "highest_price_today": {
"name": "Highest Price Today", "name": "Highest Price Today"
"description": "The highest electricity price for the current day"
}, },
"average_price_today": { "average_price_today": {
"name": "Average Price Today", "name": "Average Price Today"
"description": "The average electricity price for the current day"
}, },
"hourly_rating": { "hourly_rating": {
"name": "Hourly Price Rating", "name": "Hourly Price Rating"
"description": "Price comparison with historical data for the current hour (percentage difference)"
}, },
"daily_rating": { "daily_rating": {
"name": "Daily Price Rating", "name": "Daily Price Rating"
"description": "Price comparison with historical data for the current day (percentage difference)"
}, },
"monthly_rating": { "monthly_rating": {
"name": "Monthly Price Rating", "name": "Monthly Price Rating"
"description": "Price comparison with historical data for the current month (percentage difference)"
}, },
"data_timestamp": { "data_timestamp": {
"name": "Last Data Available", "name": "Last Data Available"
"description": "Timestamp of the most recent price data received from Tibber"
}, },
"tomorrow_data_available": { "tomorrow_data_available": {
"name": "Tomorrow's Data Available", "name": "Tomorrow's Data Available"
"description": "Indicates if price data for tomorrow is available (Yes/No/Partial)"
} }
}, },
"binary_sensor": { "binary_sensor": {

View file

@ -1,10 +1,6 @@
{ {
"name": "Tibber Price Information & Ratings", "name": "Tibber Price Information & Ratings",
"homeassistant": "2025.4.2", "homeassistant": "2025.4.2",
"hacs": "2.0.1", "hacs": "2.0.1",
"render_readme": true, "render_readme": true
"domains": [ }
"sensor",
"binary_sensor"
]
}