feat(services): add rolling 48h window support to chart services

Add dynamic rolling window mode to get_chartdata and get_apexcharts_yaml
services that automatically adapts to data availability.

When 'day' parameter is omitted, services return 48-hour window:
- With tomorrow data (after ~13:00): today + tomorrow
- Without tomorrow data: yesterday + today

Changes:
- Implement rolling window logic in get_chartdata using has_tomorrow_data()
- Generate config-template-card wrapper in get_apexcharts_yaml for dynamic
  ApexCharts span.offset based on tomorrow_data_available binary sensor
- Update service descriptions in services.yaml
- Add rolling window descriptions to all translations (de, en, nb, nl, sv)
- Document rolling window mode in docs/user/services.md
- Add ApexCharts examples with prerequisites in docs/user/automation-examples.md

BREAKING CHANGE: get_apexcharts_yaml rolling window mode requires
config-template-card in addition to apexcharts-card for dynamic offset
calculation.

Impact: Users can create auto-adapting 48h price charts without manual day
selection. Fixed day views (day: today/yesterday/tomorrow) still work with
apexcharts-card only.
This commit is contained in:
Julian Pawlowski 2025-12-01 23:46:09 +00:00
parent cf8d9ba8e8
commit e156dfb061
11 changed files with 222 additions and 34 deletions

View file

@ -40,9 +40,9 @@ get_apexcharts_yaml:
integration: tibber_prices
day:
name: Day
description: Which day to visualize (yesterday, today, or tomorrow).
description: >-
Which day to visualize (yesterday, today, or tomorrow). If not specified, returns a rolling 2-day window: today+tomorrow (when tomorrow data is available) or yesterday+today (when tomorrow data is not yet available).
required: false
default: today
example: today
selector:
select:
@ -90,7 +90,8 @@ get_chartdata:
# === DATA SELECTION ===
day:
name: Day
description: Which day(s) to fetch prices for. You can select multiple days. If not specified, returns all available data (today + tomorrow if available).
description: >-
Which day(s) to fetch prices for. You can select multiple days. If not specified, returns a rolling 2-day window: today+tomorrow (when tomorrow data is available) or yesterday+today (when tomorrow data is not yet available). This provides continuous chart display without gaps.
required: false
selector:
select:

View file

