From ef983d0a99a090b12ffeb67b15a804fc4cb477c5 Mon Sep 17 00:00:00 2001 From: Julian Pawlowski Date: Mon, 17 Nov 2025 04:11:10 +0000 Subject: [PATCH] feat(sensor): migrate chart_data_export from binary_sensor to sensor platform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../tibber_prices/binary_sensor/attributes.py | 68 +---- .../tibber_prices/binary_sensor/core.py | 176 ------------ .../binary_sensor/definitions.py | 9 - .../tibber_prices/custom_translations/de.json | 10 +- .../tibber_prices/custom_translations/nb.json | 5 + .../tibber_prices/custom_translations/nl.json | 9 +- .../tibber_prices/custom_translations/sv.json | 5 + .../tibber_prices/sensor/core.py | 269 +++++++++++++----- .../tibber_prices/sensor/definitions.py | 10 + .../tibber_prices/translations/de.json | 11 +- .../tibber_prices/translations/en.json | 11 +- .../tibber_prices/translations/nb.json | 11 +- .../tibber_prices/translations/nl.json | 11 +- .../tibber_prices/translations/sv.json | 11 +- docs/user/sensors.md | 11 +- docs/user/services.md | 2 +- 16 files changed, 280 insertions(+), 349 deletions(-) diff --git a/custom_components/tibber_prices/binary_sensor/attributes.py b/custom_components/tibber_prices/binary_sensor/attributes.py index 91b7574..3d3f855 100644 --- a/custom_components/tibber_prices/binary_sensor/attributes.py +++ b/custom_components/tibber_prices/binary_sensor/attributes.py @@ -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,31 +302,11 @@ 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) + # 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) # Add icon_color for best/peak price period sensors using shared utility add_icon_color_attribute(attributes, entity_key, is_on=is_on) @@ -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,31 +388,11 @@ 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) + # 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) # Add icon_color for best/peak price period sensors using shared utility add_icon_color_attribute(attributes, entity_key, is_on=is_on) @@ -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 diff --git a/custom_components/tibber_prices/binary_sensor/core.py b/custom_components/tibber_prices/binary_sensor/core.py index 3ba9a13..e310c89 100644 --- a/custom_components/tibber_prices/binary_sensor/core.py +++ b/custom_components/tibber_prices/binary_sensor/core.py @@ -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": } - - 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() diff --git a/custom_components/tibber_prices/binary_sensor/definitions.py b/custom_components/tibber_prices/binary_sensor/definitions.py index 4bfd554..84e2f90 100644 --- a/custom_components/tibber_prices/binary_sensor/definitions.py +++ b/custom_components/tibber_prices/binary_sensor/definitions.py @@ -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 - ), ) diff --git a/custom_components/tibber_prices/custom_translations/de.json b/custom_components/tibber_prices/custom_translations/de.json index 3fe9b6a..d4a4afc 100644 --- a/custom_components/tibber_prices/custom_translations/de.json +++ b/custom_components/tibber_prices/custom_translations/de.json @@ -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": { diff --git a/custom_components/tibber_prices/custom_translations/nb.json b/custom_components/tibber_prices/custom_translations/nb.json index 84a0733..ae683af 100644 --- a/custom_components/tibber_prices/custom_translations/nb.json +++ b/custom_components/tibber_prices/custom_translations/nb.json @@ -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": { diff --git a/custom_components/tibber_prices/custom_translations/nl.json b/custom_components/tibber_prices/custom_translations/nl.json index 7c5c82b..0eec2ba 100644 --- a/custom_components/tibber_prices/custom_translations/nl.json +++ b/custom_components/tibber_prices/custom_translations/nl.json @@ -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": { diff --git a/custom_components/tibber_prices/custom_translations/sv.json b/custom_components/tibber_prices/custom_translations/sv.json index ac0def8..1bc1fad 100644 --- a/custom_components/tibber_prices/custom_translations/sv.json +++ b/custom_components/tibber_prices/custom_translations/sv.json @@ -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": { diff --git a/custom_components/tibber_prices/sensor/core.py b/custom_components/tibber_prices/sensor/core.py index 4dc0f78..e36f7e9 100644 --- a/custom_components/tibber_prices/sensor/core.py +++ b/custom_components/tibber_prices/sensor/core.py @@ -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 + def _get_description_attributes(self) -> dict[str, str]: + """Get description/long_description/usage_tips attributes.""" + attributes = {} + + if not self.entity_description.translation_key or not self.hass: + return attributes + + language = self.hass.config.language if self.hass.config.language else "en" + + # 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 + extended_descriptions = self.coordinator.config_entry.options.get( + CONF_EXTENDED_DESCRIPTIONS, + self.coordinator.config_entry.data.get(CONF_EXTENDED_DESCRIPTIONS, DEFAULT_EXTENDED_DESCRIPTIONS), + ) + + if extended_descriptions: + long_desc = get_entity_description( + "sensor", self.entity_description.translation_key, language, "long_description" + ) + if long_desc: + attributes["long_description"] = long_desc + + 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 - async def async_extra_state_attributes(self) -> dict | None: - """Return additional state attributes asynchronously.""" + def extra_state_attributes(self) -> dict[str, Any] | None: + """Return additional state attributes.""" if not self.coordinator.data: return None - attributes = self._get_sensor_attributes() or {} + # 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() - # 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" + # Step 1: Add metadata (timestamp, error) + if chart_attrs: + for key in ("timestamp", "error"): + if key in chart_attrs: + attributes[key] = chart_attrs[key] - # Add basic description - description = await async_get_entity_description( - self.hass, "sensor", self.entity_description.translation_key, language, "description" - ) - if description: - attributes["description"] = description + # Step 2: Add descriptions + description_attrs = self._get_description_attributes() + attributes.update(description_attrs) - # 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), - ) + # 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")}) - # 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" - ) - if long_desc: - attributes["long_description"] = long_desc + return attributes if attributes else None - # Add usage tips if available - usage_tips = await async_get_entity_description( - self.hass, "sensor", self.entity_description.translation_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: - # 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 diff --git a/custom_components/tibber_prices/sensor/definitions.py b/custom_components/tibber_prices/sensor/definitions.py index 18126a2..224eeb4 100644 --- a/custom_components/tibber_prices/sensor/definitions.py +++ b/custom_components/tibber_prices/sensor/definitions.py @@ -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 + ), ) # ---------------------------------------------------------------------------- diff --git a/custom_components/tibber_prices/translations/de.json b/custom_components/tibber_prices/translations/de.json index 86acd54..2fa6a31 100644 --- a/custom_components/tibber_prices/translations/de.json +++ b/custom_components/tibber_prices/translations/de.json @@ -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" } } }, diff --git a/custom_components/tibber_prices/translations/en.json b/custom_components/tibber_prices/translations/en.json index 900cce4..c4c3a5a 100644 --- a/custom_components/tibber_prices/translations/en.json +++ b/custom_components/tibber_prices/translations/en.json @@ -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" } } }, diff --git a/custom_components/tibber_prices/translations/nb.json b/custom_components/tibber_prices/translations/nb.json index c6df791..7c26fab 100644 --- a/custom_components/tibber_prices/translations/nb.json +++ b/custom_components/tibber_prices/translations/nb.json @@ -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" } } }, diff --git a/custom_components/tibber_prices/translations/nl.json b/custom_components/tibber_prices/translations/nl.json index ee1491c..f197fbb 100644 --- a/custom_components/tibber_prices/translations/nl.json +++ b/custom_components/tibber_prices/translations/nl.json @@ -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" } } }, diff --git a/custom_components/tibber_prices/translations/sv.json b/custom_components/tibber_prices/translations/sv.json index 58ed67a..bb8b4bc 100644 --- a/custom_components/tibber_prices/translations/sv.json +++ b/custom_components/tibber_prices/translations/sv.json @@ -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" } } }, diff --git a/docs/user/sensors.md b/docs/user/sensors.md index 08a64d1..5612d18 100644 --- a/docs/user/sensors.md +++ b/docs/user/sensors.md @@ -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 diff --git a/docs/user/services.md b/docs/user/services.md index 44d0676..bc23c74 100644 --- a/docs/user/services.md +++ b/docs/user/services.md @@ -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:**