mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-03-29 21:03:40 +00:00
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
973 lines
42 KiB
Python
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
|