feat(sensor): migrate chart_data_export from binary_sensor to sensor platform

Migrated chart_data_export from binary_sensor to sensor to enable
compatibility with dashboard integrations (ApexCharts, etc.) that
require sensor entities for data selection.

Changes:
- Moved chart_data_export from binary_sensor/ to sensor/ platform
- Changed from boolean state (ON/OFF) to ENUM states ("pending", "ready", "error")
- Maintained all functionality: service call, attribute structure, caching
- Updated translations in all 5 languages (de, en, nb, nl, sv)
- Updated user documentation (sensors.md, services.md)
- Removed all chart_data_export code from binary_sensor platform

Technical details:
- State: "pending" (before first call), "ready" (data available), "error" (service failed)
- Attributes: timestamp + error (metadata) → descriptions → service response data
- Cache (_chart_data_response) bridges async service call and sync property access
- Service call: Triggered on async_added_to_hass() and async_update()

Impact: Dashboard integrations can now select chart_data_export sensor
in their entity pickers. No breaking changes for existing users - entity ID
changes from binary_sensor.* to sensor.*, but functionality identical.
This commit is contained in:
Julian Pawlowski 2025-11-17 04:11:10 +00:00
parent e17f59c283
commit ef983d0a99
16 changed files with 280 additions and 349 deletions

View file

