hass.tibber_prices/custom_components/tibber_prices/services/get_apexcharts_yaml.py
Julian Pawlowski 60e05e0815 refactor(currency)!: rename major/minor to base/subunit currency terminology
Complete terminology migration from confusing "major/minor" to clearer
"base/subunit" currency naming throughout entire codebase, translations,
documentation, tests, and services.

BREAKING CHANGES:

1. **Service API Parameters Renamed**:
   - `get_chartdata`: `minor_currency` → `subunit_currency`
   - `get_apexcharts_yaml`: Updated service_data references from
     `minor_currency: true` to `subunit_currency: true`
   - All automations/scripts using these parameters MUST be updated

2. **Configuration Option Key Changed**:
   - Config entry option: Display mode setting now uses new terminology
   - Internal key: `currency_display_mode` values remain "base"/"subunit"
   - User-facing labels updated in all 5 languages (de, en, nb, nl, sv)

3. **Sensor Entity Key Renamed**:
   - `current_interval_price_major` → `current_interval_price_base`
   - Entity ID changes: `sensor.tibber_home_current_interval_price_major`
     → `sensor.tibber_home_current_interval_price_base`
   - Energy Dashboard configurations MUST update entity references

4. **Function Signatures Changed**:
   - `format_price_unit_major()` → `format_price_unit_base()`
   - `format_price_unit_minor()` → `format_price_unit_subunit()`
   - `get_price_value()`: Parameter `in_euro` deprecated in favor of
     `config_entry` (backward compatible for now)

5. **Translation Keys Renamed**:
   - All language files: Sensor translation key
     `current_interval_price_major` → `current_interval_price_base`
   - Service parameter descriptions updated in all languages
   - Selector options updated: Display mode dropdown values

Changes by Category:

**Core Code (Python)**:
- const.py: Renamed all format_price_unit_*() functions, updated docstrings
- entity_utils/helpers.py: Updated get_price_value() with config-driven
  conversion and backward-compatible in_euro parameter
- sensor/__init__.py: Added display mode filtering for base currency sensor
- sensor/core.py:
  * Implemented suggested_display_precision property for dynamic decimal places
  * Updated native_unit_of_measurement to use get_display_unit_string()
  * Updated all price conversion calls to use config_entry parameter
- sensor/definitions.py: Renamed entity key and updated all
  suggested_display_precision values (2 decimals for most sensors)
- sensor/calculators/*.py: Updated all price conversion calls (8 calculators)
- sensor/helpers.py: Updated aggregate_price_data() signature with config_entry
- sensor/attributes/future.py: Updated future price attributes conversion

**Services**:
- services/chartdata.py: Renamed parameter minor_currency → subunit_currency
  throughout (53 occurrences), updated metadata calculation
- services/apexcharts.py: Updated service_data references in generated YAML
- services/formatters.py: Renamed parameter use_minor_currency →
  use_subunit_currency in aggregate_hourly_exact() and get_period_data()
- sensor/chart_metadata.py: Updated default parameter name

**Translations (5 Languages)**:
- All /translations/*.json:
  * Added new config step "display_settings" with comprehensive explanations
  * Renamed current_interval_price_major → current_interval_price_base
  * Updated service parameter descriptions (subunit_currency)
  * Added selector.currency_display_mode.options with translated labels
- All /custom_translations/*.json:
  * Renamed sensor description keys
  * Updated chart_metadata usage_tips references

**Documentation**:
- docs/user/docs/actions.md: Updated parameter table and feature list
- docs/user/versioned_docs/version-v0.21.0/actions.md: Backported changes

**Tests**:
- Updated 7 test files with renamed parameters and conversion logic:
  * test_connect_segments.py: Renamed minor/major to subunit/base
  * test_period_data_format.py: Updated period price conversion tests
  * test_avg_none_fallback.py: Fixed tuple unpacking for new return format
  * test_best_price_e2e.py: Added config_entry parameter to all calls
  * test_cache_validity.py: Fixed cache data structure (price_info key)
  * test_coordinator_shutdown.py: Added repair_manager mock
  * test_midnight_turnover.py: Added config_entry parameter
  * test_peak_price_e2e.py: Added config_entry parameter, fixed price_avg → price_mean
  * test_percentage_calculations.py: Added config_entry mock

**Coordinator/Period Calculation**:
- coordinator/periods.py: Added config_entry parameter to
  calculate_periods_with_relaxation() calls (2 locations)

Migration Guide:

1. **Update Service Calls in Automations/Scripts**:
   \`\`\`yaml
   # Before:
   service: tibber_prices.get_chartdata
   data:
     minor_currency: true

   # After:
   service: tibber_prices.get_chartdata
   data:
     subunit_currency: true
   \`\`\`

2. **Update Energy Dashboard Configuration**:
   - Settings → Dashboards → Energy
   - Replace sensor entity:
     `sensor.tibber_home_current_interval_price_major` →
     `sensor.tibber_home_current_interval_price_base`

3. **Review Integration Configuration**:
   - Settings → Devices & Services → Tibber Prices → Configure
   - New "Currency Display Settings" step added
   - Default mode depends on currency (EUR → subunit, Scandinavian → base)

Rationale:

The "major/minor" terminology was confusing and didn't clearly communicate:
- **Major** → Unclear if this means "primary" or "large value"
- **Minor** → Easily confused with "less important" rather than "smaller unit"

New terminology is precise and self-explanatory:
- **Base currency** → Standard ISO currency (€, kr, $, £)
- **Subunit currency** → Fractional unit (ct, øre, ¢, p)

This aligns with:
- International terminology (ISO 4217 standard)
- Banking/financial industry conventions
- User expectations from payment processing systems

Impact: Aligns currency terminology with international standards. Users must
update service calls, automations, and Energy Dashboard configuration after
upgrade.

Refs: User feedback session (December 2025) identified terminology confusion
2025-12-11 08:26:30 +00:00

973 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_subunit,
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_subunit(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', subunit_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', subunit_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', subunit_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
# Fixed gradient stop at 50% (visual appeal, no semantic meaning)
gradient_stops = [50, 100]
# Set fallback values if sensor not used
if not use_sensor_metadata:
# 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,
},
}
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,
},
}
# 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",
}
)
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
# Fixed gradient stop at 50% (visual appeal, no semantic meaning)
gradient_stops = [50, 100]
# Set fallback values if sensor not used
if not use_sensor_metadata:
# 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,
},
}
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,
},
}
# 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",
}
)
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