mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-03-30 05:13:40 +00:00
feat(apexcharts): add highlight option for best price periods in chart
This commit is contained in:
parent
f70ac9cff6
commit
cf8d9ba8e8
2 changed files with 171 additions and 10 deletions
|
|
@ -64,6 +64,15 @@ get_apexcharts_yaml:
|
||||||
- rating_level
|
- rating_level
|
||||||
- level
|
- level
|
||||||
translation_key: level_type
|
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:
|
get_chartdata:
|
||||||
name: Get Chart Data
|
name: Get Chart Data
|
||||||
description: >-
|
description: >-
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,12 @@ from custom_components.tibber_prices.const import (
|
||||||
get_translation,
|
get_translation,
|
||||||
)
|
)
|
||||||
from homeassistant.exceptions import ServiceValidationError
|
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 .formatters import get_level_translation
|
||||||
from .helpers import get_entry_and_data
|
from .helpers import get_entry_and_data
|
||||||
|
|
@ -55,10 +60,104 @@ APEXCHARTS_SERVICE_SCHEMA: Final = vol.Schema(
|
||||||
vol.Required(ATTR_ENTRY_ID): str,
|
vol.Required(ATTR_ENTRY_ID): str,
|
||||||
vol.Optional("day", default="today"): vol.In(["yesterday", "today", "tomorrow"]),
|
vol.Optional("day", default="today"): vol.In(["yesterday", "today", "tomorrow"]),
|
||||||
vol.Optional("level_type", default="rating_level"): vol.In(["rating_level", "level"]),
|
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]:
|
async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Return YAML snippet for ApexCharts card.
|
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")
|
day = call.data.get("day", "today")
|
||||||
level_type = call.data.get("level_type", "rating_level")
|
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
|
# Get user's language from hass config
|
||||||
user_language = hass.config.language or "en"
|
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")
|
currency = coordinator.data.get("currency", "EUR")
|
||||||
price_unit = format_price_unit_minor(currency)
|
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)
|
entity_registry = async_get_entity_registry(hass)
|
||||||
sample_entity = None
|
|
||||||
for entity in entity_registry.entities.values():
|
# Build entity mapping based on level_type and day for clickable states
|
||||||
if entity.config_entry_id == entry_id and entity.domain == "sensor":
|
entity_map = _build_entity_map(entity_registry, entry_id, level_type, day)
|
||||||
sample_entity = entity.entity_id
|
|
||||||
break
|
|
||||||
|
|
||||||
if level_type == "rating_level":
|
if level_type == "rating_level":
|
||||||
series_levels = [
|
series_levels = [
|
||||||
|
|
@ -122,7 +220,12 @@ async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]:
|
||||||
(PRICE_LEVEL_VERY_EXPENSIVE, "#e74c3c"),
|
(PRICE_LEVEL_VERY_EXPENSIVE, "#e74c3c"),
|
||||||
]
|
]
|
||||||
series = []
|
series = []
|
||||||
|
# Only create series for levels that have a matching entity (filter out missing levels)
|
||||||
for level_key, color in series_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
|
# Get translated name for the level using helper function
|
||||||
name = get_level_translation(level_key, level_type, user_language)
|
name = get_level_translation(level_key, level_type, user_language)
|
||||||
# Use server-side insert_nulls='segments' for clean gaps
|
# 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;"
|
f"return response.response.data;"
|
||||||
)
|
)
|
||||||
# All series use same configuration (no extremas on data_generator series)
|
# 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(
|
series.append(
|
||||||
{
|
{
|
||||||
"entity": sample_entity or "sensor.tibber_prices",
|
"entity": entity_map[level_key], # Use entity_map directly (no fallback needed)
|
||||||
"name": name,
|
"name": name,
|
||||||
"type": "area",
|
"type": "area",
|
||||||
"color": color,
|
"color": color,
|
||||||
"yaxis_id": "price",
|
"yaxis_id": "price",
|
||||||
"show": {"legend_value": False},
|
"show": show_config,
|
||||||
"data_generator": data_generator,
|
"data_generator": data_generator,
|
||||||
"stroke_width": 1,
|
"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
|
# ApexCharts requires entity time-series data for extremas feature
|
||||||
# Min/Max sensors are single values, not time-series
|
# 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
|
# Get translated title based on level_type
|
||||||
title_key = "title_rating_level" if level_type == "rating_level" else "title_level"
|
title_key = "title_rating_level" if level_type == "rating_level" else "title_level"
|
||||||
title = get_translation(["apexcharts", title_key], user_language) or (
|
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}'; }}"}},
|
"y": {"title": {"formatter": f"function() {{ return '{price_unit}'; }}"}},
|
||||||
},
|
},
|
||||||
"legend": {
|
"legend": {
|
||||||
"show": True,
|
"show": False,
|
||||||
"position": "top",
|
"position": "top",
|
||||||
"horizontalAlign": "left",
|
"horizontalAlign": "left",
|
||||||
"markers": {"radius": 2},
|
"markers": {"radius": 2},
|
||||||
|
|
@ -232,6 +377,13 @@ async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]:
|
||||||
"min": 0,
|
"min": 0,
|
||||||
"apex_config": {"title": {"text": price_unit}},
|
"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"},
|
"now": {"show": True, "color": "#8e24aa", "label": "🕒 LIVE"},
|
||||||
"all_series_config": {
|
"all_series_config": {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue