diff --git a/custom_components/tibber_prices/services.yaml b/custom_components/tibber_prices/services.yaml index b9b7ff5..24f4e2b 100644 --- a/custom_components/tibber_prices/services.yaml +++ b/custom_components/tibber_prices/services.yaml @@ -64,6 +64,15 @@ get_apexcharts_yaml: - rating_level - level translation_key: level_type + highlight_best_price: + name: Highlight Best Price Periods + description: >- + Add a semi-transparent green overlay to highlight the best price periods on the chart. This makes it easy to visually identify the optimal times for energy consumption. + required: false + default: true + example: true + selector: + boolean: get_chartdata: name: Get Chart Data description: >- diff --git a/custom_components/tibber_prices/services/get_apexcharts_yaml.py b/custom_components/tibber_prices/services/get_apexcharts_yaml.py index 019505f..5e536da 100644 --- a/custom_components/tibber_prices/services/get_apexcharts_yaml.py +++ b/custom_components/tibber_prices/services/get_apexcharts_yaml.py @@ -36,7 +36,12 @@ from custom_components.tibber_prices.const import ( get_translation, ) from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers.entity_registry import async_get as async_get_entity_registry +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 @@ -55,10 +60,104 @@ 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. @@ -89,6 +188,7 @@ async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]: 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" @@ -99,13 +199,11 @@ async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]: currency = coordinator.data.get("currency", "EUR") price_unit = format_price_unit_minor(currency) - # Get a sample entity_id for the series (first sensor from this entry) + # Get entity registry for mapping entity_registry = async_get_entity_registry(hass) - sample_entity = None - for entity in entity_registry.entities.values(): - if entity.config_entry_id == entry_id and entity.domain == "sensor": - sample_entity = entity.entity_id - break + + # 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 = [ @@ -122,7 +220,12 @@ async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]: (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 @@ -143,14 +246,18 @@ async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]: 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": sample_entity or "sensor.tibber_prices", + "entity": entity_map[level_key], # Use entity_map directly (no fallback needed) "name": name, "type": "area", "color": color, "yaxis_id": "price", - "show": {"legend_value": False}, + "show": show_config, "data_generator": data_generator, "stroke_width": 1, } @@ -160,6 +267,44 @@ async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]: # 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 ( @@ -211,7 +356,7 @@ async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]: "y": {"title": {"formatter": f"function() {{ return '{price_unit}'; }}"}}, }, "legend": { - "show": True, + "show": False, "position": "top", "horizontalAlign": "left", "markers": {"radius": 2}, @@ -232,6 +377,13 @@ async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]: "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": {