@ -274,7 +274,7 @@ def build_final_attributes_simple(
}
async def build_async_extra_state_attributes( # noqa: PLR0913, PLR0912
async def build_async_extra_state_attributes( # noqa: PLR0913
entity_key: str,
translation_key: str | None,
hass: HomeAssistant,
@ -302,28 +302,8 @@ async def build_async_extra_state_attributes( # noqa: PLR0913, PLR0912
"""
attributes = {}
# For chart_data_export: Add metadata first, descriptions next, service data last
# For other sensors: Follow normal order (dynamic_attrs first, then descriptions)
is_chart_export = entity_key == "chart_data_export"
# Extract metadata and service data for chart_data_export
chart_metadata = {}
chart_service_data = {}
if is_chart_export and dynamic_attrs:
# Separate metadata (timestamp, error) from service data
for key, value in dynamic_attrs.items():
if key in ("timestamp", "error"):
chart_metadata[key] = value
else:
chart_service_data[key] = value
# Add dynamic attributes in correct order
# Add dynamic attributes first
if dynamic_attrs:
if is_chart_export:
# For chart_data_export: Start with metadata only
attributes.update(chart_metadata)
else:
# For other sensors: Add all dynamic attributes first
# Copy and remove internal fields before exposing to user
clean_attrs = {k: v for k, v in dynamic_attrs.items() if not k.startswith("_")}
attributes.update(clean_attrs)
@ -377,14 +357,10 @@ async def build_async_extra_state_attributes( # noqa: PLR0913, PLR0912
if usage_tips:
attributes["usage_tips"] = usage_tips
# For chart_data_export: Add service data at the END (after descriptions)
if is_chart_export and chart_service_data:
attributes.update(chart_service_data)
return attributes if attributes else None
def build_sync_extra_state_attributes( # noqa: PLR0913, PLR0912
def build_sync_extra_state_attributes( # noqa: PLR0913
entity_key: str,
translation_key: str | None,
hass: HomeAssistant,
@ -412,28 +388,8 @@ def build_sync_extra_state_attributes( # noqa: PLR0913, PLR0912
"""
attributes = {}
# For chart_data_export: Add metadata first, descriptions next, service data last
# For other sensors: Follow normal order (dynamic_attrs first, then descriptions)
is_chart_export = entity_key == "chart_data_export"
# Extract metadata and service data for chart_data_export
chart_metadata = {}
chart_service_data = {}
if is_chart_export and dynamic_attrs:
# Separate metadata (timestamp, error) from service data
for key, value in dynamic_attrs.items():
if key in ("timestamp", "error"):
chart_metadata[key] = value
else:
chart_service_data[key] = value
# Add dynamic attributes in correct order
# Add dynamic attributes first
if dynamic_attrs:
if is_chart_export:
# For chart_data_export: Start with metadata only
attributes.update(chart_metadata)
else:
# For other sensors: Add all dynamic attributes first
# Copy and remove internal fields before exposing to user
clean_attrs = {k: v for k, v in dynamic_attrs.items() if not k.startswith("_")}
attributes.update(clean_attrs)
@ -484,8 +440,4 @@ def build_sync_extra_state_attributes( # noqa: PLR0913, PLR0912
if usage_tips:
attributes["usage_tips"] = usage_tips
# For chart_data_export: Add service data at the END (after descriptions)
if is_chart_export and chart_service_data:
attributes.update(chart_service_data)
return attributes if attributes else None

View file

@ -5,9 +5,6 @@ from __future__ import annotations
from datetime import timedelta
from typing import TYPE_CHECKING
import yaml
from custom_components.tibber_prices.const import CONF_CHART_DATA_CONFIG, DOMAIN
from custom_components.tibber_prices.coordinator import TIME_SENSITIVE_ENTITY_KEYS
from custom_components.tibber_prices.entity import TibberPricesEntity
from custom_components.tibber_prices.entity_utils import get_binary_sensor_icon
@ -52,8 +49,6 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity):
self._state_getter: Callable | None = self._get_state_getter()
self._attribute_getter: Callable | None = self._get_attribute_getter()
self._time_sensitive_remove_listener: Callable | None = None
self._chart_data_last_update = None # Track last service call timestamp
self._chart_data_error = None # Track last service call error
async def async_added_to_hass(self) -> None:
"""When entity is added to hass."""
@ -65,10 +60,6 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity):
self._handle_time_sensitive_update
)
# For chart_data_export, trigger initial service call
if self.entity_description.key == "chart_data_export":
await self._refresh_chart_data()
async def async_will_remove_from_hass(self) -> None:
"""When entity will be removed from hass."""
await super().async_will_remove_from_hass()
@ -94,7 +85,6 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity):
"tomorrow_data_available": self._tomorrow_data_available_state,
"has_ventilation_system": self._has_ventilation_system_state,
"realtime_consumption_enabled": self._realtime_consumption_enabled_state,
"chart_data_export": self._chart_data_export_state,
}
return state_getters.get(key)
@ -184,79 +174,6 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity):
value = features.get("realTimeConsumptionEnabled")
return value if isinstance(value, bool) else None
def _chart_data_export_state(self) -> bool | None:
"""Return True if chart data export was successful."""
if not self.coordinator.data:
return None
# Try to fetch chart data - state is ON if successful
# Note: This is called in property context, so we can't use async
# We'll check if data was cached from last async call
chart_data = self._get_cached_chart_data()
return chart_data is not None
def _get_cached_chart_data(self) -> dict | None:
"""Get cached chart data from last service call."""
# Store service response in instance variable for reuse
if not hasattr(self, "_chart_data_cache"):
self._chart_data_cache = None
return self._chart_data_cache
async def _call_chartdata_service_async(self) -> dict | None:
"""Call get_chartdata service with user-configured YAML (async)."""
# Get user-configured YAML
yaml_config = self.coordinator.config_entry.options.get(CONF_CHART_DATA_CONFIG, "")
# Parse YAML if provided, otherwise use empty dict (service defaults)
service_params = {}
if yaml_config and yaml_config.strip():
try:
parsed = yaml.safe_load(yaml_config)
# Ensure we have a dict (yaml.safe_load can return str, int, etc.)
if isinstance(parsed, dict):
service_params = parsed
else:
self.coordinator.logger.warning(
"YAML configuration must be a dictionary, got %s. Using service defaults.",
type(parsed).__name__,
extra={"entity": self.entity_description.key},
)
service_params = {}
except yaml.YAMLError as err:
self.coordinator.logger.warning(
"Invalid chart data YAML configuration: %s. Using service defaults.",
err,
extra={"entity": self.entity_description.key},
)
service_params = {} # Fall back to service defaults
# Add required entry_id parameter
service_params["entry_id"] = self.coordinator.config_entry.entry_id
# Call get_chartdata service using official HA service system
try:
response = await self.hass.services.async_call(
DOMAIN,
"get_chartdata",
service_params,
blocking=True,
return_response=True,
)
except Exception as ex:
self.coordinator.logger.exception(
"Chart data service call failed",
extra={"entity": self.entity_description.key},
)
self._chart_data_cache = None
self._chart_data_last_update = dt_util.now()
self._chart_data_error = str(ex)
return None
else:
self._chart_data_cache = response
self._chart_data_last_update = dt_util.now()
self._chart_data_error = None
return response
def _get_tomorrow_data_available_attributes(self) -> dict | None:
"""Return attributes for tomorrow_data_available binary sensor."""
return get_tomorrow_data_available_attributes(self.coordinator.data)
@ -271,58 +188,9 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity):
return lambda: get_price_intervals_attributes(self.coordinator.data, reverse_sort=False)
if key == "tomorrow_data_available":
return self._get_tomorrow_data_available_attributes
if key == "chart_data_export":
return self._get_chart_data_export_attributes
return None
def _get_chart_data_export_attributes(self) -> dict[str, object] | None:
"""
Return chart data from service call as attributes with metadata.
Strategy to avoid attribute name collisions:
- If service returns dict with SINGLE top-level key use directly
- If service returns dict with MULTIPLE top-level keys wrap in {"data": {...}}
- If service returns array/primitive wrap in {"data": <response>}
Attribute order: timestamp, error (if any), descriptions, service data (at the end).
"""
chart_data = self._get_cached_chart_data()
# Build base attributes with metadata
# timestamp = when service was last called (not current interval)
attributes: dict[str, object] = {
"timestamp": self._chart_data_last_update.isoformat() if self._chart_data_last_update else None,
}
# Add error message if service call failed
if self._chart_data_error:
attributes["error"] = self._chart_data_error
# Note: descriptions will be added by build_async_extra_state_attributes
# and will appear before service data because we return attributes first,
# then they get merged with descriptions, then service data is appended
if not chart_data:
# No data - only metadata (timestamp, error)
return attributes
# Service data goes at the END - append after metadata
# If response is a dict with multiple top-level keys, wrap it
# to avoid collision with our own attributes (timestamp, error, etc.)
if isinstance(chart_data, dict):
if len(chart_data) > 1:
# Multiple keys → wrap to prevent collision
attributes["data"] = chart_data
else:
# Single key → safe to merge directly
attributes.update(chart_data)
else:
# If response is array/list/primitive, wrap it in "data" key
attributes["data"] = chart_data
return attributes
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
@ -331,20 +199,6 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity):
# 1. Initial sensor activation (async_added_to_hass)
# 2. Config changes via Options Flow (triggers re-add)
# Hourly coordinator updates don't change the chart data content.
super()._handle_coordinator_update()
async def _refresh_chart_data(self) -> None:
"""
Refresh chart data by calling service.
Called only on:
- Initial sensor activation (async_added_to_hass)
- Config changes via Options Flow (triggers re-add async_added_to_hass)
NOT called on routine coordinator updates to avoid unnecessary service calls.
"""
await self._call_chartdata_service_async()
self.async_write_ha_state()
@property
def is_on(self) -> bool | None:
@ -417,19 +271,6 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity):
async def async_extra_state_attributes(self) -> dict | None:
"""Return additional state attributes asynchronously."""
try:
# For chart_data_export, use custom attribute builder with descriptions
if self.entity_description.key == "chart_data_export":
chart_attrs = self._get_chart_data_export_attributes()
# Add descriptions like other sensors
return await build_async_extra_state_attributes(
self.entity_description.key,
self.entity_description.translation_key,
self.hass,
config_entry=self.coordinator.config_entry,
dynamic_attrs=chart_attrs,
is_on=self.is_on,
)
# Get the dynamic attributes if the getter is available
if not self.coordinator.data:
return None
@ -462,19 +303,6 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity):
def extra_state_attributes(self) -> dict | None:
"""Return additional state attributes synchronously."""
try:
# For chart_data_export, use custom attribute builder with descriptions
if self.entity_description.key == "chart_data_export":
chart_attrs = self._get_chart_data_export_attributes()
# Add descriptions like other sensors
return build_sync_extra_state_attributes(
self.entity_description.key,
self.entity_description.translation_key,
self.hass,
config_entry=self.coordinator.config_entry,
dynamic_attrs=chart_attrs,
is_on=self.is_on,
)
# Get the dynamic attributes if the getter is available
if not self.coordinator.data:
return None
@ -507,7 +335,3 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity):
"""Force a refresh when homeassistant.update_entity is called."""
# Always refresh coordinator data
await self.coordinator.async_request_refresh()
# For chart_data_export, also refresh the service call
if self.entity_description.key == "chart_data_export":
await self._refresh_chart_data()

View file

@ -62,13 +62,4 @@ ENTITY_DESCRIPTIONS = (
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
BinarySensorEntityDescription(
key="chart_data_export",
translation_key="chart_data_export",
name="Chart Data Export",
icon="mdi:database-export",
device_class=BinarySensorDeviceClass.CONNECTIVITY, # ON = data available
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False, # Opt-in
),
)

View file

@ -432,6 +432,11 @@
"description": "Status deines Tibber-Abonnements",
"long_description": "Zeigt, ob dein Tibber-Abonnement derzeit aktiv ist, beendet wurde oder auf Aktivierung wartet. Ein Status 'Aktiv' bedeutet, dass du aktiv Strom über Tibber beziehst.",
"usage_tips": "Nutze dies zur Überwachung deines Abonnementstatus. Richte Benachrichtigungen ein, wenn sich der Status von 'Aktiv' ändert, um einen unterbrechungsfreien Service sicherzustellen."
},
"chart_data_export": {
"description": "Datenexport für Dashboard-Integrationen",
"long_description": "Dieser Sensor ruft den get_chartdata-Service mit deiner konfigurierten YAML-Konfiguration auf und stellt das Ergebnis als Entity-Attribute bereit. Der Status zeigt 'ready' wenn Daten verfügbar sind, 'error' bei Fehlern, oder 'pending' vor dem ersten Aufruf. Perfekt für Dashboard-Integrationen wie ApexCharts, die Preisdaten aus Entity-Attributen lesen.",
"usage_tips": "Konfiguriere die YAML-Parameter in den Integrationsoptionen entsprechend deinem get_chartdata-Service-Aufruf. Der Sensor aktualisiert automatisch bei Preisdaten-Updates (typischerweise nach Mitternacht und wenn morgige Daten eintreffen). Greife auf die Service-Response-Daten direkt über die Entity-Attribute zu - die Struktur entspricht exakt dem, was get_chartdata zurückgibt."
}
},
"binary_sensor": {
@ -464,11 +469,6 @@
"description": "Ob die Echtzeit-Verbrauchsüberwachung aktiv ist",
"long_description": "Zeigt an, ob die Echtzeit-Stromverbrauchsüberwachung für dein Tibber-Zuhause aktiviert und aktiv ist. Dies erfordert kompatible Messhardware (z. B. Tibber Pulse) und ein aktives Abonnement.",
"usage_tips": "Verwende dies, um zu überprüfen, ob Echtzeit-Verbrauchsdaten verfügbar sind. Aktiviere Benachrichtigungen, falls dies unerwartet auf 'Aus' wechselt, was auf potenzielle Hardware- oder Verbindungsprobleme hinweist."
},
"chart_data_export": {
"description": "Datenexport für Dashboard-Integrationen",
"long_description": "Dieser Binärsensor ruft den get_chartdata-Service auf, um Daten für externe Dashboard-Integrationen wie ApexCharts bereitzustellen. Wird verwendet, um Preisdaten in benutzerdefinierten Visualisierungen anzuzeigen.",
"usage_tips": "Konfiguriere die YAML-Parameter in den Integrationsoptionen, um Datenquellen, Zeiträume und Aggregationsmethoden festzulegen. Verwende dies mit benutzerdefinierten Karten oder ApexCharts-Dashboards zur Visualisierung von Preistrends und Verbrauchsdaten."
}
},
"home_types": {

View file

@ -432,6 +432,11 @@
"description": "Status for Tibber-abonnementet ditt",
"long_description": "Viser om Tibber-abonnementet ditt for øyeblikket er aktivt, avsluttet eller venter på aktivering. En status 'Aktiv' betyr at du aktivt mottar strøm gjennom Tibber.",
"usage_tips": "Bruk dette til å overvåke abonnementsstatusen din. Sett opp varsler hvis statusen endres fra 'Aktiv' for å sikre uavbrutt tjeneste."
},
"chart_data_export": {
"description": "Dataeksport for dashboardintegrasjoner",
"long_description": "Denne sensoren kaller get_chartdata-tjenesten med din konfigurerte YAML-konfigurasjon og eksponerer resultatet som entitetsattributter. Status viser 'ready' når data er tilgjengelig, 'error' ved feil, eller 'pending' før første kall. Perfekt for dashboardintegrasjoner som ApexCharts som trenger å lese prisdata fra entitetsattributter.",
"usage_tips": "Konfigurer YAML-parametrene i integrasjonsinnstillingene for å matche get_chartdata-tjenestekallet ditt. Sensoren vil automatisk oppdatere når prisdata oppdateres (typisk etter midnatt og når morgendagens data ankommer). Få tilgang til tjenesteresponsdataene direkte fra entitetens attributter - strukturen matcher nøyaktig det get_chartdata returnerer."
}
},
"binary_sensor": {

View file

@ -430,8 +430,13 @@
},
"subscription_status": {
"description": "Status van je Tibber-abonnement",
"long_description": "Toont of je Tibber-abonnement momenteel actief is, is beëindigd of wacht op activering. Een status 'Actief' betekent dat je actief elektriciteit ontvangt via Tibber.",
"usage_tips": "Gebruik dit om je abonnementsstatus te monitoren. Stel meldingen in als de status verandert van 'Actief' om ononderbroken service te garanderen."
"long_description": "Geeft aan of je Tibber-abonnement momenteel actief is, beëindigd of wacht op activering. Een 'Actief'-status betekent dat je actief elektriciteit via Tibber afneemt.",
"usage_tips": "Gebruik dit om je abonnementsstatus te monitoren. Stel meldingen in als de status verandert van 'Actief' om ononderbroken service te waarborgen."
},
"chart_data_export": {
"description": "Data-export voor dashboard-integraties",
"long_description": "Deze sensor roept de get_chartdata-service aan met jouw geconfigureerde YAML-configuratie en stelt het resultaat beschikbaar als entiteitsattributen. De status toont 'ready' wanneer data beschikbaar is, 'error' bij fouten, of 'pending' voor de eerste aanroep. Perfekt voor dashboard-integraties zoals ApexCharts die prijsgegevens uit entiteitsattributen moeten lezen.",
"usage_tips": "Configureer de YAML-parameters in de integratie-opties om overeen te komen met jouw get_chartdata-service-aanroep. De sensor wordt automatisch bijgewerkt wanneer prijsgegevens worden bijgewerkt (typisch na middernacht en wanneer gegevens van morgen binnenkomen). Krijg toegang tot de service-responsgegevens direct vanuit de entiteitsattributen - de structuur komt exact overeen met wat get_chartdata retourneert."
}
},
"binary_sensor": {

View file

@ -432,6 +432,11 @@
"description": "Status för ditt Tibber-abonnemang",
"long_description": "Visar om ditt Tibber-abonnemang för närvarande är aktivt, har avslutats eller väntar på aktivering. En status 'Aktiv' betyder att du aktivt tar emot elektricitet genom Tibber.",
"usage_tips": "Använd detta för att övervaka din abonnemangsstatus. Ställ in varningar om statusen ändras från 'Aktiv' för att säkerställa oavbruten service."
},
"chart_data_export": {
"description": "Dataexport för instrumentpanelsintegrationer",
"long_description": "Denna sensor anropar get_chartdata-tjänsten med din konfigurerade YAML-konfiguration och exponerar resultatet som entitetsattribut. Statusen visar 'ready' när data är tillgänglig, 'error' vid fel, eller 'pending' före första anropet. Perfekt för instrumentpanelsintegrationer som ApexCharts som behöver läsa prisdata från entitetsattribut.",
"usage_tips": "Konfigurera YAML-parametrarna i integrationsinställningarna för att matcha ditt get_chartdata-tjänsteanrop. Sensorn uppdateras automatiskt när prisdata uppdateras (vanligtvis efter midnatt och när morgondagens data anländer). Få åtkomst till tjänstesvarsdata direkt från entitetens attribut - strukturen matchar exakt vad get_chartdata returnerar."
}
},
"binary_sensor": {

View file

@ -5,6 +5,8 @@ from __future__ import annotations
from datetime import datetime, timedelta
from typing import TYPE_CHECKING, Any
import yaml
from custom_components.tibber_prices.average_utils import (
calculate_current_leading_avg,
calculate_current_leading_max,
@ -18,6 +20,7 @@ from custom_components.tibber_prices.binary_sensor.attributes import (
get_price_intervals_attributes,
)
from custom_components.tibber_prices.const import (
CONF_CHART_DATA_CONFIG,
CONF_EXTENDED_DESCRIPTIONS,
CONF_PRICE_RATING_THRESHOLD_HIGH,
CONF_PRICE_RATING_THRESHOLD_LOW,
@ -33,7 +36,6 @@ from custom_components.tibber_prices.const import (
DEFAULT_VOLATILITY_THRESHOLD_HIGH,
DEFAULT_VOLATILITY_THRESHOLD_MODERATE,
DOMAIN,
async_get_entity_description,
format_price_unit_major,
format_price_unit_minor,
get_entity_description,
@ -115,6 +117,10 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
# Centralized trend calculation cache (calculated once per coordinator update)
self._trend_calculation_cache: dict[str, Any] | None = None
self._trend_calculation_timestamp: datetime | None = None
# Chart data export (for chart_data_export sensor) - from binary_sensor
self._chart_data_last_update = None # Track last service call timestamp
self._chart_data_error = None # Track last service call error
self._chart_data_response = None # Store service response for attributes
async def async_added_to_hass(self) -> None:
"""When entity is added to hass."""
@ -132,6 +138,10 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
self._handle_minute_update
)
# For chart_data_export, trigger initial service call
if self.entity_description.key == "chart_data_export":
await self._refresh_chart_data()
async def async_will_remove_from_hass(self) -> None:
"""When entity will be removed from hass."""
await super().async_will_remove_from_hass()
@ -351,6 +361,8 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
"peak_price_next_in_minutes": lambda: self._get_period_timing_value(
period_type="peak_price", value_type="next_in_minutes"
),
# Chart data export sensor
"chart_data_export": self._get_chart_data_export_value,
}
return handlers.get(key)
@ -1967,93 +1979,74 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
# Fall back to static icon from entity description
return icon or self.entity_description.icon
@property
async def async_extra_state_attributes(self) -> dict | None:
"""Return additional state attributes asynchronously."""
if not self.coordinator.data:
return None
def _get_description_attributes(self) -> dict[str, str]:
"""Get description/long_description/usage_tips attributes."""
attributes = {}
attributes = self._get_sensor_attributes() or {}
if not self.entity_description.translation_key or not self.hass:
return attributes
# Add description 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"
# Add basic description
description = await async_get_entity_description(
self.hass, "sensor", self.entity_description.translation_key, language, "description"
)
# Add basic description (from cache, synchronous)
description = get_entity_description("sensor", self.entity_description.translation_key, language, "description")
if description:
attributes["description"] = description
# Check if extended descriptions are enabled in the config
# Check if extended descriptions are enabled
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", self.entity_description.translation_key, language, "long_description"
long_desc = get_entity_description(
"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, "sensor", self.entity_description.translation_key, language, "usage_tips"
usage_tips = get_entity_description(
"sensor", self.entity_description.translation_key, language, "usage_tips"
)
if usage_tips:
attributes["usage_tips"] = usage_tips
return attributes
@property
def extra_state_attributes(self) -> dict[str, Any] | None:
"""Return additional state attributes."""
if not self.coordinator.data:
return None
# For chart_data_export: special ordering (metadata → descriptions → service data)
if self.entity_description.key == "chart_data_export":
attributes: dict[str, Any] = {}
chart_attrs = self._get_chart_data_export_attributes()
# Step 1: Add metadata (timestamp, error)
if chart_attrs:
for key in ("timestamp", "error"):
if key in chart_attrs:
attributes[key] = chart_attrs[key]
# Step 2: Add descriptions
description_attrs = self._get_description_attributes()
attributes.update(description_attrs)
# Step 3: Add service data (everything except metadata)
if chart_attrs:
attributes.update({k: v for k, v in chart_attrs.items() if k not in ("timestamp", "error")})
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:
# Get user's language preference
language = self.hass.config.language if self.hass.config.language else "en"
translation_key = self.entity_description.translation_key
# Add basic description from cache
description = get_entity_description("sensor", 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("sensor", 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("sensor", translation_key, language, "usage_tips")
if usage_tips:
attributes["usage_tips"] = usage_tips
# For all other sensors: standard behavior
attributes: dict[str, Any] = self._get_sensor_attributes() or {}
description_attrs = self._get_description_attributes()
# Merge description attributes
for key, value in description_attrs.items():
attributes[key] = value
return attributes if attributes else None
@ -2061,6 +2054,10 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
"""Get attributes based on sensor type."""
key = self.entity_description.key
# Special handling for chart_data_export - returns chart data in attributes
if key == "chart_data_export":
return self._get_chart_data_export_attributes()
# Prepare cached data that attribute builders might need
cached_data = {
"trend_attributes": getattr(self, "_trend_attributes", None),
@ -2094,3 +2091,117 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
async def async_update(self) -> None:
"""Force a refresh when homeassistant.update_entity is called."""
await self.coordinator.async_request_refresh()
# For chart_data_export, also refresh the service call
if self.entity_description.key == "chart_data_export":
await self._refresh_chart_data()
# ========================================================================
# CHART DATA EXPORT METHODS
# ========================================================================
def _get_chart_data_export_value(self) -> str | None:
"""Return state for chart_data_export sensor."""
if self._chart_data_error:
return "error"
if self._chart_data_last_update:
return "ready"
return "pending"
async def _refresh_chart_data(self) -> None:
"""Refresh chart data by calling get_chartdata service."""
await self._call_chartdata_service_async()
# Result stored in cache variables, no need to return
# Trigger state update after refresh
self.async_write_ha_state()
async def _call_chartdata_service_async(self) -> dict | None:
"""Call get_chartdata service with user-configured YAML (async)."""
# Get user-configured YAML
yaml_config = self.coordinator.config_entry.options.get(CONF_CHART_DATA_CONFIG, "")
# Parse YAML if provided, otherwise use empty dict (service defaults)
service_params = {}
if yaml_config and yaml_config.strip():
try:
parsed = yaml.safe_load(yaml_config)
# Ensure we have a dict (yaml.safe_load can return str, int, etc.)
if isinstance(parsed, dict):
service_params = parsed
else:
self.coordinator.logger.warning(
"YAML configuration must be a dictionary, got %s. Using service defaults.",
type(parsed).__name__,
extra={"entity": self.entity_description.key},
)
service_params = {}
except yaml.YAMLError as err:
self.coordinator.logger.warning(
"Invalid chart data YAML configuration: %s. Using service defaults.",
err,
extra={"entity": self.entity_description.key},
)
service_params = {} # Fall back to service defaults
# Add required entry_id parameter
service_params["entry_id"] = self.coordinator.config_entry.entry_id
# Call get_chartdata service using official HA service system
try:
response = await self.hass.services.async_call(
DOMAIN,
"get_chartdata",
service_params,
blocking=True,
return_response=True,
)
except Exception as ex:
self.coordinator.logger.exception(
"Chart data service call failed",
extra={"entity": self.entity_description.key},
)
self._chart_data_response = None
self._chart_data_last_update = dt_util.now()
self._chart_data_error = str(ex)
return None
else:
self._chart_data_response = response
self._chart_data_last_update = dt_util.now()
self._chart_data_error = None
return response
def _get_chart_data_export_attributes(self) -> dict[str, object] | None:
"""
Return chart data from last service call as attributes with metadata.
Attribute order: timestamp, error (if any), service data (at the end).
Note: description/long_description/usage_tips are added BEFORE these attributes
by async_extra_state_attributes() / extra_state_attributes().
"""
# Build base attributes with metadata FIRST
attributes: dict[str, object] = {
"timestamp": self._chart_data_last_update.isoformat() if self._chart_data_last_update else None,
}
# Add error message if service call failed
if self._chart_data_error:
attributes["error"] = self._chart_data_error
if not self._chart_data_response:
# No data - only metadata (timestamp, error)
return attributes
# Service data goes LAST - after metadata
# Descriptions will be inserted BEFORE this by the property methods
if isinstance(self._chart_data_response, dict):
if len(self._chart_data_response) > 1:
# Multiple keys → wrap to prevent collision with metadata
attributes["data"] = self._chart_data_response
else:
# Single key → safe to merge directly
attributes.update(self._chart_data_response)
else:
# If response is array/list/primitive, wrap it in "data" key
attributes["data"] = self._chart_data_response
return attributes

View file

@ -1000,6 +1000,16 @@ DIAGNOSTIC_SENSORS = (
options=["running", "ended", "pending", "unknown"],
entity_registry_enabled_default=False,
),
SensorEntityDescription(
key="chart_data_export",
translation_key="chart_data_export",
name="Chart Data Export",
icon="mdi:database-export",
device_class=SensorDeviceClass.ENUM,
options=["pending", "ready", "error"],
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False, # Opt-in
),
)
# ----------------------------------------------------------------------------

View file

@ -648,6 +648,14 @@
"pending": "Ausstehend",
"unknown": "Unbekannt"
}
},
"chart_data_export": {
"name": "Diagramm-Datenexport",
"state": {
"pending": "Ausstehend",
"ready": "Bereit",
"error": "Fehler"
}
}
},
"binary_sensor": {
@ -668,9 +676,6 @@
},
"realtime_consumption_enabled": {
"name": "Echtzeitverbrauch aktiviert"
},
"chart_data_export": {
"name": "Diagramm-Datenexport"
}
}
},