@ -36,6 +36,7 @@ from custom_components.tibber_prices.const import (
get_translation,
)
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_registry import (
EntityRegistry,
)
@ -55,12 +56,12 @@ ATTR_DAY: Final = "day"
ATTR_ENTRY_ID: Final = "entry_id"
# Service schema
APEXCHARTS_SERVICE_SCHEMA: Final = vol.Schema(
APEXCHARTS_SERVICE_SCHEMA = vol.Schema(
{
vol.Required(ATTR_ENTRY_ID): str,
vol.Optional("day", default="today"): vol.In(["yesterday", "today", "tomorrow"]),
vol.Required(ATTR_ENTRY_ID): cv.string,
vol.Optional("day"): 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,
vol.Optional("highlight_best_price", default=True): cv.boolean,
}
)
@ -158,7 +159,7 @@ def _get_current_price_entity(entity_registry: EntityRegistry, entry_id: str) ->
)
async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]:
async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]: # noqa: PLR0912, PLR0915
"""
Return YAML snippet for ApexCharts card.
@ -186,7 +187,7 @@ async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]:
raise ServiceValidationError(translation_domain=DOMAIN, translation_key="missing_entry_id")
entry_id: str = str(entry_id_raw)
day = call.data.get("day", "today")
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)
@ -203,7 +204,8 @@ async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]:
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)
# 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 = [
@ -234,13 +236,16 @@ async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]:
else:
filter_param = f"level_filter: ['{level_key}']"
# Conditionally include day parameter (omit for rolling window mode)
day_param = f"day: ['{day}'], " if day else ""
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"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;"
@ -276,13 +281,16 @@ async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]:
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)
day_param = f"day: ['{day}'], " if day else ""
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"service_data: {{ entry_id: '{entry_id}', {day_param}"
f"period_filter: 'best_price', "
f"output_format: 'array_of_arrays', minor_currency: true }} }}); "
f"return response.response.data.map(point => [point[0], 1]);"
@ -311,22 +319,34 @@ async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]:
"Price Phases Daily Progress" if level_type == "rating_level" else "Price Level"
)
# Add translated day to title
# Add translated day to title (only if day parameter was provided)
if day:
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 mode, use config-template-card for dynamic offset
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"}
else: # today
graph_span_value = None
use_template = False
elif day: # today (explicit)
span_config = {"start": "day"}
graph_span_value = None
use_template = False
else: # Rolling window mode (None)
# 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
return {
result = {
"type": "custom:apexcharts-card",
"update_interval": "5m",
"span": span_config,
"header": {
"show": True,
"title": title,
@ -392,3 +412,55 @@ async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]:
},
"series": series,
}
# For rolling window mode, wrap in config-template-card for dynamic offset
if use_template:
# Add graph_span to base config (48h window)
result["graph_span"] = graph_span_value
# 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:
# 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'"
return {
"type": "custom:config-template-card",
"variables": {
"v_offset": template_value,
},
"entities": [tomorrow_data_sensor],
"card": {
**result,
"span": {
"end": "day",
"offset": "${v_offset}",
},
},
}
# Fallback if sensor not found: just use +1d offset
result["span"] = {"end": "day", "offset": "+1d"}
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

View file

@ -48,7 +48,7 @@ from custom_components.tibber_prices.coordinator.helpers import (
from homeassistant.exceptions import ServiceValidationError
from .formatters import aggregate_hourly_exact, get_period_data, normalize_level_filter, normalize_rating_level_filter
from .helpers import get_entry_and_data
from .helpers import get_entry_and_data, has_tomorrow_data
if TYPE_CHECKING:
from homeassistant.core import ServiceCall
@ -114,6 +114,11 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: PLR091
Supports both 15-minute intervals and hourly aggregation, with optional filtering by
price level, rating level, or period (best_price/peak_price).
Default behavior (no day parameter):
- Returns rolling 2-day window for continuous chart display
- If tomorrow data available: today + tomorrow
- If tomorrow data NOT available: yesterday + today
See services.yaml for detailed parameter documentation.
Args:
@ -132,10 +137,15 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: PLR091
raise ServiceValidationError(translation_domain=DOMAIN, translation_key="missing_entry_id")
entry_id: str = str(entry_id_raw)
# Get coordinator to check data availability
_, coordinator, _ = get_entry_and_data(hass, entry_id)
days_raw = call.data.get(ATTR_DAY)
# If no day specified, return all available data (today + tomorrow)
# If no day specified, use rolling 2-day window:
# - If tomorrow data available: today + tomorrow
# - If tomorrow data NOT available: yesterday + today
if days_raw is None:
days = ["today", "tomorrow"]
days = ["today", "tomorrow"] if has_tomorrow_data(coordinator) else ["yesterday", "today"]
# Convert single string to list for uniform processing
elif isinstance(days_raw, str):
days = [days_raw]
@ -174,8 +184,6 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: PLR091
if average_field in array_fields_template:
include_average = True
_, coordinator, _ = get_entry_and_data(hass, entry_id)
# Get thresholds from config for rating aggregation
threshold_low = coordinator.config_entry.options.get(
CONF_PRICE_RATING_THRESHOLD_LOW, DEFAULT_PRICE_RATING_THRESHOLD_LOW

View file

@ -19,9 +19,11 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Any
from custom_components.tibber_prices.const import DOMAIN
from custom_components.tibber_prices.coordinator.helpers import get_intervals_for_day_offsets
from homeassistant.exceptions import ServiceValidationError
if TYPE_CHECKING:
from custom_components.tibber_prices.coordinator import TibberPricesDataUpdateCoordinator
from homeassistant.core import HomeAssistant
@ -51,3 +53,22 @@ def get_entry_and_data(hass: HomeAssistant, entry_id: str) -> tuple[Any, Any, di
coordinator = entry.runtime_data.coordinator
data = coordinator.data or {}
return entry, coordinator, data
def has_tomorrow_data(coordinator: TibberPricesDataUpdateCoordinator) -> bool:
"""
Check if tomorrow's price data is available in coordinator.
Uses get_intervals_for_day_offsets() to automatically determine tomorrow
based on current date.
Args:
coordinator: TibberPricesDataUpdateCoordinator instance
Returns:
True if tomorrow's data exists (at least one interval), False otherwise
"""
coordinator_data = coordinator.data or {}
tomorrow_intervals = get_intervals_for_day_offsets(coordinator_data, [1])
return len(tomorrow_intervals) > 0

View file

@ -799,7 +799,7 @@
},
"day": {
"name": "Tag",
"description": "Welcher Tag visualisiert werden soll (gestern, heute oder morgen)."
"description": "Welcher Tag visualisiert werden soll (gestern, heute oder morgen). Falls nicht angegeben, wird ein rollierendes 2-Tage-Fenster zurückgegeben: heute+morgen (wenn Daten für morgen verfügbar sind) oder gestern+heute (wenn Daten für morgen noch nicht verfügbar sind)."
},
"level_type": {
"name": "Stufen-Typ",
@ -817,7 +817,7 @@
},
"day": {
"name": "Tag",
"description": "Für welche(n) Tag(e) sollen Preise abgerufen werden. Du kannst mehrere Tage auswählen. Falls nicht angegeben, werden alle verfügbaren Daten zurückgegeben (heute + morgen falls verfügbar)."
"description": "Für welche(n) Tag(e) sollen Preise abgerufen werden. Du kannst mehrere Tage auswählen. Falls nicht angegeben, wird ein rollierendes 2-Tages-Fenster zurückgegeben: heute+morgen (wenn Morgendaten verfügbar) oder gestern+heute (wenn Morgendaten noch nicht verfügbar). Dies ermöglicht eine kontinuierliche Diagrammanzeige ohne Lücken."
},
"resolution": {
"name": "Auflösung",

View file

@ -795,7 +795,7 @@
},
"day": {
"name": "Day",
"description": "Which day to visualize (yesterday, today, or tomorrow)."
"description": "Which day to visualize (yesterday, today, or tomorrow). If not specified, returns a rolling 2-day window: today+tomorrow (when tomorrow data is available) or yesterday+today (when tomorrow data is not yet available)."
},
"level_type": {
"name": "Level Type",
@ -813,7 +813,7 @@
},
"day": {
"name": "Day",
"description": "Which day(s) to fetch prices for. You can select multiple days. If not specified, returns all available data (today + tomorrow if available)."
"description": "Which day(s) to fetch prices for. You can select multiple days. If not specified, returns a rolling 2-day window: today+tomorrow (when tomorrow data is available) or yesterday+today (when tomorrow data is not yet available). This provides continuous chart display without gaps."
},
"resolution": {
"name": "Resolution",

View file

@ -795,7 +795,7 @@
},
"day": {
"name": "Dag",
"description": "Hvilken dag som skal visualiseres (i går, i dag eller i morgen)."
"description": "Hvilken dag som skal visualiseres (i går, i dag eller i morgen). Hvis ikke angitt, returneres et rullende 2-dagers vindu: i dag+i morgen (når data for i morgen er tilgjengelig) eller i går+i dag (når data for i morgen ikke er tilgjengelig ennå)."
},
"level_type": {
"name": "Nivåtype",
@ -813,7 +813,7 @@
},
"day": {
"name": "Dag",
"description": "Hvilken dag(er) skal det hentes priser for. Du kan velge flere dager. Hvis ikke angitt, returneres alle tilgjengelige data (i dag + i morgen hvis tilgjengelig)."
"description": "Hvilken dag(er) skal det hentes priser for. Du kan velge flere dager. Hvis ikke angitt, returneres et rullerende 2-dagers vindu: i dag+i morgen (når morgendagens data er tilgjengelig) eller i går+i dag (når morgendagens data ikke er tilgjengelig ennå). Dette gir kontinuerlig diagramvisning uten hull."
},
"resolution": {
"name": "Oppløsning",

View file

@ -795,7 +795,7 @@
},
"day": {
"name": "Dag",
"description": "Welke dag gevisualiseerd moet worden (gisteren, vandaag of morgen)."
"description": "Welke dag gevisualiseerd moet worden (gisteren, vandaag of morgen). Indien niet opgegeven, wordt een rollend 2-dagen venster geretourneerd: vandaag+morgen (wanneer gegevens voor morgen beschikbaar zijn) of gisteren+vandaag (wanneer gegevens voor morgen nog niet beschikbaar zijn)."
},
"level_type": {
"name": "Niveautype",
@ -813,7 +813,7 @@
},
"day": {
"name": "Dag",
"description": "Voor welke dag(en) moeten prijzen worden opgehaald. Je kunt meerdere dagen selecteren. Als niet opgegeven, worden alle beschikbare gegevens geretourneerd (vandaag + morgen indien beschikbaar)."
"description": "Voor welke dag(en) moeten prijzen worden opgehaald. Je kunt meerdere dagen selecteren. Als niet opgegeven, wordt een rollend 2-daags venster geretourneerd: vandaag+morgen (wanneer morgengegevens beschikbaar zijn) of gisteren+vandaag (wanneer morgengegevens nog niet beschikbaar zijn). Dit zorgt voor een continue grafiekweergave zonder hiaten."
},
"resolution": {
"name": "Resolutie",

View file

@ -795,7 +795,7 @@
},
"day": {
"name": "Dag",
"description": "Vilken dag som ska visualiseras (igår, idag eller imorgon)."
"description": "Vilken dag som ska visualiseras (igår, idag eller imorgon). Om inte angivet returneras ett rullande 2-dagarsfönster: idag+imorgon (när data för imorgon är tillgänglig) eller igår+idag (när data för imorgon inte är tillgänglig ännu)."
},
"level_type": {
"name": "Nivåtyp",
@ -813,7 +813,7 @@
},
"day": {
"name": "Dag",
"description": "Vilken dag(ar) ska priser hämtas för. Du kan välja flera dagar. Om inte angivet, returneras alla tillgängliga data (idag + imorgon om tillgängligt)."
"description": "Vilken dag(ar) ska priser hämtas för. Du kan välja flera dagar. Om inte angivet, returneras ett rullande 2-dagars fönster: idag+imorgon (när morgondagens data är tillgänglig) eller igår+idag (när morgondagens data inte är tillgänglig ännu). Detta ger kontinuerlig diagramvisning utan luckor."
},
"resolution": {
"name": "Upplösning",

View file

@ -241,4 +241,60 @@ Coming soon...
## ApexCharts Cards
Coming soon...
The `tibber_prices.get_apexcharts_yaml` service generates complete ApexCharts card configurations for visualizing electricity prices.
### Prerequisites
**Required:**
- [ApexCharts Card](https://github.com/RomRider/apexcharts-card) - Install via HACS
**Optional (for rolling window mode):**
- [Config Template Card](https://github.com/iantrich/config-template-card) - Install via HACS
### Installation
1. Open HACS → Frontend
2. Search for "ApexCharts Card" and install
3. (Optional) Search for "Config Template Card" and install if you want rolling window mode
### Example: Fixed Day View
```yaml
# Generate configuration via automation/script
service: tibber_prices.get_apexcharts_yaml
data:
entry_id: YOUR_ENTRY_ID
day: today # or "yesterday", "tomorrow"
level_type: rating_level # or "level" for 5-level view
response_variable: apexcharts_config
```
Then copy the generated YAML into your Lovelace dashboard.
### Example: Rolling 48h Window
For a dynamic chart that automatically adapts to data availability:
```yaml
service: tibber_prices.get_apexcharts_yaml
data:
entry_id: YOUR_ENTRY_ID
# Omit 'day' parameter for rolling window
level_type: rating_level
response_variable: apexcharts_config
```
**Behavior:**
- **When tomorrow data available** (typically after ~13:00): Shows today + tomorrow
- **When tomorrow data not available**: Shows yesterday + today
**Note:** Rolling window mode requires Config Template Card to dynamically adjust the time range.
### Features
- Color-coded price levels/ratings (green = cheap, yellow = normal, red = expensive)
- Best price period highlighting (semi-transparent green overlay)
- Automatic NULL insertion for clean gaps
- Translated labels based on your Home Assistant language
- Interactive zoom and pan
- Live marker showing current time

View file

@ -57,6 +57,25 @@ response_variable: chart_data
| `minor_currency` | Return prices in ct/øre instead of EUR/NOK | `false` |
| `round_decimals` | Decimal places (0-10) | 4 (major) or 2 (minor) |
**Rolling Window Mode:**
Omit the `day` parameter to get a dynamic 48-hour rolling window that automatically adapts to data availability:
```yaml
service: tibber_prices.get_chartdata
data:
entry_id: YOUR_ENTRY_ID
# Omit 'day' for rolling window
output_format: array_of_objects
response_variable: chart_data
```
**Behavior:**
- **When tomorrow data available** (typically after ~13:00): Returns today + tomorrow
- **When tomorrow data not available**: Returns yesterday + today
This is useful for charts that should always show a 48-hour window without manual day selection.
**Period Filter Example:**
Get best price periods as summaries instead of intervals:
@ -93,15 +112,26 @@ For detailed parameter descriptions, see the service definition in **Developer T
**Purpose:** Generates complete ApexCharts card YAML configuration for visualizing electricity prices.
**Prerequisites:**
- [ApexCharts Card](https://github.com/RomRider/apexcharts-card) (required for all configurations)
- [Config Template Card](https://github.com/iantrich/config-template-card) (required only for rolling window mode without `day` parameter)
**Quick Example:**
```yaml
service: tibber_prices.get_apexcharts_yaml
data:
entry_id: YOUR_ENTRY_ID
day: today # Optional: omit for rolling 48h window (requires config-template-card)
response_variable: apexcharts_config
```
**Rolling Window Mode:** When omitting the `day` parameter, the service generates a dynamic 48-hour rolling window that automatically shows:
- Today + Tomorrow (when tomorrow data is available)
- Yesterday + Today (when tomorrow data is not yet available)
This mode requires the Config Template Card to dynamically adjust the time window based on data availability.
Use the response in Lovelace dashboards by copying the generated YAML.
**Documentation:** See Developer Tools → Services for parameter details.