""" 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 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.core import ServiceCall 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 .get_chartdata import handle_chartdata from .helpers import get_entry_and_data # 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, ) async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]: # noqa: PLR0912, PLR0915 """ 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(["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 # 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) # Pre-calculate metadata server-side for dynamic yaxis and gradient # This avoids async issues with config-template-card variables # Create service call to get metadata metadata_call = ServiceCall( hass=hass, domain="tibber_prices", service="get_chartdata", data={ "entry_id": entry_id, "minor_currency": True, "metadata": "only", }, context=call.context, return_response=True, ) metadata_response = await handle_chartdata(metadata_call) metadata = metadata_response.get("metadata", {}) # Extract values with fallbacks yaxis_min = metadata.get("yaxis_suggested", {}).get("min", 0) yaxis_max = metadata.get("yaxis_suggested", {}).get("max", 100) avg_position = metadata.get("price_stats", {}).get("combined", {}).get("avg_position", 0.5) gradient_stop = round(avg_position * 100) return { "type": "custom:config-template-card", "variables": { "v_graph_span": template_graph_span, }, "entities": trigger_entities, "card": { **result, "span": {"start": "minute", "offset": "-120min"}, "graph_span": "${v_graph_span}", "yaxis": [ { "id": "price", "decimals": 2, "min": yaxis_min, "max": yaxis_max, "apex_config": { "title": {"text": price_unit}, "decimalsInFloat": 0, "forceNiceScale": False, }, }, { "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_stop, 100], }, }, }, }, } # 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'" # Pre-calculate metadata server-side for dynamic yaxis and gradient # This avoids async issues with config-template-card variables # Create service call to get metadata metadata_call = ServiceCall( hass=hass, domain="tibber_prices", service="get_chartdata", data={ "entry_id": entry_id, "minor_currency": True, "metadata": "only", }, context=call.context, return_response=True, ) metadata_response = await handle_chartdata(metadata_call) metadata = metadata_response.get("metadata", {}) # Extract values with fallbacks yaxis_min = metadata.get("yaxis_suggested", {}).get("min", 0) yaxis_max = metadata.get("yaxis_suggested", {}).get("max", 100) avg_position = metadata.get("price_stats", {}).get("combined", {}).get("avg_position", 0.5) gradient_stop = round(avg_position * 100) return { "type": "custom:config-template-card", "variables": { "v_offset": template_value, }, "entities": [tomorrow_data_sensor], "card": { **result, "span": { "end": "day", "offset": "${v_offset}", }, "yaxis": [ { "id": "price", "decimals": 2, "min": yaxis_min, "max": yaxis_max, "apex_config": { "title": {"text": price_unit}, "decimalsInFloat": 0, "forceNiceScale": False, }, }, { "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_stop, 100], }, }, }, }, } # 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