feat(services): add 5 scheduling services for price-optimized time windows

New services for finding optimal electricity price windows:
- find_cheapest_block: Cheapest contiguous time block (e.g., dishwasher)
- find_cheapest_hours: Cheapest N hours, non-contiguous (e.g., EV charging)
- find_cheapest_schedule: Multi-task scheduling with no-overlap (e.g., shared circuit)
- find_most_expensive_block: Most expensive contiguous block (peak avoidance)
- find_most_expensive_hours: Most expensive N hours (consumption shifting)

Key features:
- Flexible search range (today, tomorrow, today+tomorrow, rolling window)
- Power profile support for variable consumption patterns
- Price level filtering (e.g., only CHEAP/VERY_CHEAP intervals)
- Comparison details showing savings vs. alternatives
- Sliding window algorithm (O(n)) for block search, greedy scheduling
  for multi-task optimization

Also includes:
- Shared validation utilities (search range, price level, power profile)
- entry_id now optional on all services (auto-selects single home)
- Input validation for existing services (time range, filter conflicts)
- Service icons for all new and existing services
- Translations for all 5 languages (en, de, nb, nl, sv)
- Removed 10 unused config.error translation keys (replaced by exceptions)
- Tests for price window algorithms and search range resolution

Impact: Users can find optimal time windows for appliances, EV charging,
and multi-device scheduling via HA service calls. Existing services
improved with optional entry_id and better input validation.
This commit is contained in:
Julian Pawlowski 2026-04-11 18:58:27 +00:00
parent 8aa5769784
commit 6e0613c055
23 changed files with 6294 additions and 80 deletions

View file

@ -257,6 +257,52 @@ def add_detail_attributes(attributes: dict, current_period: dict) -> None:
attributes["periods_remaining"] = current_period["periods_remaining"] attributes["periods_remaining"] = current_period["periods_remaining"]
def add_period_count_attributes(
attributes: dict,
period_summaries: list[dict],
time: TibberPricesTimeService,
) -> None:
"""
Add per-day period count attributes (priority 5.5).
Counts how many periods fall on today and tomorrow so automations can check
things like "only charge if there are at least 2 cheap periods today".
Args:
attributes: Target dict to add attributes to
period_summaries: All period summaries (already filtered to today+tomorrow)
time: TibberPricesTimeService instance for date comparison
"""
now = time.now()
today = time.get_local_date()
tomorrow = time.get_local_date(offset_days=1)
count_today = 0
count_tomorrow = 0
for period in period_summaries:
start = period.get("start")
if start is None:
continue
if hasattr(start, "date"):
period_date = start.date()
else:
from datetime import datetime # noqa: PLC0415
period_date = datetime.fromisoformat(str(start)).date()
if period_date == today:
count_today += 1
elif period_date == tomorrow:
count_tomorrow += 1
_ = now # used for clarity only
if count_today > 0 or count_tomorrow > 0:
attributes["period_count_today"] = count_today
attributes["period_count_tomorrow"] = count_tomorrow
def add_relaxation_attributes(attributes: dict, current_period: dict) -> None: def add_relaxation_attributes(attributes: dict, current_period: dict) -> None:
""" """
Add relaxation information attributes (priority 6). Add relaxation information attributes (priority 6).
@ -412,6 +458,9 @@ def build_final_attributes_simple(
# 5. Detail information # 5. Detail information
add_detail_attributes(attributes, current_period) add_detail_attributes(attributes, current_period)
# 5.5 Per-day period counts (how many cheap/peak periods per day)
add_period_count_attributes(attributes, period_summaries, time)
# 6. Relaxation information (only if current period was relaxed) # 6. Relaxation information (only if current period was relaxed)
add_relaxation_attributes(attributes, current_period) add_relaxation_attributes(attributes, current_period)
@ -429,6 +478,7 @@ def build_final_attributes_simple(
result: dict = { result: dict = {
"timestamp": timestamp, "timestamp": timestamp,
} }
add_period_count_attributes(result, period_summaries, time)
if period_metadata: if period_metadata:
add_calculation_summary_attributes(result, period_metadata) add_calculation_summary_attributes(result, period_metadata)
result["periods"] = _convert_periods_to_display_units(period_summaries, factor) result["periods"] = _convert_periods_to_display_units(period_summaries, factor)

View file

@ -788,6 +788,18 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
else: else:
# Check for repair conditions after successful update # Check for repair conditions after successful update
await self._check_repair_conditions(result, current_time) await self._check_repair_conditions(result, current_time)
# Fire event when new data was fetched from API (not cached)
if api_called and result and "priceInfo" in result and len(result["priceInfo"]) > 0:
self.hass.bus.async_fire(
"tibber_prices_data_updated",
{
"home_id": self._home_id,
"entry_id": self.config_entry.entry_id,
"interval_count": len(result["priceInfo"]),
},
)
return result return result
async def _track_rate_limit_error(self, error: Exception) -> None: async def _track_rate_limit_error(self, error: Exception) -> None:

View file

@ -28,6 +28,52 @@
}, },
"refresh_user_data": { "refresh_user_data": {
"service": "mdi:refresh" "service": "mdi:refresh"
},
"find_cheapest_block": {
"service": "mdi:washing-machine",
"sections": {
"search_range": "mdi:calendar-search",
"time_alternatives": "mdi:clock-time-eight-outline",
"price_filter": "mdi:filter-variant",
"output": "mdi:tune-variant"
}
},
"find_most_expensive_block": {
"service": "mdi:lightning-bolt-circle",
"sections": {
"search_range": "mdi:calendar-search",
"time_alternatives": "mdi:clock-time-eight-outline",
"price_filter": "mdi:filter-variant",
"output": "mdi:tune-variant"
}
},
"find_cheapest_hours": {
"service": "mdi:ev-station",
"sections": {
"search_range": "mdi:calendar-search",
"time_alternatives": "mdi:clock-time-eight-outline",
"price_filter": "mdi:filter-variant",
"output": "mdi:tune-variant"
}
},
"find_most_expensive_hours": {
"service": "mdi:flash-alert",
"sections": {
"search_range": "mdi:calendar-search",
"time_alternatives": "mdi:clock-time-eight-outline",
"price_filter": "mdi:filter-variant",
"output": "mdi:tune-variant"
}
},
"find_cheapest_schedule": {
"service": "mdi:calendar-check",
"sections": {
"scheduling_options": "mdi:format-list-numbered",
"search_range": "mdi:calendar-search",
"time_alternatives": "mdi:clock-time-eight-outline",
"price_filter": "mdi:filter-variant",
"output": "mdi:tune-variant"
}
} }
} }
} }

View file

@ -1,7 +1,7 @@
get_price: get_price:
fields: fields:
entry_id: entry_id:
required: true required: false
example: "1234567890abcdef" example: "1234567890abcdef"
selector: selector:
config_entry: config_entry:
@ -19,7 +19,7 @@ get_price:
get_apexcharts_yaml: get_apexcharts_yaml:
fields: fields:
entry_id: entry_id:
required: true required: false
example: "1234567890abcdef" example: "1234567890abcdef"
selector: selector:
config_entry: config_entry:
@ -73,7 +73,7 @@ get_chartdata:
general: general:
fields: fields:
entry_id: entry_id:
required: true required: false
example: "1234567890abcdef" example: "1234567890abcdef"
selector: selector:
config_entry: config_entry:
@ -274,12 +274,772 @@ get_chartdata:
refresh_user_data: refresh_user_data:
fields: fields:
entry_id: entry_id:
required: true required: false
example: "1234567890abcdef" example: "1234567890abcdef"
selector: selector:
config_entry: config_entry:
integration: tibber_prices integration: tibber_prices
find_cheapest_block:
fields:
entry_id:
required: false
example: "1234567890abcdef"
selector:
config_entry:
integration: tibber_prices
duration:
required: true
selector:
duration:
search_range:
fields:
search_scope:
required: false
selector:
select:
options:
- today
- tomorrow
- remaining_today
- next_24h
- next_48h
translation_key: search_scope
search_start:
required: false
example: "2026-04-11T06:00:00+02:00"
selector:
datetime:
search_end:
required: false
example: "2026-04-12T00:00:00+02:00"
selector:
datetime:
time_alternatives:
collapsed: true
fields:
search_start_time:
required: false
example: "06:00:00"
selector:
time:
search_start_day_offset:
required: false
default: 0
selector:
number:
min: -7
max: 2
mode: box
search_end_time:
required: false
example: "23:00:00"
selector:
time:
search_end_day_offset:
required: false
default: 0
selector:
number:
min: -7
max: 2
mode: box
search_start_offset_minutes:
required: false
example: 60
selector:
number:
min: -10080
max: 10080
unit_of_measurement: min
mode: box
search_end_offset_minutes:
required: false
example: 480
selector:
number:
min: -10080
max: 10080
unit_of_measurement: min
mode: box
include_current_interval:
required: false
default: true
selector:
boolean:
price_filter:
collapsed: true
fields:
max_price_level:
required: false
selector:
select:
options:
- very_cheap
- cheap
- normal
- expensive
- very_expensive
translation_key: level_filter
min_price_level:
required: false
selector:
select:
options:
- very_cheap
- cheap
- normal
- expensive
- very_expensive
translation_key: level_filter
output:
collapsed: true
fields:
power_profile:
required: false
selector:
object:
include_comparison_details:
required: false
default: false
selector:
boolean:
use_base_unit:
required: false
default: false
selector:
boolean:
find_most_expensive_block:
fields:
entry_id:
required: false
example: "1234567890abcdef"
selector:
config_entry:
integration: tibber_prices
duration:
required: true
selector:
duration:
search_range:
fields:
search_scope:
required: false
selector:
select:
options:
- today
- tomorrow
- remaining_today
- next_24h
- next_48h
translation_key: search_scope
search_start:
required: false
example: "2026-04-11T06:00:00+02:00"
selector:
datetime:
search_end:
required: false
example: "2026-04-12T00:00:00+02:00"
selector:
datetime:
time_alternatives:
collapsed: true
fields:
search_start_time:
required: false
example: "06:00:00"
selector:
time:
search_start_day_offset:
required: false
default: 0
selector:
number:
min: -7
max: 2
mode: box
search_end_time:
required: false
example: "23:00:00"
selector:
time:
search_end_day_offset:
required: false
default: 0
selector:
number:
min: -7
max: 2
mode: box
search_start_offset_minutes:
required: false
example: 60
selector:
number:
min: -10080
max: 10080
unit_of_measurement: min
mode: box
search_end_offset_minutes:
required: false
example: 480
selector:
number:
min: -10080
max: 10080
unit_of_measurement: min
mode: box
include_current_interval:
required: false
default: true
selector:
boolean:
price_filter:
collapsed: true
fields:
max_price_level:
required: false
selector:
select:
options:
- very_cheap
- cheap
- normal
- expensive
- very_expensive
translation_key: level_filter
min_price_level:
required: false
selector:
select:
options:
- very_cheap
- cheap
- normal
- expensive
- very_expensive
translation_key: level_filter
output:
collapsed: true
fields:
power_profile:
required: false
selector:
object:
include_comparison_details:
required: false
default: false
selector:
boolean:
use_base_unit:
required: false
default: false
selector:
boolean:
find_cheapest_hours:
fields:
entry_id:
required: false
example: "1234567890abcdef"
selector:
config_entry:
integration: tibber_prices
duration:
required: true
selector:
duration:
min_segment_duration:
required: false
selector:
duration:
search_range:
fields:
search_scope:
required: false
selector:
select:
options:
- today
- tomorrow
- remaining_today
- next_24h
- next_48h
translation_key: search_scope
search_start:
required: false
example: "2026-04-11T06:00:00+02:00"
selector:
datetime:
search_end:
required: false
example: "2026-04-12T00:00:00+02:00"
selector:
datetime:
time_alternatives:
collapsed: true
fields:
search_start_time:
required: false
example: "06:00:00"
selector:
time:
search_start_day_offset:
required: false
default: 0
selector:
number:
min: -7
max: 2
mode: box
search_end_time:
required: false
example: "23:00:00"
selector:
time:
search_end_day_offset:
required: false
default: 0
selector:
number:
min: -7
max: 2
mode: box
search_start_offset_minutes:
required: false
example: 60
selector:
number:
min: -10080
max: 10080
unit_of_measurement: min
mode: box
search_end_offset_minutes:
required: false
example: 480
selector:
number:
min: -10080
max: 10080
unit_of_measurement: min
mode: box
include_current_interval:
required: false
default: true
selector:
boolean:
price_filter:
collapsed: true
fields:
max_price_level:
required: false
selector:
select:
options:
- very_cheap
- cheap
- normal
- expensive
- very_expensive
translation_key: level_filter
min_price_level:
required: false
selector:
select:
options:
- very_cheap
- cheap
- normal
- expensive
- very_expensive
translation_key: level_filter
output:
collapsed: true
fields:
power_profile:
required: false
selector:
object:
include_comparison_details:
required: false
default: false
selector:
boolean:
use_base_unit:
required: false
default: false
selector:
boolean:
find_most_expensive_hours:
fields:
entry_id:
required: false
example: "1234567890abcdef"
selector:
config_entry:
integration: tibber_prices
duration:
required: true
selector:
duration:
min_segment_duration:
required: false
selector:
duration:
search_range:
fields:
search_scope:
required: false
selector:
select:
options:
- today
- tomorrow
- remaining_today
- next_24h
- next_48h
translation_key: search_scope
search_start:
required: false
example: "2026-04-11T06:00:00+02:00"
selector:
datetime:
search_end:
required: false
example: "2026-04-12T00:00:00+02:00"
selector:
datetime:
time_alternatives:
collapsed: true
fields:
search_start_time:
required: false
example: "06:00:00"
selector:
time:
search_start_day_offset:
required: false
default: 0
selector:
number:
min: -7
max: 2
mode: box
search_end_time:
required: false
example: "23:00:00"
selector:
time:
search_end_day_offset:
required: false
default: 0
selector:
number:
min: -7
max: 2
mode: box
search_start_offset_minutes:
required: false
example: 60
selector:
number:
min: -10080
max: 10080
unit_of_measurement: min
mode: box
search_end_offset_minutes:
required: false
example: 480
selector:
number:
min: -10080
max: 10080
unit_of_measurement: min
mode: box
include_current_interval:
required: false
default: true
selector:
boolean:
price_filter:
collapsed: true
fields:
max_price_level:
required: false
selector:
select:
options:
- very_cheap
- cheap
- normal
- expensive
- very_expensive
translation_key: level_filter
min_price_level:
required: false
selector:
select:
options:
- very_cheap
- cheap
- normal
- expensive
- very_expensive
translation_key: level_filter
output:
collapsed: true
fields:
power_profile:
required: false
selector:
object:
include_comparison_details:
required: false
default: false
selector:
boolean:
use_base_unit:
required: false
default: false
selector:
boolean:
find_cheapest_schedule:
fields:
entry_id:
required: false
example: "1234567890abcdef"
selector:
config_entry:
integration: tibber_prices
tasks:
required: true
example: '[{"name": "dishwasher", "duration": "02:00:00"}, {"name": "washing_machine", "duration": "01:30:00"}]'
selector:
object:
scheduling_options:
fields:
gap_minutes:
required: false
default: 0
selector:
number:
min: 0
max: 120
unit_of_measurement: min
mode: box
search_range:
fields:
search_scope:
required: false
selector:
select:
options:
- today
- tomorrow
- remaining_today
- next_24h
- next_48h
translation_key: search_scope
search_start:
required: false
example: "2026-04-11T06:00:00+02:00"
selector:
datetime:
search_end:
required: false
example: "2026-04-12T00:00:00+02:00"
selector:
datetime:
time_alternatives:
collapsed: true
fields:
search_start_time:
required: false
example: "06:00:00"
selector:
time:
search_start_day_offset:
required: false
default: 0
selector:
number:
min: -7
max: 2
mode: box
search_end_time:
required: false
example: "23:00:00"
selector:
time:
search_end_day_offset:
required: false
default: 0
selector:
number:
min: -7
max: 2
mode: box
search_start_offset_minutes:
required: false
example: 60
selector:
number:
min: -10080
max: 10080
unit_of_measurement: min
mode: box
search_end_offset_minutes:
required: false
example: 480
selector:
number:
min: -10080
max: 10080
unit_of_measurement: min
mode: box
price_filter:
collapsed: true
fields:
max_price_level:
required: false
selector:
select:
options:
- very_cheap
- cheap
- normal
- expensive
- very_expensive
translation_key: level_filter
min_price_level:
required: false
selector:
select:
options:
- very_cheap
- cheap
- normal
- expensive
- very_expensive
translation_key: level_filter
output:
collapsed: true
fields:
use_base_unit:
required: false
default: false
selector:
boolean:
search_start:
required: false
example: "2026-04-11T06:00:00+02:00"
selector:
datetime:
search_end:
required: false
example: "2026-04-12T00:00:00+02:00"
selector:
datetime:
search_start_time:
required: false
example: "06:00:00"
selector:
time:
search_start_day_offset:
required: false
default: 0
selector:
number:
min: -7
max: 2
mode: box
search_end_time:
required: false
example: "23:00:00"
selector:
time:
search_end_day_offset:
required: false
default: 0
selector:
number:
min: -7
max: 2
mode: box
search_start_offset_minutes:
required: false
example: 60
selector:
number:
min: -10080
max: 10080
unit_of_measurement: min
mode: box
search_end_offset_minutes:
required: false
example: 480
selector:
number:
min: -10080
max: 10080
unit_of_measurement: min
mode: box
search_scope:
required: false
selector:
select:
options:
- today
- tomorrow
- remaining_today
- next_24h
- next_48h
max_price_level:
required: false
selector:
select:
options:
- very_cheap
- cheap
- normal
- expensive
- very_expensive
min_price_level:
required: false
selector:
select:
options:
- very_cheap
- cheap
- normal
- expensive
- very_expensive
include_comparison_details:
required: false
default: false
selector:
boolean:
power_profile:
required: false
selector:
object:
include_current_interval:
required: false
default: true
selector:
boolean:
use_base_unit:
required: false
default: false
selector:
boolean:
debug_clear_tomorrow: debug_clear_tomorrow:
fields: fields:
entry_id: entry_id:

View file

@ -25,6 +25,31 @@ from typing import TYPE_CHECKING
from custom_components.tibber_prices.const import DOMAIN from custom_components.tibber_prices.const import DOMAIN
from homeassistant.core import SupportsResponse, callback from homeassistant.core import SupportsResponse, callback
from .find_cheapest_block import (
FIND_CHEAPEST_BLOCK_SERVICE_NAME,
FIND_CHEAPEST_BLOCK_SERVICE_SCHEMA,
handle_find_cheapest_block,
)
from .find_cheapest_hours import (
FIND_CHEAPEST_HOURS_SERVICE_NAME,
FIND_CHEAPEST_HOURS_SERVICE_SCHEMA,
handle_find_cheapest_hours,
)
from .find_cheapest_schedule import (
FIND_CHEAPEST_SCHEDULE_SERVICE_NAME,
FIND_CHEAPEST_SCHEDULE_SERVICE_SCHEMA,
handle_find_cheapest_schedule,
)
from .find_most_expensive_block import (
FIND_MOST_EXPENSIVE_BLOCK_SERVICE_NAME,
FIND_MOST_EXPENSIVE_BLOCK_SERVICE_SCHEMA,
handle_find_most_expensive_block,
)
from .find_most_expensive_hours import (
FIND_MOST_EXPENSIVE_HOURS_SERVICE_NAME,
FIND_MOST_EXPENSIVE_HOURS_SERVICE_SCHEMA,
handle_find_most_expensive_hours,
)
from .get_apexcharts_yaml import ( from .get_apexcharts_yaml import (
APEXCHARTS_SERVICE_SCHEMA, APEXCHARTS_SERVICE_SCHEMA,
APEXCHARTS_YAML_SERVICE_NAME, APEXCHARTS_YAML_SERVICE_NAME,
@ -73,6 +98,41 @@ def async_setup_services(hass: HomeAssistant) -> None:
schema=GET_PRICE_SERVICE_SCHEMA, schema=GET_PRICE_SERVICE_SCHEMA,
supports_response=SupportsResponse.ONLY, supports_response=SupportsResponse.ONLY,
) )
hass.services.async_register(
DOMAIN,
FIND_CHEAPEST_BLOCK_SERVICE_NAME,
handle_find_cheapest_block,
schema=FIND_CHEAPEST_BLOCK_SERVICE_SCHEMA,
supports_response=SupportsResponse.ONLY,
)
hass.services.async_register(
DOMAIN,
FIND_CHEAPEST_HOURS_SERVICE_NAME,
handle_find_cheapest_hours,
schema=FIND_CHEAPEST_HOURS_SERVICE_SCHEMA,
supports_response=SupportsResponse.ONLY,
)
hass.services.async_register(
DOMAIN,
FIND_CHEAPEST_SCHEDULE_SERVICE_NAME,
handle_find_cheapest_schedule,
schema=FIND_CHEAPEST_SCHEDULE_SERVICE_SCHEMA,
supports_response=SupportsResponse.ONLY,
)
hass.services.async_register(
DOMAIN,
FIND_MOST_EXPENSIVE_BLOCK_SERVICE_NAME,
handle_find_most_expensive_block,
schema=FIND_MOST_EXPENSIVE_BLOCK_SERVICE_SCHEMA,
supports_response=SupportsResponse.ONLY,
)
hass.services.async_register(
DOMAIN,
FIND_MOST_EXPENSIVE_HOURS_SERVICE_NAME,
handle_find_most_expensive_hours,
schema=FIND_MOST_EXPENSIVE_HOURS_SERVICE_SCHEMA,
supports_response=SupportsResponse.ONLY,
)
hass.services.async_register( hass.services.async_register(
DOMAIN, DOMAIN,
REFRESH_USER_DATA_SERVICE_NAME, REFRESH_USER_DATA_SERVICE_NAME,

View file

@ -0,0 +1,296 @@
"""
Service handler for find_cheapest_block and find_most_expensive_block services.
Finds the cheapest (or most expensive) contiguous window of a given duration
within a search range. Designed for appliance scheduling (dishwasher, washing
machine, dryer).
"""
from __future__ import annotations
import logging
import math
from datetime import datetime, timedelta
from typing import TYPE_CHECKING
import voluptuous as vol
from custom_components.tibber_prices.const import (
DOMAIN,
get_display_unit_factor,
get_display_unit_string,
)
from custom_components.tibber_prices.utils.price_window import (
calculate_window_statistics,
find_cheapest_contiguous_window,
)
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv
from homeassistant.util import dt as dt_utils
from .helpers import (
INTERVAL_MINUTES,
PRICE_LEVEL_ORDER,
VALID_SEARCH_SCOPES,
build_rating_lookup,
build_response_interval,
filter_intervals_by_price_level,
get_entry_and_data,
resolve_home_timezone,
resolve_search_range,
validate_power_profile_length,
validate_price_level_range,
)
if TYPE_CHECKING:
from zoneinfo import ZoneInfo
from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse
_LOGGER = logging.getLogger(__name__)
FIND_CHEAPEST_BLOCK_SERVICE_NAME = "find_cheapest_block"
_COMMON_BLOCK_SCHEMA = {
vol.Optional("entry_id", default=""): cv.string,
vol.Required("duration"): vol.All(
cv.positive_time_period,
vol.Range(min=timedelta(minutes=1), max=timedelta(hours=12)),
),
vol.Optional("search_start"): cv.datetime,
vol.Optional("search_end"): cv.datetime,
vol.Optional("search_start_time"): cv.time,
vol.Optional("search_start_day_offset", default=0): vol.All(vol.Coerce(int), vol.Range(min=-7, max=2)),
vol.Optional("search_end_time"): cv.time,
vol.Optional("search_end_day_offset", default=0): vol.All(vol.Coerce(int), vol.Range(min=-7, max=2)),
vol.Optional("search_start_offset_minutes"): vol.All(vol.Coerce(int), vol.Range(min=-10080, max=10080)),
vol.Optional("search_end_offset_minutes"): vol.All(vol.Coerce(int), vol.Range(min=-10080, max=10080)),
vol.Optional("search_scope"): vol.In(VALID_SEARCH_SCOPES),
vol.Optional("max_price_level"): vol.In([lvl.lower() for lvl in PRICE_LEVEL_ORDER]),
vol.Optional("min_price_level"): vol.In([lvl.lower() for lvl in PRICE_LEVEL_ORDER]),
vol.Optional("include_comparison_details", default=False): cv.boolean,
vol.Optional("power_profile"): vol.All(
[vol.All(vol.Coerce(int), vol.Range(min=1, max=100000))],
vol.Length(min=1, max=48),
),
vol.Optional("include_current_interval", default=True): cv.boolean,
vol.Optional("use_base_unit", default=False): cv.boolean,
}
FIND_CHEAPEST_BLOCK_SERVICE_SCHEMA = vol.Schema(_COMMON_BLOCK_SCHEMA)
def _compute_price_comparison(
comparison_result: dict | None,
unit_factor: int,
stats: dict,
*,
reverse: bool,
include_details: bool = False,
) -> dict[str, float | str | None] | None:
"""Compute price comparison between the selected and opposite-direction window."""
if comparison_result is None:
return None
comparison_stats = calculate_window_statistics(
comparison_result["intervals"], unit_factor=unit_factor, round_decimals=4
)
if stats.get("price_mean") is None or comparison_stats.get("price_mean") is None:
return None
diff = round(comparison_stats["price_mean"] - stats["price_mean"], 4)
if reverse:
diff = -diff
result: dict[str, float | str | None] = {
"comparison_price_mean": comparison_stats["price_mean"],
"price_difference": abs(diff),
"comparison_window_start": (
comparison_result["intervals"][0]["startsAt"]
if isinstance(comparison_result["intervals"][0]["startsAt"], str)
else comparison_result["intervals"][0]["startsAt"].isoformat()
),
}
# Optional enrichment (P6)
if include_details:
result["comparison_price_min"] = comparison_stats.get("price_min")
result["comparison_price_max"] = comparison_stats.get("price_max")
last_start = comparison_result["intervals"][-1]["startsAt"]
if not isinstance(last_start, str):
last_start = last_start.isoformat()
result["comparison_window_end"] = (
datetime.fromisoformat(last_start) + timedelta(minutes=INTERVAL_MINUTES)
).isoformat()
return result
async def _handle_find_block( # noqa: PLR0915
call: ServiceCall,
*,
reverse: bool = False,
) -> ServiceResponse:
"""
Core handler for finding price blocks (cheapest or most expensive).
Finds the cheapest/most expensive contiguous window of the requested
duration within the search range using a sliding window algorithm.
"""
service_label = "find_most_expensive_block" if reverse else "find_cheapest_block"
hass: HomeAssistant = call.hass
entry_id: str = call.data.get("entry_id", "")
duration_td: timedelta = call.data["duration"]
use_base_unit: bool = call.data.get("use_base_unit", False)
max_price_level: str | None = call.data.get("max_price_level")
min_price_level: str | None = call.data.get("min_price_level")
include_comparison_details: bool = call.data.get("include_comparison_details", False)
power_profile: list[int] | None = call.data.get("power_profile")
duration_minutes_requested = int(duration_td.total_seconds() / 60)
# Round up to nearest quarter-hour interval
duration_minutes = math.ceil(duration_minutes_requested / INTERVAL_MINUTES) * INTERVAL_MINUTES
entry, coordinator, data = get_entry_and_data(hass, entry_id)
rating_lookup = build_rating_lookup(data)
home_id = entry.data.get("home_id")
if not home_id:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="missing_home_id",
)
# Resolve timezone
home_timezone = resolve_home_timezone(coordinator, home_id)
home_tz: ZoneInfo
from zoneinfo import ZoneInfo # noqa: PLC0415
home_tz = ZoneInfo(home_timezone)
# Resolve search range (priority: explicit datetime > time+offset > minutes offset > default)
now = dt_utils.now().astimezone(home_tz)
search_start, search_end = resolve_search_range(call.data, now, home_tz)
duration_intervals = duration_minutes // INTERVAL_MINUTES
# Validate parameter combinations
validate_price_level_range(min_price_level, max_price_level)
validate_power_profile_length(power_profile, duration_intervals)
_LOGGER.info(
"%s called: duration=%dmin, range=%s to %s",
service_label,
duration_minutes,
search_start,
search_end,
)
# Fetch intervals via pool
api_client = coordinator.api
user_data = coordinator._cached_user_data # noqa: SLF001
pool = entry.runtime_data.interval_pool
try:
price_info, _api_called = await pool.get_intervals(
api_client=api_client,
user_data=user_data,
start_time=search_start,
end_time=search_end,
)
except Exception as error:
_LOGGER.exception("Error fetching price data for %s", service_label)
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="price_fetch_failed",
) from error
# Determine currency and unit
currency = entry.data.get("currency", "EUR")
unit_factor = 1 if use_base_unit else get_display_unit_factor(entry)
price_unit = f"{currency}/kWh" if use_base_unit else get_display_unit_string(entry, currency)
# Apply optional price level filter (P5)
filtered_price_info = filter_intervals_by_price_level(price_info, min_price_level, max_price_level)
# Find cheapest/most expensive window
result = find_cheapest_contiguous_window(filtered_price_info, duration_intervals, reverse=reverse)
if result is None:
_LOGGER.info(
"%s: no window found (need %d intervals, have %d after level filter)",
service_label,
duration_intervals,
len(filtered_price_info),
)
return {
"home_id": home_id,
"search_start": search_start.isoformat(),
"search_end": search_end.isoformat(),
"duration_minutes_requested": duration_minutes_requested,
"duration_minutes": duration_minutes,
"currency": currency,
"price_unit": price_unit,
"window_found": False,
"window": None,
}
# Find the opposite-direction window for price comparison (from full unfiltered list)
comparison_result = find_cheapest_contiguous_window(price_info, duration_intervals, reverse=not reverse)
# Calculate statistics and build response
stats = calculate_window_statistics(
result["intervals"], unit_factor=unit_factor, round_decimals=4, power_profile=power_profile
)
# Calculate price comparison (difference to opposite-direction window)
price_comparison = _compute_price_comparison(
comparison_result, unit_factor, stats, reverse=reverse, include_details=include_comparison_details
)
# Build interval list with converted prices
response_intervals = [build_response_interval(iv, unit_factor, rating_lookup) for iv in result["intervals"]]
# Calculate end time (last interval start + 15 min)
last_start = result["intervals"][-1]["startsAt"]
if isinstance(last_start, str):
end_time = datetime.fromisoformat(last_start) + timedelta(minutes=INTERVAL_MINUTES)
else:
end_time = last_start + timedelta(minutes=INTERVAL_MINUTES)
response = {
"home_id": home_id,
"search_start": search_start.isoformat(),
"search_end": search_end.isoformat(),
"duration_minutes_requested": duration_minutes_requested,
"duration_minutes": duration_minutes,
"currency": currency,
"price_unit": price_unit,
"window_found": True,
"window": {
"start": result["intervals"][0]["startsAt"]
if isinstance(result["intervals"][0]["startsAt"], str)
else result["intervals"][0]["startsAt"].isoformat(),
"end": end_time.isoformat() if hasattr(end_time, "isoformat") else end_time,
"duration_minutes": duration_minutes,
"interval_count": len(result["intervals"]),
**stats,
"intervals": response_intervals,
},
"price_comparison": price_comparison or None,
}
_LOGGER.info(
"%s: found window at %s, mean=%.4f %s",
service_label,
response["window"]["start"],
stats.get("price_mean", 0) or 0,
price_unit,
)
return response
async def handle_find_cheapest_block(call: ServiceCall) -> ServiceResponse:
"""Handle find_cheapest_block service call."""
return await _handle_find_block(call, reverse=False)

View file

