hass.tibber_prices/custom_components/tibber_prices/services/get_apexcharts_yaml.py
Julian Pawlowski 6922e52368 feat(sensors): add chart_metadata sensor for lightweight chart configuration
Implemented new chart_metadata diagnostic sensor that provides essential
chart configuration values (yaxis_min, yaxis_max, gradient_stop) as
attributes, enabling dynamic chart configuration without requiring
async service calls in templates.

Sensor implementation:
- New chart_metadata.py module with metadata-only service calls
- Automatically calls get_chartdata with metadata="only" parameter
- Refreshes on coordinator updates (new price data or user data)
- State values: "pending", "ready", "error"
- Enabled by default (critical for chart features)

ApexCharts YAML generator integration:
- Checks for chart_metadata sensor availability before generation
- Uses template variables to read sensor attributes dynamically
- Fallback to fixed values (gradient_stop=50%) if sensor unavailable
- Creates separate notifications for two independent issues:
  1. Chart metadata sensor disabled (reduced functionality warning)
  2. Required custom cards missing (YAML won't work warning)
- Both notifications explain YAML generation context and provide
  complete fix instructions with regeneration requirement

Configuration:
- Supports configuration.yaml: tibber_prices.chart_metadata_config
- Optional parameters: day, minor_currency, resolution
- Defaults to minor_currency=True for ApexCharts compatibility

Translation additions:
- Entity name and state translations (all 5 languages)
- Notification messages for sensor unavailable and missing cards
- best_price_period_name for tooltip formatter

Binary sensor improvements:
- tomorrow_data_available now enabled by default (critical for automations)
- data_lifecycle_status now enabled by default (critical for debugging)

Impact: Users get dynamic chart configuration with optimized Y-axis scaling
and gradient positioning without manual calculations. ApexCharts YAML
generation now provides clear, actionable notifications when issues occur,
ensuring users understand why functionality is limited and how to fix it.
2025-12-05 20:30:54 +00:00

979 lines
42 KiB
Python

"""
ApexCharts YAML generation service handler.
This module implements the `get_apexcharts_yaml` service, which generates
ready-to-use YAML configuration for ApexCharts cards with price level visualization.
Features:
- Automatic color-coded series per price level/rating
- Server-side NULL insertion for clean gaps
- Translated level names and titles
- Responsive to user language settings
- Configurable day selection (yesterday/today/tomorrow)
Service: tibber_prices.get_apexcharts_yaml
Response: YAML configuration dict for ApexCharts card
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Final
import voluptuous as vol
from custom_components.tibber_prices.const import (
DOMAIN,
PRICE_LEVEL_CHEAP,
PRICE_LEVEL_EXPENSIVE,
PRICE_LEVEL_NORMAL,
PRICE_LEVEL_VERY_CHEAP,
PRICE_LEVEL_VERY_EXPENSIVE,
PRICE_RATING_HIGH,
PRICE_RATING_LOW,
PRICE_RATING_NORMAL,
format_price_unit_minor,
get_translation,
)
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_registry import (
EntityRegistry,
)
from homeassistant.helpers.entity_registry import (
async_get as async_get_entity_registry,
)
from .formatters import get_level_translation
from .helpers import get_entry_and_data
if TYPE_CHECKING:
from homeassistant.core import ServiceCall
# Service constants
APEXCHARTS_YAML_SERVICE_NAME: Final = "get_apexcharts_yaml"
ATTR_DAY: Final = "day"
ATTR_ENTRY_ID: Final = "entry_id"
# Service schema
APEXCHARTS_SERVICE_SCHEMA = vol.Schema(
{
vol.Required(ATTR_ENTRY_ID): cv.string,
vol.Optional("day"): vol.In(["yesterday", "today", "tomorrow", "rolling_window", "rolling_window_autozoom"]),
vol.Optional("level_type", default="rating_level"): vol.In(["rating_level", "level"]),
vol.Optional("highlight_best_price", default=True): cv.boolean,
}
)
def _build_entity_map(
entity_registry: EntityRegistry,
entry_id: str,
level_type: str,
day: str,
) -> dict[str, str]:
"""
Build entity mapping for price levels based on day.
Maps price levels to appropriate sensor entities (min/max/avg for the selected day).
Args:
entity_registry: Entity registry
entry_id: Config entry ID
level_type: "rating_level" or "level"
day: "today", "tomorrow", or "yesterday"
Returns:
Dictionary mapping level keys to entity IDs
"""
entity_map = {}
# Define mapping patterns for each combination of level_type and day
# Note: Match by entity key (in unique_id), not entity_id (user can rename)
# Note: For "yesterday", we use "today" sensors as they show current state
# Note: For "yesterday_today_tomorrow" and "today_tomorrow", we use "today" sensors (dynamic windows)
pattern_map = {
("rating_level", "today"): [
("lowest_price_today", [PRICE_RATING_LOW]),
("average_price_today", [PRICE_RATING_NORMAL]),
("highest_price_today", [PRICE_RATING_HIGH]),
],
("rating_level", "yesterday"): [
("lowest_price_today", [PRICE_RATING_LOW]),
("average_price_today", [PRICE_RATING_NORMAL]),
("highest_price_today", [PRICE_RATING_HIGH]),
],
("rating_level", "tomorrow"): [
("lowest_price_tomorrow", [PRICE_RATING_LOW]),
("average_price_tomorrow", [PRICE_RATING_NORMAL]),
("highest_price_tomorrow", [PRICE_RATING_HIGH]),
],
("rating_level", "rolling_window"): [
("lowest_price_today", [PRICE_RATING_LOW]),
("average_price_today", [PRICE_RATING_NORMAL]),
("highest_price_today", [PRICE_RATING_HIGH]),
],
("rating_level", "rolling_window_autozoom"): [
("lowest_price_today", [PRICE_RATING_LOW]),
("average_price_today", [PRICE_RATING_NORMAL]),
("highest_price_today", [PRICE_RATING_HIGH]),
],
("level", "today"): [
("lowest_price_today", [PRICE_LEVEL_VERY_CHEAP, PRICE_LEVEL_CHEAP]),
("average_price_today", [PRICE_LEVEL_NORMAL]),
("highest_price_today", [PRICE_LEVEL_EXPENSIVE, PRICE_LEVEL_VERY_EXPENSIVE]),
],
("level", "yesterday"): [
("lowest_price_today", [PRICE_LEVEL_VERY_CHEAP, PRICE_LEVEL_CHEAP]),
("average_price_today", [PRICE_LEVEL_NORMAL]),
("highest_price_today", [PRICE_LEVEL_EXPENSIVE, PRICE_LEVEL_VERY_EXPENSIVE]),
],
("level", "tomorrow"): [
("lowest_price_tomorrow", [PRICE_LEVEL_VERY_CHEAP, PRICE_LEVEL_CHEAP]),
("average_price_tomorrow", [PRICE_LEVEL_NORMAL]),
("highest_price_tomorrow", [PRICE_LEVEL_EXPENSIVE, PRICE_LEVEL_VERY_EXPENSIVE]),
],
("level", "rolling_window"): [
("lowest_price_today", [PRICE_LEVEL_VERY_CHEAP, PRICE_LEVEL_CHEAP]),
("average_price_today", [PRICE_LEVEL_NORMAL]),
("highest_price_today", [PRICE_LEVEL_EXPENSIVE, PRICE_LEVEL_VERY_EXPENSIVE]),
],
("level", "rolling_window_autozoom"): [
("lowest_price_today", [PRICE_LEVEL_VERY_CHEAP, PRICE_LEVEL_CHEAP]),
("average_price_today", [PRICE_LEVEL_NORMAL]),
("highest_price_today", [PRICE_LEVEL_EXPENSIVE, PRICE_LEVEL_VERY_EXPENSIVE]),
],
}
patterns = pattern_map.get((level_type, day), [])
for entity in entity_registry.entities.values():
if entity.config_entry_id != entry_id or entity.domain != "sensor":
continue
# Match entity against patterns using unique_id (contains entry_id_key)
# Extract key from unique_id: format is "{entry_id}_{key}"
if entity.unique_id and "_" in entity.unique_id:
entity_key = entity.unique_id.split("_", 1)[1] # Get everything after first underscore
for pattern, levels in patterns:
if pattern == entity_key:
for level in levels:
entity_map[level] = entity.entity_id
break
return entity_map
def _get_current_price_entity(entity_registry: EntityRegistry, entry_id: str) -> str | None:
"""Get current interval price entity for header display."""
return next(
(
entity.entity_id
for entity in entity_registry.entities.values()
if entity.config_entry_id == entry_id
and entity.unique_id
and entity.unique_id.endswith("_current_interval_price")
),
None,
)
def _check_custom_cards_installed(hass: Any) -> dict[str, bool]:
"""
Check if required custom cards are installed via HACS/Lovelace resources.
Args:
hass: Home Assistant instance
Returns:
Dictionary with card names as keys and installation status as bool values
"""
installed_cards = {"apexcharts-card": False, "config-template-card": False}
# Access Lovelace resources via the new API (2026.2+)
lovelace_data = hass.data.get("lovelace")
if lovelace_data and hasattr(lovelace_data, "resources"):
try:
# ResourceStorageCollection has async_items() method
resources = lovelace_data.resources
if hasattr(resources, "async_items") and hasattr(resources, "data") and isinstance(resources.data, dict):
# Can't use await here, so we check the internal storage
for resource in resources.data.values():
url = resource.get("url", "") if isinstance(resource, dict) else ""
if "apexcharts-card" in url:
installed_cards["apexcharts-card"] = True
if "config-template-card" in url:
installed_cards["config-template-card"] = True
except (AttributeError, TypeError):
# Fallback: can't determine, assume not installed
pass
return installed_cards
def _get_sensor_disabled_notification(language: str) -> dict[str, str]:
"""Get notification texts for disabled chart metadata sensor."""
title = get_translation(["apexcharts", "notification", "metadata_sensor_unavailable", "title"], language)
message = get_translation(["apexcharts", "notification", "metadata_sensor_unavailable", "message"], language)
if not title:
title = get_translation(["apexcharts", "notification", "metadata_sensor_unavailable", "title"], "en")
if not message:
message = get_translation(["apexcharts", "notification", "metadata_sensor_unavailable", "message"], "en")
if not title:
title = "Tibber Prices: Chart Metadata Sensor Disabled"
if not message:
message = (
"The Chart Metadata sensor is currently disabled. "
"Enable it to get optimized chart scaling and gradient colors.\n\n"
"[Open Tibber Prices Integration](https://my.home-assistant.io/redirect/integration/?domain=tibber_prices)\n\n"
"After enabling the sensor, regenerate the ApexCharts YAML."
)
return {"title": title, "message": message}
def _get_missing_cards_notification(language: str, missing_cards: list[str]) -> dict[str, str]:
"""Get notification texts for missing custom cards."""
title = get_translation(["apexcharts", "notification", "missing_cards", "title"], language)
message = get_translation(["apexcharts", "notification", "missing_cards", "message"], language)
if not title:
title = get_translation(["apexcharts", "notification", "missing_cards", "title"], "en")
if not message:
message = get_translation(["apexcharts", "notification", "missing_cards", "message"], "en")
if not title:
title = "Tibber Prices: Missing Custom Cards"
if not message:
message = (
"The following custom cards are required but not installed:\n"
"{cards}\n\n"
"Please click the links above to install them from HACS."
)
# Replace {cards} placeholder
cards_list = "\n".join(missing_cards)
message = message.replace("{cards}", cards_list)
return {"title": title, "message": message}
async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]: # noqa: PLR0912, PLR0915, C901
"""
Return YAML snippet for ApexCharts card.
Generates a complete ApexCharts card configuration with:
- Separate series for each price level/rating (color-coded)
- Automatic data fetching via get_chartdata service
- Translated labels and titles
- Clean gap visualization with NULL insertion
See services.yaml for detailed parameter documentation.
Args:
call: Service call with parameters
Returns:
Dictionary with ApexCharts card configuration
Raises:
ServiceValidationError: If entry_id is missing or invalid
"""
hass = call.hass
entry_id_raw = call.data.get(ATTR_ENTRY_ID)
if entry_id_raw is None:
raise ServiceValidationError(translation_domain=DOMAIN, translation_key="missing_entry_id")
entry_id: str = str(entry_id_raw)
day = call.data.get("day") # Can be None (rolling window mode)
level_type = call.data.get("level_type", "rating_level")
highlight_best_price = call.data.get("highlight_best_price", True)
# Get user's language from hass config
user_language = hass.config.language or "en"
# Get coordinator to access price data (for currency)
_, coordinator, _ = get_entry_and_data(hass, entry_id)
# Get currency from coordinator data
currency = coordinator.data.get("currency", "EUR")
price_unit = format_price_unit_minor(currency)
# Get entity registry for mapping
entity_registry = async_get_entity_registry(hass)
# Build entity mapping based on level_type and day for clickable states
# When day is None, use "today" as fallback for entity mapping
entity_map = _build_entity_map(entity_registry, entry_id, level_type, day or "today")
if level_type == "rating_level":
series_levels = [
(PRICE_RATING_LOW, "#2ecc71"),
(PRICE_RATING_NORMAL, "#f1c40f"),
(PRICE_RATING_HIGH, "#e74c3c"),
]
else:
series_levels = [
(PRICE_LEVEL_VERY_CHEAP, "#2ecc71"),
(PRICE_LEVEL_CHEAP, "#27ae60"),
(PRICE_LEVEL_NORMAL, "#f1c40f"),
(PRICE_LEVEL_EXPENSIVE, "#e67e22"),
(PRICE_LEVEL_VERY_EXPENSIVE, "#e74c3c"),
]
series = []
# Only create series for levels that have a matching entity (filter out missing levels)
for level_key, color in series_levels:
# Skip levels that don't have a corresponding sensor
if level_key not in entity_map:
continue
# Get translated name for the level using helper function
name = get_level_translation(level_key, level_type, user_language)
# Use server-side insert_nulls='segments' for clean gaps
if level_type == "rating_level":
filter_param = f"rating_level_filter: ['{level_key}']"
else:
filter_param = f"level_filter: ['{level_key}']"
# Conditionally include day parameter (omit for rolling window mode)
# For rolling_window and rolling_window_autozoom, omit day parameter (dynamic selection)
day_param = "" if day in ("rolling_window", "rolling_window_autozoom", None) else f"day: ['{day}'], "
# For rolling window modes, we'll capture metadata for dynamic config
# For static day modes, just return data array
if day in ("rolling_window", "rolling_window_autozoom", None):
data_generator = (
f"const response = await hass.callWS({{ "
f"type: 'call_service', "
f"domain: 'tibber_prices', "
f"service: 'get_chartdata', "
f"return_response: true, "
f"service_data: {{ entry_id: '{entry_id}', {day_param}{filter_param}, "
f"output_format: 'array_of_arrays', insert_nulls: 'segments', minor_currency: true, "
f"connect_segments: true }} }}); "
f"return response.response.data;"
)
else:
# Static day modes: just return data (no metadata needed)
data_generator = (
f"const response = await hass.callWS({{ "
f"type: 'call_service', "
f"domain: 'tibber_prices', "
f"service: 'get_chartdata', "
f"return_response: true, "
f"service_data: {{ entry_id: '{entry_id}', {day_param}{filter_param}, "
f"output_format: 'array_of_arrays', insert_nulls: 'segments', minor_currency: true, "
f"connect_segments: true }} }}); "
f"return response.response.data;"
)
# Configure show options based on level_type and level_key
# rating_level LOW/HIGH: Show raw state in header (entity state = min/max price of day)
# rating_level NORMAL: Hide from header (not meaningful as extrema)
# level (VERY_CHEAP/CHEAP/etc): Hide from header (entity state is aggregated value)
if level_type == "rating_level" and level_key in (PRICE_RATING_LOW, PRICE_RATING_HIGH):
show_config = {"legend_value": False, "in_header": "raw"}
else:
show_config = {"legend_value": False, "in_header": False}
series.append(
{
"entity": entity_map[level_key], # Use entity_map directly (no fallback needed)
"name": name,
"type": "area",
"color": color,
"yaxis_id": "price",
"show": show_config,
"data_generator": data_generator,
"stroke_width": 1.5,
}
)
# Note: Extrema markers don't work with data_generator approach
# ApexCharts card requires direct entity data for extremas feature, not dynamically generated data
# Get translated name for best price periods (needed for tooltip formatter)
best_price_name = get_translation(["apexcharts", "best_price_period_name"], user_language) or "Best Price Period"
# Add best price period highlight overlay (vertical bands from top to bottom)
if highlight_best_price and entity_map:
# Create vertical highlight bands using separate Y-axis (0-1 range)
# This creates a semi-transparent overlay from bottom to top without affecting price scale
# Conditionally include day parameter (omit for rolling window mode)
# For rolling_window and rolling_window_autozoom, omit day parameter (dynamic selection)
day_param = "" if day in ("rolling_window", "rolling_window_autozoom", None) else f"day: ['{day}'], "
# Store original prices for tooltip, but map to 1 for full-height overlay
# We use a custom tooltip formatter to show the real price
best_price_generator = (
f"const response = await hass.callWS({{ "
f"type: 'call_service', "
f"domain: 'tibber_prices', "
f"service: 'get_chartdata', "
f"return_response: true, "
f"service_data: {{ entry_id: '{entry_id}', {day_param}"
f"period_filter: 'best_price', "
f"output_format: 'array_of_arrays', insert_nulls: 'segments', minor_currency: true }} }}); "
f"const originalData = response.response.data; "
f"return originalData.map((point, i) => {{ "
f"const result = [point[0], point[1] === null ? null : 1]; "
f"result.originalPrice = point[1]; "
f"return result; "
f"}});"
)
# Use first entity from entity_map (reuse existing entity to avoid extra header entries)
best_price_entity = next(iter(entity_map.values()))
series.append(
{
"entity": best_price_entity,
"name": best_price_name,
"type": "area",
"color": "rgba(46, 204, 113, 0.05)", # Ultra-subtle green overlay (barely visible)
"yaxis_id": "highlight", # Use separate Y-axis (0-1) for full-height overlay
"show": {"legend_value": False, "in_header": False, "in_legend": False},
"data_generator": best_price_generator,
"stroke_width": 0,
}
)
# Get translated title based on level_type
title_key = "title_rating_level" if level_type == "rating_level" else "title_level"
title = get_translation(["apexcharts", title_key], user_language) or (
"Price Phases Daily Progress" if level_type == "rating_level" else "Price Level"
)
# Add translated day to title (only for fixed day views, not for dynamic modes)
if day and day not in ("rolling_window", "rolling_window_autozoom"):
day_translated = get_translation(["selector", "day", "options", day], user_language) or day.capitalize()
title = f"{title} - {day_translated}"
# Configure span based on selected day
# For rolling window modes, use config-template-card for dynamic config
if day == "yesterday":
span_config = {"start": "day", "offset": "-1d"}
graph_span_value = None
use_template = False
elif day == "tomorrow":
span_config = {"start": "day", "offset": "+1d"}
graph_span_value = None
use_template = False
elif day == "rolling_window":
# Rolling 48h window: yesterday+today OR today+tomorrow (shifts at 13:00)
span_config = None # Will be set in template
graph_span_value = "48h"
use_template = True
elif day == "rolling_window_autozoom":
# Rolling 48h window with auto-zoom: yesterday+today OR today+tomorrow (shifts at 13:00)
# Auto-zooms based on current time (2h lookback + remaining time)
span_config = None # Will be set in template
graph_span_value = None # Will be set in template
use_template = True
elif day: # today (explicit)
span_config = {"start": "day"}
graph_span_value = None
use_template = False
else: # Rolling window mode (None - same as rolling_window)
# Use config-template-card to dynamically set offset based on data availability
span_config = None # Will be set in template
graph_span_value = "48h"
use_template = True
result = {
"type": "custom:apexcharts-card",
"update_interval": "5m",
"header": {
"show": True,
"title": title,
"show_states": False,
},
"apex_config": {
"chart": {
"animations": {"enabled": False},
"toolbar": {"show": True, "tools": {"zoom": True, "pan": True}},
"zoom": {"enabled": True},
},
"stroke": {"curve": "stepline"},
"fill": {
"type": "gradient",
"opacity": 0.45,
"gradient": {
"shade": "light",
"type": "vertical",
"shadeIntensity": 0.2,
"opacityFrom": 0.7,
"opacityTo": 0.25,
},
},
"dataLabels": {"enabled": False},
"tooltip": {
"x": {"format": "HH:mm"},
"y": {"title": {"formatter": f"function() {{ return '{price_unit}'; }}"}},
},
"legend": {
"show": False,
"position": "bottom",
"horizontalAlign": "center",
},
"grid": {
"show": True,
"borderColor": "#f5f5f5",
"strokeDashArray": 0,
"xaxis": {"lines": {"show": False}},
"yaxis": {"lines": {"show": True}},
},
"markers": {
"size": 0, # No markers on data points
"hover": {"size": 2}, # Show marker only on hover
"strokeWidth": 1,
},
},
"yaxis": [
{
"id": "price",
"decimals": 2,
"min": 0,
"apex_config": {"title": {"text": price_unit}},
},
{
"id": "highlight",
"min": 0,
"max": 1,
"show": False, # Hide this axis (only for highlight overlay)
"opposite": True,
},
],
"now": (
{"show": True, "color": "#8e24aa"}
if day == "rolling_window_autozoom"
else {"show": True, "color": "#8e24aa", "label": "🕒 LIVE"}
),
"series": series,
}
# For rolling window mode and today_tomorrow, wrap in config-template-card for dynamic config
if use_template:
# Find tomorrow_data_available binary sensor
tomorrow_data_sensor = next(
(
entity.entity_id
for entity in entity_registry.entities.values()
if entity.config_entry_id == entry_id
and entity.unique_id
and entity.unique_id.endswith("_tomorrow_data_available")
),
None,
)
if tomorrow_data_sensor:
if day == "rolling_window_autozoom":
# rolling_window_autozoom mode: Dynamic graph_span with auto-zoom
# Shows last 120 min (8 intervals) + remaining minutes until end of time window
# Auto-zooms every 15 minutes when current interval completes
# When tomorrow data arrives after 13:00, extends to show tomorrow too
#
# Key principle: graph_span must always be divisible by 15 (full intervals)
# The current (running) interval stays included until it completes
#
# Calculation:
# 1. Round current time UP to next quarter-hour (include running interval)
# 2. Calculate minutes from end of running interval to midnight
# 3. Round to ensure full 15-minute intervals
# 4. Add 120min lookback (always 8 intervals)
# 5. If tomorrow data available: add 1440min (96 intervals)
#
# Example timeline (without tomorrow data):
# 08:00 → next quarter: 08:15 → to midnight: 945min → span: 120+945 = 1065min (71 intervals)
# 08:07 → next quarter: 08:15 → to midnight: 945min → span: 120+945 = 1065min (stays same)
# 08:15 → next quarter: 08:30 → to midnight: 930min → span: 120+930 = 1050min (70 intervals)
# 14:23 → next quarter: 14:30 → to midnight: 570min → span: 120+570 = 690min (46 intervals)
#
# After 13:00 with tomorrow data:
# 14:00 → next quarter: 14:15 → to midnight: 585min → span: 120+585+1440 = 2145min (143 intervals)
# 14:15 → next quarter: 14:30 → to midnight: 570min → span: 120+570+1440 = 2130min (142 intervals)
template_graph_span = (
f"const now = new Date(); "
f"const currentMinute = now.getMinutes(); "
f"const nextQuarterMinute = Math.ceil(currentMinute / 15) * 15; "
f"const currentIntervalEnd = new Date(now); "
f"if (nextQuarterMinute === 60) {{ "
f" currentIntervalEnd.setHours(now.getHours() + 1, 0, 0, 0); "
f"}} else {{ "
f" currentIntervalEnd.setMinutes(nextQuarterMinute, 0, 0); "
f"}} "
f"const midnight = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 0, 0, 0); "
f"const minutesFromIntervalEndToMidnight = Math.ceil((midnight - currentIntervalEnd) / 60000); "
f"const minutesRounded = Math.ceil(minutesFromIntervalEndToMidnight / 15) * 15; "
f"const lookback = 120; "
f"const hasTomorrowData = states['{tomorrow_data_sensor}'].state === 'on'; "
f"const totalMinutes = lookback + minutesRounded + (hasTomorrowData ? 1440 : 0); "
f"totalMinutes + 'min';"
)
# Find current_interval_price sensor for 15-minute update trigger
current_price_sensor = next(
(
entity.entity_id
for entity in entity_registry.entities.values()
if entity.config_entry_id == entry_id
and entity.unique_id
and entity.unique_id.endswith("_current_interval_price")
),
None,
)
trigger_entities = [tomorrow_data_sensor]
if current_price_sensor:
trigger_entities.append(current_price_sensor)
# Get metadata from chart_metadata sensor (preferred) or static fallback
# The chart_metadata sensor provides yaxis_min, yaxis_max, and gradient_stop
# as attributes, avoiding the need for async service calls in templates
chart_metadata_sensor = next(
(
entity.entity_id
for entity in entity_registry.entities.values()
if entity.config_entry_id == entry_id
and entity.unique_id
and entity.unique_id.endswith("_chart_metadata")
),
None,
)
# Track warning if sensor not available
metadata_warning = None
use_sensor_metadata = False
# Check if sensor exists and is ready
if chart_metadata_sensor:
metadata_state = hass.states.get(chart_metadata_sensor)
if metadata_state and metadata_state.state == "ready":
# Sensor ready - will use template variables
use_sensor_metadata = True
else:
# Sensor not ready - will show notification
metadata_warning = True
else:
# Sensor not found - will show notification
metadata_warning = True
# Set fallback values if sensor not used
if not use_sensor_metadata:
gradient_stop = 50
# Build yaxis config (only include min/max if not None)
yaxis_price_config = {
"id": "price",
"decimals": 2,
"apex_config": {
"title": {"text": price_unit},
"decimalsInFloat": 0,
"forceNiceScale": True,
},
}
gradient_stops = [gradient_stop, 100]
entities_list = trigger_entities
else:
# Use template variables to read sensor dynamically
# Add chart_metadata sensor to entities list
entities_list = [*trigger_entities, chart_metadata_sensor]
# Build yaxis config with template variables
yaxis_price_config = {
"id": "price",
"decimals": 2,
"min": "${v_yaxis_min}",
"max": "${v_yaxis_max}",
"apex_config": {
"title": {"text": price_unit},
"decimalsInFloat": 0,
"forceNiceScale": False,
},
}
gradient_stops = ["${v_gradient_stop}", 100]
# Build variables dict
variables_dict = {"v_graph_span": template_graph_span}
if use_sensor_metadata:
# Add dynamic metadata variables from sensor
variables_dict.update(
{
"v_yaxis_min": f"states['{chart_metadata_sensor}'].attributes.yaxis_min",
"v_yaxis_max": f"states['{chart_metadata_sensor}'].attributes.yaxis_max",
"v_gradient_stop": f"states['{chart_metadata_sensor}'].attributes.gradient_stop",
}
)
result_dict = {
"type": "custom:config-template-card",
"variables": variables_dict,
"entities": entities_list,
"card": {
**result,
"span": {"start": "minute", "offset": "-120min"},
"graph_span": "${v_graph_span}",
"yaxis": [
yaxis_price_config,
{
"id": "highlight",
"min": 0,
"max": 1,
"show": False,
"opposite": True,
},
],
"apex_config": {
**result["apex_config"],
"fill": {
"type": "gradient",
"opacity": 0.45,
"gradient": {
"shade": "light",
"type": "vertical",
"shadeIntensity": 0.2,
"opacityFrom": 0.7,
"opacityTo": 0.25,
"gradientToColors": ["#transparent"],
"stops": gradient_stops,
},
},
},
},
}
# Create separate notifications for different issues
if metadata_warning:
# Notification 1: Chart Metadata Sensor disabled
notification_texts = _get_sensor_disabled_notification(user_language)
await hass.services.async_call(
"persistent_notification",
"create",
{
"message": notification_texts["message"],
"title": notification_texts["title"],
"notification_id": f"tibber_prices_chart_metadata_{entry_id}",
},
)
# Check which custom cards are installed (always check, independent of sensor state)
installed_cards = _check_custom_cards_installed(hass)
missing_cards = [
"[apexcharts-card](https://my.home-assistant.io/redirect/hacs_repository/?owner=RomRider&repository=apexcharts-card)"
if not installed_cards["apexcharts-card"]
else None,
"[config-template-card](https://my.home-assistant.io/redirect/hacs_repository/?owner=iantrich&repository=config-template-card)"
if not installed_cards["config-template-card"]
else None,
]
missing_cards = [card for card in missing_cards if card] # Filter out None
if missing_cards:
# Notification 2: Missing Custom Cards
notification_texts = _get_missing_cards_notification(user_language, missing_cards)
await hass.services.async_call(
"persistent_notification",
"create",
{
"message": notification_texts["message"],
"title": notification_texts["title"],
"notification_id": f"tibber_prices_missing_cards_{entry_id}",
},
)
return result_dict
# Rolling window modes (day is None or rolling_window): Dynamic offset
# Add graph_span to base config (48h window)
result["graph_span"] = graph_span_value
# Wrap in config-template-card with dynamic offset calculation
# Template checks if tomorrow data is available (binary sensor state)
# If 'on' (tomorrow data available) → offset +1d (show today+tomorrow)
# If 'off' (no tomorrow data) → offset +0d (show yesterday+today)
template_value = f"states['{tomorrow_data_sensor}'].state === 'on' ? '+1d' : '+0d'"
# Get metadata from chart_metadata sensor (preferred) or static fallback
# The chart_metadata sensor provides yaxis_min, yaxis_max, and gradient_stop
# as attributes, avoiding the need for async service calls in templates
chart_metadata_sensor = next(
(
entity.entity_id
for entity in entity_registry.entities.values()
if entity.config_entry_id == entry_id
and entity.unique_id
and entity.unique_id.endswith("_chart_metadata")
),
None,
)
# Track warning if sensor not available
metadata_warning = None
use_sensor_metadata = False
# Check if sensor exists and is ready
if chart_metadata_sensor:
metadata_state = hass.states.get(chart_metadata_sensor)
if metadata_state and metadata_state.state == "ready":
# Sensor ready - will use template variables
use_sensor_metadata = True
else:
# Sensor not ready - will show notification
metadata_warning = True
else:
# Sensor not found - will show notification
metadata_warning = True
# Set fallback values if sensor not used
if not use_sensor_metadata:
gradient_stop = 50
# Build yaxis config (only include min/max if not None)
yaxis_price_config = {
"id": "price",
"decimals": 2,
"apex_config": {
"title": {"text": price_unit},
"decimalsInFloat": 0,
"forceNiceScale": True,
},
}
gradient_stops = [gradient_stop, 100]
entities_list = [tomorrow_data_sensor]
else:
# Use template variables to read sensor dynamically
# Add chart_metadata sensor to entities list
entities_list = [tomorrow_data_sensor, chart_metadata_sensor]
# Build yaxis config with template variables
yaxis_price_config = {
"id": "price",
"decimals": 2,
"min": "${v_yaxis_min}",
"max": "${v_yaxis_max}",
"apex_config": {
"title": {"text": price_unit},
"decimalsInFloat": 0,
"forceNiceScale": False,
},
}
gradient_stops = ["${v_gradient_stop}", 100]
# Build variables dict
variables_dict = {"v_offset": template_value}
if use_sensor_metadata:
# Add dynamic metadata variables from sensor
variables_dict.update(
{
"v_yaxis_min": f"states['{chart_metadata_sensor}'].attributes.yaxis_min",
"v_yaxis_max": f"states['{chart_metadata_sensor}'].attributes.yaxis_max",
"v_gradient_stop": f"states['{chart_metadata_sensor}'].attributes.gradient_stop",
}
)
result_dict = {
"type": "custom:config-template-card",
"variables": variables_dict,
"entities": entities_list,
"card": {
**result,
"span": {
"end": "day",
"offset": "${v_offset}",
},
"yaxis": [
yaxis_price_config,
{
"id": "highlight",
"min": 0,
"max": 1,
"show": False,
"opposite": True,
},
],
"apex_config": {
**result["apex_config"],
"fill": {
"type": "gradient",
"opacity": 0.45,
"gradient": {
"shade": "light",
"type": "vertical",
"shadeIntensity": 0.2,
"opacityFrom": 0.7,
"opacityTo": 0.25,
"gradientToColors": ["#transparent"],
"stops": gradient_stops,
},
},
},
},
}
# Create separate notifications for different issues
if metadata_warning:
# Notification 1: Chart Metadata Sensor disabled
notification_texts = _get_sensor_disabled_notification(user_language)
await hass.services.async_call(
"persistent_notification",
"create",
{
"message": notification_texts["message"],
"title": notification_texts["title"],
"notification_id": f"tibber_prices_chart_metadata_{entry_id}",
},
)
# Check which custom cards are installed (always check, independent of sensor state)
installed_cards = _check_custom_cards_installed(hass)
missing_cards = [
"[apexcharts-card](https://my.home-assistant.io/redirect/hacs_repository/?owner=RomRider&repository=apexcharts-card)"
if not installed_cards["apexcharts-card"]
else None,
"[config-template-card](https://my.home-assistant.io/redirect/hacs_repository/?owner=iantrich&repository=config-template-card)"
if not installed_cards["config-template-card"]
else None,
]
missing_cards = [card for card in missing_cards if card] # Filter out None
if missing_cards:
# Notification 2: Missing Custom Cards
notification_texts = _get_missing_cards_notification(user_language, missing_cards)
await hass.services.async_call(
"persistent_notification",
"create",
{
"message": notification_texts["message"],
"title": notification_texts["title"],
"notification_id": f"tibber_prices_missing_cards_{entry_id}",
},
)
return result_dict
# Fallback if sensor not found
if day == "rolling_window_autozoom":
# Fallback: show today with 24h span
result["span"] = {"start": "day"}
result["graph_span"] = "24h"
else:
# Rolling window fallback (rolling_window or None): just use +1d offset
result["span"] = {"end": "day", "offset": "+1d"}
result["graph_span"] = "48h"
return result
# Add span for fixed-day views
if span_config:
result["span"] = span_config
# Add graph_span if needed
if graph_span_value:
result["graph_span"] = graph_span_value
return result