hass.tibber_prices/custom_components/tibber_prices/services/get_apexcharts_yaml.py

394 lines
14 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.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: Final = vol.Schema(
{
vol.Required(ATTR_ENTRY_ID): str,
vol.Optional("day", default="today"): vol.In(["yesterday", "today", "tomorrow"]),
vol.Optional("level_type", default="rating_level"): vol.In(["rating_level", "level"]),
vol.Optional("highlight_best_price", default=True): bool,
}
)
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
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]),
],
("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]),
],
}
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,
)
async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]:
"""
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", "today")
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
entity_map = _build_entity_map(entity_registry, entry_id, level_type, day)
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}']"
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: ['{day}'], {filter_param}, "
f"output_format: 'array_of_arrays', insert_nulls: 'segments', minor_currency: true, "
f"connect_segments: true }} }}); "
f"return response.response.data;"
)
# All series use same configuration (no extremas on data_generator series)
# Hide all levels in header since data_generator series don't show meaningful state values
# (the entity state is the min/max/avg price, not the current price for this level)
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,
}
)
# Note: Extrema markers don't work with data_generator approach
# ApexCharts requires entity time-series data for extremas feature
# Min/Max sensors are single values, not time-series
# Get translated name for best price periods (needed for tooltip formatter)
best_price_name = (
get_translation(["binary_sensor", "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
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: ['{day}'], "
f"period_filter: 'best_price', "
f"output_format: 'array_of_arrays', minor_currency: true }} }}); "
f"return response.response.data.map(point => [point[0], 1]);"
)
# 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.2)", # Semi-transparent green
"yaxis_id": "highlight",
"show": {"legend_value": False, "in_header": False, "in_legend": False},
"data_generator": best_price_generator,
"stroke_width": 0,
"curve": "stepline",
}
)
# 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
day_translated = get_translation(["selector", "day", "options", day], user_language) or day.capitalize()
title = f"{title} - {day_translated}"
# Configure span based on selected day
if day == "yesterday":
span_config = {"start": "day", "offset": "-1d"}
elif day == "tomorrow":
span_config = {"start": "day", "offset": "+1d"}
else: # today
span_config = {"start": "day"}
return {
"type": "custom:apexcharts-card",
"update_interval": "5m",
"span": span_config,
"header": {
"show": True,
"title": title,
"show_states": True,
},
"apex_config": {
"chart": {
"animations": {"enabled": False},
"toolbar": {"show": True, "tools": {"zoom": True, "pan": True}},
"zoom": {"enabled": True},
},
"stroke": {"curve": "stepline", "width": 2},
"fill": {
"type": "gradient",
"opacity": 0.4,
"gradient": {
"shade": "dark",
"type": "vertical",
"shadeIntensity": 0.5,
"opacityFrom": 0.7,
"opacityTo": 0.2,
},
},
"dataLabels": {"enabled": False},
"tooltip": {
"x": {"format": "HH:mm"},
"y": {"title": {"formatter": f"function() {{ return '{price_unit}'; }}"}},
},
"legend": {
"show": False,
"position": "top",
"horizontalAlign": "left",
"markers": {"radius": 2},
},
"grid": {
"show": True,
"borderColor": "#40475D",
"strokeDashArray": 4,
"xaxis": {"lines": {"show": True}},
"yaxis": {"lines": {"show": True}},
},
"markers": {"size": 0},
},
"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", "label": "🕒 LIVE"},
"all_series_config": {
"stroke_width": 1,
"group_by": {"func": "raw", "duration": "15min"},
},
"series": series,
}