@ -0,0 +1,334 @@
"""
Service handler for find_cheapest_hours and find_most_expensive_hours services.
Finds the cheapest (or most expensive) N minutes of intervals within a search range.
Intervals need not be contiguous designed for flexible loads
(battery charging, EV, water heater with thermostat).
"""
from __future__ import annotations
import logging
import math
from datetime import datetime, timedelta
from typing import TYPE_CHECKING
import voluptuous as vol
from custom_components.tibber_prices.const import (
DOMAIN,
get_display_unit_factor,
get_display_unit_string,
)
from custom_components.tibber_prices.utils.price_window import (
calculate_window_statistics,
find_cheapest_n_intervals,
)
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv
from homeassistant.util import dt as dt_utils
from .helpers import (
INTERVAL_MINUTES,
PRICE_LEVEL_ORDER,
VALID_SEARCH_SCOPES,
build_rating_lookup,
build_response_interval,
filter_intervals_by_price_level,
get_entry_and_data,
resolve_home_timezone,
resolve_search_range,
validate_power_profile_length,
validate_price_level_range,
)
if TYPE_CHECKING:
from zoneinfo import ZoneInfo
from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse
_LOGGER = logging.getLogger(__name__)
FIND_CHEAPEST_HOURS_SERVICE_NAME = "find_cheapest_hours"
_COMMON_HOURS_SCHEMA = {
vol.Optional("entry_id", default=""): cv.string,
vol.Required("duration"): vol.All(
cv.positive_time_period,
vol.Range(min=timedelta(minutes=1), max=timedelta(hours=24)),
),
vol.Optional("search_start"): cv.datetime,
vol.Optional("search_end"): cv.datetime,
vol.Optional("search_start_time"): cv.time,
vol.Optional("search_start_day_offset", default=0): vol.All(vol.Coerce(int), vol.Range(min=-7, max=2)),
vol.Optional("search_end_time"): cv.time,
vol.Optional("search_end_day_offset", default=0): vol.All(vol.Coerce(int), vol.Range(min=-7, max=2)),
vol.Optional("search_start_offset_minutes"): vol.All(vol.Coerce(int), vol.Range(min=-10080, max=10080)),
vol.Optional("search_end_offset_minutes"): vol.All(vol.Coerce(int), vol.Range(min=-10080, max=10080)),
vol.Optional("min_segment_duration"): vol.All(
cv.positive_time_period,
vol.Range(min=timedelta(minutes=1), max=timedelta(hours=4)),
),
vol.Optional("search_scope"): vol.In(VALID_SEARCH_SCOPES),
vol.Optional("max_price_level"): vol.In([lvl.lower() for lvl in PRICE_LEVEL_ORDER]),
vol.Optional("min_price_level"): vol.In([lvl.lower() for lvl in PRICE_LEVEL_ORDER]),
vol.Optional("include_comparison_details", default=False): cv.boolean,
vol.Optional("power_profile"): vol.All(
[vol.All(vol.Coerce(int), vol.Range(min=1, max=100000))],
vol.Length(min=1, max=96),
),
vol.Optional("include_current_interval", default=True): cv.boolean,
vol.Optional("use_base_unit", default=False): cv.boolean,
}
FIND_CHEAPEST_HOURS_SERVICE_SCHEMA = vol.Schema(_COMMON_HOURS_SCHEMA)
def _build_found_response( # noqa: PLR0913
*,
result: dict,
comparison_result: dict | None,
reverse: bool,
home_id: str,
search_start: datetime,
search_end: datetime,
total_minutes_requested: int,
total_minutes: int,
min_segment_minutes_requested: int,
min_segment_minutes: int,
currency: str,
price_unit: str,
unit_factor: int,
service_label: str,
rating_lookup: dict[str, str | None],
include_comparison_details: bool = False,
power_profile: list[int] | None = None,
) -> dict:
"""Build the service response when intervals are found."""
stats = calculate_window_statistics(
result["intervals"], unit_factor=unit_factor, round_decimals=4, power_profile=power_profile
)
# Calculate price comparison (difference to opposite-direction selection)
price_comparison: dict[str, float | str | None] = {}
if comparison_result is not None:
comparison_stats = calculate_window_statistics(
comparison_result["intervals"], unit_factor=unit_factor, round_decimals=4
)
own_mean = stats.get("price_mean")
comp_mean = comparison_stats.get("price_mean")
if own_mean is not None and comp_mean is not None:
diff = round(float(comp_mean) - float(own_mean), 4)
if reverse:
diff = -diff
price_comparison = {
"comparison_price_mean": comp_mean,
"price_difference": abs(round(diff, 4)),
}
if include_comparison_details:
price_comparison["comparison_price_min"] = comparison_stats.get("price_min")
price_comparison["comparison_price_max"] = comparison_stats.get("price_max")
response_intervals = [build_response_interval(iv, unit_factor, rating_lookup) for iv in result["intervals"]]
response_segments = []
for seg in result["segments"]:
seg_stats = calculate_window_statistics(seg["intervals"], unit_factor=unit_factor, round_decimals=4)
last_start = seg["intervals"][-1]["startsAt"]
if isinstance(last_start, str):
seg_end = datetime.fromisoformat(last_start) + timedelta(minutes=INTERVAL_MINUTES)
else:
seg_end = last_start + timedelta(minutes=INTERVAL_MINUTES)
response_segments.append(
{
"start": seg["start"],
"end": seg_end.isoformat() if hasattr(seg_end, "isoformat") else seg_end,
"duration_minutes": seg["duration_minutes"],
"interval_count": seg["interval_count"],
"price_mean": seg_stats.get("price_mean"),
"intervals": [build_response_interval(iv, unit_factor, rating_lookup) for iv in seg["intervals"]],
}
)
actual_minutes = len(result["intervals"]) * INTERVAL_MINUTES
_LOGGER.info(
"%s: found %d intervals in %d segments, mean=%.4f %s",
service_label,
len(result["intervals"]),
len(response_segments),
stats.get("price_mean", 0) or 0,
price_unit,
)
return {
"home_id": home_id,
"search_start": search_start.isoformat(),
"search_end": search_end.isoformat(),
"total_minutes_requested": total_minutes_requested,
"total_minutes": total_minutes,
"min_segment_minutes_requested": min_segment_minutes_requested,
"min_segment_minutes": min_segment_minutes,
"currency": currency,
"price_unit": price_unit,
"intervals_found": True,
"schedule": {
"total_minutes": actual_minutes,
"interval_count": len(result["intervals"]),
**stats,
"segment_count": len(response_segments),
"segments": response_segments,
"intervals": response_intervals,
},
"price_comparison": price_comparison or None,
}
async def _handle_find_hours(
call: ServiceCall,
*,
reverse: bool = False,
) -> ServiceResponse:
"""
Core handler for finding price hours (cheapest or most expensive).
Finds the cheapest/most expensive N intervals (not necessarily contiguous)
within the search range. Results are grouped into contiguous segments for
scheduling convenience.
"""
service_label = "find_most_expensive_hours" if reverse else "find_cheapest_hours"
hass: HomeAssistant = call.hass
entry_id: str = call.data.get("entry_id", "")
duration_td: timedelta = call.data["duration"]
min_segment_td: timedelta | None = call.data.get("min_segment_duration")
use_base_unit: bool = call.data.get("use_base_unit", False)
max_price_level: str | None = call.data.get("max_price_level")
min_price_level: str | None = call.data.get("min_price_level")
include_comparison_details: bool = call.data.get("include_comparison_details", False)
power_profile: list[int] | None = call.data.get("power_profile")
total_minutes_requested = int(duration_td.total_seconds() / 60)
min_segment_minutes_requested = int(min_segment_td.total_seconds() / 60) if min_segment_td else INTERVAL_MINUTES
# Round up to nearest quarter-hour intervals
total_minutes = math.ceil(total_minutes_requested / INTERVAL_MINUTES) * INTERVAL_MINUTES
min_segment_minutes = math.ceil(min_segment_minutes_requested / INTERVAL_MINUTES) * INTERVAL_MINUTES
entry, coordinator, data = get_entry_and_data(hass, entry_id)
rating_lookup = build_rating_lookup(data)
home_id = entry.data.get("home_id")
if not home_id:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="missing_home_id",
)
# Resolve timezone
home_timezone = resolve_home_timezone(coordinator, home_id)
home_tz: ZoneInfo
from zoneinfo import ZoneInfo # noqa: PLC0415
home_tz = ZoneInfo(home_timezone)
# Resolve search range (priority: explicit datetime > time+offset > minutes offset > default)
now = dt_utils.now().astimezone(home_tz)
search_start, search_end = resolve_search_range(call.data, now, home_tz)
total_intervals = total_minutes // INTERVAL_MINUTES
min_segment_intervals = min_segment_minutes // INTERVAL_MINUTES
# Validate parameter combinations
validate_price_level_range(min_price_level, max_price_level)
validate_power_profile_length(power_profile, total_intervals)
_LOGGER.info(
"%s called: total=%dmin, min_segment=%dmin, range=%s to %s",
service_label,
total_minutes,
min_segment_minutes,
search_start,
search_end,
)
# Fetch intervals via pool
api_client = coordinator.api
user_data = coordinator._cached_user_data # noqa: SLF001
pool = entry.runtime_data.interval_pool
try:
price_info, _api_called = await pool.get_intervals(
api_client=api_client,
user_data=user_data,
start_time=search_start,
end_time=search_end,
)
except Exception as error:
_LOGGER.exception("Error fetching price data for %s", service_label)
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="price_fetch_failed",
) from error
# Determine currency and unit
currency = entry.data.get("currency", "EUR")
unit_factor = 1 if use_base_unit else get_display_unit_factor(entry)
price_unit = f"{currency}/kWh" if use_base_unit else get_display_unit_string(entry, currency)
# Apply optional price level filter (P5)
filtered_price_info = filter_intervals_by_price_level(price_info, min_price_level, max_price_level)
# Find cheapest/most expensive intervals
result = find_cheapest_n_intervals(filtered_price_info, total_intervals, min_segment_intervals, reverse=reverse)
if result is None:
_LOGGER.info(
"%s: not enough intervals (need %d, have %d after level filter)",
service_label,
total_intervals,
len(filtered_price_info),
)
return {
"home_id": home_id,
"search_start": search_start.isoformat(),
"search_end": search_end.isoformat(),
"total_minutes_requested": total_minutes_requested,
"total_minutes": total_minutes,
"min_segment_minutes_requested": min_segment_minutes_requested,
"min_segment_minutes": min_segment_minutes,
"currency": currency,
"price_unit": price_unit,
"intervals_found": False,
"schedule": None,
}
# Find opposite-direction selection for price comparison (from full unfiltered list)
comparison_result = find_cheapest_n_intervals(
price_info, total_intervals, min_segment_intervals, reverse=not reverse
)
return _build_found_response(
result=result,
comparison_result=comparison_result,
reverse=reverse,
home_id=home_id,
search_start=search_start,
search_end=search_end,
total_minutes_requested=total_minutes_requested,
total_minutes=total_minutes,
min_segment_minutes_requested=min_segment_minutes_requested,
min_segment_minutes=min_segment_minutes,
currency=currency,
price_unit=price_unit,
unit_factor=unit_factor,
service_label=service_label,
rating_lookup=rating_lookup,
include_comparison_details=include_comparison_details,
power_profile=power_profile,
)
async def handle_find_cheapest_hours(call: ServiceCall) -> ServiceResponse:
"""Handle find_cheapest_hours service call."""
return await _handle_find_hours(call, reverse=False)

View file

@ -0,0 +1,331 @@
"""
Service handler for find_cheapest_schedule service.
Finds optimal non-overlapping blocks for multiple tasks within a search range.
Uses a greedy algorithm: tasks are sorted by duration (longest first), then
each task claims the cheapest available contiguous window in the remaining pool.
"""
from __future__ import annotations
import logging
import math
from datetime import datetime, timedelta
from typing import TYPE_CHECKING, Any
import voluptuous as vol
from custom_components.tibber_prices.const import (
DOMAIN,
get_display_unit_factor,
get_display_unit_string,
)
from custom_components.tibber_prices.utils.price_window import (
calculate_window_statistics,
)
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv
from homeassistant.util import dt as dt_utils
from .helpers import (
INTERVAL_MINUTES,
PRICE_LEVEL_ORDER,
VALID_SEARCH_SCOPES,
build_rating_lookup,
build_response_interval,
filter_intervals_by_price_level,
get_entry_and_data,
resolve_home_timezone,
resolve_search_range,
validate_power_profile_length,
validate_price_level_range,
)
if TYPE_CHECKING:
from zoneinfo import ZoneInfo
from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse
_LOGGER = logging.getLogger(__name__)
FIND_CHEAPEST_SCHEDULE_SERVICE_NAME = "find_cheapest_schedule"
_TASK_SCHEMA = vol.Schema(
{
vol.Required("name"): cv.string,
vol.Required("duration"): vol.All(
cv.positive_time_period,
vol.Range(min=timedelta(minutes=1), max=timedelta(hours=12)),
),
vol.Optional("power_profile"): vol.All(
[vol.All(vol.Coerce(int), vol.Range(min=1, max=100000))],
vol.Length(min=1, max=48),
),
}
)
FIND_CHEAPEST_SCHEDULE_SERVICE_SCHEMA = vol.Schema(
{
vol.Optional("entry_id", default=""): cv.string,
vol.Required("tasks"): vol.All(
[_TASK_SCHEMA],
vol.Length(min=1, max=4),
),
vol.Optional("gap_minutes", default=0): vol.All(vol.Coerce(int), vol.Range(min=0, max=120)),
vol.Optional("search_start"): cv.datetime,
vol.Optional("search_end"): cv.datetime,
vol.Optional("search_start_time"): cv.time,
vol.Optional("search_start_day_offset", default=0): vol.All(vol.Coerce(int), vol.Range(min=-7, max=2)),
vol.Optional("search_end_time"): cv.time,
vol.Optional("search_end_day_offset", default=0): vol.All(vol.Coerce(int), vol.Range(min=-7, max=2)),
vol.Optional("search_start_offset_minutes"): vol.All(vol.Coerce(int), vol.Range(min=-10080, max=10080)),
vol.Optional("search_end_offset_minutes"): vol.All(vol.Coerce(int), vol.Range(min=-10080, max=10080)),
vol.Optional("search_scope"): vol.In(VALID_SEARCH_SCOPES),
vol.Optional("max_price_level"): vol.In([lvl.lower() for lvl in PRICE_LEVEL_ORDER]),
vol.Optional("min_price_level"): vol.In([lvl.lower() for lvl in PRICE_LEVEL_ORDER]),
vol.Optional("use_base_unit", default=False): cv.boolean,
}
)
def _find_cheapest_window_in_pool(
pool: list[dict[str, Any]],
duration_intervals: int,
available: list[bool],
) -> tuple[int, int] | None:
"""
Find the cheapest contiguous window of `duration_intervals` in available pool slots.
Args:
pool: Full sorted interval list.
duration_intervals: Required contiguous count.
available: Boolean mask, same length as pool. True = still available.
Returns:
(start_index, end_index_exclusive) of the best window, or None if not found.
"""
n = len(pool)
best_sum: float | None = None
best_start: int = -1
i = 0
while i <= n - duration_intervals:
# Check if a contiguous block starting at i is fully available
# and all intervals are contiguous in time (no gaps)
block: list[dict[str, Any]] = []
j = i
while j < n and len(block) < duration_intervals:
if not available[j]:
break
if block:
# Check temporal contiguity
prev_start = block[-1]["startsAt"]
curr_start = pool[j]["startsAt"]
prev_dt = datetime.fromisoformat(prev_start) if isinstance(prev_start, str) else prev_start
curr_dt = datetime.fromisoformat(curr_start) if isinstance(curr_start, str) else curr_start
if curr_dt - prev_dt != timedelta(minutes=INTERVAL_MINUTES):
# Gap in time — can't extend this block, skip to j+1
break
block.append(pool[j])
j += 1
if len(block) == duration_intervals:
window_sum = sum(iv["total"] for iv in block)
if best_sum is None or window_sum < best_sum:
best_sum = window_sum
best_start = i
i += 1
else:
# Skip past the blocking unavailable/non-contiguous slot
i = j + 1
if best_start == -1:
return None
return (best_start, best_start + duration_intervals)
async def handle_find_cheapest_schedule(call: ServiceCall) -> ServiceResponse: # noqa: PLR0915
"""Handle find_cheapest_schedule service call."""
service_label = "find_cheapest_schedule"
hass: HomeAssistant = call.hass
entry_id: str = call.data.get("entry_id", "")
tasks_raw: list[dict[str, Any]] = call.data["tasks"]
gap_minutes: int = call.data.get("gap_minutes", 0)
use_base_unit: bool = call.data.get("use_base_unit", False)
max_price_level: str | None = call.data.get("max_price_level")
min_price_level: str | None = call.data.get("min_price_level")
# Round gap up to nearest quarter interval
gap_intervals = math.ceil(gap_minutes / INTERVAL_MINUTES) if gap_minutes > 0 else 0
entry, coordinator, data = get_entry_and_data(hass, entry_id)
rating_lookup = build_rating_lookup(data)
home_id = entry.data.get("home_id")
if not home_id:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="missing_home_id",
)
home_timezone = resolve_home_timezone(coordinator, home_id)
home_tz: ZoneInfo
from zoneinfo import ZoneInfo # noqa: PLC0415
home_tz = ZoneInfo(home_timezone)
now = dt_utils.now().astimezone(home_tz)
search_start, search_end = resolve_search_range(call.data, now, home_tz)
# Resolve task durations (round up to intervals)
tasks: list[dict[str, Any]] = []
for task in tasks_raw:
dur_td: timedelta = task["duration"]
dur_minutes_req = int(dur_td.total_seconds() / 60)
dur_minutes = math.ceil(dur_minutes_req / INTERVAL_MINUTES) * INTERVAL_MINUTES
dur_intervals = dur_minutes // INTERVAL_MINUTES
validate_power_profile_length(task.get("power_profile"), dur_intervals)
tasks.append(
{
"name": task["name"],
"duration_minutes_requested": dur_minutes_req,
"duration_minutes": dur_minutes,
"duration_intervals": dur_intervals,
"power_profile": task.get("power_profile"),
}
)
# Validate parameter combinations
validate_price_level_range(min_price_level, max_price_level)
_LOGGER.info(
"%s called: %d tasks, gap=%dmin, range=%s to %s",
service_label,
len(tasks),
gap_minutes,
search_start,
search_end,
)
# Fetch intervals
api_client = coordinator.api
user_data = coordinator._cached_user_data # noqa: SLF001
pool = entry.runtime_data.interval_pool
try:
price_info, _api_called = await pool.get_intervals(
api_client=api_client,
user_data=user_data,
start_time=search_start,
end_time=search_end,
)
except Exception as error:
_LOGGER.exception("Error fetching price data for %s", service_label)
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="price_fetch_failed",
) from error
currency = entry.data.get("currency", "EUR")
unit_factor = 1 if use_base_unit else get_display_unit_factor(entry)
price_unit = f"{currency}/kWh" if use_base_unit else get_display_unit_string(entry, currency)
# Apply optional level filter
filtered_price_info = filter_intervals_by_price_level(price_info, min_price_level, max_price_level)
if not filtered_price_info:
return {
"home_id": home_id,
"search_start": search_start.isoformat(),
"search_end": search_end.isoformat(),
"currency": currency,
"price_unit": price_unit,
"all_tasks_scheduled": False,
"tasks": [],
"total_estimated_cost": None,
}
# Greedy assignment: longest task first
tasks_sorted = sorted(tasks, key=lambda t: t["duration_intervals"], reverse=True)
available = [True] * len(filtered_price_info)
assignments: list[dict[str, Any]] = []
unscheduled: list[str] = []
for task in tasks_sorted:
dur_intervals = task["duration_intervals"]
window = _find_cheapest_window_in_pool(filtered_price_info, dur_intervals, available)
if window is None:
_LOGGER.info("%s: no window found for task '%s'", service_label, task["name"])
unscheduled.append(task["name"])
continue
start_idx, end_idx = window
task_intervals = filtered_price_info[start_idx:end_idx]
# Mark task intervals + trailing gap as unavailable
gap_end = min(end_idx + gap_intervals, len(filtered_price_info))
for k in range(start_idx, gap_end):
available[k] = False
stats = calculate_window_statistics(
task_intervals,
unit_factor=unit_factor,
round_decimals=4,
power_profile=task.get("power_profile"),
)
first_start = task_intervals[0]["startsAt"]
last_start = task_intervals[-1]["startsAt"]
first_dt = datetime.fromisoformat(first_start) if isinstance(first_start, str) else first_start
last_dt = datetime.fromisoformat(last_start) if isinstance(last_start, str) else last_start
end_dt = last_dt + timedelta(minutes=INTERVAL_MINUTES)
# Build enriched interval list for this task
task_response_intervals = [build_response_interval(iv, unit_factor, rating_lookup) for iv in task_intervals]
assignments.append(
{
"name": task["name"],
"start": first_dt.isoformat(),
"end": end_dt.isoformat(),
"duration_minutes_requested": task["duration_minutes_requested"],
"duration_minutes": task["duration_minutes"],
**stats,
"intervals": task_response_intervals,
}
)
# Sort final assignments by start time
assignments.sort(key=lambda a: a["start"])
# Sum estimated costs
total_cost_values: list[float] = [
a["estimated_total_cost"] for a in assignments if a.get("estimated_total_cost") is not None
]
total_estimated_cost = round(sum(total_cost_values), 4) if total_cost_values else None
all_scheduled = len(unscheduled) == 0
_LOGGER.info(
"%s: scheduled %d/%d tasks, total_cost=%s",
service_label,
len(assignments),
len(tasks),
total_estimated_cost,
)
result: dict[str, Any] = {
"home_id": home_id,
"search_start": search_start.isoformat(),
"search_end": search_end.isoformat(),
"currency": currency,
"price_unit": price_unit,
"all_tasks_scheduled": all_scheduled,
"unscheduled_tasks": unscheduled or None,
"tasks": assignments,
"total_estimated_cost": total_estimated_cost,
}
return result

View file

@ -0,0 +1,26 @@
"""
Service handler for find_most_expensive_block service.
Finds the most expensive contiguous window of a given duration within a search range.
Mirror of find_cheapest_block with reversed price selection.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import voluptuous as vol
from .find_cheapest_block import _COMMON_BLOCK_SCHEMA, _handle_find_block
if TYPE_CHECKING:
from homeassistant.core import ServiceCall, ServiceResponse
FIND_MOST_EXPENSIVE_BLOCK_SERVICE_NAME = "find_most_expensive_block"
FIND_MOST_EXPENSIVE_BLOCK_SERVICE_SCHEMA = vol.Schema(_COMMON_BLOCK_SCHEMA)
async def handle_find_most_expensive_block(call: ServiceCall) -> ServiceResponse:
"""Handle find_most_expensive_block service call."""
return await _handle_find_block(call, reverse=True)

View file

@ -0,0 +1,26 @@
"""
Service handler for find_most_expensive_hours service.
Finds the most expensive N minutes of intervals within a search range.
Mirror of find_cheapest_hours with reversed price selection.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import voluptuous as vol
from .find_cheapest_hours import _COMMON_HOURS_SCHEMA, _handle_find_hours
if TYPE_CHECKING:
from homeassistant.core import ServiceCall, ServiceResponse
FIND_MOST_EXPENSIVE_HOURS_SERVICE_NAME = "find_most_expensive_hours"
FIND_MOST_EXPENSIVE_HOURS_SERVICE_SCHEMA = vol.Schema(_COMMON_HOURS_SCHEMA)
async def handle_find_most_expensive_hours(call: ServiceCall) -> ServiceResponse:
"""Handle find_most_expensive_hours service call."""
return await _handle_find_hours(call, reverse=True)

View file

@ -25,7 +25,6 @@ import voluptuous as vol
from custom_components.tibber_prices.const import ( from custom_components.tibber_prices.const import (
CONF_CURRENCY_DISPLAY_MODE, CONF_CURRENCY_DISPLAY_MODE,
DISPLAY_MODE_SUBUNIT, DISPLAY_MODE_SUBUNIT,
DOMAIN,
PRICE_LEVEL_CHEAP, PRICE_LEVEL_CHEAP,
PRICE_LEVEL_EXPENSIVE, PRICE_LEVEL_EXPENSIVE,
PRICE_LEVEL_NORMAL, PRICE_LEVEL_NORMAL,
@ -37,7 +36,6 @@ from custom_components.tibber_prices.const import (
get_display_unit_string, get_display_unit_string,
get_translation, get_translation,
) )
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_registry import ( from homeassistant.helpers.entity_registry import (
EntityRegistry, EntityRegistry,
@ -60,7 +58,7 @@ ATTR_ENTRY_ID: Final = "entry_id"
# Service schema # Service schema
APEXCHARTS_SERVICE_SCHEMA = vol.Schema( APEXCHARTS_SERVICE_SCHEMA = vol.Schema(
{ {
vol.Required(ATTR_ENTRY_ID): cv.string, vol.Optional(ATTR_ENTRY_ID, default=""): cv.string,
vol.Optional("day"): vol.In(["yesterday", "today", "tomorrow", "rolling_window", "rolling_window_autozoom"]), 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("level_type", default="rating_level"): vol.In(["rating_level", "level"]),
vol.Optional("resolution", default="interval"): vol.In(["interval", "hourly"]), vol.Optional("resolution", default="interval"): vol.In(["interval", "hourly"]),
@ -290,10 +288,7 @@ async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]: # noqa:
""" """
hass = call.hass hass = call.hass
entry_id_raw = call.data.get(ATTR_ENTRY_ID) entry_id_input: str = 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) day = call.data.get("day") # Can be None (rolling window mode)
level_type = call.data.get("level_type", "rating_level") level_type = call.data.get("level_type", "rating_level")
@ -305,7 +300,8 @@ async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]: # noqa:
user_language = hass.config.language or "en" user_language = hass.config.language or "en"
# Get coordinator to access price data (for currency) and config entry for display settings # Get coordinator to access price data (for currency) and config entry for display settings
config_entry, coordinator, _ = get_entry_and_data(hass, entry_id) config_entry, coordinator, _ = get_entry_and_data(hass, entry_id_input)
entry_id: str = config_entry.entry_id
# Get currency from coordinator data # Get currency from coordinator data
currency = coordinator.data.get("currency", "EUR") currency = coordinator.data.get("currency", "EUR")

View file

@ -270,7 +270,7 @@ ATTR_ENTRY_ID: Final = "entry_id"
# Service schema # Service schema
CHARTDATA_SERVICE_SCHEMA: Final = vol.Schema( CHARTDATA_SERVICE_SCHEMA: Final = vol.Schema(
{ {
vol.Required(ATTR_ENTRY_ID): str, vol.Optional(ATTR_ENTRY_ID, default=""): str,
vol.Optional(ATTR_DAY): vol.All(vol.Coerce(list), [vol.In(["yesterday", "today", "tomorrow"])]), vol.Optional(ATTR_DAY): vol.All(vol.Coerce(list), [vol.In(["yesterday", "today", "tomorrow"])]),
vol.Optional("resolution", default="interval"): vol.In(["interval", "hourly"]), vol.Optional("resolution", default="interval"): vol.In(["interval", "hourly"]),
vol.Optional("output_format", default="array_of_objects"): vol.In(["array_of_objects", "array_of_arrays"]), vol.Optional("output_format", default="array_of_objects"): vol.In(["array_of_objects", "array_of_arrays"]),
@ -346,10 +346,7 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: PLR091
""" """
hass = call.hass hass = call.hass
entry_id_raw = call.data.get(ATTR_ENTRY_ID) entry_id: str = 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)
# Get coordinator to check data availability # Get coordinator to check data availability
_, coordinator, _ = get_entry_and_data(hass, entry_id) _, coordinator, _ = get_entry_and_data(hass, entry_id)
@ -393,6 +390,39 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: PLR091
level_filter = call.data.get("level_filter") level_filter = call.data.get("level_filter")
rating_level_filter = call.data.get("rating_level_filter") rating_level_filter = call.data.get("rating_level_filter")
# --- Parameter dependency validation ---
# level_filter and rating_level_filter are mutually exclusive
if level_filter and rating_level_filter:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="level_and_rating_filter_conflict",
)
has_filter = bool(level_filter or rating_level_filter)
# insert_nulls modes "segments"/"all" require a level or rating filter
if insert_nulls != "none" and not has_filter:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="insert_nulls_requires_filter",
translation_placeholders={"mode": insert_nulls},
)
# connect_segments requires insert_nulls="segments" (with a filter)
if connect_segments and insert_nulls != "segments":
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="connect_segments_requires_segments_mode",
)
# array_fields is only meaningful with array_of_arrays format
if call.data.get("array_fields") and output_format != "array_of_arrays":
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="array_fields_requires_array_format",
)
# === METADATA-ONLY MODE === # === METADATA-ONLY MODE ===
# Early return: calculate and return only metadata, skip all data processing # Early return: calculate and return only metadata, skip all data processing
if metadata == "only": if metadata == "only":

View file

@ -36,7 +36,7 @@ GET_PRICE_SERVICE_NAME = "get_price"
GET_PRICE_SERVICE_SCHEMA = vol.Schema( GET_PRICE_SERVICE_SCHEMA = vol.Schema(
{ {
vol.Required("entry_id"): cv.string, vol.Optional("entry_id", default=""): cv.string,
vol.Required("start_time"): cv.datetime, vol.Required("start_time"): cv.datetime,
vol.Required("end_time"): cv.datetime, vol.Required("end_time"): cv.datetime,
} }
@ -70,7 +70,7 @@ async def handle_get_price(call: ServiceCall) -> ServiceResponse:
""" """
hass: HomeAssistant = call.hass hass: HomeAssistant = call.hass
entry_id: str = call.data["entry_id"] entry_id: str = call.data.get("entry_id", "")
start_time: datetime = call.data["start_time"] start_time: datetime = call.data["start_time"]
end_time: datetime = call.data["end_time"] end_time: datetime = call.data["end_time"]
@ -126,6 +126,13 @@ async def handle_get_price(call: ServiceCall) -> ServiceResponse:
# Step 2: Convert to home timezone # Step 2: Convert to home timezone
end_time = end_time.astimezone(home_tz) end_time = end_time.astimezone(home_tz)
# Validate: end must be after start
if end_time <= start_time:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="end_before_start",
)
_LOGGER.info( _LOGGER.info(
"get_price service called: entry_id=%s, home_id=%s, range=%s to %s", "get_price service called: entry_id=%s, home_id=%s, range=%s to %s",
entry_id, entry_id,

View file

@ -2,52 +2,198 @@
Shared utilities for service handlers. Shared utilities for service handlers.
This module provides common helper functions used across multiple service handlers, This module provides common helper functions used across multiple service handlers,
such as entry validation and data extraction. such as entry validation, data extraction, timezone resolution, and search range handling.
Functions: Functions:
get_entry_and_data: Validate config entry and extract coordinator data get_entry_and_data: Validate config entry and extract coordinator data
has_tomorrow_data: Check if tomorrow's price data is available
resolve_home_timezone: Extract home timezone from coordinator
localize_to_home_tz: Localize datetime to Tibber home timezone
calculate_end_of_tomorrow: Calculate end of tomorrow in home timezone
floor_to_quarter_hour: Floor datetime to quarter-hour boundary
resolve_search_range: Resolve search start/end from various input formats
filter_intervals_by_price_level: Filter intervals by Tibber price level
VALID_SEARCH_SCOPES: Set of valid search_scope shorthand values
PRICE_LEVEL_ORDER: Ordered tuple of price levels (lowest to highest)
Used by: Used by:
- services/chartdata.py: Chart data export service - services/chartdata.py: Chart data export service
- services/apexcharts.py: ApexCharts YAML generation - services/apexcharts.py: ApexCharts YAML generation
- services/refresh_user_data.py: User data refresh - services/refresh_user_data.py: User data refresh
- services/find_cheapest_block.py: Block service (cheapest + most expensive)
- services/find_cheapest_hours.py: Hours service (cheapest + most expensive)
- services/find_most_expensive_block.py: Most expensive block wrapper
- services/find_most_expensive_hours.py: Most expensive hours wrapper
""" """
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timedelta
from datetime import time as dt_time
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from custom_components.tibber_prices.const import DOMAIN from custom_components.tibber_prices.const import DOMAIN
from custom_components.tibber_prices.coordinator.helpers import get_intervals_for_day_offsets from custom_components.tibber_prices.coordinator.helpers import get_intervals_for_day_offsets
from homeassistant.exceptions import ServiceValidationError from homeassistant.exceptions import ServiceValidationError
from homeassistant.util import dt as dt_utils
if TYPE_CHECKING: if TYPE_CHECKING:
from zoneinfo import ZoneInfo
from custom_components.tibber_prices.coordinator import TibberPricesDataUpdateCoordinator from custom_components.tibber_prices.coordinator import TibberPricesDataUpdateCoordinator
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
# Interval duration in minutes (quarter-hourly resolution)
INTERVAL_MINUTES = 15
# Valid scopes for the search_scope shorthand parameter
VALID_SEARCH_SCOPES = frozenset({"today", "tomorrow", "remaining_today", "next_24h", "next_48h"})
# Price level hierarchy (lowest to highest)
PRICE_LEVEL_ORDER = ("VERY_CHEAP", "CHEAP", "NORMAL", "EXPENSIVE", "VERY_EXPENSIVE")
_PRICE_LEVEL_RANK: dict[str, int] = {lvl: i for i, lvl in enumerate(PRICE_LEVEL_ORDER)}
# Parameters that define explicit search range boundaries
_EXPLICIT_RANGE_PARAMS = frozenset(
{
"search_start",
"search_end",
"search_start_time",
"search_end_time",
"search_start_offset_minutes",
"search_end_offset_minutes",
"search_start_day_offset",
"search_end_day_offset",
}
)
def validate_search_params(call_data: dict[str, Any]) -> None:
"""
Validate search range parameter combinations.
Checks for mutually exclusive parameters and required co-dependencies.
Must be called before resolve_search_range().
Raises:
ServiceValidationError: If parameter combinations are invalid
"""
has_scope = "search_scope" in call_data
# search_scope conflicts with all explicit range parameters
if has_scope:
# day_offset params always appear (voluptuous defaults to 0), exclude from conflict check
conflicts = _EXPLICIT_RANGE_PARAMS - {"search_start_day_offset", "search_end_day_offset"}
conflicting = [p for p in conflicts if p in call_data]
if conflicting:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="scope_conflicts_with_range",
translation_placeholders={"params": ", ".join(sorted(conflicting))},
)
# day_offset without matching time parameter is meaningless
# Schema defaults provide 0, but user explicitly setting non-zero without time is an error.
# We detect explicit usage by checking for non-default values when time is absent.
if "search_start_time" not in call_data and call_data.get("search_start_day_offset", 0) != 0:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="day_offset_requires_time",
translation_placeholders={"offset_param": "search_start_day_offset", "time_param": "search_start_time"},
)
if "search_end_time" not in call_data and call_data.get("search_end_day_offset", 0) != 0:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="day_offset_requires_time",
translation_placeholders={"offset_param": "search_end_day_offset", "time_param": "search_end_time"},
)
def validate_price_level_range(
min_price_level: str | None,
max_price_level: str | None,
) -> None:
"""
Validate that min_price_level <= max_price_level in the level hierarchy.
Raises:
ServiceValidationError: If min level is higher than max level
"""
if min_price_level is None or max_price_level is None:
return
min_rank = _PRICE_LEVEL_RANK.get(min_price_level.upper(), 0)
max_rank = _PRICE_LEVEL_RANK.get(max_price_level.upper(), len(PRICE_LEVEL_ORDER) - 1)
if min_rank > max_rank:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="min_level_exceeds_max",
translation_placeholders={"min_level": min_price_level, "max_level": max_price_level},
)
def validate_power_profile_length(
power_profile: list[int] | None,
duration_intervals: int,
) -> None:
"""
Validate that power_profile length matches the number of intervals.
Raises:
ServiceValidationError: If lengths don't match
"""
if power_profile is None:
return
if len(power_profile) != duration_intervals:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="power_profile_length_mismatch",
translation_placeholders={
"profile_length": str(len(power_profile)),
"interval_count": str(duration_intervals),
"duration_minutes": str(duration_intervals * INTERVAL_MINUTES),
},
)
def get_entry_and_data(hass: HomeAssistant, entry_id: str) -> tuple[Any, Any, dict]: def get_entry_and_data(hass: HomeAssistant, entry_id: str) -> tuple[Any, Any, dict]:
""" """
Validate entry and extract coordinator and data. Validate entry and extract coordinator and data.
If entry_id is empty, auto-selects the single config entry for this domain.
Raises an error if there are zero or multiple entries and no entry_id is given.
Args: Args:
hass: Home Assistant instance hass: Home Assistant instance
entry_id: Config entry ID to validate entry_id: Config entry ID to validate (empty string to auto-select)
Returns: Returns:
Tuple of (entry, coordinator, data) Tuple of (entry, coordinator, data)
Raises: Raises:
ServiceValidationError: If entry_id is missing or invalid ServiceValidationError: If entry cannot be resolved
""" """
if not entry_id: if not entry_id:
raise ServiceValidationError(translation_domain=DOMAIN, translation_key="missing_entry_id") entries = hass.config_entries.async_entries(DOMAIN)
entry = next( if len(entries) == 1:
(e for e in hass.config_entries.async_entries(DOMAIN) if e.entry_id == entry_id), entry = entries[0]
None, elif len(entries) == 0:
) raise ServiceValidationError(translation_domain=DOMAIN, translation_key="no_entries_found")
else:
raise ServiceValidationError(translation_domain=DOMAIN, translation_key="multiple_entries_no_entry_id")
else:
entry = next(
(e for e in hass.config_entries.async_entries(DOMAIN) if e.entry_id == entry_id),
None,
)
if not entry or not hasattr(entry, "runtime_data") or not entry.runtime_data: if not entry or not hasattr(entry, "runtime_data") or not entry.runtime_data:
raise ServiceValidationError(translation_domain=DOMAIN, translation_key="invalid_entry_id") raise ServiceValidationError(translation_domain=DOMAIN, translation_key="invalid_entry_id")
coordinator = entry.runtime_data.coordinator coordinator = entry.runtime_data.coordinator
@ -72,3 +218,276 @@ def has_tomorrow_data(coordinator: TibberPricesDataUpdateCoordinator) -> bool:
coordinator_data = coordinator.data or {} coordinator_data = coordinator.data or {}
tomorrow_intervals = get_intervals_for_day_offsets(coordinator_data, [1]) tomorrow_intervals = get_intervals_for_day_offsets(coordinator_data, [1])
return len(tomorrow_intervals) > 0 return len(tomorrow_intervals) > 0
def resolve_home_timezone(
coordinator: Any,
home_id: str,
) -> str:
"""Extract home timezone from coordinator's cached user data."""
user_data = coordinator._cached_user_data # noqa: SLF001
if not user_data:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="user_data_not_available",
)
if "viewer" in user_data:
for home in user_data["viewer"].get("homes", []):
if home.get("id") == home_id:
tz = home.get("timeZone")
if tz:
return tz
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="timezone_not_found",
)
def localize_to_home_tz(dt_value: datetime, home_tz: ZoneInfo) -> datetime:
"""
Localize a datetime to the Tibber home timezone.
Handles the critical two-step process:
1. GUI naive datetime localize to HA server timezone
2. Convert from HA timezone to home timezone
"""
if dt_value.tzinfo is None:
dt_value = dt_utils.as_local(dt_value)
return dt_value.astimezone(home_tz)
def calculate_end_of_tomorrow(home_tz: ZoneInfo) -> datetime:
"""Calculate end of tomorrow in home timezone."""
now_home = dt_utils.now().astimezone(home_tz)
tomorrow = (now_home + timedelta(days=1)).date()
# End of tomorrow = midnight at start of day after tomorrow
return now_home.replace(
year=tomorrow.year,
month=tomorrow.month,
day=tomorrow.day,
hour=0,
minute=0,
second=0,
microsecond=0,
) + timedelta(days=1)
def floor_to_quarter_hour(dt_value: datetime) -> datetime:
"""Floor a datetime to the current quarter-hour boundary."""
return dt_value.replace(minute=(dt_value.minute // INTERVAL_MINUTES) * INTERVAL_MINUTES, second=0, microsecond=0)
def _resolve_time_with_day_offset(
time_value: dt_time,
day_offset: int,
home_tz: ZoneInfo,
) -> datetime:
"""Resolve a time-of-day + day offset to a full datetime in home timezone."""
now_home = dt_utils.now().astimezone(home_tz)
target_date = (now_home + timedelta(days=day_offset)).date()
return datetime(
year=target_date.year,
month=target_date.month,
day=target_date.day,
hour=time_value.hour,
minute=time_value.minute,
second=time_value.second,
tzinfo=home_tz,
)
def _resolve_scope(scope: str, now: datetime, _home_tz: ZoneInfo) -> tuple[datetime, datetime]:
"""
Convert a search_scope shorthand into explicit start/end datetimes.
Args:
scope: One of "today", "tomorrow", "remaining_today", "next_24h", "next_48h"
now: Current datetime in home timezone
home_tz: Home timezone for date calculations
Returns:
Tuple of (start, end) datetimes in home timezone
"""
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
tomorrow_start = today_start + timedelta(days=1)
day_after_start = today_start + timedelta(days=2)
if scope == "today":
return today_start, tomorrow_start
if scope == "tomorrow":
return tomorrow_start, day_after_start
if scope == "remaining_today":
return floor_to_quarter_hour(now), tomorrow_start
if scope == "next_24h":
return floor_to_quarter_hour(now), now + timedelta(hours=24)
if scope == "next_48h":
return floor_to_quarter_hour(now), now + timedelta(hours=48)
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_search_scope",
)
def filter_intervals_by_price_level(
intervals: list[dict[str, Any]],
min_price_level: str | None,
max_price_level: str | None,
) -> list[dict[str, Any]]:
"""
Filter intervals by Tibber price level.
Keeps only intervals whose 'level' field is within the requested range.
If an interval has no 'level' field it is kept (avoids silently dropping data on API changes).
Args:
intervals: Price interval dicts with optional 'level' key
min_price_level: Lower bound level (inclusive), e.g. "CHEAP"
max_price_level: Upper bound level (inclusive), e.g. "NORMAL"
Returns:
Filtered list; same list reference if no filter is active
"""
if min_price_level is None and max_price_level is None:
return intervals
min_rank = _PRICE_LEVEL_RANK.get(min_price_level.upper(), 0) if min_price_level else 0
max_rank = (
_PRICE_LEVEL_RANK.get(max_price_level.upper(), len(PRICE_LEVEL_ORDER) - 1)
if max_price_level
else len(PRICE_LEVEL_ORDER) - 1
)
result = []
for iv in intervals:
level = iv.get("level")
if level is None:
result.append(iv)
continue
rank = _PRICE_LEVEL_RANK.get(str(level).upper())
if rank is None:
result.append(iv)
continue
if min_rank <= rank <= max_rank:
result.append(iv)
return result
def build_rating_lookup(coordinator_data: dict[str, Any]) -> dict[str, str | None]:
"""
Build a startsAt rating_level lookup from enriched coordinator data.
The coordinator's priceInfo contains rating_level (LOW/NORMAL/HIGH) computed
from trailing 24h averages with hysteresis. Pool intervals lack this field,
so this lookup allows annotating service responses with rating_level.
Args:
coordinator_data: coordinator.data dict with enriched priceInfo
Returns:
Dict mapping startsAt ISO string to lowercase rating_level (or None)
"""
lookup: dict[str, str | None] = {}
for iv in coordinator_data.get("priceInfo", []):
starts_at = iv.get("startsAt")
rating = iv.get("rating_level")
if starts_at:
lookup[starts_at] = rating.lower() if isinstance(rating, str) else None
return lookup
def build_response_interval(
iv: dict[str, Any],
unit_factor: int,
rating_lookup: dict[str, str | None],
) -> dict[str, Any]:
"""
Build an enriched interval dict for service responses.
Converts a raw pool interval into a companion-friendly format with
ends_at, level, and rating_level fields.
Args:
iv: Raw interval dict from pool (startsAt, total, level, ...)
unit_factor: Price unit multiplier (1 for base unit, 100 for cents, etc.)
rating_lookup: startsAt rating_level mapping from coordinator data
Returns:
Enriched interval dict for service response
"""
starts_at = iv["startsAt"]
if isinstance(starts_at, str):
ends_at = (datetime.fromisoformat(starts_at) + timedelta(minutes=INTERVAL_MINUTES)).isoformat()
else:
ends_at = (starts_at + timedelta(minutes=INTERVAL_MINUTES)).isoformat()
return {
"starts_at": starts_at,
"ends_at": ends_at,
"price": round(iv["total"] * unit_factor, 4),
"level": (iv.get("level") or "").lower() or None,
"rating_level": rating_lookup.get(starts_at),
}
def resolve_search_range(
call_data: dict[str, Any],
now: datetime,
home_tz: ZoneInfo,
) -> tuple[datetime, datetime]:
"""
Resolve search start/end from scope shorthand, explicit datetime, time+offset, or defaults.
Priority (highest to lowest):
0. search_scope shorthand (today, tomorrow, remaining_today, next_24h, next_48h)
1. Explicit datetime (search_start / search_end)
2. Time-of-day + day offset (search_start_time + search_start_day_offset)
3. Minutes offset (search_start_offset_minutes / search_end_offset_minutes)
4. Default (now for start, end of tomorrow for end)
Calls validate_search_params() first to check for conflicting combinations.
"""
validate_search_params(call_data)
include_current = call_data.get("include_current_interval", True)
# Priority 0: search_scope shorthand
if "search_scope" in call_data:
return _resolve_scope(call_data["search_scope"], now, home_tz)
# --- Resolve start ---
if "search_start" in call_data:
search_start = localize_to_home_tz(call_data["search_start"], home_tz)
elif "search_start_time" in call_data:
day_offset = call_data.get("search_start_day_offset", 0)
search_start = _resolve_time_with_day_offset(call_data["search_start_time"], day_offset, home_tz)
elif "search_start_offset_minutes" in call_data:
search_start = now + timedelta(minutes=call_data["search_start_offset_minutes"])
if include_current:
search_start = floor_to_quarter_hour(search_start)
else:
search_start = floor_to_quarter_hour(now) if include_current else now
# --- Resolve end ---
if "search_end" in call_data:
search_end = localize_to_home_tz(call_data["search_end"], home_tz)
elif "search_end_time" in call_data:
day_offset = call_data.get("search_end_day_offset", 0)
search_end = _resolve_time_with_day_offset(call_data["search_end_time"], day_offset, home_tz)
elif "search_end_offset_minutes" in call_data:
search_end = now + timedelta(minutes=call_data["search_end_offset_minutes"])
else:
search_end = calculate_end_of_tomorrow(home_tz)
if search_end <= search_start:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="end_before_start",
)
return search_start, search_end

View file

@ -40,7 +40,7 @@ ATTR_ENTRY_ID: Final = "entry_id"
# Service schema # Service schema
REFRESH_USER_DATA_SERVICE_SCHEMA: Final = vol.Schema( REFRESH_USER_DATA_SERVICE_SCHEMA: Final = vol.Schema(
{ {
vol.Required(ATTR_ENTRY_ID): str, vol.Optional(ATTR_ENTRY_ID, default=""): str,
} }
) )
@ -64,15 +64,9 @@ async def handle_refresh_user_data(call: ServiceCall) -> dict[str, Any]:
ServiceValidationError: If entry_id is missing or invalid ServiceValidationError: If entry_id is missing or invalid
""" """
entry_id = call.data.get(ATTR_ENTRY_ID) entry_id = call.data.get(ATTR_ENTRY_ID, "")
hass = call.hass hass = call.hass
if not entry_id:
return {
"success": False,
"message": "Entry ID is required",
}
# Get the entry and coordinator # Get the entry and coordinator
try: try:
_, coordinator, _ = get_entry_and_data(hass, entry_id) _, coordinator, _ = get_entry_and_data(hass, entry_id)

View file

@ -59,15 +59,7 @@
"home_already_configured": "Dieses Zuhause ist bereits in einem anderen Eintrag konfiguriert. Jedes Zuhause kann nur einmal konfiguriert werden.", "home_already_configured": "Dieses Zuhause ist bereits in einem anderen Eintrag konfiguriert. Jedes Zuhause kann nur einmal konfiguriert werden.",
"no_active_subscription": "Dieses Zuhause hat keinen aktiven Tibber-Vertrag. Nur Häuser mit aktivem Stromvertrag können zu Home Assistant hinzugefügt werden.", "no_active_subscription": "Dieses Zuhause hat keinen aktiven Tibber-Vertrag. Nur Häuser mit aktivem Stromvertrag können zu Home Assistant hinzugefügt werden.",
"subscription_expired": "Der Tibber-Vertrag für dieses Zuhause ist abgelaufen. Nur Häuser mit aktivem oder zukünftigem Stromvertrag können zu Home Assistant hinzugefügt werden.", "subscription_expired": "Der Tibber-Vertrag für dieses Zuhause ist abgelaufen. Nur Häuser mit aktivem oder zukünftigem Stromvertrag können zu Home Assistant hinzugefügt werden.",
"future_subscription_warning": "Hinweis: Der Tibber-Vertrag für dieses Zuhause hat noch nicht begonnen. Die Funktionalität ist möglicherweise eingeschränkt, bis der Vertrag aktiv wird.", "future_subscription_warning": "Hinweis: Der Tibber-Vertrag für dieses Zuhause hat noch nicht begonnen. Die Funktionalität ist möglicherweise eingeschränkt, bis der Vertrag aktiv wird."
"invalid_yaml_syntax": "Ungültige YAML-Syntax. Bitte überprüfe Einrückungen, Doppelpunkte und Sonderzeichen.",
"invalid_yaml_structure": "YAML muss ein Dictionary/Objekt sein (Schlüssel: Wert-Paare), keine Liste oder reiner Text.",
"service_call_failed": "Service-Aufruf-Validierung fehlgeschlagen: {error_detail}",
"missing_entry_id": "Eintrag-ID wird benötigt, wurde aber nicht bereitgestellt.",
"invalid_entry_id": "Ungültige Eintrag-ID oder Eintrag nicht gefunden.",
"missing_home_id": "Home-ID fehlt in der Konfiguration.",
"user_data_not_available": "Benutzerdaten nicht verfügbar. Bitte aktualisiere zuerst die Benutzerdaten.",
"price_fetch_failed": "Preisdaten konnten nicht abgerufen werden. Bitte prüfe die Logs für Details."
}, },
"abort": { "abort": {
"already_configured": "Alle verfügbaren Tibber-Zuhause sind bereits konfiguriert. Jedes Zuhause kann nur einmal konfiguriert werden.", "already_configured": "Alle verfügbaren Tibber-Zuhause sind bereits konfiguriert. Jedes Zuhause kann nur einmal konfiguriert werden.",
@ -1055,6 +1047,62 @@
"description": "Dieses Update enthält Breaking Changes, die automatisch angewendet wurden.\n\n**Umbenannte Entitäten ({count})**\n\nDie folgenden Entity-Keys wurden umbenannt. Deine bestehenden Entity-IDs und Automationen bleiben erhalten:\n\n{entity_list}\n\n**Geänderte Dauer-Sensorwerte**\n\nAlle Dauer-Sensoren (verbleibende Zeit, startet in, Periodendauer, Trendänderungs-Countdown) geben ihren Zustandswert jetzt in **Minuten** statt Stunden an. Die Anzeigeeinheit in Dashboards bleibt standardmäßig Stunden.\n\nWenn du Automationen mit numerischen Vergleichen auf diesen Sensoren hast, aktualisiere deine Schwellwerte:\n- Alt: `state < 0.25` (15 Minuten als Stunden)\n- Neu: `state < 15` (15 Minuten)\n\nSchließe diesen Hinweis, nachdem du deine Automationen überprüft hast." "description": "Dieses Update enthält Breaking Changes, die automatisch angewendet wurden.\n\n**Umbenannte Entitäten ({count})**\n\nDie folgenden Entity-Keys wurden umbenannt. Deine bestehenden Entity-IDs und Automationen bleiben erhalten:\n\n{entity_list}\n\n**Geänderte Dauer-Sensorwerte**\n\nAlle Dauer-Sensoren (verbleibende Zeit, startet in, Periodendauer, Trendänderungs-Countdown) geben ihren Zustandswert jetzt in **Minuten** statt Stunden an. Die Anzeigeeinheit in Dashboards bleibt standardmäßig Stunden.\n\nWenn du Automationen mit numerischen Vergleichen auf diesen Sensoren hast, aktualisiere deine Schwellwerte:\n- Alt: `state < 0.25` (15 Minuten als Stunden)\n- Neu: `state < 15` (15 Minuten)\n\nSchließe diesen Hinweis, nachdem du deine Automationen überprüft hast."
} }
}, },
"exceptions": {
"no_entries_found": {
"message": "Keine Tibber Prices Integrationseinträge gefunden. Bitte richte die Integration zuerst ein."
},
"multiple_entries_no_entry_id": {
"message": "Mehrere Tibber Prices Einträge gefunden. Bitte gib 'entry_id' an, um den gewünschten Eintrag auszuwählen."
},
"invalid_entry_id": {
"message": "Ungültige oder nicht verfügbare Konfigurations-Entry. Bitte überprüfe die Entry-ID und stelle sicher, dass die Integration geladen ist."
},
"missing_home_id": {
"message": "Home-ID nicht im Konfigurationseintrag gefunden. Bitte konfiguriere die Integration neu."
},
"user_data_not_available": {
"message": "Benutzerdaten sind noch nicht verfügbar. Bitte warte, bis das erste Datenupdate abgeschlossen ist."
},
"timezone_not_found": {
"message": "Zeitzone des Zuhauses konnte nicht ermittelt werden. Bitte überprüfe die Konfiguration in deinem Tibber-Konto."
},
"end_before_start": {
"message": "Endzeit muss nach der Startzeit liegen."
},
"price_fetch_failed": {
"message": "Preisdaten konnten nicht von der Tibber-API abgerufen werden. Bitte versuche es später erneut."
},
"invalid_search_scope": {
"message": "Ungültiger Suchbereich. Gültige Werte sind: today, tomorrow, remaining_today, next_24h, next_48h."
},
"scope_conflicts_with_range": {
"message": "search_scope kann nicht mit expliziten Bereichsparametern kombiniert werden: {params}. Verwende entweder search_scope ODER explizite Start-/Endparameter."
},
"day_offset_requires_time": {
"message": "{offset_param} erfordert, dass {time_param} gesetzt ist. Der Tagesoffset ändert nur das Datum eines expliziten Zeitparameters."
},
"min_level_exceeds_max": {
"message": "min_price_level '{min_level}' ist höher als max_price_level '{max_level}'. Das Mindestlevel muss gleich oder niedriger als das Maximallevel sein."
},
"power_profile_length_mismatch": {
"message": "power_profile hat {profile_length} Einträge, aber die Dauer erfordert {interval_count} Intervalle ({duration_minutes} Minuten). Das power_profile muss genau einen Eintrag pro 15-Minuten-Intervall haben."
},
"level_and_rating_filter_conflict": {
"message": "level_filter und rating_level_filter können nicht gleichzeitig verwendet werden. Verwende nur einen Filtertyp pro Anfrage."
},
"insert_nulls_requires_filter": {
"message": "insert_nulls-Modus '{mode}' erfordert einen level_filter oder rating_level_filter zur Segmentdefinition. Ohne Filter verwende insert_nulls: none."
},
"connect_segments_requires_segments_mode": {
"message": "connect_segments erfordert, dass insert_nulls auf 'segments' gesetzt ist. Setze insert_nulls: segments, um Segmentverbindung zu nutzen."
},
"array_fields_requires_array_format": {
"message": "array_fields kann nur mit output_format: array_of_arrays verwendet werden. Ändere das Ausgabeformat oder entferne array_fields."
},
"invalid_array_fields": {
"message": "Ungültige array_fields-Vorlage. Verwende Feldnamen in geschweiften Klammern, z.B. '{start_time}, {price_per_kwh}, {level}'."
}
},
"services": { "services": {
"get_price": { "get_price": {
"name": "Preisdaten abrufen", "name": "Preisdaten abrufen",
@ -1261,6 +1309,474 @@
"description": "Die Konfigurationseintrag-ID für die Tibber-Integration." "description": "Die Konfigurationseintrag-ID für die Tibber-Integration."
} }
} }
},
"find_cheapest_block": {
"name": "Günstigsten Block finden",
"description": "Findet das günstigste zusammenhängende Zeitfenster einer bestimmten Dauer. Gedacht für Geräteplanung: Spülmaschine, Waschmaschine, Trockner usw. Gibt das günstigste Fenster mit Start-/Endzeiten und Preisstatistiken zurück.",
"sections": {
"search_range": {
"name": "Suchbereich",
"description": "Zeitfenster fuer die Suche festlegen."
},
"time_alternatives": {
"name": "Alternative Zeitbereich-Optionen",
"description": "Alternative Moeglichkeiten zum Festlegen des Suchbereichs ueber Tageszeit und Offsets."
},
"price_filter": {
"name": "Preisstufen-Filter",
"description": "Suche auf Intervalle innerhalb des angegebenen Preisstufen-Bereichs einschraenken."
},
"output": {
"name": "Ausgabeoptionen",
"description": "Kostenabschaetzung und Vergleichsausgabe steuern."
}
},
"fields": {
"entry_id": {
"name": "Eintrag-ID",
"description": "Die Konfigurationseintrag-ID für die Tibber-Integration."
},
"duration": {
"name": "Dauer",
"description": "Länge des gewünschten zusammenhängenden Fensters. Wird automatisch auf die nächste Viertelstunde aufgerundet. Maximum: 12 Stunden."
},
"search_start": {
"name": "Suchbeginn",
"description": "Beginn des Suchbereichs als exaktes Datum und Uhrzeit. Höchste Priorität — überschreibt alle anderen Startoptionen. Standardmäßig jetzt, wenn nicht angegeben."
},
"search_end": {
"name": "Suchende",
"description": "Ende des Suchbereichs als exaktes Datum und Uhrzeit. Höchste Priorität — überschreibt alle anderen Endoptionen. Standardmäßig Ende von morgen, wenn nicht angegeben."
},
"search_start_time": {
"name": "Suchbeginn-Uhrzeit",
"description": "Alternative: Suche ab dieser Uhrzeit starten. Mit Tages-Versatz kombinieren. Wird ignoriert, wenn Suchbeginn (Datum/Uhrzeit) gesetzt ist."
},
"search_start_day_offset": {
"name": "Suchbeginn Tages-Versatz",
"description": "Tages-Versatz für Suchbeginn-Uhrzeit. -7 bis 2: -1 = gestern, 0 = heute, 1 = morgen. Negative Werte suchen in der Vergangenheit. Nur mit Suchbeginn-Uhrzeit verwendet."
},
"search_end_time": {
"name": "Suchende-Uhrzeit",
"description": "Alternative: Suche bis zu dieser Uhrzeit. Mit Tages-Versatz kombinieren. Wird ignoriert, wenn Suchende (Datum/Uhrzeit) gesetzt ist."
},
"search_end_day_offset": {
"name": "Suchende Tages-Versatz",
"description": "Tages-Versatz für Suchende-Uhrzeit. -7 bis 2: -1 = gestern, 0 = heute, 1 = morgen. Negative Werte suchen in der Vergangenheit. Nur mit Suchende-Uhrzeit verwendet."
},
"search_start_offset_minutes": {
"name": "Suchbeginn-Versatz (Minuten)",
"description": "Alternative: Suche startet in dieser Anzahl Minuten ab jetzt. Positiv = Zukunft (60 = in 1 Stunde), negativ = Vergangenheit (-60 = vor 1 Stunde). Wird ignoriert, wenn Suchbeginn oder Suchbeginn-Uhrzeit gesetzt ist."
},
"search_end_offset_minutes": {
"name": "Suchende-Versatz (Minuten)",
"description": "Alternative: Suche endet in dieser Anzahl Minuten ab jetzt. Positiv = Zukunft (480 = in 8 Stunden), negativ = Vergangenheit (-60 = vor 1 Stunde). Wird ignoriert, wenn Suchende oder Suchende-Uhrzeit gesetzt ist."
},
"include_current_interval": {
"name": "Aktuelles Intervall einbeziehen",
"description": "Das aktuell laufende 15-Minuten-Intervall in die Suche einbeziehen. Wenn aktiviert (Standard), beginnt die Suche am Anfang des aktuellen Intervalls, sodass es Teil des Ergebnisses sein kann."
},
"use_base_unit": {
"name": "Basiswährung verwenden",
"description": "Preise in Basiswährung (EUR, NOK) statt der konfigurierten Anzeigeeinheit (ct, øre) erzwingen. Nützlich für Berechnungen."
},
"search_scope": {
"name": "Suchbereich (Shortcut)",
"description": "Kurzwahl fuer haeufige Suchbereiche. Ueberschreibt alle anderen Zeitbereich-Optionen. today/tomorrow = ganzer Kalendertag, remaining_today = jetzt bis Mitternacht, next_24h/next_48h = rollierendes Fenster ab jetzt."
},
"max_price_level": {
"name": "Maximale Preisstufe",
"description": "Nur Intervalle bis zu dieser Tibber-Preisstufe beruecksichtigen. very_cheap = restriktivste, very_expensive = keine Einschraenkung."
},
"min_price_level": {
"name": "Minimale Preisstufe",
"description": "Nur Intervalle ab dieser Tibber-Preisstufe beruecksichtigen. Nuetzlich fuer find_most_expensive, um wirklich teure Intervalle zu fokussieren."
},
"include_comparison_details": {
"name": "Vergleichsdetails einschliessen",
"description": "Das price_comparison-Ergebnis um zusaetzliche Felder ergaenzen: comparison_price_min, comparison_price_max und (nur Block) comparison_window_end."
},
"power_profile": {
"name": "Leistungsprofil",
"description": "Variable Leistungsaufnahme in Watt pro 15-Minuten-Intervall. Wenn gesetzt, gibt estimated_total_cost den tatsaechlichen Verbrauch statt einer festen 1-kW-Last an."
}
}
},
"find_most_expensive_block": {
"name": "Teuersten Block finden",
"description": "Findet das teuerste zusammenhängende Zeitfenster einer bestimmten Dauer. Nützlich zur Erkennung von Spitzenpreiszeiträumen, die vermieden werden sollten. Gibt das teuerste Fenster mit Start-/Endzeiten und Preisstatistiken zurück.",
"sections": {
"search_range": {
"name": "Suchbereich",
"description": "Zeitfenster fuer die Suche festlegen."
},
"time_alternatives": {
"name": "Alternative Zeitbereich-Optionen",
"description": "Alternative Moeglichkeiten zum Festlegen des Suchbereichs ueber Tageszeit und Offsets."
},
"price_filter": {
"name": "Preisstufen-Filter",
"description": "Suche auf Intervalle innerhalb des angegebenen Preisstufen-Bereichs einschraenken."
},
"output": {
"name": "Ausgabeoptionen",
"description": "Kostenabschaetzung und Vergleichsausgabe steuern."
}
},
"fields": {
"entry_id": {
"name": "Eintrag-ID",
"description": "Die Konfigurationseintrag-ID für die Tibber-Integration."
},
"duration": {
"name": "Dauer",
"description": "Länge des gewünschten zusammenhängenden Fensters. Wird automatisch auf die nächste Viertelstunde aufgerundet. Maximum: 12 Stunden."
},
"search_start": {
"name": "Suchbeginn",
"description": "Beginn des Suchbereichs als exaktes Datum und Uhrzeit. Höchste Priorität — überschreibt alle anderen Startoptionen. Standardmäßig jetzt, wenn nicht angegeben."
},
"search_end": {
"name": "Suchende",
"description": "Ende des Suchbereichs als exaktes Datum und Uhrzeit. Höchste Priorität — überschreibt alle anderen Endoptionen. Standardmäßig Ende von morgen, wenn nicht angegeben."
},
"search_start_time": {
"name": "Suchbeginn-Uhrzeit",
"description": "Alternative: Suche ab dieser Uhrzeit starten. Mit Tages-Versatz kombinieren. Wird ignoriert, wenn Suchbeginn (Datum/Uhrzeit) gesetzt ist."
},
"search_start_day_offset": {
"name": "Suchbeginn Tages-Versatz",
"description": "Tages-Versatz für Suchbeginn-Uhrzeit. -7 bis 2: -1 = gestern, 0 = heute, 1 = morgen. Negative Werte suchen in der Vergangenheit. Nur mit Suchbeginn-Uhrzeit verwendet."
},
"search_end_time": {
"name": "Suchende-Uhrzeit",
"description": "Alternative: Suche bis zu dieser Uhrzeit. Mit Tages-Versatz kombinieren. Wird ignoriert, wenn Suchende (Datum/Uhrzeit) gesetzt ist."
},
"search_end_day_offset": {
"name": "Suchende Tages-Versatz",
"description": "Tages-Versatz für Suchende-Uhrzeit. -7 bis 2: -1 = gestern, 0 = heute, 1 = morgen. Negative Werte suchen in der Vergangenheit. Nur mit Suchende-Uhrzeit verwendet."
},
"search_start_offset_minutes": {
"name": "Suchbeginn-Versatz (Minuten)",
"description": "Alternative: Suche startet in dieser Anzahl Minuten ab jetzt. Positiv = Zukunft (60 = in 1 Stunde), negativ = Vergangenheit (-60 = vor 1 Stunde). Wird ignoriert, wenn Suchbeginn oder Suchbeginn-Uhrzeit gesetzt ist."
},
"search_end_offset_minutes": {
"name": "Suchende-Versatz (Minuten)",
"description": "Alternative: Suche endet in dieser Anzahl Minuten ab jetzt. Positiv = Zukunft (480 = in 8 Stunden), negativ = Vergangenheit (-60 = vor 1 Stunde). Wird ignoriert, wenn Suchende oder Suchende-Uhrzeit gesetzt ist."
},
"include_current_interval": {
"name": "Aktuelles Intervall einbeziehen",
"description": "Das aktuell laufende 15-Minuten-Intervall in die Suche einbeziehen. Wenn aktiviert (Standard), beginnt die Suche am Anfang des aktuellen Intervalls, sodass es Teil des Ergebnisses sein kann."
},
"use_base_unit": {
"name": "Basiswährung verwenden",
"description": "Preise in Basiswährung (EUR, NOK) statt der konfigurierten Anzeigeeinheit (ct, øre) erzwingen. Nützlich für Berechnungen."
},
"search_scope": {
"name": "Suchbereich (Shortcut)",
"description": "Kurzwahl fuer haeufige Suchbereiche. Ueberschreibt alle anderen Zeitbereich-Optionen. today/tomorrow = ganzer Kalendertag, remaining_today = jetzt bis Mitternacht, next_24h/next_48h = rollierendes Fenster ab jetzt."
},
"max_price_level": {
"name": "Maximale Preisstufe",
"description": "Nur Intervalle bis zu dieser Tibber-Preisstufe beruecksichtigen. very_cheap = restriktivste, very_expensive = keine Einschraenkung."
},
"min_price_level": {
"name": "Minimale Preisstufe",
"description": "Nur Intervalle ab dieser Tibber-Preisstufe beruecksichtigen. Nuetzlich fuer find_most_expensive, um wirklich teure Intervalle zu fokussieren."
},
"include_comparison_details": {
"name": "Vergleichsdetails einschliessen",
"description": "Das price_comparison-Ergebnis um zusaetzliche Felder ergaenzen: comparison_price_min, comparison_price_max und (nur Block) comparison_window_end."
},
"power_profile": {
"name": "Leistungsprofil",
"description": "Variable Leistungsaufnahme in Watt pro 15-Minuten-Intervall. Wenn gesetzt, gibt estimated_total_cost den tatsaechlichen Verbrauch statt einer festen 1-kW-Last an."
}
}
},
"find_cheapest_hours": {
"name": "Günstigste Stunden finden",
"description": "Findet die günstigsten Intervalle für eine bestimmte Gesamtdauer, nicht unbedingt zusammenhängend. Gedacht für flexible Lasten: Batterieladung, E-Auto, Warmwasserspeicher. Gibt einen Zeitplan mit Intervallen gruppiert in zusammenhängende Segmente zurück.",
"sections": {
"search_range": {
"name": "Suchbereich",
"description": "Zeitfenster fuer die Suche festlegen."
},
"time_alternatives": {
"name": "Alternative Zeitbereich-Optionen",
"description": "Alternative Moeglichkeiten zum Festlegen des Suchbereichs ueber Tageszeit und Offsets."
},
"price_filter": {
"name": "Preisstufen-Filter",
"description": "Suche auf Intervalle innerhalb des angegebenen Preisstufen-Bereichs einschraenken."
},
"output": {
"name": "Ausgabeoptionen",
"description": "Kostenabschaetzung und Vergleichsausgabe steuern."
}
},
"fields": {
"entry_id": {
"name": "Eintrag-ID",
"description": "Die Konfigurationseintrag-ID für die Tibber-Integration."
},
"duration": {
"name": "Dauer",
"description": "Benötigte günstige Gesamtzeit. Wird automatisch auf die nächste Viertelstunde aufgerundet. Maximum: 24 Stunden."
},
"search_start": {
"name": "Suchbeginn",
"description": "Beginn des Suchbereichs als exaktes Datum und Uhrzeit. Höchste Priorität — überschreibt alle anderen Startoptionen. Standardmäßig jetzt, wenn nicht angegeben."
},
"search_end": {
"name": "Suchende",
"description": "Ende des Suchbereichs als exaktes Datum und Uhrzeit. Höchste Priorität — überschreibt alle anderen Endoptionen. Standardmäßig Ende von morgen, wenn nicht angegeben."
},
"search_start_time": {
"name": "Suchbeginn-Uhrzeit",
"description": "Alternative: Suche ab dieser Uhrzeit starten. Mit Tages-Versatz kombinieren. Wird ignoriert, wenn Suchbeginn (Datum/Uhrzeit) gesetzt ist."
},
"search_start_day_offset": {
"name": "Suchbeginn Tages-Versatz",
"description": "Tages-Versatz für Suchbeginn-Uhrzeit. -7 bis 2: -1 = gestern, 0 = heute, 1 = morgen. Negative Werte suchen in der Vergangenheit. Nur mit Suchbeginn-Uhrzeit verwendet."
},
"search_end_time": {
"name": "Suchende-Uhrzeit",
"description": "Alternative: Suche bis zu dieser Uhrzeit. Mit Tages-Versatz kombinieren. Wird ignoriert, wenn Suchende (Datum/Uhrzeit) gesetzt ist."
},
"search_end_day_offset": {
"name": "Suchende Tages-Versatz",
"description": "Tages-Versatz für Suchende-Uhrzeit. -7 bis 2: -1 = gestern, 0 = heute, 1 = morgen. Negative Werte suchen in der Vergangenheit. Nur mit Suchende-Uhrzeit verwendet."
},
"search_start_offset_minutes": {
"name": "Suchbeginn-Versatz (Minuten)",
"description": "Alternative: Suche startet in dieser Anzahl Minuten ab jetzt. Positiv = Zukunft (60 = in 1 Stunde), negativ = Vergangenheit (-60 = vor 1 Stunde). Wird ignoriert, wenn Suchbeginn oder Suchbeginn-Uhrzeit gesetzt ist."
},
"search_end_offset_minutes": {
"name": "Suchende-Versatz (Minuten)",
"description": "Alternative: Suche endet in dieser Anzahl Minuten ab jetzt. Positiv = Zukunft (480 = in 8 Stunden), negativ = Vergangenheit (-60 = vor 1 Stunde). Wird ignoriert, wenn Suchende oder Suchende-Uhrzeit gesetzt ist."
},
"include_current_interval": {
"name": "Aktuelles Intervall einbeziehen",
"description": "Das aktuell laufende 15-Minuten-Intervall in die Suche einbeziehen. Wenn aktiviert (Standard), beginnt die Suche am Anfang des aktuellen Intervalls, sodass es Teil des Ergebnisses sein kann."
},
"min_segment_duration": {
"name": "Minimale Segmentdauer",
"description": "Minimale zusammenhängende Laufzeit. Verhindert schnelles Ein-/Ausschalten bei Geräten mit Mindestlaufzeiten. Wird automatisch auf die nächste Viertelstunde aufgerundet. Standard: 15 Minuten. Maximum: 4 Stunden."
},
"use_base_unit": {
"name": "Basiswährung verwenden",
"description": "Preise in Basiswährung (EUR, NOK) statt der konfigurierten Anzeigeeinheit (ct, øre) erzwingen. Nützlich für Berechnungen."
},
"search_scope": {
"name": "Suchbereich (Shortcut)",
"description": "Kurzwahl fuer haeufige Suchbereiche. Ueberschreibt alle anderen Zeitbereich-Optionen. today/tomorrow = ganzer Kalendertag, remaining_today = jetzt bis Mitternacht, next_24h/next_48h = rollierendes Fenster ab jetzt."
},
"max_price_level": {
"name": "Maximale Preisstufe",
"description": "Nur Intervalle bis zu dieser Tibber-Preisstufe beruecksichtigen. very_cheap = restriktivste, very_expensive = keine Einschraenkung."
},
"min_price_level": {
"name": "Minimale Preisstufe",
"description": "Nur Intervalle ab dieser Tibber-Preisstufe beruecksichtigen. Nuetzlich fuer find_most_expensive, um wirklich teure Intervalle zu fokussieren."
},
"include_comparison_details": {
"name": "Vergleichsdetails einschliessen",
"description": "Das price_comparison-Ergebnis um zusaetzliche Felder ergaenzen: comparison_price_min, comparison_price_max und (nur Block) comparison_window_end."
},
"power_profile": {
"name": "Leistungsprofil",
"description": "Variable Leistungsaufnahme in Watt pro 15-Minuten-Intervall. Wenn gesetzt, gibt estimated_total_cost den tatsaechlichen Verbrauch statt einer festen 1-kW-Last an."
}
}
},
"find_most_expensive_hours": {
"name": "Teuerste Stunden finden",
"description": "Findet die teuersten Intervalle für eine bestimmte Gesamtdauer, nicht unbedingt zusammenhängend. Nützlich zur Erkennung von Spitzenpreiszeiträumen, die vermieden werden sollten. Gibt einen Zeitplan mit Intervallen gruppiert in zusammenhängende Segmente zurück.",
"sections": {
"search_range": {
"name": "Suchbereich",
"description": "Zeitfenster fuer die Suche festlegen."
},
"time_alternatives": {
"name": "Alternative Zeitbereich-Optionen",
"description": "Alternative Moeglichkeiten zum Festlegen des Suchbereichs ueber Tageszeit und Offsets."
},
"price_filter": {
"name": "Preisstufen-Filter",
"description": "Suche auf Intervalle innerhalb des angegebenen Preisstufen-Bereichs einschraenken."
},
"output": {
"name": "Ausgabeoptionen",
"description": "Kostenabschaetzung und Vergleichsausgabe steuern."
}
},
"fields": {
"entry_id": {
"name": "Eintrag-ID",
"description": "Die Konfigurationseintrag-ID für die Tibber-Integration."
},
"duration": {
"name": "Dauer",
"description": "Zu findende teure Gesamtzeit. Wird automatisch auf die nächste Viertelstunde aufgerundet. Maximum: 24 Stunden."
},
"search_start": {
"name": "Suchbeginn",
"description": "Beginn des Suchbereichs als exaktes Datum und Uhrzeit. Höchste Priorität — überschreibt alle anderen Startoptionen. Standardmäßig jetzt, wenn nicht angegeben."
},
"search_end": {
"name": "Suchende",
"description": "Ende des Suchbereichs als exaktes Datum und Uhrzeit. Höchste Priorität — überschreibt alle anderen Endoptionen. Standardmäßig Ende von morgen, wenn nicht angegeben."
},
"search_start_time": {
"name": "Suchbeginn-Uhrzeit",
"description": "Alternative: Suche ab dieser Uhrzeit starten. Mit Tages-Versatz kombinieren. Wird ignoriert, wenn Suchbeginn (Datum/Uhrzeit) gesetzt ist."
},
"search_start_day_offset": {
"name": "Suchbeginn Tages-Versatz",
"description": "Tages-Versatz für Suchbeginn-Uhrzeit. -7 bis 2: -1 = gestern, 0 = heute, 1 = morgen. Negative Werte suchen in der Vergangenheit. Nur mit Suchbeginn-Uhrzeit verwendet."
},
"search_end_time": {
"name": "Suchende-Uhrzeit",
"description": "Alternative: Suche bis zu dieser Uhrzeit. Mit Tages-Versatz kombinieren. Wird ignoriert, wenn Suchende (Datum/Uhrzeit) gesetzt ist."
},
"search_end_day_offset": {
"name": "Suchende Tages-Versatz",
"description": "Tages-Versatz für Suchende-Uhrzeit. -7 bis 2: -1 = gestern, 0 = heute, 1 = morgen. Negative Werte suchen in der Vergangenheit. Nur mit Suchende-Uhrzeit verwendet."
},
"search_start_offset_minutes": {
"name": "Suchbeginn-Versatz (Minuten)",
"description": "Alternative: Suche startet in dieser Anzahl Minuten ab jetzt. Positiv = Zukunft (60 = in 1 Stunde), negativ = Vergangenheit (-60 = vor 1 Stunde). Wird ignoriert, wenn Suchbeginn oder Suchbeginn-Uhrzeit gesetzt ist."
},
"search_end_offset_minutes": {
"name": "Suchende-Versatz (Minuten)",
"description": "Alternative: Suche endet in dieser Anzahl Minuten ab jetzt. Positiv = Zukunft (480 = in 8 Stunden), negativ = Vergangenheit (-60 = vor 1 Stunde). Wird ignoriert, wenn Suchende oder Suchende-Uhrzeit gesetzt ist."
},
"include_current_interval": {
"name": "Aktuelles Intervall einbeziehen",
"description": "Das aktuell laufende 15-Minuten-Intervall in die Suche einbeziehen. Wenn aktiviert (Standard), beginnt die Suche am Anfang des aktuellen Intervalls, sodass es Teil des Ergebnisses sein kann."
},
"min_segment_duration": {
"name": "Minimale Segmentdauer",
"description": "Minimale zusammenhängende Laufzeit. Verhindert schnelles Ein-/Ausschalten bei Geräten mit Mindestlaufzeiten. Wird automatisch auf die nächste Viertelstunde aufgerundet. Standard: 15 Minuten. Maximum: 4 Stunden."
},
"use_base_unit": {
"name": "Basiswährung verwenden",
"description": "Preise in Basiswährung (EUR, NOK) statt der konfigurierten Anzeigeeinheit (ct, øre) erzwingen. Nützlich für Berechnungen."
},
"search_scope": {
"name": "Suchbereich (Shortcut)",
"description": "Kurzwahl fuer haeufige Suchbereiche. Ueberschreibt alle anderen Zeitbereich-Optionen. today/tomorrow = ganzer Kalendertag, remaining_today = jetzt bis Mitternacht, next_24h/next_48h = rollierendes Fenster ab jetzt."
},
"max_price_level": {
"name": "Maximale Preisstufe",
"description": "Nur Intervalle bis zu dieser Tibber-Preisstufe beruecksichtigen. very_cheap = restriktivste, very_expensive = keine Einschraenkung."
},
"min_price_level": {
"name": "Minimale Preisstufe",
"description": "Nur Intervalle ab dieser Tibber-Preisstufe beruecksichtigen. Nuetzlich fuer find_most_expensive, um wirklich teure Intervalle zu fokussieren."
},
"include_comparison_details": {
"name": "Vergleichsdetails einschliessen",
"description": "Das price_comparison-Ergebnis um zusaetzliche Felder ergaenzen: comparison_price_min, comparison_price_max und (nur Block) comparison_window_end."
},
"power_profile": {
"name": "Leistungsprofil",
"description": "Variable Leistungsaufnahme in Watt pro 15-Minuten-Intervall. Wenn gesetzt, gibt estimated_total_cost den tatsaechlichen Verbrauch statt einer festen 1-kW-Last an."
}
}
},
"find_cheapest_schedule": {
"name": "Guenstigstes Programm planen",
"description": "Plant mehrere Geraete optimal ohne Zeitueberschneidung. Jede Aufgabe erhaelt das guenstigste verfuegbare zusammenhaengende Zeitfenster.",
"sections": {
"scheduling_options": {
"name": "Planungsoptionen",
"description": "Aufgaben und Puffer zwischen den Geraeteaufzeiten konfigurieren."
},
"search_range": {
"name": "Suchbereich",
"description": "Zeitfenster fuer die Suche festlegen."
},
"time_alternatives": {
"name": "Alternative Zeitbereich-Optionen",
"description": "Alternative Moeglichkeiten zum Festlegen des Suchbereichs ueber Tageszeit und Offsets."
},
"price_filter": {
"name": "Preisstufen-Filter",
"description": "Suche auf Intervalle innerhalb des angegebenen Preisstufen-Bereichs einschraenken."
},
"output": {
"name": "Ausgabeoptionen",
"description": "Kostenabschaetzung und Vergleichsausgabe steuern."
}
},
"fields": {
"entry_id": {
"name": "Eintrag-ID",
"description": "Die Konfigurationseintrag-ID für die Tibber-Integration."
},
"tasks": {
"name": "Aufgaben",
"description": "Liste der zu planenden Aufgaben. Jede Aufgabe benoetigt name (Text) und duration (hh:mm:ss). Optional: power_profile (Watt pro 15-min-Intervall). Maximal 4 Aufgaben."
},
"gap_minutes": {
"name": "Pause zwischen Aufgaben (Minuten)",
"description": "Mindestpause in Minuten zwischen aufeinanderfolgenden Aufgaben. Wird auf 15 Minuten aufgerundet. Standard: 0 (keine Pause)."
},
"search_scope": {
"name": "Suchbereich (Shortcut)",
"description": "Kurzwahl fuer haeufige Suchbereiche. Ueberschreibt alle anderen Zeitbereich-Optionen. today/tomorrow = ganzer Kalendertag, remaining_today = jetzt bis Mitternacht, next_24h/next_48h = rollierendes Fenster ab jetzt."
},
"search_start": {
"name": "Suchbeginn",
"description": "Beginn des Suchbereichs als exaktes Datum und Uhrzeit. Höchste Priorität — überschreibt alle anderen Startoptionen. Standardmäßig jetzt, wenn nicht angegeben."
},
"search_end": {
"name": "Suchende",
"description": "Ende des Suchbereichs als exaktes Datum und Uhrzeit. Höchste Priorität — überschreibt alle anderen Endoptionen. Standardmäßig Ende von morgen, wenn nicht angegeben."
},
"search_start_time": {
"name": "Suchbeginn-Uhrzeit",
"description": "Alternative: Suche ab dieser Uhrzeit starten. Mit Tages-Versatz kombinieren. Wird ignoriert, wenn Suchbeginn (Datum/Uhrzeit) gesetzt ist."
},
"search_start_day_offset": {
"name": "Suchbeginn Tages-Versatz",
"description": "Tages-Versatz für Suchbeginn-Uhrzeit. -7 bis 2: -1 = gestern, 0 = heute, 1 = morgen. Negative Werte suchen in der Vergangenheit. Nur mit Suchbeginn-Uhrzeit verwendet."
},
"search_end_time": {
"name": "Suchende-Uhrzeit",
"description": "Alternative: Suche bis zu dieser Uhrzeit. Mit Tages-Versatz kombinieren. Wird ignoriert, wenn Suchende (Datum/Uhrzeit) gesetzt ist."
},
"search_end_day_offset": {
"name": "Suchende Tages-Versatz",
"description": "Tages-Versatz für Suchende-Uhrzeit. -7 bis 2: -1 = gestern, 0 = heute, 1 = morgen. Negative Werte suchen in der Vergangenheit. Nur mit Suchende-Uhrzeit verwendet."
},
"search_start_offset_minutes": {
"name": "Suchbeginn-Versatz (Minuten)",
"description": "Alternative: Suche startet in dieser Anzahl Minuten ab jetzt. Positiv = Zukunft (60 = in 1 Stunde), negativ = Vergangenheit (-60 = vor 1 Stunde). Wird ignoriert, wenn Suchbeginn oder Suchbeginn-Uhrzeit gesetzt ist."
},
"search_end_offset_minutes": {
"name": "Suchende-Versatz (Minuten)",
"description": "Alternative: Suche endet in dieser Anzahl Minuten ab jetzt. Positiv = Zukunft (480 = in 8 Stunden), negativ = Vergangenheit (-60 = vor 1 Stunde). Wird ignoriert, wenn Suchende oder Suchende-Uhrzeit gesetzt ist."
},
"include_current_interval": {
"name": "Aktuelles Intervall einbeziehen",
"description": "Das aktuell laufende 15-Minuten-Intervall in die Suche einbeziehen. Wenn aktiviert (Standard), beginnt die Suche am Anfang des aktuellen Intervalls, sodass es Teil des Ergebnisses sein kann."
},
"max_price_level": {
"name": "Maximale Preisstufe",
"description": "Nur Intervalle bis zu dieser Tibber-Preisstufe beruecksichtigen. very_cheap = restriktivste, very_expensive = keine Einschraenkung."
},
"min_price_level": {
"name": "Minimale Preisstufe",
"description": "Nur Intervalle ab dieser Tibber-Preisstufe beruecksichtigen. Nuetzlich fuer find_most_expensive, um wirklich teure Intervalle zu fokussieren."
},
"use_base_unit": {
"name": "Basiswährung verwenden",
"description": "Preise in Basiswährung (EUR, NOK) statt der konfigurierten Anzeigeeinheit (ct, øre) erzwingen. Nützlich für Berechnungen."
}
}
} }
}, },
"selector": { "selector": {
@ -1361,6 +1877,15 @@
"median": "Median", "median": "Median",
"mean": "Arithmetisches Mittel" "mean": "Arithmetisches Mittel"
} }
},
"search_scope": {
"options": {
"today": "Heute",
"tomorrow": "Morgen",
"remaining_today": "Rest heute",
"next_24h": "Naechste 24 Stunden",
"next_48h": "Naechste 48 Stunden"
}
} }
}, },
"title": "Tibber Preisinformationen & Bewertungen" "title": "Tibber Preisinformationen & Bewertungen"

View file

@ -59,15 +59,7 @@
"home_already_configured": "This home is already configured in another entry. Each home can only be configured once.", "home_already_configured": "This home is already configured in another entry. Each home can only be configured once.",
"no_active_subscription": "This home does not have an active Tibber contract. Only homes with active electricity contracts can be added to Home Assistant.", "no_active_subscription": "This home does not have an active Tibber contract. Only homes with active electricity contracts can be added to Home Assistant.",
"subscription_expired": "The Tibber contract for this home has expired. Only homes with active or future electricity contracts can be added to Home Assistant.", "subscription_expired": "The Tibber contract for this home has expired. Only homes with active or future electricity contracts can be added to Home Assistant.",
"future_subscription_warning": "Note: This home's Tibber contract has not started yet. Functionality may be limited until the contract becomes active.", "future_subscription_warning": "Note: This home's Tibber contract has not started yet. Functionality may be limited until the contract becomes active."
"invalid_yaml_syntax": "Invalid YAML syntax. Please check indentation, colons, and special characters.",
"invalid_yaml_structure": "YAML must be a dictionary/object (key: value pairs), not a list or plain text.",
"service_call_failed": "Service call validation failed: {error_detail}",
"missing_entry_id": "Entry ID is required but was not provided.",
"invalid_entry_id": "Invalid entry ID or entry not found.",
"missing_home_id": "Home ID is missing from the configuration entry.",
"user_data_not_available": "User data is not available. Please refresh user data first.",
"price_fetch_failed": "Failed to fetch price data. Please check logs for details."
}, },
"abort": { "abort": {
"already_configured": "All available Tibber homes are already configured. Each home can only be configured once.", "already_configured": "All available Tibber homes are already configured. Each home can only be configured once.",
@ -1055,6 +1047,62 @@
"description": "This update includes breaking changes that were applied automatically.\n\n**Renamed Entities ({count})**\n\nThe following entity keys were renamed. Your existing entity IDs and automations remain intact:\n\n{entity_list}\n\n**Duration Sensor Value Change**\n\nAll duration sensors (remaining time, starts in, period duration, trend change countdown) now report their state value in **minutes** instead of hours. The display unit in dashboards remains hours by default.\n\nIf you have automations using numeric comparisons on these sensors, update your thresholds:\n- Old: `state < 0.25` (15 minutes as hours)\n- New: `state < 15` (15 minutes)\n\nDismiss this notice after reviewing your automations." "description": "This update includes breaking changes that were applied automatically.\n\n**Renamed Entities ({count})**\n\nThe following entity keys were renamed. Your existing entity IDs and automations remain intact:\n\n{entity_list}\n\n**Duration Sensor Value Change**\n\nAll duration sensors (remaining time, starts in, period duration, trend change countdown) now report their state value in **minutes** instead of hours. The display unit in dashboards remains hours by default.\n\nIf you have automations using numeric comparisons on these sensors, update your thresholds:\n- Old: `state < 0.25` (15 minutes as hours)\n- New: `state < 15` (15 minutes)\n\nDismiss this notice after reviewing your automations."
} }
}, },
"exceptions": {
"no_entries_found": {
"message": "No Tibber Prices integration entries found. Please set up the integration first."
},
"multiple_entries_no_entry_id": {
"message": "Multiple Tibber Prices entries found. Please specify 'entry_id' to select which entry to use."
},
"invalid_entry_id": {
"message": "Invalid or unavailable config entry. Please check the entry ID and ensure the integration is loaded."
},
"missing_home_id": {
"message": "Home ID not found in the config entry. Please reconfigure the integration."
},
"user_data_not_available": {
"message": "User data is not yet available. Please wait for the first data update to complete."
},
"timezone_not_found": {
"message": "Could not determine the home timezone. Please verify the home configuration in your Tibber account."
},
"end_before_start": {
"message": "End time must be after start time."
},
"price_fetch_failed": {
"message": "Failed to fetch price data from the Tibber API. Please try again later."
},
"invalid_search_scope": {
"message": "Invalid search scope value. Valid scopes are: today, tomorrow, remaining_today, next_24h, next_48h."
},
"scope_conflicts_with_range": {
"message": "search_scope cannot be combined with explicit range parameters: {params}. Use either search_scope OR explicit start/end parameters."
},
"day_offset_requires_time": {
"message": "{offset_param} requires {time_param} to be set. Day offset only modifies the date of an explicit time parameter."
},
"min_level_exceeds_max": {
"message": "min_price_level '{min_level}' is higher than max_price_level '{max_level}'. The minimum level must be equal to or lower than the maximum level."
},
"power_profile_length_mismatch": {
"message": "power_profile has {profile_length} entries but the duration requires {interval_count} intervals ({duration_minutes} minutes). The power_profile must have exactly one entry per 15-minute interval."
},
"level_and_rating_filter_conflict": {
"message": "level_filter and rating_level_filter cannot be used together. Use only one filter type per request."
},
"insert_nulls_requires_filter": {
"message": "insert_nulls mode '{mode}' requires a level_filter or rating_level_filter to define segments. Without a filter, use insert_nulls: none."
},
"connect_segments_requires_segments_mode": {
"message": "connect_segments requires insert_nulls to be set to 'segments'. Set insert_nulls: segments to use segment connection."
},
"array_fields_requires_array_format": {
"message": "array_fields can only be used with output_format: array_of_arrays. Change the output format or remove array_fields."
},
"invalid_array_fields": {
"message": "Invalid array_fields template. Use field names in curly braces, e.g. '{start_time}, {price_per_kwh}, {level}'."
}
},
"services": { "services": {
"get_price": { "get_price": {
"name": "Get Price Data", "name": "Get Price Data",
@ -1262,6 +1310,474 @@
} }
} }
}, },
"find_cheapest_block": {
"name": "Find Cheapest Block",
"description": "Finds the cheapest contiguous time window of a given duration. Designed for appliance scheduling: dishwasher, washing machine, dryer, etc. Returns the single cheapest window with start/end times and price statistics.",
"sections": {
"search_range": {
"name": "Search Range",
"description": "Define the time window to search within."
},
"time_alternatives": {
"name": "Alternative Time Range Options",
"description": "Alternative ways to define the search range using time-of-day and offsets."
},
"price_filter": {
"name": "Price Level Filter",
"description": "Restrict search to intervals within the specified price level range."
},
"output": {
"name": "Output Options",
"description": "Control cost estimation and comparison output."
}
},
"fields": {
"entry_id": {
"name": "Entry ID",
"description": "The config entry ID for the Tibber integration."
},
"duration": {
"name": "Duration",
"description": "Length of the desired contiguous window. Automatically rounded up to the next quarter-hour. Maximum: 12 hours."
},
"search_start": {
"name": "Search Start",
"description": "Start of the search range as exact date and time. Highest priority — overrides all other start options. Defaults to now if not specified."
},
"search_end": {
"name": "Search End",
"description": "End of the search range as exact date and time. Highest priority — overrides all other end options. Defaults to end of tomorrow if not specified."
},
"search_start_time": {
"name": "Search Start Time",
"description": "Alternative: start searching at this time of day. Combine with day offset. Ignored if Search Start (datetime) is set."
},
"search_start_day_offset": {
"name": "Search Start Day Offset",
"description": "Day offset for Search Start Time. -7 to 2: -1 = yesterday, 0 = today, 1 = tomorrow. Negative values search in the past. Only used with Search Start Time."
},
"search_end_time": {
"name": "Search End Time",
"description": "Alternative: stop searching at this time of day. Combine with day offset. Ignored if Search End (datetime) is set."
},
"search_end_day_offset": {
"name": "Search End Day Offset",
"description": "Day offset for Search End Time. -7 to 2: -1 = yesterday, 0 = today, 1 = tomorrow. Negative values search in the past. Only used with Search End Time."
},
"search_start_offset_minutes": {
"name": "Search Start Offset (minutes)",
"description": "Alternative: start searching this many minutes from now. Positive = future (60 = in 1 hour), negative = past (-60 = 1 hour ago). Ignored if Search Start or Search Start Time is set."
},
"search_end_offset_minutes": {
"name": "Search End Offset (minutes)",
"description": "Alternative: stop searching this many minutes from now. Positive = future (480 = in 8 hours), negative = past (-60 = 1 hour ago). Ignored if Search End or Search End Time is set."
},
"include_current_interval": {
"name": "Include Current Interval",
"description": "Include the currently running 15-minute interval in the search. When enabled (default), the search starts at the beginning of the current interval so it can be part of the result."
},
"use_base_unit": {
"name": "Use Base Currency Unit",
"description": "Force prices in base currency (EUR, NOK) instead of the configured display unit (ct, øre). Useful for calculations."
},
"search_scope": {
"name": "Search Scope",
"description": "Shorthand for common search ranges. Overrides all other time range options. today / tomorrow = full calendar day, remaining_today = now until midnight, next_24h / next_48h = rolling window from now."
},
"max_price_level": {
"name": "Maximum Price Level",
"description": "Only consider intervals at or below this Tibber price level. very_cheap = most restrictive, very_expensive = no restriction."
},
"min_price_level": {
"name": "Minimum Price Level",
"description": "Only consider intervals at or above this Tibber price level. Useful for find_most_expensive to focus on truly expensive intervals."
},
"include_comparison_details": {
"name": "Include Comparison Details",
"description": "Enrich the price_comparison result with additional fields: comparison_price_min, comparison_price_max, and (block only) comparison_window_end."
},
"power_profile": {
"name": "Power Profile",
"description": "Variable power draw in watts per 15-minute interval. When set, estimated_total_cost reflects actual consumption instead of a flat 1 kW load. The profile is extended by repeating the last value if shorter than the window."
}
}
},
"find_most_expensive_block": {
"name": "Find Most Expensive Block",
"description": "Finds the most expensive contiguous time window of a given duration. Useful for identifying peak price periods to avoid. Returns the single most expensive window with start/end times and price statistics.",
"sections": {
"search_range": {
"name": "Search Range",
"description": "Define the time window to search within."
},
"time_alternatives": {
"name": "Alternative Time Range Options",
"description": "Alternative ways to define the search range using time-of-day and offsets."
},
"price_filter": {
"name": "Price Level Filter",
"description": "Restrict search to intervals within the specified price level range."
},
"output": {
"name": "Output Options",
"description": "Control cost estimation and comparison output."
}
},
"fields": {
"entry_id": {
"name": "Entry ID",
"description": "The config entry ID for the Tibber integration."
},
"duration": {
"name": "Duration",
"description": "Length of the desired contiguous window. Automatically rounded up to the next quarter-hour. Maximum: 12 hours."
},
"search_start": {
"name": "Search Start",
"description": "Start of the search range as exact date and time. Highest priority — overrides all other start options. Defaults to now if not specified."
},
"search_end": {
"name": "Search End",
"description": "End of the search range as exact date and time. Highest priority — overrides all other end options. Defaults to end of tomorrow if not specified."
},
"search_start_time": {
"name": "Search Start Time",
"description": "Alternative: start searching at this time of day. Combine with day offset. Ignored if Search Start (datetime) is set."
},
"search_start_day_offset": {
"name": "Search Start Day Offset",
"description": "Day offset for Search Start Time. -7 to 2: -1 = yesterday, 0 = today, 1 = tomorrow. Negative values search in the past. Only used with Search Start Time."
},
"search_end_time": {
"name": "Search End Time",
"description": "Alternative: stop searching at this time of day. Combine with day offset. Ignored if Search End (datetime) is set."
},
"search_end_day_offset": {
"name": "Search End Day Offset",
"description": "Day offset for Search End Time. -7 to 2: -1 = yesterday, 0 = today, 1 = tomorrow. Negative values search in the past. Only used with Search End Time."
},
"search_start_offset_minutes": {
"name": "Search Start Offset (minutes)",
"description": "Alternative: start searching this many minutes from now. Positive = future (60 = in 1 hour), negative = past (-60 = 1 hour ago). Ignored if Search Start or Search Start Time is set."
},
"search_end_offset_minutes": {
"name": "Search End Offset (minutes)",
"description": "Alternative: stop searching this many minutes from now. Positive = future (480 = in 8 hours), negative = past (-60 = 1 hour ago). Ignored if Search End or Search End Time is set."
},
"include_current_interval": {
"name": "Include Current Interval",
"description": "Include the currently running 15-minute interval in the search. When enabled (default), the search starts at the beginning of the current interval so it can be part of the result."
},
"use_base_unit": {
"name": "Use Base Currency Unit",
"description": "Force prices in base currency (EUR, NOK) instead of the configured display unit (ct, øre). Useful for calculations."
},
"search_scope": {
"name": "Search Scope",
"description": "Shorthand for common search ranges. Overrides all other time range options. today / tomorrow = full calendar day, remaining_today = now until midnight, next_24h / next_48h = rolling window from now."
},
"max_price_level": {
"name": "Maximum Price Level",
"description": "Only consider intervals at or below this Tibber price level. very_cheap = most restrictive, very_expensive = no restriction."
},
"min_price_level": {
"name": "Minimum Price Level",
"description": "Only consider intervals at or above this Tibber price level. Useful for find_most_expensive to focus on truly expensive intervals."
},
"include_comparison_details": {
"name": "Include Comparison Details",
"description": "Enrich the price_comparison result with additional fields: comparison_price_min, comparison_price_max, and (block only) comparison_window_end."
},
"power_profile": {
"name": "Power Profile",
"description": "Variable power draw in watts per 15-minute interval. When set, estimated_total_cost reflects actual consumption instead of a flat 1 kW load. The profile is extended by repeating the last value if shorter than the window."
}
}
},
"find_cheapest_hours": {
"name": "Find Cheapest Hours",
"description": "Finds the cheapest intervals totaling a given duration, not necessarily contiguous. Designed for flexible loads: battery charging, EV, water heater. Returns a schedule of intervals grouped into contiguous segments.",
"sections": {
"search_range": {
"name": "Search Range",
"description": "Define the time window to search within."
},
"time_alternatives": {
"name": "Alternative Time Range Options",
"description": "Alternative ways to define the search range using time-of-day and offsets."
},
"price_filter": {
"name": "Price Level Filter",
"description": "Restrict search to intervals within the specified price level range."
},
"output": {
"name": "Output Options",
"description": "Control cost estimation and comparison output."
}
},
"fields": {
"entry_id": {
"name": "Entry ID",
"description": "The config entry ID for the Tibber integration."
},
"duration": {
"name": "Duration",
"description": "Total cheap time needed. Automatically rounded up to the next quarter-hour. Maximum: 24 hours."
},
"search_start": {
"name": "Search Start",
"description": "Start of the search range as exact date and time. Highest priority — overrides all other start options. Defaults to now if not specified."
},
"search_end": {
"name": "Search End",
"description": "End of the search range as exact date and time. Highest priority — overrides all other end options. Defaults to end of tomorrow if not specified."
},
"search_start_time": {
"name": "Search Start Time",
"description": "Alternative: start searching at this time of day. Combine with day offset. Ignored if Search Start (datetime) is set."
},
"search_start_day_offset": {
"name": "Search Start Day Offset",
"description": "Day offset for Search Start Time. -7 to 2: -1 = yesterday, 0 = today, 1 = tomorrow. Negative values search in the past. Only used with Search Start Time."
},
"search_end_time": {
"name": "Search End Time",
"description": "Alternative: stop searching at this time of day. Combine with day offset. Ignored if Search End (datetime) is set."
},
"search_end_day_offset": {
"name": "Search End Day Offset",
"description": "Day offset for Search End Time. -7 to 2: -1 = yesterday, 0 = today, 1 = tomorrow. Negative values search in the past. Only used with Search End Time."
},
"search_start_offset_minutes": {
"name": "Search Start Offset (minutes)",
"description": "Alternative: start searching this many minutes from now. Positive = future (60 = in 1 hour), negative = past (-60 = 1 hour ago). Ignored if Search Start or Search Start Time is set."
},
"search_end_offset_minutes": {
"name": "Search End Offset (minutes)",
"description": "Alternative: stop searching this many minutes from now. Positive = future (480 = in 8 hours), negative = past (-60 = 1 hour ago). Ignored if Search End or Search End Time is set."
},
"include_current_interval": {
"name": "Include Current Interval",
"description": "Include the currently running 15-minute interval in the search. When enabled (default), the search starts at the beginning of the current interval so it can be part of the result."
},
"min_segment_duration": {
"name": "Minimum Segment Duration",
"description": "Minimum contiguous run length. Prevents rapid on/off cycling for devices with minimum run times. Automatically rounded up to the next quarter-hour. Default: 15 minutes. Maximum: 4 hours."
},
"use_base_unit": {
"name": "Use Base Currency Unit",
"description": "Force prices in base currency (EUR, NOK) instead of the configured display unit (ct, øre). Useful for calculations."
},
"search_scope": {
"name": "Search Scope",
"description": "Shorthand for common search ranges. Overrides all other time range options. today / tomorrow = full calendar day, remaining_today = now until midnight, next_24h / next_48h = rolling window from now."
},
"max_price_level": {
"name": "Maximum Price Level",
"description": "Only consider intervals at or below this Tibber price level. very_cheap = most restrictive, very_expensive = no restriction."
},
"min_price_level": {
"name": "Minimum Price Level",
"description": "Only consider intervals at or above this Tibber price level. Useful for find_most_expensive to focus on truly expensive intervals."
},
"include_comparison_details": {
"name": "Include Comparison Details",
"description": "Enrich the price_comparison result with additional fields: comparison_price_min, comparison_price_max, and (block only) comparison_window_end."
},
"power_profile": {
"name": "Power Profile",
"description": "Variable power draw in watts per 15-minute interval. When set, estimated_total_cost reflects actual consumption instead of a flat 1 kW load. The profile is extended by repeating the last value if shorter than the window."
}
}
},
"find_most_expensive_hours": {
"name": "Find Most Expensive Hours",
"description": "Finds the most expensive intervals totaling a given duration, not necessarily contiguous. Useful for identifying peak price periods to avoid. Returns a schedule of intervals grouped into contiguous segments.",
"sections": {
"search_range": {
"name": "Search Range",
"description": "Define the time window to search within."
},
"time_alternatives": {
"name": "Alternative Time Range Options",
"description": "Alternative ways to define the search range using time-of-day and offsets."
},
"price_filter": {
"name": "Price Level Filter",
"description": "Restrict search to intervals within the specified price level range."
},
"output": {
"name": "Output Options",
"description": "Control cost estimation and comparison output."
}
},
"fields": {
"entry_id": {
"name": "Entry ID",
"description": "The config entry ID for the Tibber integration."
},
"duration": {
"name": "Duration",
"description": "Total expensive time to find. Automatically rounded up to the next quarter-hour. Maximum: 24 hours."
},
"search_start": {
"name": "Search Start",
"description": "Start of the search range as exact date and time. Highest priority — overrides all other start options. Defaults to now if not specified."
},
"search_end": {
"name": "Search End",
"description": "End of the search range as exact date and time. Highest priority — overrides all other end options. Defaults to end of tomorrow if not specified."
},
"search_start_time": {
"name": "Search Start Time",
"description": "Alternative: start searching at this time of day. Combine with day offset. Ignored if Search Start (datetime) is set."
},
"search_start_day_offset": {
"name": "Search Start Day Offset",
"description": "Day offset for Search Start Time. -7 to 2: -1 = yesterday, 0 = today, 1 = tomorrow. Negative values search in the past. Only used with Search Start Time."
},
"search_end_time": {
"name": "Search End Time",
"description": "Alternative: stop searching at this time of day. Combine with day offset. Ignored if Search End (datetime) is set."
},
"search_end_day_offset": {
"name": "Search End Day Offset",
"description": "Day offset for Search End Time. -7 to 2: -1 = yesterday, 0 = today, 1 = tomorrow. Negative values search in the past. Only used with Search End Time."
},
"search_start_offset_minutes": {
"name": "Search Start Offset (minutes)",
"description": "Alternative: start searching this many minutes from now. Positive = future (60 = in 1 hour), negative = past (-60 = 1 hour ago). Ignored if Search Start or Search Start Time is set."
},
"search_end_offset_minutes": {
"name": "Search End Offset (minutes)",
"description": "Alternative: stop searching this many minutes from now. Positive = future (480 = in 8 hours), negative = past (-60 = 1 hour ago). Ignored if Search End or Search End Time is set."
},
"include_current_interval": {
"name": "Include Current Interval",
"description": "Include the currently running 15-minute interval in the search. When enabled (default), the search starts at the beginning of the current interval so it can be part of the result."
},
"min_segment_duration": {
"name": "Minimum Segment Duration",
"description": "Minimum contiguous run length. Prevents rapid on/off cycling for devices with minimum run times. Automatically rounded up to the next quarter-hour. Default: 15 minutes. Maximum: 4 hours."
},
"use_base_unit": {
"name": "Use Base Currency Unit",
"description": "Force prices in base currency (EUR, NOK) instead of the configured display unit (ct, øre). Useful for calculations."
},
"search_scope": {
"name": "Search Scope",
"description": "Shorthand for common search ranges. Overrides all other time range options. today / tomorrow = full calendar day, remaining_today = now until midnight, next_24h / next_48h = rolling window from now."
},
"max_price_level": {
"name": "Maximum Price Level",
"description": "Only consider intervals at or below this Tibber price level. very_cheap = most restrictive, very_expensive = no restriction."
},
"min_price_level": {
"name": "Minimum Price Level",
"description": "Only consider intervals at or above this Tibber price level. Useful for find_most_expensive to focus on truly expensive intervals."
},
"include_comparison_details": {
"name": "Include Comparison Details",
"description": "Enrich the price_comparison result with additional fields: comparison_price_min, comparison_price_max, and (block only) comparison_window_end."
},
"power_profile": {
"name": "Power Profile",
"description": "Variable power draw in watts per 15-minute interval. When set, estimated_total_cost reflects actual consumption instead of a flat 1 kW load. The profile is extended by repeating the last value if shorter than the window."
}
}
},
"find_cheapest_schedule": {
"name": "Find Cheapest Schedule",
"description": "Schedules multiple appliances optimally without time overlap. Each task gets the cheapest available contiguous window; tasks are placed greedily in ascending cost order. Returns a per-task schedule with start/end times and price stats.",
"sections": {
"scheduling_options": {
"name": "Scheduling Options",
"description": "Configure tasks and gap constraints between them."
},
"search_range": {
"name": "Search Range",
"description": "Define the time window to search within."
},
"time_alternatives": {
"name": "Alternative Time Range Options",
"description": "Alternative ways to define the search range using time-of-day and offsets."
},
"price_filter": {
"name": "Price Level Filter",
"description": "Restrict search to intervals within the specified price level range."
},
"output": {
"name": "Output Options",
"description": "Control output currency unit."
}
},
"fields": {
"entry_id": {
"name": "Entry ID",
"description": "The config entry ID for the Tibber integration."
},
"tasks": {
"name": "Tasks",
"description": "List of tasks to schedule. Each task requires name (string) and duration (hh:mm:ss). Optionally add power_profile (list of watts per 15-min interval). Maximum 4 tasks."
},
"gap_minutes": {
"name": "Gap Between Tasks (minutes)",
"description": "Minimum gap in minutes between consecutive scheduled tasks. Rounded up to 15 minutes. Default: 0 (no gap)."
},
"search_scope": {
"name": "Search Scope",
"description": "Shorthand for common search ranges. Overrides all other time range options. today / tomorrow = full calendar day, remaining_today = now until midnight, next_24h / next_48h = rolling window from now."
},
"search_start": {
"name": "Search Start",
"description": "Start of the search range as exact date and time. Highest priority — overrides all other start options. Defaults to now if not specified."
},
"search_end": {
"name": "Search End",
"description": "End of the search range as exact date and time. Highest priority — overrides all other end options. Defaults to end of tomorrow if not specified."
},
"search_start_time": {
"name": "Search Start Time",
"description": "Alternative: start searching at this time of day. Combine with day offset. Ignored if Search Start (datetime) is set."
},
"search_start_day_offset": {
"name": "Search Start Day Offset",
"description": "Day offset for Search Start Time. -7 to 2: -1 = yesterday, 0 = today, 1 = tomorrow. Only used with Search Start Time."
},
"search_end_time": {
"name": "Search End Time",
"description": "Alternative: stop searching at this time of day. Combine with day offset. Ignored if Search End (datetime) is set."
},
"search_end_day_offset": {
"name": "Search End Day Offset",
"description": "Day offset for Search End Time. -7 to 2: -1 = yesterday, 0 = today, 1 = tomorrow. Only used with Search End Time."
},
"search_start_offset_minutes": {
"name": "Search Start Offset (minutes)",
"description": "Alternative: start searching this many minutes from now. Positive = future, negative = past. Ignored if Search Start or Search Start Time is set."
},
"search_end_offset_minutes": {
"name": "Search End Offset (minutes)",
"description": "Alternative: stop searching this many minutes from now. Positive = future, negative = past. Ignored if Search End or Search End Time is set."
},
"include_current_interval": {
"name": "Include Current Interval",
"description": "Include the currently running 15-minute interval in the search."
},
"max_price_level": {
"name": "Maximum Price Level",
"description": "Only consider intervals at or below this Tibber price level. very_cheap = most restrictive, very_expensive = no restriction."
},
"min_price_level": {
"name": "Minimum Price Level",
"description": "Only consider intervals at or above this Tibber price level. Useful for find_most_expensive to focus on truly expensive intervals."
},
"use_base_unit": {
"name": "Use Base Currency Unit",
"description": "Force prices in base currency (EUR, NOK) instead of the configured display unit (ct, øre). Useful for calculations."
}
}
},
"debug_clear_tomorrow": { "debug_clear_tomorrow": {
"name": "Debug: Clear Tomorrow Data", "name": "Debug: Clear Tomorrow Data",
"description": "DEBUG/TESTING: Removes tomorrow's price data from the interval pool cache. Use this to test the tomorrow data refresh cycle without waiting for the next day. After calling this service, the lifecycle sensor will show 'searching_tomorrow' (after 13:00) and the next Timer #1 cycle will fetch new data from the API.", "description": "DEBUG/TESTING: Removes tomorrow's price data from the interval pool cache. Use this to test the tomorrow data refresh cycle without waiting for the next day. After calling this service, the lifecycle sensor will show 'searching_tomorrow' (after 13:00) and the next Timer #1 cycle will fetch new data from the API.",
@ -1371,6 +1887,15 @@
"median": "Median", "median": "Median",
"mean": "Arithmetic Mean" "mean": "Arithmetic Mean"
} }
},
"search_scope": {
"options": {
"today": "Today",
"tomorrow": "Tomorrow",
"remaining_today": "Remaining Today",
"next_24h": "Next 24 Hours",
"next_48h": "Next 48 Hours"
}
} }
}, },
"title": "Tibber Price Information & Ratings" "title": "Tibber Price Information & Ratings"

View file

@ -59,15 +59,7 @@
"home_already_configured": "Dette hjemmet er allerede konfigurert i en annen oppføring. Hvert hjem kan kun konfigureres én gang.", "home_already_configured": "Dette hjemmet er allerede konfigurert i en annen oppføring. Hvert hjem kan kun konfigureres én gang.",
"no_active_subscription": "Dette hjemmet har ikke en aktiv Tibber-kontrakt. Bare hjem med aktive strømkontrakter kan legges til Home Assistant.", "no_active_subscription": "Dette hjemmet har ikke en aktiv Tibber-kontrakt. Bare hjem med aktive strømkontrakter kan legges til Home Assistant.",
"subscription_expired": "Tibber-kontrakten for dette hjemmet har utløpt. Bare hjem med aktive eller fremtidige strømkontrakter kan legges til Home Assistant.", "subscription_expired": "Tibber-kontrakten for dette hjemmet har utløpt. Bare hjem med aktive eller fremtidige strømkontrakter kan legges til Home Assistant.",
"future_subscription_warning": "Merk: Tibber-kontrakten for dette hjemmet har ikke startet ennå. Funksjonaliteten kan være begrenset til kontrakten blir aktiv.", "future_subscription_warning": "Merk: Tibber-kontrakten for dette hjemmet har ikke startet ennå. Funksjonaliteten kan være begrenset til kontrakten blir aktiv."
"invalid_yaml_syntax": "Ugyldig YAML-syntaks. Vennligst sjekk innrykk, kolon og spesialtegn.",
"invalid_yaml_structure": "YAML må være en ordbok/objekt (nøkkel: verdi-par), ikke en liste eller ren tekst.",
"service_call_failed": "Service-kall validering feilet: {error_detail}",
"missing_entry_id": "Oppførings-ID er påkrevd, men ble ikke oppgitt.",
"invalid_entry_id": "Ugyldig oppførings-ID eller oppføring ikke funnet.",
"missing_home_id": "Hjem-ID mangler fra konfigurasjonsoppføringen.",
"user_data_not_available": "Brukerdata er ikke tilgjengelig. Vennligst oppdater brukerdata først.",
"price_fetch_failed": "Kunne ikke hente prisdata. Vennligst sjekk loggene for detaljer."
}, },
"abort": { "abort": {
"already_configured": "Alle tilgjengelige Tibber-hjem er allerede konfigurert. Hvert hjem kan kun konfigureres én gang.", "already_configured": "Alle tilgjengelige Tibber-hjem er allerede konfigurert. Hvert hjem kan kun konfigureres én gang.",
@ -1055,6 +1047,62 @@
"description": "Denne oppdateringen inkluderer endringer som ble brukt automatisk.\n\n**Omdøpte entiteter ({count})**\n\nFølgende entity-nøkler ble omdøpt. Dine eksisterende entity-ID-er og automatiseringer forblir intakte:\n\n{entity_list}\n\n**Endrede varighetssensorverdier**\n\nAlle varighetssensorer (gjenværende tid, starter om, periodevarighet, trendendrings-nedtelling) rapporterer nå tilstandsverdien i **minutter** i stedet for timer. Visningsenheten i dashboards forblir timer som standard.\n\nHvis du har automatiseringer med numeriske sammenligninger på disse sensorene, oppdater tersklene:\n- Gammelt: `state < 0.25` (15 minutter som timer)\n- Nytt: `state < 15` (15 minutter)\n\nAvvis dette varselet etter å ha gjennomgått automatiseringene dine." "description": "Denne oppdateringen inkluderer endringer som ble brukt automatisk.\n\n**Omdøpte entiteter ({count})**\n\nFølgende entity-nøkler ble omdøpt. Dine eksisterende entity-ID-er og automatiseringer forblir intakte:\n\n{entity_list}\n\n**Endrede varighetssensorverdier**\n\nAlle varighetssensorer (gjenværende tid, starter om, periodevarighet, trendendrings-nedtelling) rapporterer nå tilstandsverdien i **minutter** i stedet for timer. Visningsenheten i dashboards forblir timer som standard.\n\nHvis du har automatiseringer med numeriske sammenligninger på disse sensorene, oppdater tersklene:\n- Gammelt: `state < 0.25` (15 minutter som timer)\n- Nytt: `state < 15` (15 minutter)\n\nAvvis dette varselet etter å ha gjennomgått automatiseringene dine."
} }
}, },
"exceptions": {
"no_entries_found": {
"message": "Ingen Tibber Prices integrasjonsoppføringer funnet. Vennligst sett opp integrasjonen først."
},
"multiple_entries_no_entry_id": {
"message": "Flere Tibber Prices oppføringer funnet. Vennligst angi 'entry_id' for å velge hvilken oppføring som skal brukes."
},
"invalid_entry_id": {
"message": "Ugyldig eller utilgjengelig konfigurasjonsoppføring. Vennligst sjekk oppførings-ID-en og sørg for at integrasjonen er lastet."
},
"missing_home_id": {
"message": "Hjemme-ID ikke funnet i konfigurasjonsoppføringen. Vennligst rekonfigurer integrasjonen."
},
"user_data_not_available": {
"message": "Brukerdata er ikke tilgjengelig ennå. Vennligst vent til den første dataoppdateringen er fullført."
},
"timezone_not_found": {
"message": "Kunne ikke bestemme hjemmets tidssone. Vennligst sjekk hjemmekonfigurasjonen i Tibber-kontoen din."
},
"end_before_start": {
"message": "Sluttid må være etter starttid."
},
"price_fetch_failed": {
"message": "Kunne ikke hente prisdata fra Tibber API. Vennligst prøv igjen senere."
},
"invalid_search_scope": {
"message": "Ugyldig søkeområde. Gyldige verdier er: today, tomorrow, remaining_today, next_24h, next_48h."
},
"scope_conflicts_with_range": {
"message": "search_scope kan ikke kombineres med eksplisitte områdeparametre: {params}. Bruk enten search_scope ELLER eksplisitte start-/sluttparametre."
},
"day_offset_requires_time": {
"message": "{offset_param} krever at {time_param} er satt. Dagsforskyvning endrer kun datoen til en eksplisitt tidsparameter."
},
"min_level_exceeds_max": {
"message": "min_price_level '{min_level}' er høyere enn max_price_level '{max_level}'. Minimumsnivået må være lik eller lavere enn maksimumsnivået."
},
"power_profile_length_mismatch": {
"message": "power_profile har {profile_length} oppføringer, men varigheten krever {interval_count} intervaller ({duration_minutes} minutter). power_profile må ha nøyaktig én oppføring per 15-minutters intervall."
},
"level_and_rating_filter_conflict": {
"message": "level_filter og rating_level_filter kan ikke brukes sammen. Bruk kun én filtertype per forespørsel."
},
"insert_nulls_requires_filter": {
"message": "insert_nulls-modus '{mode}' krever en level_filter eller rating_level_filter for å definere segmenter. Uten filter, bruk insert_nulls: none."
},
"connect_segments_requires_segments_mode": {
"message": "connect_segments krever at insert_nulls er satt til 'segments'. Sett insert_nulls: segments for å bruke segmentforbindelse."
},
"array_fields_requires_array_format": {
"message": "array_fields kan kun brukes med output_format: array_of_arrays. Endre utdataformatet eller fjern array_fields."
},
"invalid_array_fields": {
"message": "Ugyldig array_fields-mal. Bruk feltnavn i krøllparenteser, f.eks. '{start_time}, {price_per_kwh}, {level}'."
}
},
"services": { "services": {
"get_price": { "get_price": {
"name": "Hent prisdata", "name": "Hent prisdata",
@ -1261,6 +1309,474 @@
"description": "Konfig-oppførings-ID for Tibber-integrasjonen." "description": "Konfig-oppførings-ID for Tibber-integrasjonen."
} }
} }
},
"find_cheapest_block": {
"name": "Finn billigste blokk",
"description": "Finner det billigste sammenhengende tidsvinduet med en gitt varighet. Designet for apparatplanlegging: oppvaskmaskin, vaskemaskin, tørketrommel osv. Returnerer det billigste vinduet med start-/sluttider og prisstatistikk.",
"sections": {
"search_range": {
"name": "Soekeomraade",
"description": "Definer tidsvinduet for soeket."
},
"time_alternatives": {
"name": "Alternative tidsalternativer",
"description": "Alternative maater aa definere soekeomraadet paa med tidspunkt og offsets."
},
"price_filter": {
"name": "Prisnivaae-filter",
"description": "Begrens soeket til intervaller innenfor det angitte prisnivaae-omraadet."
},
"output": {
"name": "Utdata-alternativer",
"description": "Styr kostnadsestimat og sammenligningsutdata."
}
},
"fields": {
"entry_id": {
"name": "Oppførings-ID",
"description": "Konfig-oppførings-ID for Tibber-integrasjonen."
},
"duration": {
"name": "Varighet",
"description": "Lengden på det ønskede sammenhengende vinduet. Rundes automatisk opp til nærmeste kvarter. Maksimum: 12 timer."
},
"search_start": {
"name": "Søkestart",
"description": "Start av søkeområdet som eksakt dato og tid. Høyeste prioritet — overstyrer alle andre startalternativer. Standard er nå hvis ikke angitt."
},
"search_end": {
"name": "Søkeslutt",
"description": "Slutt av søkeområdet som eksakt dato og tid. Høyeste prioritet — overstyrer alle andre sluttalternativer. Standard er slutten av i morgen hvis ikke angitt."
},
"search_start_time": {
"name": "Søkestart-klokkeslett",
"description": "Alternativ: Start søk fra dette klokkeslettet. Kombiner med dagsforskyvning. Ignoreres hvis Søkestart (dato/tid) er satt."
},
"search_start_day_offset": {
"name": "Søkestart dagsforskyvning",
"description": "Dagsforskyvning for Søkestart-klokkeslett. -7 til 2: -1 = i går, 0 = i dag, 1 = i morgen. Negative verdier søker i fortiden. Brukes kun med Søkestart-klokkeslett."
},
"search_end_time": {
"name": "Søkeslutt-klokkeslett",
"description": "Alternativ: Søk til dette klokkeslettet. Kombiner med dagsforskyvning. Ignoreres hvis Søkeslutt (dato/tid) er satt."
},
"search_end_day_offset": {
"name": "Søkeslutt dagsforskyvning",
"description": "Dagsforskyvning for Søkeslutt-klokkeslett. -7 til 2: -1 = i går, 0 = i dag, 1 = i morgen. Negative verdier søker i fortiden. Brukes kun med Søkeslutt-klokkeslett."
},
"search_start_offset_minutes": {
"name": "Søkestart-forskyvning (minutter)",
"description": "Alternativ: Start søk dette antall minutter fra nå. Positiv = fremtid (60 = om 1 time), negativ = fortid (-60 = 1 time siden). Ignoreres hvis Søkestart eller Søkestart-klokkeslett er satt."
},
"search_end_offset_minutes": {
"name": "Søkeslutt-forskyvning (minutter)",
"description": "Alternativ: Stopp søk dette antall minutter fra nå. Positiv = fremtid (480 = om 8 timer), negativ = fortid (-60 = 1 time siden). Ignoreres hvis Søkeslutt eller Søkeslutt-klokkeslett er satt."
},
"include_current_interval": {
"name": "Inkluder gjeldende intervall",
"description": "Inkluder det pågående 15-minutters intervallet i søket. Når aktivert (standard), starter søket ved begynnelsen av gjeldende intervall slik at det kan være en del av resultatet."
},
"use_base_unit": {
"name": "Bruk basisvaluta",
"description": "Tving priser i basisvaluta (EUR, NOK) i stedet for konfigurert visningsenhet (ct, øre). Nyttig for beregninger."
},
"search_scope": {
"name": "Soekeomfang (snarvei)",
"description": "Snarvei for vanlige soekeomraader. Overstyrer alle andre tidsalternativer. today/tomorrow = hele kalenderdagen, remaining_today = naa til midnatt, next_24h/next_48h = rullende vindu fra naa."
},
"max_price_level": {
"name": "Maksimalt prisnivaae",
"description": "Ta bare med intervaller paa eller under dette Tibber-prisnivaeet. very_cheap = mest restriktivt, very_expensive = ingen begrensning."
},
"min_price_level": {
"name": "Minimalt prisnivaae",
"description": "Ta bare med intervaller paa eller over dette Tibber-prisnivaeet. Nyttig for find_most_expensive for aa fokusere paa virkelig dyre intervaller."
},
"include_comparison_details": {
"name": "Inkluder sammenligningsdetaljer",
"description": "Berik price_comparison-resultatet med tilleggsfelter: comparison_price_min, comparison_price_max og (kun blokk) comparison_window_end."
},
"power_profile": {
"name": "Effektprofil",
"description": "Variabelt effektforbruk i watt per 15-minuttersintervall. Naa satt, gjenspeiler estimated_total_cost faktisk forbruk i stedet for en fast 1 kW-last."
}
}
},
"find_most_expensive_block": {
"name": "Finn dyreste blokk",
"description": "Finner det dyreste sammenhengende tidsvinduet med en gitt varighet. Nyttig for å identifisere topprisperioder som bør unngås. Returnerer det dyreste vinduet med start-/sluttider og prisstatistikk.",
"sections": {
"search_range": {
"name": "Soekeomraade",
"description": "Definer tidsvinduet for soeket."
},
"time_alternatives": {
"name": "Alternative tidsalternativer",
"description": "Alternative maater aa definere soekeomraadet paa med tidspunkt og offsets."
},
"price_filter": {
"name": "Prisnivaae-filter",
"description": "Begrens soeket til intervaller innenfor det angitte prisnivaae-omraadet."
},
"output": {
"name": "Utdata-alternativer",
"description": "Styr kostnadsestimat og sammenligningsutdata."
}
},
"fields": {
"entry_id": {
"name": "Oppførings-ID",
"description": "Konfig-oppførings-ID for Tibber-integrasjonen."
},
"duration": {
"name": "Varighet",
"description": "Lengden på det ønskede sammenhengende vinduet. Rundes automatisk opp til nærmeste kvarter. Maksimum: 12 timer."
},
"search_start": {
"name": "Søkestart",
"description": "Start av søkeområdet som eksakt dato og tid. Høyeste prioritet — overstyrer alle andre startalternativer. Standard er nå hvis ikke angitt."
},
"search_end": {
"name": "Søkeslutt",
"description": "Slutt av søkeområdet som eksakt dato og tid. Høyeste prioritet — overstyrer alle andre sluttalternativer. Standard er slutten av i morgen hvis ikke angitt."
},
"search_start_time": {
"name": "Søkestart-klokkeslett",
"description": "Alternativ: Start søk fra dette klokkeslettet. Kombiner med dagsforskyvning. Ignoreres hvis Søkestart (dato/tid) er satt."
},
"search_start_day_offset": {
"name": "Søkestart dagsforskyvning",
"description": "Dagsforskyvning for Søkestart-klokkeslett. -7 til 2: -1 = i går, 0 = i dag, 1 = i morgen. Negative verdier søker i fortiden. Brukes kun med Søkestart-klokkeslett."
},
"search_end_time": {
"name": "Søkeslutt-klokkeslett",
"description": "Alternativ: Søk til dette klokkeslettet. Kombiner med dagsforskyvning. Ignoreres hvis Søkeslutt (dato/tid) er satt."
},
"search_end_day_offset": {
"name": "Søkeslutt dagsforskyvning",
"description": "Dagsforskyvning for Søkeslutt-klokkeslett. -7 til 2: -1 = i går, 0 = i dag, 1 = i morgen. Negative verdier søker i fortiden. Brukes kun med Søkeslutt-klokkeslett."
},
"search_start_offset_minutes": {
"name": "Søkestart-forskyvning (minutter)",
"description": "Alternativ: Start søk dette antall minutter fra nå. Positiv = fremtid (60 = om 1 time), negativ = fortid (-60 = 1 time siden). Ignoreres hvis Søkestart eller Søkestart-klokkeslett er satt."
},
"search_end_offset_minutes": {
"name": "Søkeslutt-forskyvning (minutter)",
"description": "Alternativ: Stopp søk dette antall minutter fra nå. Positiv = fremtid (480 = om 8 timer), negativ = fortid (-60 = 1 time siden). Ignoreres hvis Søkeslutt eller Søkeslutt-klokkeslett er satt."
},
"include_current_interval": {
"name": "Inkluder gjeldende intervall",
"description": "Inkluder det pågående 15-minutters intervallet i søket. Når aktivert (standard), starter søket ved begynnelsen av gjeldende intervall slik at det kan være en del av resultatet."
},
"use_base_unit": {
"name": "Bruk basisvaluta",
"description": "Tving priser i basisvaluta (EUR, NOK) i stedet for konfigurert visningsenhet (ct, øre). Nyttig for beregninger."
},
"search_scope": {
"name": "Soekeomfang (snarvei)",
"description": "Snarvei for vanlige soekeomraader. Overstyrer alle andre tidsalternativer. today/tomorrow = hele kalenderdagen, remaining_today = naa til midnatt, next_24h/next_48h = rullende vindu fra naa."
},
"max_price_level": {
"name": "Maksimalt prisnivaae",
"description": "Ta bare med intervaller paa eller under dette Tibber-prisnivaeet. very_cheap = mest restriktivt, very_expensive = ingen begrensning."
},
"min_price_level": {
"name": "Minimalt prisnivaae",
"description": "Ta bare med intervaller paa eller over dette Tibber-prisnivaeet. Nyttig for find_most_expensive for aa fokusere paa virkelig dyre intervaller."
},
"include_comparison_details": {
"name": "Inkluder sammenligningsdetaljer",
"description": "Berik price_comparison-resultatet med tilleggsfelter: comparison_price_min, comparison_price_max og (kun blokk) comparison_window_end."
},
"power_profile": {
"name": "Effektprofil",
"description": "Variabelt effektforbruk i watt per 15-minuttersintervall. Naa satt, gjenspeiler estimated_total_cost faktisk forbruk i stedet for en fast 1 kW-last."
}
}
},
"find_cheapest_hours": {
"name": "Finn billigste timer",
"description": "Finner de billigste intervallene for en gitt total varighet, ikke nødvendigvis sammenhengende. Designet for fleksible laster: batterilading, elbil, varmtvannsbereder. Returnerer en tidsplan med intervaller gruppert i sammenhengende segmenter.",
"sections": {
"search_range": {
"name": "Soekeomraade",
"description": "Definer tidsvinduet for soeket."
},
"time_alternatives": {
"name": "Alternative tidsalternativer",
"description": "Alternative maater aa definere soekeomraadet paa med tidspunkt og offsets."
},
"price_filter": {
"name": "Prisnivaae-filter",
"description": "Begrens soeket til intervaller innenfor det angitte prisnivaae-omraadet."
},
"output": {
"name": "Utdata-alternativer",
"description": "Styr kostnadsestimat og sammenligningsutdata."
}
},
"fields": {
"entry_id": {
"name": "Oppførings-ID",
"description": "Konfig-oppførings-ID for Tibber-integrasjonen."
},
"duration": {
"name": "Varighet",
"description": "Nødvendig billig total tid. Rundes automatisk opp til nærmeste kvarter. Maksimum: 24 timer."
},
"search_start": {
"name": "Søkestart",
"description": "Start av søkeområdet som eksakt dato og tid. Høyeste prioritet — overstyrer alle andre startalternativer. Standard er nå hvis ikke angitt."
},
"search_end": {
"name": "Søkeslutt",
"description": "Slutt av søkeområdet som eksakt dato og tid. Høyeste prioritet — overstyrer alle andre sluttalternativer. Standard er slutten av i morgen hvis ikke angitt."
},
"search_start_time": {
"name": "Søkestart-klokkeslett",
"description": "Alternativ: Start søk fra dette klokkeslettet. Kombiner med dagsforskyvning. Ignoreres hvis Søkestart (dato/tid) er satt."
},
"search_start_day_offset": {
"name": "Søkestart dagsforskyvning",
"description": "Dagsforskyvning for Søkestart-klokkeslett. -7 til 2: -1 = i går, 0 = i dag, 1 = i morgen. Negative verdier søker i fortiden. Brukes kun med Søkestart-klokkeslett."
},
"search_end_time": {
"name": "Søkeslutt-klokkeslett",
"description": "Alternativ: Søk til dette klokkeslettet. Kombiner med dagsforskyvning. Ignoreres hvis Søkeslutt (dato/tid) er satt."
},
"search_end_day_offset": {
"name": "Søkeslutt dagsforskyvning",
"description": "Dagsforskyvning for Søkeslutt-klokkeslett. -7 til 2: -1 = i går, 0 = i dag, 1 = i morgen. Negative verdier søker i fortiden. Brukes kun med Søkeslutt-klokkeslett."
},
"search_start_offset_minutes": {
"name": "Søkestart-forskyvning (minutter)",
"description": "Alternativ: Start søk dette antall minutter fra nå. Positiv = fremtid (60 = om 1 time), negativ = fortid (-60 = 1 time siden). Ignoreres hvis Søkestart eller Søkestart-klokkeslett er satt."
},
"search_end_offset_minutes": {
"name": "Søkeslutt-forskyvning (minutter)",
"description": "Alternativ: Stopp søk dette antall minutter fra nå. Positiv = fremtid (480 = om 8 timer), negativ = fortid (-60 = 1 time siden). Ignoreres hvis Søkeslutt eller Søkeslutt-klokkeslett er satt."
},
"include_current_interval": {
"name": "Inkluder gjeldende intervall",
"description": "Inkluder det pågående 15-minutters intervallet i søket. Når aktivert (standard), starter søket ved begynnelsen av gjeldende intervall slik at det kan være en del av resultatet."
},
"min_segment_duration": {
"name": "Minimum segmentvarighet",
"description": "Minimum sammenhengende kjøretid. Forhindrer rask av/på-sykling for enheter med minimum kjøretid. Rundes automatisk opp til nærmeste kvarter. Standard: 15 minutter. Maksimum: 4 timer."
},
"use_base_unit": {
"name": "Bruk basisvaluta",
"description": "Tving priser i basisvaluta (EUR, NOK) i stedet for konfigurert visningsenhet (ct, øre). Nyttig for beregninger."
},
"search_scope": {
"name": "Soekeomfang (snarvei)",
"description": "Snarvei for vanlige soekeomraader. Overstyrer alle andre tidsalternativer. today/tomorrow = hele kalenderdagen, remaining_today = naa til midnatt, next_24h/next_48h = rullende vindu fra naa."
},
"max_price_level": {
"name": "Maksimalt prisnivaae",
"description": "Ta bare med intervaller paa eller under dette Tibber-prisnivaeet. very_cheap = mest restriktivt, very_expensive = ingen begrensning."
},
"min_price_level": {
"name": "Minimalt prisnivaae",
"description": "Ta bare med intervaller paa eller over dette Tibber-prisnivaeet. Nyttig for find_most_expensive for aa fokusere paa virkelig dyre intervaller."
},
"include_comparison_details": {
"name": "Inkluder sammenligningsdetaljer",
"description": "Berik price_comparison-resultatet med tilleggsfelter: comparison_price_min, comparison_price_max og (kun blokk) comparison_window_end."
},
"power_profile": {
"name": "Effektprofil",
"description": "Variabelt effektforbruk i watt per 15-minuttersintervall. Naa satt, gjenspeiler estimated_total_cost faktisk forbruk i stedet for en fast 1 kW-last."
}
}
},
"find_most_expensive_hours": {
"name": "Finn dyreste timer",
"description": "Finner de dyreste intervallene for en gitt total varighet, ikke nødvendigvis sammenhengende. Nyttig for å identifisere topprisperioder som bør unngås. Returnerer en tidsplan med intervaller gruppert i sammenhengende segmenter.",
"sections": {
"search_range": {
"name": "Soekeomraade",
"description": "Definer tidsvinduet for soeket."
},
"time_alternatives": {
"name": "Alternative tidsalternativer",
"description": "Alternative maater aa definere soekeomraadet paa med tidspunkt og offsets."
},
"price_filter": {
"name": "Prisnivaae-filter",
"description": "Begrens soeket til intervaller innenfor det angitte prisnivaae-omraadet."
},
"output": {
"name": "Utdata-alternativer",
"description": "Styr kostnadsestimat og sammenligningsutdata."
}
},
"fields": {
"entry_id": {
"name": "Oppførings-ID",
"description": "Konfig-oppførings-ID for Tibber-integrasjonen."
},
"duration": {
"name": "Varighet",
"description": "Dyr total tid som skal finnes. Rundes automatisk opp til nærmeste kvarter. Maksimum: 24 timer."
},
"search_start": {
"name": "Søkestart",
"description": "Start av søkeområdet som eksakt dato og tid. Høyeste prioritet — overstyrer alle andre startalternativer. Standard er nå hvis ikke angitt."
},
"search_end": {
"name": "Søkeslutt",
"description": "Slutt av søkeområdet som eksakt dato og tid. Høyeste prioritet — overstyrer alle andre sluttalternativer. Standard er slutten av i morgen hvis ikke angitt."
},
"search_start_time": {
"name": "Søkestart-klokkeslett",
"description": "Alternativ: Start søk fra dette klokkeslettet. Kombiner med dagsforskyvning. Ignoreres hvis Søkestart (dato/tid) er satt."
},
"search_start_day_offset": {
"name": "Søkestart dagsforskyvning",
"description": "Dagsforskyvning for Søkestart-klokkeslett. -7 til 2: -1 = i går, 0 = i dag, 1 = i morgen. Negative verdier søker i fortiden. Brukes kun med Søkestart-klokkeslett."
},
"search_end_time": {
"name": "Søkeslutt-klokkeslett",
"description": "Alternativ: Søk til dette klokkeslettet. Kombiner med dagsforskyvning. Ignoreres hvis Søkeslutt (dato/tid) er satt."
},
"search_end_day_offset": {
"name": "Søkeslutt dagsforskyvning",
"description": "Dagsforskyvning for Søkeslutt-klokkeslett. -7 til 2: -1 = i går, 0 = i dag, 1 = i morgen. Negative verdier søker i fortiden. Brukes kun med Søkeslutt-klokkeslett."
},
"search_start_offset_minutes": {
"name": "Søkestart-forskyvning (minutter)",
"description": "Alternativ: Start søk dette antall minutter fra nå. Positiv = fremtid (60 = om 1 time), negativ = fortid (-60 = 1 time siden). Ignoreres hvis Søkestart eller Søkestart-klokkeslett er satt."
},
"search_end_offset_minutes": {
"name": "Søkeslutt-forskyvning (minutter)",
"description": "Alternativ: Stopp søk dette antall minutter fra nå. Positiv = fremtid (480 = om 8 timer), negativ = fortid (-60 = 1 time siden). Ignoreres hvis Søkeslutt eller Søkeslutt-klokkeslett er satt."
},
"include_current_interval": {
"name": "Inkluder gjeldende intervall",
"description": "Inkluder det pågående 15-minutters intervallet i søket. Når aktivert (standard), starter søket ved begynnelsen av gjeldende intervall slik at det kan være en del av resultatet."
},
"min_segment_duration": {
"name": "Minimum segmentvarighet",
"description": "Minimum sammenhengende kjøretid. Forhindrer rask av/på-sykling for enheter med minimum kjøretid. Rundes automatisk opp til nærmeste kvarter. Standard: 15 minutter. Maksimum: 4 timer."
},
"use_base_unit": {
"name": "Bruk basisvaluta",
"description": "Tving priser i basisvaluta (EUR, NOK) i stedet for konfigurert visningsenhet (ct, øre). Nyttig for beregninger."
},
"search_scope": {
"name": "Soekeomfang (snarvei)",
"description": "Snarvei for vanlige soekeomraader. Overstyrer alle andre tidsalternativer. today/tomorrow = hele kalenderdagen, remaining_today = naa til midnatt, next_24h/next_48h = rullende vindu fra naa."
},
"max_price_level": {
"name": "Maksimalt prisnivaae",
"description": "Ta bare med intervaller paa eller under dette Tibber-prisnivaeet. very_cheap = mest restriktivt, very_expensive = ingen begrensning."
},
"min_price_level": {
"name": "Minimalt prisnivaae",
"description": "Ta bare med intervaller paa eller over dette Tibber-prisnivaeet. Nyttig for find_most_expensive for aa fokusere paa virkelig dyre intervaller."
},
"include_comparison_details": {
"name": "Inkluder sammenligningsdetaljer",
"description": "Berik price_comparison-resultatet med tilleggsfelter: comparison_price_min, comparison_price_max og (kun blokk) comparison_window_end."
},
"power_profile": {
"name": "Effektprofil",
"description": "Variabelt effektforbruk i watt per 15-minuttersintervall. Naa satt, gjenspeiler estimated_total_cost faktisk forbruk i stedet for en fast 1 kW-last."
}
}
},
"find_cheapest_schedule": {
"name": "Finn billigste tidsplan",
"description": "Planlegger flere apparater optimalt uten tidsoverlapp. Hver oppgave tildeles det billigste tilgjengelige sammenhengende tidsvinduet.",
"sections": {
"scheduling_options": {
"name": "Planleggingsalternativer",
"description": "Konfigurer oppgaver og pause mellom dem."
},
"search_range": {
"name": "Soekeomraade",
"description": "Definer tidsvinduet for soeket."
},
"time_alternatives": {
"name": "Alternative tidsalternativer",
"description": "Alternative maater aa definere soekeomraadet paa med tidspunkt og offsets."
},
"price_filter": {
"name": "Prisnivaae-filter",
"description": "Begrens soeket til intervaller innenfor det angitte prisnivaae-omraadet."
},
"output": {
"name": "Utdata-alternativer",
"description": "Styr kostnadsestimat og sammenligningsutdata."
}
},
"fields": {
"entry_id": {
"name": "Oppførings-ID",
"description": "Konfig-oppførings-ID for Tibber-integrasjonen."
},
"tasks": {
"name": "Oppgaver",
"description": "Liste over oppgaver som skal planlegges. Hver oppgave trenger name (tekst) og duration (hh:mm:ss). Eventuelt power_profile (watt per 15-min-intervall). Maks 4 oppgaver."
},
"gap_minutes": {
"name": "Pause mellom oppgaver (minutter)",
"description": "Minimum pause i minutter mellom paafoeglende planlagte oppgaver. Avrundes opp til 15 minutter. Standard: 0 (ingen pause)."
},
"search_scope": {
"name": "Soekeomfang (snarvei)",
"description": "Snarvei for vanlige soekeomraader. Overstyrer alle andre tidsalternativer. today/tomorrow = hele kalenderdagen, remaining_today = naa til midnatt, next_24h/next_48h = rullende vindu fra naa."
},
"search_start": {
"name": "Søkestart",
"description": "Start av søkeområdet som eksakt dato og tid. Høyeste prioritet — overstyrer alle andre startalternativer. Standard er nå hvis ikke angitt."
},
"search_end": {
"name": "Søkeslutt",
"description": "Slutt av søkeområdet som eksakt dato og tid. Høyeste prioritet — overstyrer alle andre sluttalternativer. Standard er slutten av i morgen hvis ikke angitt."
},
"search_start_time": {
"name": "Søkestart-klokkeslett",
"description": "Alternativ: Start søk fra dette klokkeslettet. Kombiner med dagsforskyvning. Ignoreres hvis Søkestart (dato/tid) er satt."
},
"search_start_day_offset": {
"name": "Søkestart dagsforskyvning",
"description": "Dagsforskyvning for Søkestart-klokkeslett. -7 til 2: -1 = i går, 0 = i dag, 1 = i morgen. Negative verdier søker i fortiden. Brukes kun med Søkestart-klokkeslett."
},
"search_end_time": {
"name": "Søkeslutt-klokkeslett",
"description": "Alternativ: Søk til dette klokkeslettet. Kombiner med dagsforskyvning. Ignoreres hvis Søkeslutt (dato/tid) er satt."
},
"search_end_day_offset": {
"name": "Søkeslutt dagsforskyvning",
"description": "Dagsforskyvning for Søkeslutt-klokkeslett. -7 til 2: -1 = i går, 0 = i dag, 1 = i morgen. Negative verdier søker i fortiden. Brukes kun med Søkeslutt-klokkeslett."
},
"search_start_offset_minutes": {
"name": "Søkestart-forskyvning (minutter)",
"description": "Alternativ: Start søk dette antall minutter fra nå. Positiv = fremtid (60 = om 1 time), negativ = fortid (-60 = 1 time siden). Ignoreres hvis Søkestart eller Søkestart-klokkeslett er satt."
},
"search_end_offset_minutes": {
"name": "Søkeslutt-forskyvning (minutter)",
"description": "Alternativ: Stopp søk dette antall minutter fra nå. Positiv = fremtid (480 = om 8 timer), negativ = fortid (-60 = 1 time siden). Ignoreres hvis Søkeslutt eller Søkeslutt-klokkeslett er satt."
},
"include_current_interval": {
"name": "Inkluder gjeldende intervall",
"description": "Inkluder det pågående 15-minutters intervallet i søket. Når aktivert (standard), starter søket ved begynnelsen av gjeldende intervall slik at det kan være en del av resultatet."
},
"max_price_level": {
"name": "Maksimalt prisnivaae",
"description": "Ta bare med intervaller paa eller under dette Tibber-prisnivaeet. very_cheap = mest restriktivt, very_expensive = ingen begrensning."
},
"min_price_level": {
"name": "Minimalt prisnivaae",
"description": "Ta bare med intervaller paa eller over dette Tibber-prisnivaeet. Nyttig for find_most_expensive for aa fokusere paa virkelig dyre intervaller."
},
"use_base_unit": {
"name": "Bruk basisvaluta",
"description": "Tving priser i basisvaluta (EUR, NOK) i stedet for konfigurert visningsenhet (ct, øre). Nyttig for beregninger."
}
}
} }
}, },
"selector": { "selector": {
@ -1361,6 +1877,15 @@
"median": "Median", "median": "Median",
"mean": "Aritmetisk gjennomsnitt" "mean": "Aritmetisk gjennomsnitt"
} }
},
"search_scope": {
"options": {
"today": "I dag",
"tomorrow": "I morgen",
"remaining_today": "Resten av dagen",
"next_24h": "Neste 24 timer",
"next_48h": "Neste 48 timer"
}
} }
}, },
"title": "Tibber Prisinformasjon & Vurderinger" "title": "Tibber Prisinformasjon & Vurderinger"

View file