View file

@ -644,6 +644,14 @@
"pending": "Pending",
"unknown": "Unknown"
}
},
"chart_data_export": {
"name": "Chart Data Export",
"state": {
"pending": "Pending",
"ready": "Ready",
"error": "Error"
}
}
},
"binary_sensor": {
@ -664,9 +672,6 @@
},
"realtime_consumption_enabled": {
"name": "Realtime Consumption Enabled"
},
"chart_data_export": {
"name": "Chart Data Export"
}
}
},

View file

@ -644,6 +644,14 @@
"pending": "Venter",
"unknown": "Ukjent"
}
},
"chart_data_export": {
"name": "Diagramdataeksport",
"state": {
"pending": "Venter",
"ready": "Klar",
"error": "Feil"
}
}
},
"binary_sensor": {
@ -664,9 +672,6 @@
},
"realtime_consumption_enabled": {
"name": "Sanntidsforbruk aktivert"
},
"chart_data_export": {
"name": "Diagramdataeksport"
}
}
},

View file

@ -644,6 +644,14 @@
"pending": "In afwachting",
"unknown": "Onbekend"
}
},
"chart_data_export": {
"name": "Grafiek Data Export",
"state": {
"pending": "In afwachting",
"ready": "Klaar",
"error": "Fout"
}
}
},
"binary_sensor": {
@ -664,9 +672,6 @@
},
"realtime_consumption_enabled": {
"name": "Realtime verbruik ingeschakeld"
},
"chart_data_export": {
"name": "Grafiek Data Export"
}
}
},