@ -59,15 +59,7 @@
"home_already_configured": "Dit huis is al geconfigureerd in een ander item. Elk huis kan slechts één keer worden geconfigureerd.", "home_already_configured": "Dit huis is al geconfigureerd in een ander item. Elk huis kan slechts één keer worden geconfigureerd.",
"no_active_subscription": "Dit huis heeft geen actief Tibber-contract. Alleen huizen met actieve elektriciteitscontracten kunnen worden toegevoegd aan Home Assistant.", "no_active_subscription": "Dit huis heeft geen actief Tibber-contract. Alleen huizen met actieve elektriciteitscontracten kunnen worden toegevoegd aan Home Assistant.",
"subscription_expired": "Het Tibber-contract voor dit huis is verlopen. Alleen huizen met actieve of toekomstige elektriciteitscontracten kunnen worden toegevoegd aan Home Assistant.", "subscription_expired": "Het Tibber-contract voor dit huis is verlopen. Alleen huizen met actieve of toekomstige elektriciteitscontracten kunnen worden toegevoegd aan Home Assistant.",
"future_subscription_warning": "Let op: Het Tibber-contract van dit huis is nog niet gestart. De functionaliteit kan beperkt zijn totdat het contract actief wordt.", "future_subscription_warning": "Let op: Het Tibber-contract van dit huis is nog niet gestart. De functionaliteit kan beperkt zijn totdat het contract actief wordt."
"invalid_yaml_syntax": "Ongeldige YAML-syntaxis. Controleer inspringing, dubbele punten en speciale tekens.",
"invalid_yaml_structure": "YAML moet een dictionary/object zijn (sleutel: waarde paren), geen lijst of platte tekst.",
"service_call_failed": "Service call validatie mislukt: {error_detail}",
"missing_entry_id": "Entry-ID is verplicht maar werd niet opgegeven.",
"invalid_entry_id": "Ongeldige entry-ID of item niet gevonden.",
"missing_home_id": "Huis-ID ontbreekt in het configuratie-item.",
"user_data_not_available": "Gebruikersgegevens zijn niet beschikbaar. Ververs eerst de gebruikersgegevens.",
"price_fetch_failed": "Ophalen van prijsgegevens mislukt. Controleer de logs voor details."
}, },
"abort": { "abort": {
"already_configured": "Alle beschikbare Tibber-huizen zijn al geconfigureerd. Elk huis kan slechts één keer worden geconfigureerd.", "already_configured": "Alle beschikbare Tibber-huizen zijn al geconfigureerd. Elk huis kan slechts één keer worden geconfigureerd.",
@ -1055,6 +1047,62 @@
"description": "Deze update bevat wijzigingen die automatisch zijn toegepast.\n\n**Hernoemde entiteiten ({count})**\n\nDe volgende entity-sleutels zijn hernoemd. Je bestaande entity-ID's en automatiseringen blijven intact:\n\n{entity_list}\n\n**Gewijzigde duur-sensorwaarden**\n\nAlle duur-sensoren (resterende tijd, start over, periodeduur, trendwijzigings-aftelling) rapporteren hun statuswaarde nu in **minuten** in plaats van uren. De weergave-eenheid in dashboards blijft standaard uren.\n\nAls je automatiseringen hebt met numerieke vergelijkingen op deze sensoren, werk dan je drempelwaarden bij:\n- Oud: `state < 0.25` (15 minuten als uren)\n- Nieuw: `state < 15` (15 minuten)\n\nSluit deze melding nadat je je automatiseringen hebt gecontroleerd." "description": "Deze update bevat wijzigingen die automatisch zijn toegepast.\n\n**Hernoemde entiteiten ({count})**\n\nDe volgende entity-sleutels zijn hernoemd. Je bestaande entity-ID's en automatiseringen blijven intact:\n\n{entity_list}\n\n**Gewijzigde duur-sensorwaarden**\n\nAlle duur-sensoren (resterende tijd, start over, periodeduur, trendwijzigings-aftelling) rapporteren hun statuswaarde nu in **minuten** in plaats van uren. De weergave-eenheid in dashboards blijft standaard uren.\n\nAls je automatiseringen hebt met numerieke vergelijkingen op deze sensoren, werk dan je drempelwaarden bij:\n- Oud: `state < 0.25` (15 minuten als uren)\n- Nieuw: `state < 15` (15 minuten)\n\nSluit deze melding nadat je je automatiseringen hebt gecontroleerd."
} }
}, },
"exceptions": {
"no_entries_found": {
"message": "Geen Tibber Prices integratie-items gevonden. Stel de integratie eerst in."
},
"multiple_entries_no_entry_id": {
"message": "Meerdere Tibber Prices items gevonden. Geef 'entry_id' op om het gewenste item te selecteren."
},
"invalid_entry_id": {
"message": "Ongeldige of niet-beschikbare configuratie-item. Controleer de item-ID en zorg dat de integratie geladen is."
},
"missing_home_id": {
"message": "Huis-ID niet gevonden in het configuratie-item. Configureer de integratie opnieuw."
},
"user_data_not_available": {
"message": "Gebruikersgegevens zijn nog niet beschikbaar. Wacht tot de eerste gegevensupdate is voltooid."
},
"timezone_not_found": {
"message": "Kon de tijdzone van het huis niet bepalen. Controleer de huisconfiguratie in je Tibber-account."
},
"end_before_start": {
"message": "Eindtijd moet na de starttijd liggen."
},
"price_fetch_failed": {
"message": "Kon prijsgegevens niet ophalen bij de Tibber API. Probeer het later opnieuw."
},
"invalid_search_scope": {
"message": "Ongeldig zoekbereik. Geldige waarden zijn: today, tomorrow, remaining_today, next_24h, next_48h."
},
"scope_conflicts_with_range": {
"message": "search_scope kan niet gecombineerd worden met expliciete bereikparameters: {params}. Gebruik search_scope OF expliciete start-/eindparameters."
},
"day_offset_requires_time": {
"message": "{offset_param} vereist dat {time_param} ingesteld is. Dagoffset wijzigt alleen de datum van een expliciete tijdparameter."
},
"min_level_exceeds_max": {
"message": "min_price_level '{min_level}' is hoger dan max_price_level '{max_level}'. Het minimumniveau moet gelijk aan of lager zijn dan het maximumniveau."
},
"power_profile_length_mismatch": {
"message": "power_profile heeft {profile_length} items maar de duur vereist {interval_count} intervallen ({duration_minutes} minuten). Het power_profile moet precies één item per 15-minuten-interval hebben."
},
"level_and_rating_filter_conflict": {
"message": "level_filter en rating_level_filter kunnen niet samen gebruikt worden. Gebruik slechts één filtertype per verzoek."
},
"insert_nulls_requires_filter": {
"message": "insert_nulls-modus '{mode}' vereist een level_filter of rating_level_filter om segmenten te definiëren. Zonder filter, gebruik insert_nulls: none."
},
"connect_segments_requires_segments_mode": {
"message": "connect_segments vereist dat insert_nulls op 'segments' staat. Stel insert_nulls: segments in om segmentverbinding te gebruiken."
},
"array_fields_requires_array_format": {
"message": "array_fields kan alleen gebruikt worden met output_format: array_of_arrays. Wijzig het uitvoerformaat of verwijder array_fields."
},
"invalid_array_fields": {
"message": "Ongeldig array_fields-sjabloon. Gebruik veldnamen tussen accolades, bijv. '{start_time}, {price_per_kwh}, {level}'."
}
},
"services": { "services": {
"get_price": { "get_price": {
"name": "Prijsgegevens Ophalen", "name": "Prijsgegevens Ophalen",
@ -1261,6 +1309,474 @@
"description": "De config-item-ID voor de Tibber-integratie." "description": "De config-item-ID voor de Tibber-integratie."
} }
} }
},
"find_cheapest_block": {
"name": "Goedkoopste blok vinden",
"description": "Vindt het goedkoopste aaneengesloten tijdvenster van een bepaalde duur. Ontworpen voor apparaatplanning: vaatwasser, wasmachine, droger enz. Retourneert het goedkoopste venster met start-/eindtijden en prijsstatistieken.",
"sections": {
"search_range": {
"name": "Zoekbereik",
"description": "Definieer het tijdvenster voor het zoeken."
},
"time_alternatives": {
"name": "Alternatieve tijdbereiksopties",
"description": "Alternatieve manieren om het zoekbereik te definieren via tijdstip en offsets."
},
"price_filter": {
"name": "Prijsniveau-filter",
"description": "Beperk het zoeken tot intervallen binnen het opgegeven prijsniveaubereik."
},
"output": {
"name": "Uitvoeropties",
"description": "Stuur kostnadsraming en vergelijkingsuitvoer."
}
},
"fields": {
"entry_id": {
"name": "Item-ID",
"description": "De config-item-ID voor de Tibber-integratie."
},
"duration": {
"name": "Duur",
"description": "Lengte van het gewenste aaneengesloten venster. Automatisch afgerond naar het volgende kwartier. Maximum: 12 uur."
},
"search_start": {
"name": "Zoekstart",
"description": "Begin van het zoekbereik als exacte datum en tijd. Hoogste prioriteit — overschrijft alle andere startopties. Standaard is nu als niet opgegeven."
},
"search_end": {
"name": "Zoekeinde",
"description": "Einde van het zoekbereik als exacte datum en tijd. Hoogste prioriteit — overschrijft alle andere eindopties. Standaard is einde van morgen als niet opgegeven."
},
"search_start_time": {
"name": "Zoekstart-tijd",
"description": "Alternatief: Start zoeken vanaf dit tijdstip. Combineer met dagoffset. Wordt genegeerd als Zoekstart (datum/tijd) is ingesteld."
},
"search_start_day_offset": {
"name": "Zoekstart dagoffset",
"description": "Dagoffset voor Zoekstart-tijd. -7 tot 2: -1 = gisteren, 0 = vandaag, 1 = morgen. Negatieve waarden zoeken in het verleden. Wordt alleen gebruikt met Zoekstart-tijd."
},
"search_end_time": {
"name": "Zoekeinde-tijd",
"description": "Alternatief: Zoek tot dit tijdstip. Combineer met dagoffset. Wordt genegeerd als Zoekeinde (datum/tijd) is ingesteld."
},
"search_end_day_offset": {
"name": "Zoekeinde dagoffset",
"description": "Dagoffset voor Zoekeinde-tijd. -7 tot 2: -1 = gisteren, 0 = vandaag, 1 = morgen. Negatieve waarden zoeken in het verleden. Wordt alleen gebruikt met Zoekeinde-tijd."
},
"search_start_offset_minutes": {
"name": "Zoekstart-offset (minuten)",
"description": "Alternatief: Start met zoeken over dit aantal minuten vanaf nu. Positief = toekomst (60 = over 1 uur), negatief = verleden (-60 = 1 uur geleden). Wordt genegeerd als Zoekstart of Zoekstart-tijd is ingesteld."
},
"search_end_offset_minutes": {
"name": "Zoekeinde-offset (minuten)",
"description": "Alternatief: Stop met zoeken over dit aantal minuten vanaf nu. Positief = toekomst (480 = over 8 uur), negatief = verleden (-60 = 1 uur geleden). Wordt genegeerd als Zoekeinde of Zoekeinde-tijd is ingesteld."
},
"include_current_interval": {
"name": "Huidig interval opnemen",
"description": "Het huidige lopende 15-minuten interval opnemen in de zoekopdracht. Indien ingeschakeld (standaard), begint de zoekopdracht aan het begin van het huidige interval zodat het deel kan uitmaken van het resultaat."
},
"use_base_unit": {
"name": "Basisvaluta gebruiken",
"description": "Forceer prijzen in basisvaluta (EUR, NOK) in plaats van de geconfigureerde weergave-eenheid (ct, øre). Handig voor berekeningen."
},
"search_scope": {
"name": "Zoekbereik (snelkoppeling)",
"description": "Snelkoppeling voor veelgebruikte zoekbereiken. Overschrijft alle andere tijdopties. today/tomorrow = volledige kalenderdag, remaining_today = nu tot middernacht, next_24h/next_48h = rolling venster vanaf nu."
},
"max_price_level": {
"name": "Maximaal prijsniveau",
"description": "Overweeg alleen intervallen op of onder dit Tibber-prijsniveau. very_cheap = meest restrictief, very_expensive = geen beperking."
},
"min_price_level": {
"name": "Minimaal prijsniveau",
"description": "Overweeg alleen intervallen op of boven dit Tibber-prijsniveau. Nuttig voor find_most_expensive om te focussen op echt dure intervallen."
},
"include_comparison_details": {
"name": "Vergelijkingsdetails meenemen",
"description": "Verrijk het price_comparison-resultaat met extra velden: comparison_price_min, comparison_price_max en (alleen blok) comparison_window_end."
},
"power_profile": {
"name": "Vermogensprofiel",
"description": "Variabel vermogensverbruik in watt per 15-minuten-interval. Indien ingesteld, weerspiegelt estimated_total_cost het werkelijke verbruik."
}
}
},
"find_most_expensive_block": {
"name": "Duurste blok vinden",
"description": "Vindt het duurste aaneengesloten tijdvenster van een bepaalde duur. Nuttig voor het identificeren van piekprijsperioden die vermeden moeten worden. Retourneert het duurste venster met start-/eindtijden en prijsstatistieken.",
"sections": {
"search_range": {
"name": "Zoekbereik",
"description": "Definieer het tijdvenster voor het zoeken."
},
"time_alternatives": {
"name": "Alternatieve tijdbereiksopties",
"description": "Alternatieve manieren om het zoekbereik te definieren via tijdstip en offsets."
},
"price_filter": {
"name": "Prijsniveau-filter",
"description": "Beperk het zoeken tot intervallen binnen het opgegeven prijsniveaubereik."
},
"output": {
"name": "Uitvoeropties",
"description": "Stuur kostnadsraming en vergelijkingsuitvoer."
}
},
"fields": {
"entry_id": {
"name": "Item-ID",
"description": "De config-item-ID voor de Tibber-integratie."
},
"duration": {
"name": "Duur",
"description": "Lengte van het gewenste aaneengesloten venster. Automatisch afgerond naar het volgende kwartier. Maximum: 12 uur."
},
"search_start": {
"name": "Zoekstart",
"description": "Begin van het zoekbereik als exacte datum en tijd. Hoogste prioriteit — overschrijft alle andere startopties. Standaard is nu als niet opgegeven."
},
"search_end": {
"name": "Zoekeinde",
"description": "Einde van het zoekbereik als exacte datum en tijd. Hoogste prioriteit — overschrijft alle andere eindopties. Standaard is einde van morgen als niet opgegeven."
},
"search_start_time": {
"name": "Zoekstart-tijd",
"description": "Alternatief: Start zoeken vanaf dit tijdstip. Combineer met dagoffset. Wordt genegeerd als Zoekstart (datum/tijd) is ingesteld."
},
"search_start_day_offset": {
"name": "Zoekstart dagoffset",
"description": "Dagoffset voor Zoekstart-tijd. -7 tot 2: -1 = gisteren, 0 = vandaag, 1 = morgen. Negatieve waarden zoeken in het verleden. Wordt alleen gebruikt met Zoekstart-tijd."
},
"search_end_time": {
"name": "Zoekeinde-tijd",
"description": "Alternatief: Zoek tot dit tijdstip. Combineer met dagoffset. Wordt genegeerd als Zoekeinde (datum/tijd) is ingesteld."
},
"search_end_day_offset": {
"name": "Zoekeinde dagoffset",
"description": "Dagoffset voor Zoekeinde-tijd. -7 tot 2: -1 = gisteren, 0 = vandaag, 1 = morgen. Negatieve waarden zoeken in het verleden. Wordt alleen gebruikt met Zoekeinde-tijd."
},
"search_start_offset_minutes": {
"name": "Zoekstart-offset (minuten)",
"description": "Alternatief: Start met zoeken over dit aantal minuten vanaf nu. Positief = toekomst (60 = over 1 uur), negatief = verleden (-60 = 1 uur geleden). Wordt genegeerd als Zoekstart of Zoekstart-tijd is ingesteld."
},
"search_end_offset_minutes": {
"name": "Zoekeinde-offset (minuten)",
"description": "Alternatief: Stop met zoeken over dit aantal minuten vanaf nu. Positief = toekomst (480 = over 8 uur), negatief = verleden (-60 = 1 uur geleden). Wordt genegeerd als Zoekeinde of Zoekeinde-tijd is ingesteld."
},
"include_current_interval": {
"name": "Huidig interval opnemen",
"description": "Het huidige lopende 15-minuten interval opnemen in de zoekopdracht. Indien ingeschakeld (standaard), begint de zoekopdracht aan het begin van het huidige interval zodat het deel kan uitmaken van het resultaat."
},
"use_base_unit": {
"name": "Basisvaluta gebruiken",
"description": "Forceer prijzen in basisvaluta (EUR, NOK) in plaats van de geconfigureerde weergave-eenheid (ct, øre). Handig voor berekeningen."
},
"search_scope": {
"name": "Zoekbereik (snelkoppeling)",
"description": "Snelkoppeling voor veelgebruikte zoekbereiken. Overschrijft alle andere tijdopties. today/tomorrow = volledige kalenderdag, remaining_today = nu tot middernacht, next_24h/next_48h = rolling venster vanaf nu."
},
"max_price_level": {
"name": "Maximaal prijsniveau",
"description": "Overweeg alleen intervallen op of onder dit Tibber-prijsniveau. very_cheap = meest restrictief, very_expensive = geen beperking."
},
"min_price_level": {
"name": "Minimaal prijsniveau",
"description": "Overweeg alleen intervallen op of boven dit Tibber-prijsniveau. Nuttig voor find_most_expensive om te focussen op echt dure intervallen."
},
"include_comparison_details": {
"name": "Vergelijkingsdetails meenemen",
"description": "Verrijk het price_comparison-resultaat met extra velden: comparison_price_min, comparison_price_max en (alleen blok) comparison_window_end."
},
"power_profile": {
"name": "Vermogensprofiel",
"description": "Variabel vermogensverbruik in watt per 15-minuten-interval. Indien ingesteld, weerspiegelt estimated_total_cost het werkelijke verbruik."
}
}
},
"find_cheapest_hours": {
"name": "Goedkoopste uren vinden",
"description": "Vindt de goedkoopste intervallen voor een bepaalde totale duur, niet noodzakelijk aaneengesloten. Ontworpen voor flexibele belastingen: batterijladen, elektrisch voertuig, warmwaterboiler. Retourneert een schema van intervallen gegroepeerd in aaneengesloten segmenten.",
"sections": {
"search_range": {
"name": "Zoekbereik",
"description": "Definieer het tijdvenster voor het zoeken."
},
"time_alternatives": {
"name": "Alternatieve tijdbereiksopties",
"description": "Alternatieve manieren om het zoekbereik te definieren via tijdstip en offsets."
},
"price_filter": {
"name": "Prijsniveau-filter",
"description": "Beperk het zoeken tot intervallen binnen het opgegeven prijsniveaubereik."
},
"output": {
"name": "Uitvoeropties",
"description": "Stuur kostnadsraming en vergelijkingsuitvoer."
}
},
"fields": {
"entry_id": {
"name": "Item-ID",
"description": "De config-item-ID voor de Tibber-integratie."
},
"duration": {
"name": "Duur",
"description": "Benodigde goedkope totale tijd. Automatisch afgerond naar het volgende kwartier. Maximum: 24 uur."
},
"search_start": {
"name": "Zoekstart",
"description": "Begin van het zoekbereik als exacte datum en tijd. Hoogste prioriteit — overschrijft alle andere startopties. Standaard is nu als niet opgegeven."
},
"search_end": {
"name": "Zoekeinde",
"description": "Einde van het zoekbereik als exacte datum en tijd. Hoogste prioriteit — overschrijft alle andere eindopties. Standaard is einde van morgen als niet opgegeven."
},
"search_start_time": {
"name": "Zoekstart-tijd",
"description": "Alternatief: Start zoeken vanaf dit tijdstip. Combineer met dagoffset. Wordt genegeerd als Zoekstart (datum/tijd) is ingesteld."
},
"search_start_day_offset": {
"name": "Zoekstart dagoffset",
"description": "Dagoffset voor Zoekstart-tijd. -7 tot 2: -1 = gisteren, 0 = vandaag, 1 = morgen. Negatieve waarden zoeken in het verleden. Wordt alleen gebruikt met Zoekstart-tijd."
},
"search_end_time": {
"name": "Zoekeinde-tijd",
"description": "Alternatief: Zoek tot dit tijdstip. Combineer met dagoffset. Wordt genegeerd als Zoekeinde (datum/tijd) is ingesteld."
},
"search_end_day_offset": {
"name": "Zoekeinde dagoffset",
"description": "Dagoffset voor Zoekeinde-tijd. -7 tot 2: -1 = gisteren, 0 = vandaag, 1 = morgen. Negatieve waarden zoeken in het verleden. Wordt alleen gebruikt met Zoekeinde-tijd."
},
"search_start_offset_minutes": {
"name": "Zoekstart-offset (minuten)",
"description": "Alternatief: Start met zoeken over dit aantal minuten vanaf nu. Positief = toekomst (60 = over 1 uur), negatief = verleden (-60 = 1 uur geleden). Wordt genegeerd als Zoekstart of Zoekstart-tijd is ingesteld."
},
"search_end_offset_minutes": {
"name": "Zoekeinde-offset (minuten)",
"description": "Alternatief: Stop met zoeken over dit aantal minuten vanaf nu. Positief = toekomst (480 = over 8 uur), negatief = verleden (-60 = 1 uur geleden). Wordt genegeerd als Zoekeinde of Zoekeinde-tijd is ingesteld."
},
"include_current_interval": {
"name": "Huidig interval opnemen",
"description": "Het huidige lopende 15-minuten interval opnemen in de zoekopdracht. Indien ingeschakeld (standaard), begint de zoekopdracht aan het begin van het huidige interval zodat het deel kan uitmaken van het resultaat."
},
"min_segment_duration": {
"name": "Minimale segmentduur",
"description": "Minimale aaneengesloten looptijd. Voorkomt snel aan-/uitschakelen bij apparaten met minimale looptijden. Automatisch afgerond naar het volgende kwartier. Standaard: 15 minuten. Maximum: 4 uur."
},
"use_base_unit": {
"name": "Basisvaluta gebruiken",
"description": "Forceer prijzen in basisvaluta (EUR, NOK) in plaats van de geconfigureerde weergave-eenheid (ct, øre). Handig voor berekeningen."
},
"search_scope": {
"name": "Zoekbereik (snelkoppeling)",
"description": "Snelkoppeling voor veelgebruikte zoekbereiken. Overschrijft alle andere tijdopties. today/tomorrow = volledige kalenderdag, remaining_today = nu tot middernacht, next_24h/next_48h = rolling venster vanaf nu."
},
"max_price_level": {
"name": "Maximaal prijsniveau",
"description": "Overweeg alleen intervallen op of onder dit Tibber-prijsniveau. very_cheap = meest restrictief, very_expensive = geen beperking."
},
"min_price_level": {
"name": "Minimaal prijsniveau",
"description": "Overweeg alleen intervallen op of boven dit Tibber-prijsniveau. Nuttig voor find_most_expensive om te focussen op echt dure intervallen."
},
"include_comparison_details": {
"name": "Vergelijkingsdetails meenemen",
"description": "Verrijk het price_comparison-resultaat met extra velden: comparison_price_min, comparison_price_max en (alleen blok) comparison_window_end."
},
"power_profile": {
"name": "Vermogensprofiel",
"description": "Variabel vermogensverbruik in watt per 15-minuten-interval. Indien ingesteld, weerspiegelt estimated_total_cost het werkelijke verbruik."
}
}
},
"find_most_expensive_hours": {
"name": "Duurste uren vinden",
"description": "Vindt de duurste intervallen voor een bepaalde totale duur, niet noodzakelijk aaneengesloten. Nuttig voor het identificeren van piekprijsperioden die vermeden moeten worden. Retourneert een schema van intervallen gegroepeerd in aaneengesloten segmenten.",
"sections": {
"search_range": {
"name": "Zoekbereik",
"description": "Definieer het tijdvenster voor het zoeken."
},
"time_alternatives": {
"name": "Alternatieve tijdbereiksopties",
"description": "Alternatieve manieren om het zoekbereik te definieren via tijdstip en offsets."
},
"price_filter": {
"name": "Prijsniveau-filter",
"description": "Beperk het zoeken tot intervallen binnen het opgegeven prijsniveaubereik."
},
"output": {
"name": "Uitvoeropties",
"description": "Stuur kostnadsraming en vergelijkingsuitvoer."
}
},
"fields": {
"entry_id": {
"name": "Item-ID",
"description": "De config-item-ID voor de Tibber-integratie."
},
"duration": {
"name": "Duur",
"description": "Te vinden dure totale tijd. Automatisch afgerond naar het volgende kwartier. Maximum: 24 uur."
},
"search_start": {
"name": "Zoekstart",
"description": "Begin van het zoekbereik als exacte datum en tijd. Hoogste prioriteit — overschrijft alle andere startopties. Standaard is nu als niet opgegeven."
},
"search_end": {
"name": "Zoekeinde",
"description": "Einde van het zoekbereik als exacte datum en tijd. Hoogste prioriteit — overschrijft alle andere eindopties. Standaard is einde van morgen als niet opgegeven."
},
"search_start_time": {
"name": "Zoekstart-tijd",
"description": "Alternatief: Start zoeken vanaf dit tijdstip. Combineer met dagoffset. Wordt genegeerd als Zoekstart (datum/tijd) is ingesteld."
},
"search_start_day_offset": {
"name": "Zoekstart dagoffset",
"description": "Dagoffset voor Zoekstart-tijd. -7 tot 2: -1 = gisteren, 0 = vandaag, 1 = morgen. Negatieve waarden zoeken in het verleden. Wordt alleen gebruikt met Zoekstart-tijd."
},
"search_end_time": {
"name": "Zoekeinde-tijd",
"description": "Alternatief: Zoek tot dit tijdstip. Combineer met dagoffset. Wordt genegeerd als Zoekeinde (datum/tijd) is ingesteld."
},
"search_end_day_offset": {
"name": "Zoekeinde dagoffset",
"description": "Dagoffset voor Zoekeinde-tijd. -7 tot 2: -1 = gisteren, 0 = vandaag, 1 = morgen. Negatieve waarden zoeken in het verleden. Wordt alleen gebruikt met Zoekeinde-tijd."
},
"search_start_offset_minutes": {
"name": "Zoekstart-offset (minuten)",
"description": "Alternatief: Start met zoeken over dit aantal minuten vanaf nu. Positief = toekomst (60 = over 1 uur), negatief = verleden (-60 = 1 uur geleden). Wordt genegeerd als Zoekstart of Zoekstart-tijd is ingesteld."
},
"search_end_offset_minutes": {
"name": "Zoekeinde-offset (minuten)",
"description": "Alternatief: Stop met zoeken over dit aantal minuten vanaf nu. Positief = toekomst (480 = over 8 uur), negatief = verleden (-60 = 1 uur geleden). Wordt genegeerd als Zoekeinde of Zoekeinde-tijd is ingesteld."
},
"include_current_interval": {
"name": "Huidig interval opnemen",
"description": "Het huidige lopende 15-minuten interval opnemen in de zoekopdracht. Indien ingeschakeld (standaard), begint de zoekopdracht aan het begin van het huidige interval zodat het deel kan uitmaken van het resultaat."
},
"min_segment_duration": {
"name": "Minimale segmentduur",
"description": "Minimale aaneengesloten looptijd. Voorkomt snel aan-/uitschakelen bij apparaten met minimale looptijden. Automatisch afgerond naar het volgende kwartier. Standaard: 15 minuten. Maximum: 4 uur."
},
"use_base_unit": {
"name": "Basisvaluta gebruiken",
"description": "Forceer prijzen in basisvaluta (EUR, NOK) in plaats van de geconfigureerde weergave-eenheid (ct, øre). Handig voor berekeningen."
},
"search_scope": {
"name": "Zoekbereik (snelkoppeling)",
"description": "Snelkoppeling voor veelgebruikte zoekbereiken. Overschrijft alle andere tijdopties. today/tomorrow = volledige kalenderdag, remaining_today = nu tot middernacht, next_24h/next_48h = rolling venster vanaf nu."
},
"max_price_level": {
"name": "Maximaal prijsniveau",
"description": "Overweeg alleen intervallen op of onder dit Tibber-prijsniveau. very_cheap = meest restrictief, very_expensive = geen beperking."
},
"min_price_level": {
"name": "Minimaal prijsniveau",
"description": "Overweeg alleen intervallen op of boven dit Tibber-prijsniveau. Nuttig voor find_most_expensive om te focussen op echt dure intervallen."
},
"include_comparison_details": {
"name": "Vergelijkingsdetails meenemen",
"description": "Verrijk het price_comparison-resultaat met extra velden: comparison_price_min, comparison_price_max en (alleen blok) comparison_window_end."
},
"power_profile": {
"name": "Vermogensprofiel",
"description": "Variabel vermogensverbruik in watt per 15-minuten-interval. Indien ingesteld, weerspiegelt estimated_total_cost het werkelijke verbruik."
}
}
},
"find_cheapest_schedule": {
"name": "Goedkoopste schema vinden",
"description": "Plant meerdere apparaten optimaal zonder tijdoverlap. Elke taak krijgt het goedkoopste beschikbare aaneengesloten tijdvenster.",
"sections": {
"scheduling_options": {
"name": "Planningsopties",
"description": "Configureer taken en tussenpozen."
},
"search_range": {
"name": "Zoekbereik",
"description": "Definieer het tijdvenster voor het zoeken."
},
"time_alternatives": {
"name": "Alternatieve tijdbereiksopties",
"description": "Alternatieve manieren om het zoekbereik te definieren via tijdstip en offsets."
},
"price_filter": {
"name": "Prijsniveau-filter",
"description": "Beperk het zoeken tot intervallen binnen het opgegeven prijsniveaubereik."
},
"output": {
"name": "Uitvoeropties",
"description": "Stuur kostnadsraming en vergelijkingsuitvoer."
}
},
"fields": {
"entry_id": {
"name": "Item-ID",
"description": "De config-item-ID voor de Tibber-integratie."
},
"tasks": {
"name": "Taken",
"description": "Lijst van in te plannen taken. Elke taak heeft name (tekst) en duration (hh:mm:ss). Optioneel power_profile (watt per 15-min-interval). Maximaal 4 taken."
},
"gap_minutes": {
"name": "Tussenpose tussen taken (minuten)",
"description": "Minimale tussenpose in minuten tussen opeenvolgende ingeplande taken. Afgerond omhoog tot 15 minuten. Standaard: 0 (geen tussenpose)."
},
"search_scope": {
"name": "Zoekbereik (snelkoppeling)",
"description": "Snelkoppeling voor veelgebruikte zoekbereiken. Overschrijft alle andere tijdopties. today/tomorrow = volledige kalenderdag, remaining_today = nu tot middernacht, next_24h/next_48h = rolling venster vanaf nu."
},
"search_start": {
"name": "Zoekstart",
"description": "Begin van het zoekbereik als exacte datum en tijd. Hoogste prioriteit — overschrijft alle andere startopties. Standaard is nu als niet opgegeven."
},
"search_end": {
"name": "Zoekeinde",
"description": "Einde van het zoekbereik als exacte datum en tijd. Hoogste prioriteit — overschrijft alle andere eindopties. Standaard is einde van morgen als niet opgegeven."
},
"search_start_time": {
"name": "Zoekstart-tijd",
"description": "Alternatief: Start zoeken vanaf dit tijdstip. Combineer met dagoffset. Wordt genegeerd als Zoekstart (datum/tijd) is ingesteld."
},
"search_start_day_offset": {
"name": "Zoekstart dagoffset",
"description": "Dagoffset voor Zoekstart-tijd. -7 tot 2: -1 = gisteren, 0 = vandaag, 1 = morgen. Negatieve waarden zoeken in het verleden. Wordt alleen gebruikt met Zoekstart-tijd."
},
"search_end_time": {
"name": "Zoekeinde-tijd",
"description": "Alternatief: Zoek tot dit tijdstip. Combineer met dagoffset. Wordt genegeerd als Zoekeinde (datum/tijd) is ingesteld."
},
"search_end_day_offset": {
"name": "Zoekeinde dagoffset",
"description": "Dagoffset voor Zoekeinde-tijd. -7 tot 2: -1 = gisteren, 0 = vandaag, 1 = morgen. Negatieve waarden zoeken in het verleden. Wordt alleen gebruikt met Zoekeinde-tijd."
},
"search_start_offset_minutes": {
"name": "Zoekstart-offset (minuten)",
"description": "Alternatief: Start met zoeken over dit aantal minuten vanaf nu. Positief = toekomst (60 = over 1 uur), negatief = verleden (-60 = 1 uur geleden). Wordt genegeerd als Zoekstart of Zoekstart-tijd is ingesteld."
},
"search_end_offset_minutes": {
"name": "Zoekeinde-offset (minuten)",
"description": "Alternatief: Stop met zoeken over dit aantal minuten vanaf nu. Positief = toekomst (480 = over 8 uur), negatief = verleden (-60 = 1 uur geleden). Wordt genegeerd als Zoekeinde of Zoekeinde-tijd is ingesteld."
},
"include_current_interval": {
"name": "Huidig interval opnemen",
"description": "Het huidige lopende 15-minuten interval opnemen in de zoekopdracht. Indien ingeschakeld (standaard), begint de zoekopdracht aan het begin van het huidige interval zodat het deel kan uitmaken van het resultaat."
},
"max_price_level": {
"name": "Maximaal prijsniveau",
"description": "Overweeg alleen intervallen op of onder dit Tibber-prijsniveau. very_cheap = meest restrictief, very_expensive = geen beperking."
},
"min_price_level": {
"name": "Minimaal prijsniveau",
"description": "Overweeg alleen intervallen op of boven dit Tibber-prijsniveau. Nuttig voor find_most_expensive om te focussen op echt dure intervallen."
},
"use_base_unit": {
"name": "Basisvaluta gebruiken",
"description": "Forceer prijzen in basisvaluta (EUR, NOK) in plaats van de geconfigureerde weergave-eenheid (ct, øre). Handig voor berekeningen."
}
}
} }
}, },
"selector": { "selector": {
@ -1361,6 +1877,15 @@
"median": "Mediaan", "median": "Mediaan",
"mean": "Rekenkundig Gemiddelde" "mean": "Rekenkundig Gemiddelde"
} }
},
"search_scope": {
"options": {
"today": "Vandaag",
"tomorrow": "Morgen",
"remaining_today": "Rest van vandaag",
"next_24h": "Komende 24 uur",
"next_48h": "Komende 48 uur"
}
} }
}, },
"title": "Tibber Prijsinformatie & Beoordelingen" "title": "Tibber Prijsinformatie & Beoordelingen"

View file

@ -59,15 +59,7 @@
"home_already_configured": "Detta hem är redan konfigurerat i en annan post. Varje hem kan bara konfigureras en gång.", "home_already_configured": "Detta hem är redan konfigurerat i en annan post. Varje hem kan bara konfigureras en gång.",
"no_active_subscription": "Detta hem har inget aktivt Tibber-avtal. Endast hem med aktiva elavtal kan läggas till i Home Assistant.", "no_active_subscription": "Detta hem har inget aktivt Tibber-avtal. Endast hem med aktiva elavtal kan läggas till i Home Assistant.",
"subscription_expired": "Tibber-avtalet för detta hem har löpt ut. Endast hem med aktiva eller framtida elavtal kan läggas till i Home Assistant.", "subscription_expired": "Tibber-avtalet för detta hem har löpt ut. Endast hem med aktiva eller framtida elavtal kan läggas till i Home Assistant.",
"future_subscription_warning": "Obs: Detta hems Tibber-avtal har inte börjat ännu. Funktionaliteten kan vara begränsad tills avtalet blir aktivt.", "future_subscription_warning": "Obs: Detta hems Tibber-avtal har inte börjat ännu. Funktionaliteten kan vara begränsad tills avtalet blir aktivt."
"invalid_yaml_syntax": "Ogiltig YAML-syntax. Kontrollera indragning, kolon och specialtecken.",
"invalid_yaml_structure": "YAML måste vara en ordbok/objekt (nyckel: värde-par), inte en lista eller vanlig text.",
"service_call_failed": "Tjänsteanrop validering misslyckades: {error_detail}",
"missing_entry_id": "Post-ID krävs men angavs inte.",
"invalid_entry_id": "Ogiltigt post-ID eller post hittades inte.",
"missing_home_id": "Hem-ID saknas från konfigurationsposten.",
"user_data_not_available": "Användardata är inte tillgänglig. Uppdatera användardata först.",
"price_fetch_failed": "Misslyckades med att hämta prisdata. Kontrollera loggarna för detaljer."
}, },
"abort": { "abort": {
"already_configured": "Alla tillgängliga Tibber-hem är redan konfigurerade. Varje hem kan bara konfigureras en gång.", "already_configured": "Alla tillgängliga Tibber-hem är redan konfigurerade. Varje hem kan bara konfigureras en gång.",
@ -1055,6 +1047,62 @@
"description": "Denna uppdatering innehåller ändringar som tillämpades automatiskt.\n\n**Omdöpta entiteter ({count})**\n\nFöljande entity-nycklar döptes om automatiskt. Dina befintliga entity-ID:n och automatiseringar förblir intakta:\n\n{entity_list}\n\n**Ändrade varaktighetssensorvärden**\n\nAlla varaktighetssensorer (återstående tid, startar om, periodvaraktighet, trendändrings-nedräkning) rapporterar nu sitt tillståndsvärde i **minuter** istället för timmar. Visningsenheten i dashboards förblir timmar som standard.\n\nOm du har automatiseringar med numeriska jämförelser på dessa sensorer, uppdatera dina tröskelvärden:\n- Gammalt: `state < 0.25` (15 minuter som timmar)\n- Nytt: `state < 15` (15 minuter)\n\nStäng detta meddelande efter att du har granskat dina automatiseringar." "description": "Denna uppdatering innehåller ändringar som tillämpades automatiskt.\n\n**Omdöpta entiteter ({count})**\n\nFöljande entity-nycklar döptes om automatiskt. Dina befintliga entity-ID:n och automatiseringar förblir intakta:\n\n{entity_list}\n\n**Ändrade varaktighetssensorvärden**\n\nAlla varaktighetssensorer (återstående tid, startar om, periodvaraktighet, trendändrings-nedräkning) rapporterar nu sitt tillståndsvärde i **minuter** istället för timmar. Visningsenheten i dashboards förblir timmar som standard.\n\nOm du har automatiseringar med numeriska jämförelser på dessa sensorer, uppdatera dina tröskelvärden:\n- Gammalt: `state < 0.25` (15 minuter som timmar)\n- Nytt: `state < 15` (15 minuter)\n\nStäng detta meddelande efter att du har granskat dina automatiseringar."
} }
}, },
"exceptions": {
"no_entries_found": {
"message": "Inga Tibber Prices integrationsposter hittades. Konfigurera integrationen först."
},
"multiple_entries_no_entry_id": {
"message": "Flera Tibber Prices poster hittades. Ange 'entry_id' för att välja vilken post som ska användas."
},
"invalid_entry_id": {
"message": "Ogiltig eller otillgänglig konfigurationspost. Kontrollera post-ID:t och se till att integrationen är laddad."
},
"missing_home_id": {
"message": "Hem-ID hittades inte i konfigurationsposten. Konfigurera om integrationen."
},
"user_data_not_available": {
"message": "Användardata är inte tillgänglig ännu. Vänta tills den första datauppdateringen är klar."
},
"timezone_not_found": {
"message": "Kunde inte fastställa hemmets tidszon. Kontrollera hemkonfigurationen i ditt Tibber-konto."
},
"end_before_start": {
"message": "Sluttid måste vara efter starttid."
},
"price_fetch_failed": {
"message": "Kunde inte hämta prisdata från Tibber API. Försök igen senare."
},
"invalid_search_scope": {
"message": "Ogiltigt sökområde. Giltiga värden är: today, tomorrow, remaining_today, next_24h, next_48h."
},
"scope_conflicts_with_range": {
"message": "search_scope kan inte kombineras med explicita områdesparametrar: {params}. Använd antingen search_scope ELLER explicita start-/slutparametrar."
},
"day_offset_requires_time": {
"message": "{offset_param} kräver att {time_param} är satt. Dagsförskjutning ändrar bara datumet för en explicit tidsparameter."
},
"min_level_exceeds_max": {
"message": "min_price_level '{min_level}' är högre än max_price_level '{max_level}'. Miniminivån måste vara lika med eller lägre än maximinivån."
},
"power_profile_length_mismatch": {
"message": "power_profile har {profile_length} poster men varaktigheten kräver {interval_count} intervaller ({duration_minutes} minuter). power_profile måste ha exakt en post per 15-minutersintervall."
},
"level_and_rating_filter_conflict": {
"message": "level_filter och rating_level_filter kan inte användas tillsammans. Använd bara en filtertyp per begäran."
},
"insert_nulls_requires_filter": {
"message": "insert_nulls-läge '{mode}' kräver ett level_filter eller rating_level_filter för att definiera segment. Utan filter, använd insert_nulls: none."
},
"connect_segments_requires_segments_mode": {
"message": "connect_segments kräver att insert_nulls är satt till 'segments'. Ställ in insert_nulls: segments för att använda segmentanslutning."
},
"array_fields_requires_array_format": {
"message": "array_fields kan bara användas med output_format: array_of_arrays. Ändra utdataformatet eller ta bort array_fields."
},
"invalid_array_fields": {
"message": "Ogiltig array_fields-mall. Använd fältnamn inom klammerparenteser, t.ex. '{start_time}, {price_per_kwh}, {level}'."
}
},
"services": { "services": {
"get_price": { "get_price": {
"name": "Hämta prisdata", "name": "Hämta prisdata",
@ -1261,6 +1309,474 @@
"description": "Config entry-ID för Tibber-integrationen." "description": "Config entry-ID för Tibber-integrationen."
} }
} }
},
"find_cheapest_block": {
"name": "Hitta billigaste blocket",
"description": "Hittar det billigaste sammanhängande tidsfönstret med en given varaktighet. Designat för apparatschemaläggning: diskmaskin, tvättmaskin, torktumlare osv. Returnerar det billigaste fönstret med start-/sluttider och prisstatistik.",
"sections": {
"search_range": {
"name": "Soekomraade",
"description": "Definiera tidsfoenstret att soeka inom."
},
"time_alternatives": {
"name": "Alternativa tidsinstaellningar",
"description": "Alternativa saett att definiera soekomraadet via tidpunkt och offset."
},
"price_filter": {
"name": "Prisnivaaefilter",
"description": "Begraensa soekningen till intervall inom det angivna prisnivaaeintervallet."
},
"output": {
"name": "Utdataalternativ",
"description": "Styr kostnadsuppskattning och jaemfoerelseresultat."
}
},
"fields": {
"entry_id": {
"name": "Entry-ID",
"description": "Config entry-ID för Tibber-integrationen."
},
"duration": {
"name": "Varaktighet",
"description": "Längd på det önskade sammanhängande fönstret. Avrundas automatiskt uppåt till närmaste kvart. Maximum: 12 timmar."
},
"search_start": {
"name": "Sökstart",
"description": "Start av sökintervallet som exakt datum och tid. Högsta prioritet — åsidosätter alla andra startalternativ. Standard är nu om inte angivet."
},
"search_end": {
"name": "Sökslut",
"description": "Slut av sökintervallet som exakt datum och tid. Högsta prioritet — åsidosätter alla andra slutalternativ. Standard är slutet av imorgon om inte angivet."
},
"search_start_time": {
"name": "Sökstart-klockslag",
"description": "Alternativ: Börja söka från detta klockslag. Kombinera med dagförskjutning. Ignoreras om Sökstart (datum/tid) är satt."
},
"search_start_day_offset": {
"name": "Sökstart dagförskjutning",
"description": "Dagförskjutning för Sökstart-klockslag. -7 till 2: -1 = igår, 0 = idag, 1 = imorgon. Negativa värden söker i det förflutna. Används bara med Sökstart-klockslag."
},
"search_end_time": {
"name": "Sökslut-klockslag",
"description": "Alternativ: Sök till detta klockslag. Kombinera med dagförskjutning. Ignoreras om Sökslut (datum/tid) är satt."
},
"search_end_day_offset": {
"name": "Sökslut dagförskjutning",
"description": "Dagförskjutning för Sökslut-klockslag. -7 till 2: -1 = igår, 0 = idag, 1 = imorgon. Negativa värden söker i det förflutna. Används bara med Sökslut-klockslag."
},
"search_start_offset_minutes": {
"name": "Sökstart-förskjutning (minuter)",
"description": "Alternativ: Börja söka detta antal minuter från nu. Positivt = framtid (60 = om 1 timme), negativt = förflutet (-60 = 1 timme sedan). Ignoreras om Sökstart eller Sökstart-klockslag är satt."
},
"search_end_offset_minutes": {
"name": "Sökslut-förskjutning (minuter)",
"description": "Alternativ: Sluta söka detta antal minuter från nu. Positivt = framtid (480 = om 8 timmar), negativt = förflutet (-60 = 1 timme sedan). Ignoreras om Sökslut eller Sökslut-klockslag är satt."
},
"include_current_interval": {
"name": "Inkludera aktuellt intervall",
"description": "Inkludera det pågående 15-minutersintervallet i sökningen. När aktiverat (standard), börjar sökningen vid början av det aktuella intervallet så att det kan vara en del av resultatet."
},
"use_base_unit": {
"name": "Använd basvaluta",
"description": "Tvinga priser i basvaluta (EUR, NOK) istället för konfigurerad visningsenhet (ct, öre). Användbart för beräkningar."
},
"search_scope": {
"name": "Soekumfaang (genvaeg)",
"description": "Genvaeg foer vanliga soekomraaden. Aasidosaetter alla andra tidsalternativ. today/tomorrow = hela kalenderdagen, remaining_today = nu till midnatt, next_24h/next_48h = rullande foenster fraen nu."
},
"max_price_level": {
"name": "Maximal prisnivaae",
"description": "Ta bara med intervall paa eller under denna Tibber-prisnivaae. very_cheap = mest restriktivt, very_expensive = ingen begraensning."
},
"min_price_level": {
"name": "Minimal prisnivaae",
"description": "Ta bara med intervall paa eller oever denna Tibber-prisnivaae. Anvaendbart foer find_most_expensive foer att fokusera paa verkligt dyra intervall."
},
"include_comparison_details": {
"name": "Inkludera jaemfoerelsdetaljer",
"description": "Berika price_comparison-resultatet med ytterligare faelt: comparison_price_min, comparison_price_max och (endast block) comparison_window_end."
},
"power_profile": {
"name": "Effektprofil",
"description": "Variabel effektfoerbruekning i watt per 15-minutersintervall. Om instaellt, aaterspeglar estimated_total_cost faktisk foerbruekning istaellet foer en fast 1 kW-last."
}
}
},
"find_most_expensive_block": {
"name": "Hitta dyraste blocket",
"description": "Hittar det dyraste sammanhängande tidsfönstret med en given varaktighet. Användbart för att identifiera topprisperioder som bör undvikas. Returnerar det dyraste fönstret med start-/sluttider och prisstatistik.",
"sections": {
"search_range": {
"name": "Soekomraade",
"description": "Definiera tidsfoenstret att soeka inom."
},
"time_alternatives": {
"name": "Alternativa tidsinstaellningar",
"description": "Alternativa saett att definiera soekomraadet via tidpunkt och offset."
},
"price_filter": {
"name": "Prisnivaaefilter",
"description": "Begraensa soekningen till intervall inom det angivna prisnivaaeintervallet."
},
"output": {
"name": "Utdataalternativ",
"description": "Styr kostnadsuppskattning och jaemfoerelseresultat."
}
},
"fields": {
"entry_id": {
"name": "Entry-ID",
"description": "Config entry-ID för Tibber-integrationen."
},
"duration": {
"name": "Varaktighet",
"description": "Längd på det önskade sammanhängande fönstret. Avrundas automatiskt uppåt till närmaste kvart. Maximum: 12 timmar."
},
"search_start": {
"name": "Sökstart",
"description": "Start av sökintervallet som exakt datum och tid. Högsta prioritet — åsidosätter alla andra startalternativ. Standard är nu om inte angivet."
},
"search_end": {
"name": "Sökslut",
"description": "Slut av sökintervallet som exakt datum och tid. Högsta prioritet — åsidosätter alla andra slutalternativ. Standard är slutet av imorgon om inte angivet."
},
"search_start_time": {
"name": "Sökstart-klockslag",
"description": "Alternativ: Börja söka från detta klockslag. Kombinera med dagförskjutning. Ignoreras om Sökstart (datum/tid) är satt."
},
"search_start_day_offset": {
"name": "Sökstart dagförskjutning",
"description": "Dagförskjutning för Sökstart-klockslag. -7 till 2: -1 = igår, 0 = idag, 1 = imorgon. Negativa värden söker i det förflutna. Används bara med Sökstart-klockslag."
},
"search_end_time": {
"name": "Sökslut-klockslag",
"description": "Alternativ: Sök till detta klockslag. Kombinera med dagförskjutning. Ignoreras om Sökslut (datum/tid) är satt."
},
"search_end_day_offset": {
"name": "Sökslut dagförskjutning",
"description": "Dagförskjutning för Sökslut-klockslag. -7 till 2: -1 = igår, 0 = idag, 1 = imorgon. Negativa värden söker i det förflutna. Används bara med Sökslut-klockslag."
},
"search_start_offset_minutes": {
"name": "Sökstart-förskjutning (minuter)",
"description": "Alternativ: Börja söka detta antal minuter från nu. Positivt = framtid (60 = om 1 timme), negativt = förflutet (-60 = 1 timme sedan). Ignoreras om Sökstart eller Sökstart-klockslag är satt."
},
"search_end_offset_minutes": {
"name": "Sökslut-förskjutning (minuter)",
"description": "Alternativ: Sluta söka detta antal minuter från nu. Positivt = framtid (480 = om 8 timmar), negativt = förflutet (-60 = 1 timme sedan). Ignoreras om Sökslut eller Sökslut-klockslag är satt."
},
"include_current_interval": {
"name": "Inkludera aktuellt intervall",
"description": "Inkludera det pågående 15-minutersintervallet i sökningen. När aktiverat (standard), börjar sökningen vid början av det aktuella intervallet så att det kan vara en del av resultatet."
},
"use_base_unit": {
"name": "Använd basvaluta",
"description": "Tvinga priser i basvaluta (EUR, NOK) istället för konfigurerad visningsenhet (ct, öre). Användbart för beräkningar."
},
"search_scope": {
"name": "Soekumfaang (genvaeg)",
"description": "Genvaeg foer vanliga soekomraaden. Aasidosaetter alla andra tidsalternativ. today/tomorrow = hela kalenderdagen, remaining_today = nu till midnatt, next_24h/next_48h = rullande foenster fraen nu."
},
"max_price_level": {
"name": "Maximal prisnivaae",
"description": "Ta bara med intervall paa eller under denna Tibber-prisnivaae. very_cheap = mest restriktivt, very_expensive = ingen begraensning."
},
"min_price_level": {
"name": "Minimal prisnivaae",
"description": "Ta bara med intervall paa eller oever denna Tibber-prisnivaae. Anvaendbart foer find_most_expensive foer att fokusera paa verkligt dyra intervall."
},
"include_comparison_details": {
"name": "Inkludera jaemfoerelsdetaljer",
"description": "Berika price_comparison-resultatet med ytterligare faelt: comparison_price_min, comparison_price_max och (endast block) comparison_window_end."
},
"power_profile": {
"name": "Effektprofil",
"description": "Variabel effektfoerbruekning i watt per 15-minutersintervall. Om instaellt, aaterspeglar estimated_total_cost faktisk foerbruekning istaellet foer en fast 1 kW-last."
}
}
},
"find_cheapest_hours": {
"name": "Hitta billigaste timmarna",
"description": "Hittar de billigaste intervallen för en given total varaktighet, inte nödvändigtvis sammanhängande. Designat för flexibla laster: batteriladdning, elbil, varmvattenberedare. Returnerar ett schema av intervaller grupperade i sammanhängande segment.",
"sections": {
"search_range": {
"name": "Soekomraade",
"description": "Definiera tidsfoenstret att soeka inom."
},
"time_alternatives": {
"name": "Alternativa tidsinstaellningar",
"description": "Alternativa saett att definiera soekomraadet via tidpunkt och offset."
},
"price_filter": {
"name": "Prisnivaaefilter",
"description": "Begraensa soekningen till intervall inom det angivna prisnivaaeintervallet."
},
"output": {
"name": "Utdataalternativ",
"description": "Styr kostnadsuppskattning och jaemfoerelseresultat."
}
},
"fields": {
"entry_id": {
"name": "Entry-ID",
"description": "Config entry-ID för Tibber-integrationen."
},
"duration": {
"name": "Varaktighet",
"description": "Behövd billig total tid. Avrundas automatiskt uppåt till närmaste kvart. Maximum: 24 timmar."
},
"search_start": {
"name": "Sökstart",
"description": "Start av sökintervallet som exakt datum och tid. Högsta prioritet — åsidosätter alla andra startalternativ. Standard är nu om inte angivet."
},
"search_end": {
"name": "Sökslut",
"description": "Slut av sökintervallet som exakt datum och tid. Högsta prioritet — åsidosätter alla andra slutalternativ. Standard är slutet av imorgon om inte angivet."
},
"search_start_time": {
"name": "Sökstart-klockslag",
"description": "Alternativ: Börja söka från detta klockslag. Kombinera med dagförskjutning. Ignoreras om Sökstart (datum/tid) är satt."
},
"search_start_day_offset": {
"name": "Sökstart dagförskjutning",
"description": "Dagförskjutning för Sökstart-klockslag. -7 till 2: -1 = igår, 0 = idag, 1 = imorgon. Negativa värden söker i det förflutna. Används bara med Sökstart-klockslag."
},
"search_end_time": {
"name": "Sökslut-klockslag",
"description": "Alternativ: Sök till detta klockslag. Kombinera med dagförskjutning. Ignoreras om Sökslut (datum/tid) är satt."
},
"search_end_day_offset": {
"name": "Sökslut dagförskjutning",
"description": "Dagförskjutning för Sökslut-klockslag. -7 till 2: -1 = igår, 0 = idag, 1 = imorgon. Negativa värden söker i det förflutna. Används bara med Sökslut-klockslag."
},
"search_start_offset_minutes": {
"name": "Sökstart-förskjutning (minuter)",
"description": "Alternativ: Börja söka detta antal minuter från nu. Positivt = framtid (60 = om 1 timme), negativt = förflutet (-60 = 1 timme sedan). Ignoreras om Sökstart eller Sökstart-klockslag är satt."
},
"search_end_offset_minutes": {
"name": "Sökslut-förskjutning (minuter)",
"description": "Alternativ: Sluta söka detta antal minuter från nu. Positivt = framtid (480 = om 8 timmar), negativt = förflutet (-60 = 1 timme sedan). Ignoreras om Sökslut eller Sökslut-klockslag är satt."
},
"include_current_interval": {
"name": "Inkludera aktuellt intervall",
"description": "Inkludera det pågående 15-minutersintervallet i sökningen. När aktiverat (standard), börjar sökningen vid början av det aktuella intervallet så att det kan vara en del av resultatet."
},
"min_segment_duration": {
"name": "Minsta segmentvaraktighet",
"description": "Minsta sammanhängande körtid. Förhindrar snabb av/på-cykling för enheter med minsta körtider. Avrundas automatiskt uppåt till närmaste kvart. Standard: 15 minuter. Maximum: 4 timmar."
},
"use_base_unit": {
"name": "Använd basvaluta",
"description": "Tvinga priser i basvaluta (EUR, NOK) istället för konfigurerad visningsenhet (ct, öre). Användbart för beräkningar."
},
"search_scope": {
"name": "Soekumfaang (genvaeg)",
"description": "Genvaeg foer vanliga soekomraaden. Aasidosaetter alla andra tidsalternativ. today/tomorrow = hela kalenderdagen, remaining_today = nu till midnatt, next_24h/next_48h = rullande foenster fraen nu."
},
"max_price_level": {
"name": "Maximal prisnivaae",
"description": "Ta bara med intervall paa eller under denna Tibber-prisnivaae. very_cheap = mest restriktivt, very_expensive = ingen begraensning."
},
"min_price_level": {
"name": "Minimal prisnivaae",
"description": "Ta bara med intervall paa eller oever denna Tibber-prisnivaae. Anvaendbart foer find_most_expensive foer att fokusera paa verkligt dyra intervall."
},
"include_comparison_details": {
"name": "Inkludera jaemfoerelsdetaljer",
"description": "Berika price_comparison-resultatet med ytterligare faelt: comparison_price_min, comparison_price_max och (endast block) comparison_window_end."
},
"power_profile": {
"name": "Effektprofil",
"description": "Variabel effektfoerbruekning i watt per 15-minutersintervall. Om instaellt, aaterspeglar estimated_total_cost faktisk foerbruekning istaellet foer en fast 1 kW-last."
}
}
},
"find_most_expensive_hours": {
"name": "Hitta dyraste timmarna",
"description": "Hittar de dyraste intervallen för en given total varaktighet, inte nödvändigtvis sammanhängande. Användbart för att identifiera topprisperioder som bör undvikas. Returnerar ett schema av intervaller grupperade i sammanhängande segment.",
"sections": {
"search_range": {
"name": "Soekomraade",
"description": "Definiera tidsfoenstret att soeka inom."
},
"time_alternatives": {
"name": "Alternativa tidsinstaellningar",
"description": "Alternativa saett att definiera soekomraadet via tidpunkt och offset."
},
"price_filter": {
"name": "Prisnivaaefilter",
"description": "Begraensa soekningen till intervall inom det angivna prisnivaaeintervallet."
},
"output": {
"name": "Utdataalternativ",
"description": "Styr kostnadsuppskattning och jaemfoerelseresultat."
}
},
"fields": {
"entry_id": {
"name": "Entry-ID",
"description": "Config entry-ID för Tibber-integrationen."
},
"duration": {
"name": "Varaktighet",
"description": "Dyr total tid att hitta. Avrundas automatiskt uppåt till närmaste kvart. Maximum: 24 timmar."
},
"search_start": {
"name": "Sökstart",
"description": "Start av sökintervallet som exakt datum och tid. Högsta prioritet — åsidosätter alla andra startalternativ. Standard är nu om inte angivet."
},
"search_end": {
"name": "Sökslut",
"description": "Slut av sökintervallet som exakt datum och tid. Högsta prioritet — åsidosätter alla andra slutalternativ. Standard är slutet av imorgon om inte angivet."
},
"search_start_time": {
"name": "Sökstart-klockslag",
"description": "Alternativ: Börja söka från detta klockslag. Kombinera med dagförskjutning. Ignoreras om Sökstart (datum/tid) är satt."
},
"search_start_day_offset": {
"name": "Sökstart dagförskjutning",
"description": "Dagförskjutning för Sökstart-klockslag. -7 till 2: -1 = igår, 0 = idag, 1 = imorgon. Negativa värden söker i det förflutna. Används bara med Sökstart-klockslag."
},
"search_end_time": {
"name": "Sökslut-klockslag",
"description": "Alternativ: Sök till detta klockslag. Kombinera med dagförskjutning. Ignoreras om Sökslut (datum/tid) är satt."
},
"search_end_day_offset": {
"name": "Sökslut dagförskjutning",
"description": "Dagförskjutning för Sökslut-klockslag. -7 till 2: -1 = igår, 0 = idag, 1 = imorgon. Negativa värden söker i det förflutna. Används bara med Sökslut-klockslag."
},
"search_start_offset_minutes": {
"name": "Sökstart-förskjutning (minuter)",
"description": "Alternativ: Börja söka detta antal minuter från nu. Positivt = framtid (60 = om 1 timme), negativt = förflutet (-60 = 1 timme sedan). Ignoreras om Sökstart eller Sökstart-klockslag är satt."
},
"search_end_offset_minutes": {
"name": "Sökslut-förskjutning (minuter)",
"description": "Alternativ: Sluta söka detta antal minuter från nu. Positivt = framtid (480 = om 8 timmar), negativt = förflutet (-60 = 1 timme sedan). Ignoreras om Sökslut eller Sökslut-klockslag är satt."
},
"include_current_interval": {
"name": "Inkludera aktuellt intervall",
"description": "Inkludera det pågående 15-minutersintervallet i sökningen. När aktiverat (standard), börjar sökningen vid början av det aktuella intervallet så att det kan vara en del av resultatet."
},
"min_segment_duration": {
"name": "Minsta segmentvaraktighet",
"description": "Minsta sammanhängande körtid. Förhindrar snabb av/på-cykling för enheter med minsta körtider. Avrundas automatiskt uppåt till närmaste kvart. Standard: 15 minuter. Maximum: 4 timmar."
},
"use_base_unit": {
"name": "Använd basvaluta",
"description": "Tvinga priser i basvaluta (EUR, NOK) istället för konfigurerad visningsenhet (ct, öre). Användbart för beräkningar."
},
"search_scope": {
"name": "Soekumfaang (genvaeg)",
"description": "Genvaeg foer vanliga soekomraaden. Aasidosaetter alla andra tidsalternativ. today/tomorrow = hela kalenderdagen, remaining_today = nu till midnatt, next_24h/next_48h = rullande foenster fraen nu."
},
"max_price_level": {
"name": "Maximal prisnivaae",
"description": "Ta bara med intervall paa eller under denna Tibber-prisnivaae. very_cheap = mest restriktivt, very_expensive = ingen begraensning."
},
"min_price_level": {
"name": "Minimal prisnivaae",
"description": "Ta bara med intervall paa eller oever denna Tibber-prisnivaae. Anvaendbart foer find_most_expensive foer att fokusera paa verkligt dyra intervall."
},
"include_comparison_details": {
"name": "Inkludera jaemfoerelsdetaljer",
"description": "Berika price_comparison-resultatet med ytterligare faelt: comparison_price_min, comparison_price_max och (endast block) comparison_window_end."
},
"power_profile": {
"name": "Effektprofil",
"description": "Variabel effektfoerbruekning i watt per 15-minutersintervall. Om instaellt, aaterspeglar estimated_total_cost faktisk foerbruekning istaellet foer en fast 1 kW-last."
}
}
},
"find_cheapest_schedule": {
"name": "Hitta billigaste schema",
"description": "Schemalaggar flera apparater optimalt utan tidsoeverlapp. Varje uppgift tilldelas det billigaste tillgaengliga sammanhangande tidsfoenster.",
"sections": {
"scheduling_options": {
"name": "Schemalagningsalternativ",
"description": "Konfigurera uppgifter och pauser mellan dem."
},
"search_range": {
"name": "Soekomraade",
"description": "Definiera tidsfoenstret att soeka inom."
},
"time_alternatives": {
"name": "Alternativa tidsinstaellningar",
"description": "Alternativa saett att definiera soekomraadet via tidpunkt och offset."
},
"price_filter": {
"name": "Prisnivaaefilter",
"description": "Begraensa soekningen till intervall inom det angivna prisnivaaeintervallet."
},
"output": {
"name": "Utdataalternativ",
"description": "Styr kostnadsuppskattning och jaemfoerelseresultat."
}
},
"fields": {
"entry_id": {
"name": "Entry-ID",
"description": "Config entry-ID för Tibber-integrationen."
},
"tasks": {
"name": "Uppgifter",
"description": "Lista med uppgifter att schemalagga. Varje uppgift kraever name (text) och duration (hh:mm:ss). Valfritt power_profile (watt per 15-min-intervall). Maximalt 4 uppgifter."
},
"gap_minutes": {
"name": "Paus mellan uppgifter (minuter)",
"description": "Minsta paus i minuter mellan paa varandra foeoljande schemalagda uppgifter. Avrundas uppaat till 15 minuter. Standard: 0 (ingen paus)."
},
"search_scope": {
"name": "Soekumfaang (genvaeg)",
"description": "Genvaeg foer vanliga soekomraaden. Aasidosaetter alla andra tidsalternativ. today/tomorrow = hela kalenderdagen, remaining_today = nu till midnatt, next_24h/next_48h = rullande foenster fraen nu."
},
"search_start": {
"name": "Sökstart",
"description": "Start av sökintervallet som exakt datum och tid. Högsta prioritet — åsidosätter alla andra startalternativ. Standard är nu om inte angivet."
},
"search_end": {
"name": "Sökslut",
"description": "Slut av sökintervallet som exakt datum och tid. Högsta prioritet — åsidosätter alla andra slutalternativ. Standard är slutet av imorgon om inte angivet."
},
"search_start_time": {
"name": "Sökstart-klockslag",
"description": "Alternativ: Börja söka från detta klockslag. Kombinera med dagförskjutning. Ignoreras om Sökstart (datum/tid) är satt."
},
"search_start_day_offset": {
"name": "Sökstart dagförskjutning",
"description": "Dagförskjutning för Sökstart-klockslag. -7 till 2: -1 = igår, 0 = idag, 1 = imorgon. Negativa värden söker i det förflutna. Används bara med Sökstart-klockslag."
},
"search_end_time": {
"name": "Sökslut-klockslag",
"description": "Alternativ: Sök till detta klockslag. Kombinera med dagförskjutning. Ignoreras om Sökslut (datum/tid) är satt."
},
"search_end_day_offset": {
"name": "Sökslut dagförskjutning",
"description": "Dagförskjutning för Sökslut-klockslag. -7 till 2: -1 = igår, 0 = idag, 1 = imorgon. Negativa värden söker i det förflutna. Används bara med Sökslut-klockslag."
},
"search_start_offset_minutes": {
"name": "Sökstart-förskjutning (minuter)",
"description": "Alternativ: Börja söka detta antal minuter från nu. Positivt = framtid (60 = om 1 timme), negativt = förflutet (-60 = 1 timme sedan). Ignoreras om Sökstart eller Sökstart-klockslag är satt."
},
"search_end_offset_minutes": {
"name": "Sökslut-förskjutning (minuter)",
"description": "Alternativ: Sluta söka detta antal minuter från nu. Positivt = framtid (480 = om 8 timmar), negativt = förflutet (-60 = 1 timme sedan). Ignoreras om Sökslut eller Sökslut-klockslag är satt."
},
"include_current_interval": {
"name": "Inkludera aktuellt intervall",
"description": "Inkludera det pågående 15-minutersintervallet i sökningen. När aktiverat (standard), börjar sökningen vid början av det aktuella intervallet så att det kan vara en del av resultatet."
},
"max_price_level": {
"name": "Maximal prisnivaae",
"description": "Ta bara med intervall paa eller under denna Tibber-prisnivaae. very_cheap = mest restriktivt, very_expensive = ingen begraensning."
},
"min_price_level": {
"name": "Minimal prisnivaae",
"description": "Ta bara med intervall paa eller oever denna Tibber-prisnivaae. Anvaendbart foer find_most_expensive foer att fokusera paa verkligt dyra intervall."
},
"use_base_unit": {
"name": "Använd basvaluta",
"description": "Tvinga priser i basvaluta (EUR, NOK) istället för konfigurerad visningsenhet (ct, öre). Användbart för beräkningar."
}
}
} }
}, },
"selector": { "selector": {
@ -1361,6 +1877,15 @@
"median": "Median", "median": "Median",
"mean": "Aritmetiskt medelvärde" "mean": "Aritmetiskt medelvärde"
} }
},
"search_scope": {
"options": {
"today": "Idag",
"tomorrow": "Imorgon",
"remaining_today": "Aaterstoden av idag",
"next_24h": "Naesta 24 timmar",
"next_48h": "Naesta 48 timmar"
}
} }
}, },
"title": "Tibber Prisinformation & Betyg" "title": "Tibber Prisinformation & Betyg"

View file