View file

@ -644,6 +644,14 @@
"pending": "Väntar",
"unknown": "Okänd"
}
},
"chart_data_export": {
"name": "Diagramdataexport",
"state": {
"pending": "Väntar",
"ready": "Redo",
"error": "Fel"
}
}
},
"binary_sensor": {
@ -664,9 +672,6 @@
},
"realtime_consumption_enabled": {
"name": "Realtidsförbrukning aktiverad"
},
"chart_data_export": {
"name": "Diagramdataexport"
}
}
},

View file

@ -33,7 +33,7 @@ Coming soon...
### Chart Data Export
**Entity ID:** `binary_sensor.tibber_home_NAME_chart_data_export`
**Entity ID:** `sensor.tibber_home_NAME_chart_data_export`
**Default State:** Disabled (must be manually enabled)
> **⚠️ Legacy Feature**: This sensor is maintained for backward compatibility. For new integrations, use the **`tibber_prices.get_chartdata`** service instead, which offers more flexibility and better performance.
@ -45,6 +45,7 @@ This diagnostic sensor provides cached chart-friendly price data that can be con
- **Configurable via Options Flow**: Service parameters can be configured through the integration's options menu (Step 7 of 7)
- **Automatic Updates**: Data refreshes on coordinator updates (every 15 minutes)
- **Attribute-Based Output**: Chart data is stored in sensor attributes for easy access
- **State Indicator**: Shows `pending` (before first call), `ready` (data available), or `error` (service call failed)
**Important Notes:**
@ -54,8 +55,10 @@ This diagnostic sensor provides cached chart-friendly price data that can be con
**Attributes:**
The sensor exposes a single attribute containing the chart data in your configured format:
The sensor exposes chart data with metadata in attributes:
- **`timestamp`**: When the data was last fetched
- **`error`**: Error message if service call failed
- **`data`** (or custom name): Array of price data points in configured format
**Configuration:**
@ -78,7 +81,7 @@ See the `tibber_prices.get_chartdata` service documentation below for a complete
# ApexCharts card consuming the sensor
type: custom:apexcharts-card
series:
- entity: binary_sensor.tibber_home_chart_data_export
- entity: sensor.tibber_home_chart_data_export
data_generator: |
return entity.attributes.data;
```
@ -91,7 +94,7 @@ If you're currently using this sensor, consider migrating to the service:
# Old approach (sensor)
- service: apexcharts_card.update
data:
entity: binary_sensor.tibber_home_chart_data_export
entity: sensor.tibber_home_chart_data_export
# New approach (service)
- service: tibber_prices.get_chartdata

View file

@ -126,7 +126,7 @@ data:
## Migration from Chart Data Export Sensor
If you're currently using the `binary_sensor.tibber_home_chart_data_export` sensor, consider migrating to `tibber_prices.get_chartdata`:
If you're currently using the `sensor.tibber_home_chart_data_export` sensor, consider migrating to `tibber_prices.get_chartdata`:
**Benefits:**