@ -0,0 +1,342 @@
"""
Pure algorithms for finding cheapest price windows.
Two independent algorithms:
1. find_cheapest_contiguous_window Sliding window for appliance scheduling
2. find_cheapest_n_intervals Cheapest N picks for flexible-load scheduling
These are stateless pure functions with no Home Assistant dependencies.
"""
from __future__ import annotations
import statistics
from datetime import datetime, timedelta
from typing import Any
def find_cheapest_contiguous_window(
intervals: list[dict[str, Any]],
duration_intervals: int,
*,
reverse: bool = False,
) -> dict[str, Any] | None:
"""
Find the cheapest (or most expensive) contiguous window of exactly N intervals.
Uses a sliding window algorithm (O(n)) to find the window with the
lowest (or highest) average price.
Args:
intervals: Sorted list of price interval dicts with 'startsAt' and 'total' keys.
Must be pre-sorted by startsAt in ascending order.
duration_intervals: Number of consecutive intervals required.
reverse: If True, find the most expensive window instead of cheapest.
Returns:
Dict with window details (start, end, intervals, statistics),
or None if not enough intervals available.
"""
n = len(intervals)
if n == 0 or duration_intervals <= 0 or n < duration_intervals:
return None
# Calculate initial window sum
window_sum = sum(intervals[i]["total"] for i in range(duration_intervals))
best_sum = window_sum
best_start = 0
# Slide the window
for i in range(1, n - duration_intervals + 1):
window_sum += intervals[i + duration_intervals - 1]["total"]
window_sum -= intervals[i - 1]["total"]
if (window_sum > best_sum) if reverse else (window_sum < best_sum):
best_sum = window_sum
best_start = i
best_intervals = intervals[best_start : best_start + duration_intervals]
return {
"start": best_intervals[0]["startsAt"],
"end_interval_start": best_intervals[-1]["startsAt"],
"intervals": best_intervals,
}
def find_cheapest_n_intervals(
intervals: list[dict[str, Any]],
count: int,
min_segment_intervals: int = 1,
*,
reverse: bool = False,
) -> dict[str, Any] | None:
"""
Find the cheapest (or most expensive) N intervals, not necessarily contiguous.
Picks the cheapest (or most expensive) intervals by price, then groups them
into contiguous segments. If min_segment_intervals > 1, short segments are
discarded and replaced with next-cheapest/most-expensive available intervals
until all segments meet the minimum length.
Args:
intervals: Sorted list of price interval dicts with 'startsAt' and 'total' keys.
Must be pre-sorted by startsAt in ascending order.
count: Number of intervals to select.
min_segment_intervals: Minimum contiguous length for each segment.
Default 1 means no constraint.
reverse: If True, find the most expensive intervals instead of cheapest.
Returns:
Dict with schedule details (segments, intervals, statistics),
or None if not enough intervals available.
"""
n = len(intervals)
if n == 0 or count <= 0 or n < count:
return None
if min_segment_intervals <= 1:
# Simple case: pick cheapest/most expensive N, then sort chronologically
indexed = [(i, iv) for i, iv in enumerate(intervals)]
indexed.sort(key=lambda x: x[1]["total"], reverse=reverse)
selected_indices = sorted(idx for idx, _ in indexed[:count])
selected = [intervals[i] for i in selected_indices]
segments = group_intervals_into_segments(selected)
return {
"intervals": selected,
"segments": segments,
}
# Complex case: enforce minimum segment length
return _find_with_min_segment(intervals, count, min_segment_intervals, reverse=reverse)
def _find_with_min_segment(
intervals: list[dict[str, Any]],
count: int,
min_segment: int,
*,
reverse: bool = False,
) -> dict[str, Any] | None:
"""
Find cheapest/most expensive N intervals with minimum segment length constraint.
Iteratively picks intervals, discards segments that are too
short, and replaces them with next-best alternatives.
Converges in at most `count` iterations (worst case: every replacement
creates a new short segment that gets discarded).
"""
n = len(intervals)
# Build index lookup: interval original index → position
# Price-sorted indices for picking cheapest/most expensive available
price_order = sorted(range(n), key=lambda i: intervals[i]["total"], reverse=reverse)
selected: set[int] = set()
excluded: set[int] = set()
# Initial pick: cheapest 'count' intervals
picked = 0
for idx in price_order:
if picked >= count:
break
if idx not in excluded:
selected.add(idx)
picked += 1
if len(selected) < count:
return None
# Iterative refinement: discard short segments, replace with next-cheapest
max_iterations = count + 1 # Safety bound
for _ in range(max_iterations):
sorted_selected = sorted(selected)
segments = _group_indices_into_segments(sorted_selected)
short_segments = [seg for seg in segments if len(seg) < min_segment]
if not short_segments:
break # All segments meet minimum length
# Exclude all indices in short segments
for seg in short_segments:
for idx in seg:
selected.discard(idx)
excluded.add(idx)
# Refill from price order
needed = count - len(selected)
for idx in price_order:
if needed <= 0:
break
if idx not in selected and idx not in excluded:
selected.add(idx)
needed -= 1
if len(selected) < count:
# Not enough intervals available after exclusions
# Return best effort with what we have
break
sorted_selected = sorted(selected)
result_intervals = [intervals[i] for i in sorted_selected]
segments = group_intervals_into_segments(result_intervals)
return {
"intervals": result_intervals,
"segments": segments,
}
def _group_indices_into_segments(indices: list[int]) -> list[list[int]]:
"""Group sorted integer indices into contiguous runs."""
if not indices:
return []
segments: list[list[int]] = [[indices[0]]]
for i in range(1, len(indices)):
if indices[i] == indices[i - 1] + 1:
segments[-1].append(indices[i])
else:
segments.append([indices[i]])
return segments
def group_intervals_into_segments(
intervals: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""
Group chronologically sorted intervals into contiguous segments.
Two intervals are contiguous if the second starts exactly 15 minutes
after the first.
Args:
intervals: Chronologically sorted interval dicts with 'startsAt' key.
Returns:
List of segment dicts, each containing:
- start: ISO timestamp of first interval
- end_interval_start: ISO timestamp of last interval in segment
- duration_minutes: Total segment duration
- interval_count: Number of intervals in segment
- intervals: The interval dicts in this segment
"""
if not intervals:
return []
segments: list[dict[str, Any]] = []
current_segment: list[dict[str, Any]] = [intervals[0]]
for i in range(1, len(intervals)):
prev_start = _parse_timestamp(intervals[i - 1]["startsAt"])
curr_start = _parse_timestamp(intervals[i]["startsAt"])
if curr_start - prev_start == timedelta(minutes=15):
current_segment.append(intervals[i])
else:
segments.append(_build_segment(current_segment))
current_segment = [intervals[i]]
segments.append(_build_segment(current_segment))
return segments
def _build_segment(intervals: list[dict[str, Any]]) -> dict[str, Any]:
"""Build a segment dict from a list of contiguous intervals."""
return {
"start": intervals[0]["startsAt"],
"end_interval_start": intervals[-1]["startsAt"],
"duration_minutes": len(intervals) * 15,
"interval_count": len(intervals),
"intervals": intervals,
}
def calculate_window_statistics(
intervals: list[dict[str, Any]],
unit_factor: int = 1,
round_decimals: int = 4,
interval_minutes: int = 15,
power_profile: list[int] | None = None,
) -> dict[str, float | None]:
"""
Calculate price statistics for a list of intervals.
Args:
intervals: List of interval dicts with 'total' key (base currency).
unit_factor: Multiplication factor for display (100 for subunit, 1 for base).
round_decimals: Number of decimal places for rounding.
interval_minutes: Duration of each interval in minutes (for cost calculation).
power_profile: Optional list of power values in watts, one per interval.
If shorter than the interval list, the last value is repeated.
When provided, estimated_total_cost reflects variable power draw instead
of a constant 1 kW load, and estimated_load_kwh is added to the result.
Returns:
Dict with price_mean, price_median, price_min, price_max, price_spread,
and estimated_total_cost (total cost for the given or constant 1 kW load).
When power_profile is provided, also includes estimated_load_kwh.
"""
if not intervals:
result: dict[str, float | None] = {
"price_mean": None,
"price_median": None,
"price_min": None,
"price_max": None,
"price_spread": None,
"estimated_total_cost": None,
}
if power_profile is not None:
result["estimated_load_kwh"] = None
return result
prices = [iv["total"] * unit_factor for iv in intervals]
mean = round(statistics.mean(prices), round_decimals)
median = round(statistics.median(prices), round_decimals)
price_min = round(min(prices), round_decimals)
price_max = round(max(prices), round_decimals)
spread = round(price_max - price_min, round_decimals)
hours_per_interval = interval_minutes / 60
if power_profile is not None:
# Extend profile to cover all intervals by repeating the last value
last_watts = power_profile[-1] if power_profile else 1000
profile = list(power_profile) + [last_watts] * max(0, len(intervals) - len(power_profile))
load_kwh_per_interval = [w / 1000 * hours_per_interval for w in profile[: len(intervals)]]
estimated_cost = round(
sum(p * kwh for p, kwh in zip(prices, load_kwh_per_interval, strict=False)), round_decimals
)
estimated_load_kwh = round(sum(load_kwh_per_interval), round_decimals)
return {
"price_mean": mean,
"price_median": median,
"price_min": price_min,
"price_max": price_max,
"price_spread": spread,
"estimated_total_cost": estimated_cost,
"estimated_load_kwh": estimated_load_kwh,
}
# Estimated cost for running a 1 kW constant load during all intervals
# Each interval covers interval_minutes/60 hours, price is per kWh
estimated_cost = round(sum(p * hours_per_interval for p in prices), round_decimals)
return {
"price_mean": mean,
"price_median": median,
"price_min": price_min,
"price_max": price_max,
"price_spread": spread,
"estimated_total_cost": estimated_cost,
}
def _parse_timestamp(ts: str | datetime) -> datetime:
"""Parse an ISO timestamp string or pass through a datetime object."""
if isinstance(ts, datetime):
return ts
return datetime.fromisoformat(ts)

View file

@ -0,0 +1,258 @@
"""
Tests for resolve_search_range helper and negative offset support.
Verifies that services can search into the past using:
- Negative search_start_day_offset / search_end_day_offset
- Negative search_start_offset_minutes / search_end_offset_minutes
- Explicit past search_start / search_end datetimes
Also validates schema boundaries for all 4 services.
"""
from __future__ import annotations
from datetime import datetime, timedelta
from datetime import time as dt_time
from zoneinfo import ZoneInfo
import pytest
import voluptuous as vol
from custom_components.tibber_prices.services.find_cheapest_block import (
_COMMON_BLOCK_SCHEMA,
)
from custom_components.tibber_prices.services.find_cheapest_hours import (
_COMMON_HOURS_SCHEMA,
)
from custom_components.tibber_prices.services.helpers import (
resolve_search_range,
)
BERLIN = ZoneInfo("Europe/Berlin")
# =============================================================================
# resolve_search_range: Negative day offsets
# =============================================================================
class TestResolveSearchRangeNegativeDayOffset:
"""Test that negative day offsets correctly resolve to past dates."""
def test_negative_start_day_offset(self) -> None:
"""Start yesterday at 06:00."""
now = datetime(2026, 4, 11, 14, 30, tzinfo=BERLIN)
call_data = {
"search_start_time": dt_time(6, 0, 0),
"search_start_day_offset": -1,
}
start, _end = resolve_search_range(call_data, now, BERLIN)
# Should be yesterday 06:00
assert start.day == 10
assert start.hour == 6
assert start.minute == 0
def test_negative_both_day_offsets(self) -> None:
"""Full day in the past: yesterday 00:00 to yesterday 23:59."""
now = datetime(2026, 4, 11, 14, 30, tzinfo=BERLIN)
call_data = {
"search_start_time": dt_time(0, 0, 0),
"search_start_day_offset": -1,
"search_end_time": dt_time(23, 59, 0),
"search_end_day_offset": -1,
}
start, end = resolve_search_range(call_data, now, BERLIN)
assert start.day == 10
assert start.hour == 0
assert end.day == 10
assert end.hour == 23
def test_negative_7_day_offset(self) -> None:
"""Start 7 days ago."""
now = datetime(2026, 4, 11, 14, 30, tzinfo=BERLIN)
call_data = {
"search_start_time": dt_time(0, 0, 0),
"search_start_day_offset": -7,
"search_end_time": dt_time(23, 59, 0),
"search_end_day_offset": -7,
}
start, end = resolve_search_range(call_data, now, BERLIN)
assert start.day == 4
assert end.day == 4
def test_cross_day_range_past_to_today(self) -> None:
"""Start yesterday, end today."""
now = datetime(2026, 4, 11, 14, 30, tzinfo=BERLIN)
call_data = {
"search_start_time": dt_time(18, 0, 0),
"search_start_day_offset": -1,
"search_end_time": dt_time(6, 0, 0),
"search_end_day_offset": 0,
}
start, end = resolve_search_range(call_data, now, BERLIN)
assert start.day == 10
assert start.hour == 18
assert end.day == 11
assert end.hour == 6
# =============================================================================
# resolve_search_range: Negative offset minutes
# =============================================================================
class TestResolveSearchRangeNegativeOffsetMinutes:
"""Test that negative offset minutes correctly resolve to past times."""
def test_negative_start_offset(self) -> None:
"""Start 2 hours ago."""
now = datetime(2026, 4, 11, 14, 30, tzinfo=BERLIN)
call_data = {
"search_start_offset_minutes": -120,
"include_current_interval": True,
}
start, _end = resolve_search_range(call_data, now, BERLIN)
# -120 min from 14:30 = 12:30, floored to 12:30
assert start.hour == 12
assert start.minute == 30
def test_negative_start_offset_floors_to_quarter(self) -> None:
"""Negative offset gets floored to quarter-hour boundary."""
now = datetime(2026, 4, 11, 14, 37, tzinfo=BERLIN)
call_data = {
"search_start_offset_minutes": -60,
"include_current_interval": True,
}
start, _end = resolve_search_range(call_data, now, BERLIN)
# -60 min from 14:37 = 13:37, floored to 13:30
assert start.hour == 13
assert start.minute == 30
def test_negative_end_offset(self) -> None:
"""End 1 hour ago (fully historical range)."""
now = datetime(2026, 4, 11, 14, 30, tzinfo=BERLIN)
call_data = {
"search_start_offset_minutes": -180,
"search_end_offset_minutes": -60,
"include_current_interval": True,
}
start, end = resolve_search_range(call_data, now, BERLIN)
# Start: -180 min → 11:30, End: -60 min → 13:30
assert start.hour == 11
assert start.minute == 30
assert end.hour == 13
assert end.minute == 30
def test_large_negative_offset_crosses_day(self) -> None:
"""Large negative offset crosses day boundary."""
now = datetime(2026, 4, 11, 2, 0, tzinfo=BERLIN)
call_data = {
"search_start_offset_minutes": -180,
"include_current_interval": True,
}
start, _end = resolve_search_range(call_data, now, BERLIN)
# -180 min from 02:00 = 23:00 yesterday
assert start.day == 10
assert start.hour == 23
# =============================================================================
# Schema validation: day_offset boundaries
# =============================================================================
class TestSchemaValidation:
"""Verify that schemas accept negative offsets within bounds."""
def _validate_block_schema(self, data: dict) -> dict:
"""Validate data through block schema."""
schema = vol.Schema(_COMMON_BLOCK_SCHEMA)
return schema(data)
def _validate_hours_schema(self, data: dict) -> dict:
"""Validate data through hours schema."""
schema = vol.Schema(_COMMON_HOURS_SCHEMA)
return schema(data)
def test_block_schema_accepts_negative_day_offset(self) -> None:
"""Block schema allows negative day offsets."""
result = self._validate_block_schema(
{
"entry_id": "test",
"duration": timedelta(hours=1),
"search_start_day_offset": -3,
"search_end_day_offset": -1,
}
)
assert result["search_start_day_offset"] == -3
assert result["search_end_day_offset"] == -1
def test_block_schema_accepts_negative_offset_minutes(self) -> None:
"""Block schema allows negative offset minutes."""
result = self._validate_block_schema(
{
"entry_id": "test",
"duration": timedelta(hours=1),
"search_start_offset_minutes": -1440,
"search_end_offset_minutes": -60,
}
)
assert result["search_start_offset_minutes"] == -1440
assert result["search_end_offset_minutes"] == -60
def test_block_schema_rejects_out_of_bounds_day_offset(self) -> None:
"""Block schema rejects day offset < -7."""
with pytest.raises(vol.Invalid):
self._validate_block_schema(
{
"entry_id": "test",
"duration": timedelta(hours=1),
"search_start_day_offset": -8,
}
)
def test_block_schema_max_day_offset_still_2(self) -> None:
"""Block schema still limits forward to +2."""
with pytest.raises(vol.Invalid):
self._validate_block_schema(
{
"entry_id": "test",
"duration": timedelta(hours=1),
"search_start_day_offset": 3,
}
)
def test_hours_schema_accepts_negative_day_offset(self) -> None:
"""Hours schema allows negative day offsets."""
result = self._validate_hours_schema(
{
"entry_id": "test",
"duration": timedelta(hours=2),
"search_start_day_offset": -7,
"search_end_day_offset": -5,
}
)
assert result["search_start_day_offset"] == -7
def test_hours_schema_accepts_negative_offset_minutes(self) -> None:
"""Hours schema allows negative offset minutes."""
result = self._validate_hours_schema(
{
"entry_id": "test",
"duration": timedelta(hours=2),
"search_start_offset_minutes": -10080,
"search_end_offset_minutes": -60,
}
)
assert result["search_start_offset_minutes"] == -10080
def test_hours_schema_rejects_out_of_bounds_offset_minutes(self) -> None:
"""Hours schema rejects offset minutes outside ±10080."""
with pytest.raises(vol.Invalid):
self._validate_hours_schema(
{
"entry_id": "test",
"duration": timedelta(hours=2),
"search_start_offset_minutes": -10081,
}
)

602
tests/test_price_window.py Normal file
View file

@ -0,0 +1,602 @@
"""
Tests for price window algorithms.
Tests the pure algorithm functions in utils/price_window.py:
- find_cheapest_contiguous_window (sliding window)
- find_cheapest_n_intervals (cheapest N picks with optional min-segment)
- group_intervals_into_segments
- calculate_window_statistics
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from custom_components.tibber_prices.utils.price_window import (
calculate_window_statistics,
find_cheapest_contiguous_window,
find_cheapest_n_intervals,
group_intervals_into_segments,
)
# =============================================================================
# Test Helpers
# =============================================================================
def _make_intervals(
prices: list[float],
start: datetime | None = None,
gap_after: set[int] | None = None,
) -> list[dict]:
"""
Create interval dicts from a list of prices.
Args:
prices: List of price values (one per 15-min interval).
start: Start datetime (defaults to 2026-04-11T00:00:00+02:00).
gap_after: Set of indices after which to insert a 15-min gap
(making non-contiguous intervals).
"""
if start is None:
start = datetime(2026, 4, 11, 0, 0, tzinfo=UTC)
intervals = []
current = start
for i, price in enumerate(prices):
intervals.append(
{
"startsAt": current.isoformat(),
"total": price,
"energy": price * 0.8,
"tax": price * 0.2,
"level": "NORMAL",
}
)
skip = 2 if gap_after and i in gap_after else 1
current += timedelta(minutes=15 * skip)
return intervals
# =============================================================================
# find_cheapest_contiguous_window
# =============================================================================
class TestFindCheapestContiguousWindow:
"""Tests for the sliding window algorithm."""
def test_empty_intervals(self) -> None:
"""Return None for empty input."""
assert find_cheapest_contiguous_window([], 4) is None
def test_duration_exceeds_available(self) -> None:
"""Return None when not enough intervals."""
intervals = _make_intervals([10.0, 20.0, 15.0])
assert find_cheapest_contiguous_window(intervals, 4) is None
def test_zero_duration(self) -> None:
"""Return None for zero duration."""
intervals = _make_intervals([10.0, 20.0])
assert find_cheapest_contiguous_window(intervals, 0) is None
def test_exact_fit(self) -> None:
"""Window equals all available intervals."""
prices = [10.0, 20.0, 15.0, 12.0]
intervals = _make_intervals(prices)
result = find_cheapest_contiguous_window(intervals, 4)
assert result is not None
assert len(result["intervals"]) == 4
def test_single_interval(self) -> None:
"""Window of 1 picks the cheapest single interval."""
prices = [30.0, 10.0, 20.0, 40.0]
intervals = _make_intervals(prices)
result = find_cheapest_contiguous_window(intervals, 1)
assert result is not None
assert len(result["intervals"]) == 1
assert result["intervals"][0]["total"] == 10.0
def test_u_shaped_curve(self) -> None:
"""Finds cheap window in center of U-shaped price curve."""
# U-shape: expensive morning, cheap midday, expensive evening
prices = [30.0, 25.0, 15.0, 10.0, 8.0, 9.0, 12.0, 20.0, 28.0, 35.0]
intervals = _make_intervals(prices)
result = find_cheapest_contiguous_window(intervals, 4)
assert result is not None
# Should be intervals 3-6: [10.0, 8.0, 9.0, 12.0] = sum 39.0
selected_prices = [iv["total"] for iv in result["intervals"]]
assert selected_prices == [10.0, 8.0, 9.0, 12.0]
def test_v_shaped_curve(self) -> None:
"""Finds cheapest block on V-shaped day (classic Issue #108 scenario)."""
# V-shape: expensive → cheap minimum → expensive
prices = [25.0, 20.0, 15.0, 10.0, 5.0, 10.0, 15.0, 20.0]
intervals = _make_intervals(prices)
# 4-interval window: cheapest is centered on minimum
result = find_cheapest_contiguous_window(intervals, 4)
assert result is not None
selected_prices = [iv["total"] for iv in result["intervals"]]
# [15.0, 10.0, 5.0, 10.0] = 40.0 or [10.0, 5.0, 10.0, 15.0] = 40.0
assert sum(selected_prices) == 40.0
def test_flat_prices(self) -> None:
"""All prices equal: picks first window."""
prices = [10.0] * 8
intervals = _make_intervals(prices)
result = find_cheapest_contiguous_window(intervals, 4)
assert result is not None
# First window (index 0)
assert result["intervals"][0]["startsAt"] == intervals[0]["startsAt"]
def test_cheapest_at_end(self) -> None:
"""Cheapest window is the last N intervals."""
prices = [30.0, 25.0, 20.0, 15.0, 10.0, 5.0, 3.0, 2.0]
intervals = _make_intervals(prices)
result = find_cheapest_contiguous_window(intervals, 4)
assert result is not None
selected_prices = [iv["total"] for iv in result["intervals"]]
assert selected_prices == [10.0, 5.0, 3.0, 2.0]
def test_cheapest_at_start(self) -> None:
"""Cheapest window is the first N intervals."""
prices = [2.0, 3.0, 5.0, 10.0, 15.0, 20.0, 25.0, 30.0]
intervals = _make_intervals(prices)
result = find_cheapest_contiguous_window(intervals, 4)
assert result is not None
selected_prices = [iv["total"] for iv in result["intervals"]]
assert selected_prices == [2.0, 3.0, 5.0, 10.0]
def test_negative_prices(self) -> None:
"""Handles negative prices (renewable surplus)."""
prices = [5.0, 3.0, -1.0, -3.0, -2.0, 1.0, 4.0, 8.0]
intervals = _make_intervals(prices)
result = find_cheapest_contiguous_window(intervals, 3)
assert result is not None
selected_prices = [iv["total"] for iv in result["intervals"]]
# [-1.0, -3.0, -2.0] = -6.0 is cheapest 3-block
assert selected_prices == [-1.0, -3.0, -2.0]
def test_midnight_crossing(self) -> None:
"""Window can span midnight."""
# 8 intervals starting at 22:00 → crossing midnight
start = datetime(2026, 4, 11, 22, 0, tzinfo=UTC)
prices = [20.0, 15.0, 10.0, 5.0, 3.0, 2.0, 8.0, 12.0]
intervals = _make_intervals(prices, start=start)
result = find_cheapest_contiguous_window(intervals, 4)
assert result is not None
selected_prices = [iv["total"] for iv in result["intervals"]]
assert selected_prices == [5.0, 3.0, 2.0, 8.0]
# =============================================================================
# find_cheapest_n_intervals
# =============================================================================
class TestFindCheapestNIntervals:
"""Tests for the cheapest-N-picks algorithm."""
def test_empty_intervals(self) -> None:
"""Return None for empty input."""
assert find_cheapest_n_intervals([], 4) is None
def test_count_exceeds_available(self) -> None:
"""Return None when not enough intervals."""
intervals = _make_intervals([10.0, 20.0, 15.0])
assert find_cheapest_n_intervals(intervals, 4) is None
def test_zero_count(self) -> None:
"""Return None for zero count."""
intervals = _make_intervals([10.0])
assert find_cheapest_n_intervals(intervals, 0) is None
def test_picks_cheapest(self) -> None:
"""Picks the N cheapest intervals regardless of position."""
prices = [30.0, 10.0, 25.0, 5.0, 20.0, 8.0, 35.0, 15.0]
intervals = _make_intervals(prices)
result = find_cheapest_n_intervals(intervals, 3)
assert result is not None
selected_prices = sorted(iv["total"] for iv in result["intervals"])
assert selected_prices == [5.0, 8.0, 10.0]
def test_chronological_order(self) -> None:
"""Result intervals are sorted chronologically."""
prices = [30.0, 10.0, 25.0, 5.0, 20.0, 8.0]
intervals = _make_intervals(prices)
result = find_cheapest_n_intervals(intervals, 3)
assert result is not None
starts = [iv["startsAt"] for iv in result["intervals"]]
assert starts == sorted(starts)
def test_segments_grouped(self) -> None:
"""Result contains segments grouping contiguous intervals."""
# Cheapest 4 from: 30, 10, 8, 5, 20, 3, 2, 35
# Picks: 2(idx6), 3(idx5), 5(idx3), 8(idx2)
prices = [30.0, 10.0, 8.0, 5.0, 20.0, 3.0, 2.0, 35.0]
intervals = _make_intervals(prices)
result = find_cheapest_n_intervals(intervals, 4)
assert result is not None
assert "segments" in result
assert len(result["segments"]) >= 1
def test_single_contiguous_segment(self) -> None:
"""All picked intervals form one segment."""
# Cheapest 3: indices 2,3,4 → [5.0, 3.0, 4.0] all adjacent
prices = [20.0, 15.0, 5.0, 3.0, 4.0, 25.0, 30.0]
intervals = _make_intervals(prices)
result = find_cheapest_n_intervals(intervals, 3)
assert result is not None
assert len(result["segments"]) == 1
assert result["segments"][0]["interval_count"] == 3
def test_min_segment_basic(self) -> None:
"""With min_segment=2, single-interval segments are excluded."""
# Prices: 10, 20, 30, 5, 40, 8, 7, 35
# Without constraint: picks 5(idx3), 7(idx6), 8(idx5) → 3 isolated singles
# With min_segment=2: must form segments ≥2 intervals
prices = [10.0, 20.0, 30.0, 5.0, 40.0, 8.0, 7.0, 35.0]
intervals = _make_intervals(prices)
result = find_cheapest_n_intervals(intervals, 3, min_segment_intervals=2)
assert result is not None
# All segments should be ≥ 2 intervals
for seg in result["segments"]:
assert seg["interval_count"] >= 2
def test_min_segment_forces_different_selection(self) -> None:
"""Min segment constraint changes the selection vs. no constraint."""
prices = [10.0, 50.0, 50.0, 5.0, 50.0, 50.0, 8.0, 50.0]
intervals = _make_intervals(prices)
# Without constraint: picks indices 0(10), 3(5), 6(8)
result_no_constraint = find_cheapest_n_intervals(intervals, 3, min_segment_intervals=1)
assert result_no_constraint is not None
prices_no = sorted(iv["total"] for iv in result_no_constraint["intervals"])
assert prices_no == [5.0, 8.0, 10.0]
# With constraint (min 2): those are all isolated → must find alternatives
result_constrained = find_cheapest_n_intervals(intervals, 3, min_segment_intervals=2)
assert result_constrained is not None
# Selection will be different
prices_constrained = sorted(iv["total"] for iv in result_constrained["intervals"])
assert prices_constrained != prices_no
def test_negative_prices(self) -> None:
"""Handles negative prices correctly."""
prices = [5.0, -3.0, 10.0, -5.0, 8.0, -1.0]
intervals = _make_intervals(prices)
result = find_cheapest_n_intervals(intervals, 3)
assert result is not None
selected_prices = sorted(iv["total"] for iv in result["intervals"])
assert selected_prices == [-5.0, -3.0, -1.0]
def test_exact_fit(self) -> None:
"""Count equals available intervals."""
prices = [10.0, 20.0, 15.0]
intervals = _make_intervals(prices)
result = find_cheapest_n_intervals(intervals, 3)
assert result is not None
assert len(result["intervals"]) == 3
# =============================================================================
# group_intervals_into_segments
# =============================================================================
class TestGroupIntervalsIntoSegments:
"""Tests for segment grouping."""
def test_empty(self) -> None:
"""Empty input returns empty list."""
assert group_intervals_into_segments([]) == []
def test_single_interval(self) -> None:
"""Single interval becomes one segment."""
intervals = _make_intervals([10.0])
segments = group_intervals_into_segments(intervals)
assert len(segments) == 1
assert segments[0]["interval_count"] == 1
assert segments[0]["duration_minutes"] == 15
def test_all_contiguous(self) -> None:
"""All contiguous intervals form one segment."""
intervals = _make_intervals([10.0, 20.0, 15.0, 12.0])
segments = group_intervals_into_segments(intervals)
assert len(segments) == 1
assert segments[0]["interval_count"] == 4
assert segments[0]["duration_minutes"] == 60
def test_gap_creates_segments(self) -> None:
"""A gap creates separate segments."""
# Gap after index 1 (30-min gap instead of 15-min)
intervals = _make_intervals([10.0, 20.0, 15.0, 12.0], gap_after={1})
segments = group_intervals_into_segments(intervals)
assert len(segments) == 2
assert segments[0]["interval_count"] == 2
assert segments[1]["interval_count"] == 2
def test_multiple_gaps(self) -> None:
"""Multiple gaps create multiple segments."""
intervals = _make_intervals(
[10.0, 20.0, 15.0, 12.0, 8.0],
gap_after={0, 2},
)
segments = group_intervals_into_segments(intervals)
assert len(segments) == 3
# =============================================================================
# calculate_window_statistics
# =============================================================================
class TestCalculateWindowStatistics:
"""Tests for price statistics calculation."""
def test_empty(self) -> None:
"""Empty input returns all None."""
stats = calculate_window_statistics([])
assert stats["price_mean"] is None
assert stats["price_median"] is None
assert stats["estimated_total_cost"] is None
def test_basic_stats(self) -> None:
"""Correct mean, median, min, max, spread."""
intervals = _make_intervals([10.0, 20.0, 30.0, 40.0])
stats = calculate_window_statistics(intervals)
assert stats["price_mean"] == 25.0
assert stats["price_median"] == 25.0
assert stats["price_min"] == 10.0
assert stats["price_max"] == 40.0
assert stats["price_spread"] == 30.0
# 4 intervals x 15min = 1h, cost = sum(price x 0.25h) = (10+20+30+40) x 0.25 = 25.0
assert stats["estimated_total_cost"] == 25.0
def test_unit_factor(self) -> None:
"""Unit factor multiplies all values."""
intervals = _make_intervals([0.10, 0.20, 0.30])
stats = calculate_window_statistics(intervals, unit_factor=100)
assert stats["price_mean"] == 20.0
assert stats["price_min"] == 10.0
assert stats["price_max"] == 30.0
# 3 intervals x 15min, prices in subunit: (10+20+30) x 0.25 = 15.0
assert stats["estimated_total_cost"] == 15.0
def test_single_interval(self) -> None:
"""Single interval: mean=median=min=max, spread=0."""
intervals = _make_intervals([15.0])
stats = calculate_window_statistics(intervals)
assert stats["price_mean"] == 15.0
assert stats["price_median"] == 15.0
assert stats["price_min"] == 15.0
assert stats["price_max"] == 15.0
assert stats["price_spread"] == 0.0
# 1 interval x 0.25h x 15.0 = 3.75
assert stats["estimated_total_cost"] == 3.75
def test_negative_prices(self) -> None:
"""Handles negative prices."""
intervals = _make_intervals([-10.0, -5.0, -20.0])
stats = calculate_window_statistics(intervals)
assert stats["price_min"] == -20.0
assert stats["price_max"] == -5.0
assert stats["price_spread"] == 15.0
# 3 intervals x 0.25h: (-10+-5+-20) x 0.25 = -8.75
assert stats["estimated_total_cost"] == -8.75
def test_rounding(self) -> None:
"""Results are rounded to specified decimals."""
intervals = _make_intervals([1.0 / 3.0, 2.0 / 3.0])
stats = calculate_window_statistics(intervals, round_decimals=2)
assert stats["price_mean"] == 0.5
assert stats["price_min"] == 0.33
assert stats["price_max"] == 0.67
# (0.333...+0.666...) x 0.25 = 0.25
assert stats["estimated_total_cost"] == 0.25
# =============================================================================
# Reverse mode (find most expensive)
# =============================================================================
class TestFindMostExpensiveContiguousWindow:
"""Tests for the sliding window algorithm with reverse=True."""
def test_finds_most_expensive_block(self) -> None:
"""Reverse mode finds the most expensive contiguous window."""
prices = [10.0, 20.0, 30.0, 40.0, 5.0, 3.0, 2.0, 1.0]
intervals = _make_intervals(prices)
result = find_cheapest_contiguous_window(intervals, 4, reverse=True)
assert result is not None
selected_prices = [iv["total"] for iv in result["intervals"]]
assert selected_prices == [10.0, 20.0, 30.0, 40.0]
def test_most_expensive_at_end(self) -> None:
"""Most expensive window at the end."""
prices = [1.0, 2.0, 3.0, 4.0, 10.0, 20.0, 30.0, 40.0]
intervals = _make_intervals(prices)
result = find_cheapest_contiguous_window(intervals, 4, reverse=True)
assert result is not None
selected_prices = [iv["total"] for iv in result["intervals"]]
assert selected_prices == [10.0, 20.0, 30.0, 40.0]
def test_reverse_single_interval(self) -> None:
"""Reverse picks the most expensive single interval."""
prices = [5.0, 40.0, 10.0, 30.0]
intervals = _make_intervals(prices)
result = find_cheapest_contiguous_window(intervals, 1, reverse=True)
assert result is not None
assert result["intervals"][0]["total"] == 40.0
def test_reverse_empty_returns_none(self) -> None:
"""Edge case: empty input."""
assert find_cheapest_contiguous_window([], 4, reverse=True) is None
def test_reverse_vs_forward_different(self) -> None:
"""Reverse and forward give different results on asymmetric data."""
prices = [5.0, 10.0, 30.0, 25.0, 3.0, 2.0]
intervals = _make_intervals(prices)
cheapest = find_cheapest_contiguous_window(intervals, 2)
most_expensive = find_cheapest_contiguous_window(intervals, 2, reverse=True)
assert cheapest is not None
assert most_expensive is not None
cheap_sum = sum(iv["total"] for iv in cheapest["intervals"])
exp_sum = sum(iv["total"] for iv in most_expensive["intervals"])
assert exp_sum > cheap_sum
class TestFindMostExpensiveNIntervals:
"""Tests for the cheapest-N-picks algorithm with reverse=True."""
def test_picks_most_expensive(self) -> None:
"""Reverse picks the N most expensive intervals."""
prices = [30.0, 10.0, 25.0, 5.0, 20.0, 8.0, 35.0, 15.0]
intervals = _make_intervals(prices)
result = find_cheapest_n_intervals(intervals, 3, reverse=True)
assert result is not None
selected_prices = sorted((iv["total"] for iv in result["intervals"]), reverse=True)
assert selected_prices == [35.0, 30.0, 25.0]
def test_reverse_chronological_order(self) -> None:
"""Reverse result intervals are still sorted chronologically."""
prices = [30.0, 10.0, 25.0, 5.0, 20.0, 8.0, 35.0, 15.0]
intervals = _make_intervals(prices)
result = find_cheapest_n_intervals(intervals, 3, reverse=True)
assert result is not None
starts = [iv["startsAt"] for iv in result["intervals"]]
assert starts == sorted(starts)
def test_reverse_min_segment(self) -> None:
"""Reverse with min_segment constraint picks expensive segments."""
prices = [5.0, 30.0, 35.0, 3.0, 2.0, 40.0, 38.0, 1.0]
intervals = _make_intervals(prices)
result = find_cheapest_n_intervals(intervals, 4, min_segment_intervals=2, reverse=True)
assert result is not None
for seg in result["segments"]:
assert seg["interval_count"] >= 2
def test_reverse_empty_returns_none(self) -> None:
"""Edge case: empty input."""
assert find_cheapest_n_intervals([], 4, reverse=True) is None
def test_reverse_vs_forward_different(self) -> None:
"""Reverse and forward produce different sets."""
prices = [5.0, 10.0, 30.0, 25.0, 3.0, 2.0, 40.0, 15.0]
intervals = _make_intervals(prices)
cheapest = find_cheapest_n_intervals(intervals, 3)
most_expensive = find_cheapest_n_intervals(intervals, 3, reverse=True)
assert cheapest is not None
assert most_expensive is not None
cheap_prices = sorted(iv["total"] for iv in cheapest["intervals"])
exp_prices = sorted(iv["total"] for iv in most_expensive["intervals"])
assert cheap_prices != exp_prices
# =============================================================================
# Price Comparison (Cheapest vs Most Expensive)
# =============================================================================
class TestPriceComparison:
"""Tests for price comparison between cheapest and most expensive windows."""
def test_contiguous_window_spread(self) -> None:
"""Price difference between cheapest and most expensive contiguous windows."""
# Prices: clear cheap period (0.05) and clear expensive period (0.30)
prices = [0.05, 0.05, 0.05, 0.05, 0.20, 0.20, 0.30, 0.30, 0.30, 0.30]
intervals = _make_intervals(prices)
cheapest = find_cheapest_contiguous_window(intervals, 4, reverse=False)
most_expensive = find_cheapest_contiguous_window(intervals, 4, reverse=True)
assert cheapest is not None
assert most_expensive is not None
cheap_stats = calculate_window_statistics(cheapest["intervals"])
expensive_stats = calculate_window_statistics(most_expensive["intervals"])
assert cheap_stats["price_mean"] is not None
assert expensive_stats["price_mean"] is not None
spread = round(expensive_stats["price_mean"] - cheap_stats["price_mean"], 4)
# Mean of [0.30, 0.30, 0.30, 0.30] - mean of [0.05, 0.05, 0.05, 0.05] = 0.25
assert spread > 0
assert abs(spread - 0.25) < 0.001
def test_spread_symmetric(self) -> None:
"""Price difference is the same regardless of which direction we compute from."""
prices = [0.10, 0.10, 0.40, 0.40, 0.15, 0.15, 0.35, 0.35]
intervals = _make_intervals(prices)
cheapest = find_cheapest_contiguous_window(intervals, 2, reverse=False)
most_expensive = find_cheapest_contiguous_window(intervals, 2, reverse=True)
assert cheapest is not None
assert most_expensive is not None
cheap_stats = calculate_window_statistics(cheapest["intervals"])
expensive_stats = calculate_window_statistics(most_expensive["intervals"])
spread_cheap_to_exp = expensive_stats["price_mean"] - cheap_stats["price_mean"]
spread_exp_to_cheap = cheap_stats["price_mean"] - expensive_stats["price_mean"]
assert abs(spread_cheap_to_exp + spread_exp_to_cheap) < 0.0001
def test_n_intervals_spread(self) -> None:
"""Price difference between cheapest and most expensive N picks."""
prices = [0.02, 0.50, 0.03, 0.45, 0.01, 0.48, 0.04, 0.42]
intervals = _make_intervals(prices)
cheapest = find_cheapest_n_intervals(intervals, 3, reverse=False)
most_expensive = find_cheapest_n_intervals(intervals, 3, reverse=True)
assert cheapest is not None
assert most_expensive is not None
cheap_stats = calculate_window_statistics(cheapest["intervals"])
expensive_stats = calculate_window_statistics(most_expensive["intervals"])
assert cheap_stats["price_mean"] is not None
assert expensive_stats["price_mean"] is not None
# Cheapest 3: [0.01, 0.02, 0.03] → mean 0.02
# Most expensive 3: [0.50, 0.48, 0.45] → mean ~0.4767
spread = expensive_stats["price_mean"] - cheap_stats["price_mean"]
assert spread > 0.4
def test_flat_prices_zero_spread(self) -> None:
"""Flat prices produce zero price difference."""
prices = [0.25, 0.25, 0.25, 0.25, 0.25, 0.25]
intervals = _make_intervals(prices)
cheapest = find_cheapest_contiguous_window(intervals, 3, reverse=False)
most_expensive = find_cheapest_contiguous_window(intervals, 3, reverse=True)
assert cheapest is not None
assert most_expensive is not None
cheap_stats = calculate_window_statistics(cheapest["intervals"])
expensive_stats = calculate_window_statistics(most_expensive["intervals"])
spread = expensive_stats["price_mean"] - cheap_stats["price_mean"]
assert abs(spread) < 0.0001
def test_single_interval_no_spread(self) -> None:
"""With only 1 interval and duration=1, cheapest==most expensive (no difference)."""
intervals = _make_intervals([0.30])
cheapest = find_cheapest_contiguous_window(intervals, 1, reverse=False)
most_expensive = find_cheapest_contiguous_window(intervals, 1, reverse=True)
assert cheapest is not None
assert most_expensive is not None
cheap_stats = calculate_window_statistics(cheapest["intervals"])
expensive_stats = calculate_window_statistics(most_expensive["intervals"])
spread = expensive_stats["price_mean"] - cheap_stats["price_mean"]
assert abs(spread) < 0.0001