Compare commits

..

No commits in common. "27ab58bbf500d40c136cddc45ab0ae0d9bb1ff47" and "9042ea6efbe3139e82b46c0cc07d17daeb26229d" have entirely different histories.

11 changed files with 22 additions and 264 deletions

View file

@ -9,10 +9,7 @@ from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from custom_components.tibber_prices.api.exceptions import ( from custom_components.tibber_prices.api.exceptions import TibberPricesApiClientError
TibberPricesApiClientCommunicationError,
TibberPricesApiClientError,
)
from homeassistant.util import dt as dt_utils from homeassistant.util import dt as dt_utils
from .cache import TibberPricesIntervalPoolFetchGroupCache from .cache import TibberPricesIntervalPoolFetchGroupCache
@ -204,11 +201,9 @@ class TibberPricesIntervalPool:
) )
# Fetch missing ranges from API # Fetch missing ranges from API
api_fetch_failed = False
if missing_ranges: if missing_ranges:
fetch_time_iso = dt_utils.now().isoformat() fetch_time_iso = dt_utils.now().isoformat()
try:
# Fetch with callback for immediate caching # Fetch with callback for immediate caching
await self._fetcher.fetch_missing_ranges( await self._fetcher.fetch_missing_ranges(
api_client=api_client, api_client=api_client,
@ -216,29 +211,13 @@ class TibberPricesIntervalPool:
missing_ranges=missing_ranges, missing_ranges=missing_ranges,
on_intervals_fetched=lambda intervals, _: self._add_intervals(intervals, fetch_time_iso), on_intervals_fetched=lambda intervals, _: self._add_intervals(intervals, fetch_time_iso),
) )
except TibberPricesApiClientCommunicationError as err:
if cached_intervals:
# Transient API error (e.g. 503) but we have cached data - use it as
# fallback so the coordinator can finish initializing. The next regular
# update cycle will retry the API automatically.
_LOGGER.warning(
"API temporarily unavailable for home %s (%s) - using %d cached intervals as fallback",
self._home_id,
err,
len(cached_intervals),
)
api_fetch_failed = True
else:
# No cached data at all - re-raise so the caller can decide
raise
# After caching all API responses, read from cache again to get final result # After caching all API responses, read from cache again to get final result
# This ensures we return exactly what user requested, filtering out extra intervals # This ensures we return exactly what user requested, filtering out extra intervals
final_result = self._get_cached_intervals(start_time_iso, end_time_iso) final_result = self._get_cached_intervals(start_time_iso, end_time_iso)
# Track if API was called (True if any missing ranges were attempted) # Track if API was called (True if any missing ranges were fetched)
# If fetch failed but we fell back to cache, treat as "no API call succeeded" api_called = len(missing_ranges) > 0
api_called = len(missing_ranges) > 0 and not api_fetch_failed
_LOGGER_DETAILS.debug( _LOGGER_DETAILS.debug(
"Pool returning %d intervals for home %s (from cache: %d, fetched from API: %d ranges, api_called=%s)", "Pool returning %d intervals for home %s (from cache: %d, fetched from API: %d ranges, api_called=%s)",

View file

@ -11,5 +11,5 @@
"requirements": [ "requirements": [
"aiofiles>=23.2.1" "aiofiles>=23.2.1"
], ],
"version": "0.31.0b1" "version": "0.31.0b0"
} }

View file

@ -202,7 +202,7 @@ def _build_found_response( # noqa: PLR0913
} }
async def _handle_find_hours( # noqa: PLR0915 async def _handle_find_hours(
call: ServiceCall, call: ServiceCall,
*, *,
reverse: bool = False, reverse: bool = False,
@ -258,15 +258,6 @@ async def _handle_find_hours( # noqa: PLR0915
min_segment_intervals = min_segment_minutes // INTERVAL_MINUTES min_segment_intervals = min_segment_minutes // INTERVAL_MINUTES
# Validate parameter combinations # Validate parameter combinations
if min_segment_minutes > total_minutes:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="min_segment_exceeds_duration",
translation_placeholders={
"min_segment_minutes": str(min_segment_minutes),
"duration_minutes": str(total_minutes),
},
)
validate_price_level_range(min_price_level, max_price_level) validate_price_level_range(min_price_level, max_price_level)
validate_power_profile_length(power_profile, total_intervals) validate_power_profile_length(power_profile, total_intervals)

View file

@ -226,16 +226,6 @@ async def handle_find_cheapest_schedule(call: ServiceCall) -> ServiceResponse:
include_comparison_details: bool = call.data.get("include_comparison_details", False) include_comparison_details: bool = call.data.get("include_comparison_details", False)
level_filter_active = min_price_level is not None or max_price_level is not None level_filter_active = min_price_level is not None or max_price_level is not None
# Validate task names are unique (before any expensive operations)
task_names = [t["name"] for t in tasks_raw]
duplicate_names = sorted({n for n in task_names if task_names.count(n) > 1})
if duplicate_names:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="duplicate_task_names",
translation_placeholders={"names": ", ".join(duplicate_names)},
)
# Round gap up to nearest quarter interval # Round gap up to nearest quarter interval
gap_intervals = math.ceil(gap_minutes / INTERVAL_MINUTES) if gap_minutes > 0 else 0 gap_intervals = math.ceil(gap_minutes / INTERVAL_MINUTES) if gap_minutes > 0 else 0
@ -279,21 +269,6 @@ async def handle_find_cheapest_schedule(call: ServiceCall) -> ServiceResponse:
# Validate parameter combinations # Validate parameter combinations
validate_price_level_range(min_price_level, max_price_level) validate_price_level_range(min_price_level, max_price_level)
# Validate that total task time + gaps fits within the search window
window_minutes = int((search_end - search_start).total_seconds() / 60)
total_task_minutes = sum(t["duration_minutes"] for t in tasks)
total_gap_minutes = gap_intervals * INTERVAL_MINUTES * max(0, len(tasks) - 1)
required_minutes = total_task_minutes + total_gap_minutes
if required_minutes > window_minutes:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="tasks_exceed_search_window",
translation_placeholders={
"total_minutes": str(required_minutes),
"window_minutes": str(window_minutes),
},
)
_LOGGER.info( _LOGGER.info(
"%s called: %d tasks, gap=%dmin, range=%s to %s", "%s called: %d tasks, gap=%dmin, range=%s to %s",
service_label, service_label,

View file

@ -409,13 +409,6 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: PLR091
translation_placeholders={"mode": insert_nulls}, translation_placeholders={"mode": insert_nulls},
) )
# insert_nulls="all" is only implemented for level/rating filters, not period_filter
if insert_nulls == "all" and period_filter and not (level_filter or rating_level_filter):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="insert_nulls_all_with_period_filter",
)
# connect_segments requires insert_nulls="segments" (with a filter) # connect_segments requires insert_nulls="segments" (with a filter)
if connect_segments and insert_nulls != "segments": if connect_segments and insert_nulls != "segments":
raise ServiceValidationError( raise ServiceValidationError(
@ -423,13 +416,6 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: PLR091
translation_key="connect_segments_requires_segments_mode", translation_key="connect_segments_requires_segments_mode",
) )
# connect_segments is not applicable to period_filter (periods are already contiguous)
if connect_segments and period_filter:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="connect_segments_with_period_filter",
)
# array_fields is only meaningful with array_of_arrays format # array_fields is only meaningful with array_of_arrays format
if call.data.get("array_fields") and output_format != "array_of_arrays": if call.data.get("array_fields") and output_format != "array_of_arrays":
raise ServiceValidationError( raise ServiceValidationError(
@ -690,58 +676,6 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: PLR091
chart_data.append(data_point) chart_data.append(data_point)
elif insert_nulls == "segments" and period_filter is not None and period_timestamps is not None:
# Mode 'segments' with period_filter: Insert NULL separators at period boundaries
# so that separate best/peak price periods appear as distinct areas in the chart,
# rather than one continuous area spanning the gaps between periods.
prev_start_time: Any | None = None
for interval in all_prices:
start_time = interval.get("startsAt")
price = interval.get("total")
if start_time is None or price is None:
continue
start_str = start_time.isoformat() if hasattr(start_time, "isoformat") else start_time
if start_str not in period_timestamps:
# Leaving a period — close the previous segment with a NULL
if prev_start_time is not None:
chart_data.append({start_time_field: start_str, price_field: None})
prev_start_time = None
continue
# Still inside a period — check for a temporal gap (new disjoint period starting)
if prev_start_time is not None:
interval_duration = coordinator.time.get_interval_duration()
expected = prev_start_time + interval_duration
if start_time != expected:
prev_str = (
prev_start_time.isoformat() if hasattr(prev_start_time, "isoformat") else prev_start_time
)
chart_data.append({start_time_field: prev_str, price_field: None})
converted_price = round(price * 100, 2) if subunit_currency else round(price, 4)
if round_decimals is not None:
converted_price = round(converted_price, round_decimals)
data_point: dict = {
start_time_field: start_str,
price_field: converted_price,
}
if include_level and "level" in interval:
data_point[level_field] = interval["level"]
if include_rating_level and "rating_level" in interval:
data_point[rating_level_field] = interval["rating_level"]
day_key = _get_day_key_for_interval(start_time)
if include_average and day_key and day_key in day_averages:
data_point[average_field] = day_averages[day_key]
_add_energy_tax_fields(data_point, interval, converted_price)
chart_data.append(data_point)
prev_start_time = start_time
elif insert_nulls == "segments" and (level_filter or rating_level_filter): elif insert_nulls == "segments" and (level_filter or rating_level_filter):
# Mode 'segments': Add NULL points at segment boundaries for clean gaps # Mode 'segments': Add NULL points at segment boundaries for clean gaps
# Process ALL intervals as one continuous list - no special midnight handling needed # Process ALL intervals as one continuous list - no special midnight handling needed

View file

@ -95,18 +95,6 @@ def validate_search_params(call_data: dict[str, Any]) -> None:
translation_placeholders={"params": ", ".join(sorted(conflicting))}, translation_placeholders={"params": ", ".join(sorted(conflicting))},
) )
# search_start and search_start_time are mutually exclusive start-time specifications
if "search_start" in call_data and "search_start_time" in call_data:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="start_time_conflict",
)
if "search_end" in call_data and "search_end_time" in call_data:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="end_time_conflict",
)
# day_offset without matching time parameter is meaningless # day_offset without matching time parameter is meaningless
# Schema defaults provide 0, but user explicitly setting non-zero without time is an error. # 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. # We detect explicit usage by checking for non-default values when time is absent.
@ -500,10 +488,6 @@ def resolve_search_range(
raise ServiceValidationError( raise ServiceValidationError(
translation_domain=DOMAIN, translation_domain=DOMAIN,
translation_key="end_before_start", translation_key="end_before_start",
translation_placeholders={
"search_start": search_start.strftime("%Y-%m-%d %H:%M %z"),
"search_end": search_end.strftime("%Y-%m-%d %H:%M %z"),
},
) )
return search_start, search_end return search_start, search_end

View file

@ -1175,7 +1175,7 @@
"message": "Zeitzone des Zuhauses konnte nicht ermittelt werden. Bitte überprüfe die Konfiguration in deinem Tibber-Konto." "message": "Zeitzone des Zuhauses konnte nicht ermittelt werden. Bitte überprüfe die Konfiguration in deinem Tibber-Konto."
}, },
"end_before_start": { "end_before_start": {
"message": "Der Endzeitpunkt ({search_end}) muss nach dem Startzeitpunkt ({search_start}) liegen. Überprüfe die Zeit-Parameter und eventuelle Day-Offsets." "message": "Endzeit muss nach der Startzeit liegen."
}, },
"price_fetch_failed": { "price_fetch_failed": {
"message": "Preisdaten konnten nicht von der Tibber-API abgerufen werden. Bitte versuche es später erneut." "message": "Preisdaten konnten nicht von der Tibber-API abgerufen werden. Bitte versuche es später erneut."
@ -1208,28 +1208,7 @@
"message": "array_fields kann nur mit output_format: array_of_arrays verwendet werden. Ändere das Ausgabeformat oder entferne array_fields." "message": "array_fields kann nur mit output_format: array_of_arrays verwendet werden. Ändere das Ausgabeformat oder entferne array_fields."
}, },
"invalid_array_fields": { "invalid_array_fields": {
"message": "Der Wert {template} für array_fields ist ungültig. Feldnamen müssen in geschweifte Klammern eingeschlossen sein, z.B. '{start_time}, {price_per_kwh}, {level}'." "message": "Ungültige array_fields-Vorlage. Verwende Feldnamen in geschweiften Klammern, z.B. '{start_time}, {price_per_kwh}, {level}'."
},
"min_segment_exceeds_duration": {
"message": "min_segment_duration ({min_segment_minutes} Min.) darf die Gesamtdauer ({duration_minutes} Min.) nicht überschreiten. Reduziere min_segment_duration oder erhöhe duration."
},
"start_time_conflict": {
"message": "search_start und search_start_time definieren beide den Startzeitpunkt — verwende nur einen. Nutze search_start für ein genaues Datum/Uhrzeit oder search_start_time für eine Tageszeit."
},
"end_time_conflict": {
"message": "search_end und search_end_time definieren beide den Endzeitpunkt — verwende nur einen. Nutze search_end für ein genaues Datum/Uhrzeit oder search_end_time für eine Tageszeit."
},
"insert_nulls_all_with_period_filter": {
"message": "insert_nulls: all wird mit period_filter nicht unterstützt. Verwende stattdessen insert_nulls: segments — das fügt Lücken zwischen einzelnen Perioden im Diagramm ein."
},
"connect_segments_with_period_filter": {
"message": "connect_segments kann nicht zusammen mit period_filter verwendet werden. Perioden sind bereits zusammenhängend — connect_segments wirkt nur bei level_filter oder rating_level_filter."
},
"duplicate_task_names": {
"message": "Aufgabennamen müssen eindeutig sein. Doppelt vergeben: {names}. Vergib unterschiedliche Namen, damit die Ergebnisse den richtigen Aufgaben zugeordnet werden können."
},
"tasks_exceed_search_window": {
"message": "Die Gesamtdauer aller Aufgaben inklusive Pausen ({total_minutes} Min.) überschreitet das Suchfenster ({window_minutes} Min.). Reduziere die Aufgabendauern, verringere gap_minutes oder erweitere den Suchzeitraum."
} }
}, },
"services": { "services": {

View file

@ -1175,7 +1175,7 @@
"message": "Could not determine the home timezone. Please verify the home configuration in your Tibber account." "message": "Could not determine the home timezone. Please verify the home configuration in your Tibber account."
}, },
"end_before_start": { "end_before_start": {
"message": "End time ({search_end}) must be after start time ({search_start}). Check your time parameters and any day offsets." "message": "End time must be after start time."
}, },
"price_fetch_failed": { "price_fetch_failed": {
"message": "Failed to fetch price data from the Tibber API. Please try again later." "message": "Failed to fetch price data from the Tibber API. Please try again later."
@ -1208,28 +1208,7 @@
"message": "array_fields can only be used with output_format: array_of_arrays. Change the output format or remove array_fields." "message": "array_fields can only be used with output_format: array_of_arrays. Change the output format or remove array_fields."
}, },
"invalid_array_fields": { "invalid_array_fields": {
"message": "The array_fields value {template} is invalid. Field names must be wrapped in curly braces, e.g. '{start_time}, {price_per_kwh}, {level}'." "message": "Invalid array_fields template. Use field names in curly braces, e.g. '{start_time}, {price_per_kwh}, {level}'."
},
"min_segment_exceeds_duration": {
"message": "min_segment_duration ({min_segment_minutes} min) cannot exceed the total duration ({duration_minutes} min). Reduce min_segment_duration or increase duration."
},
"start_time_conflict": {
"message": "search_start and search_start_time both specify the start time — use only one. Choose search_start for an exact datetime or search_start_time for a time of day."
},
"end_time_conflict": {
"message": "search_end and search_end_time both specify the end time — use only one. Choose search_end for an exact datetime or search_end_time for a time of day."
},
"insert_nulls_all_with_period_filter": {
"message": "insert_nulls: all is not supported with period_filter. Use insert_nulls: segments instead — this adds gaps between separate periods in the chart."
},
"connect_segments_with_period_filter": {
"message": "connect_segments cannot be used with period_filter. Periods are already contiguous — connect_segments only has effect with level_filter or rating_level_filter."
},
"duplicate_task_names": {
"message": "Task names must be unique. Duplicate: {names}. Give each task a different name so the results can be matched to the correct task."
},
"tasks_exceed_search_window": {
"message": "Total task time including gaps ({total_minutes} min) exceeds the search window ({window_minutes} min). Reduce task durations, lower gap_minutes, or extend the search range."
} }
}, },
"services": { "services": {

View file

@ -1175,7 +1175,7 @@
"message": "Kunne ikke bestemme hjemmets tidssone. Vennligst sjekk hjemmekonfigurasjonen i Tibber-kontoen din." "message": "Kunne ikke bestemme hjemmets tidssone. Vennligst sjekk hjemmekonfigurasjonen i Tibber-kontoen din."
}, },
"end_before_start": { "end_before_start": {
"message": "Sluttidspunktet ({search_end}) må være etter starttidspunktet ({search_start}). Sjekk tid-parameterne og eventuelle day-offsets." "message": "Sluttid må være etter starttid."
}, },
"price_fetch_failed": { "price_fetch_failed": {
"message": "Kunne ikke hente prisdata fra Tibber API. Vennligst prøv igjen senere." "message": "Kunne ikke hente prisdata fra Tibber API. Vennligst prøv igjen senere."
@ -1208,28 +1208,7 @@
"message": "array_fields kan kun brukes med output_format: array_of_arrays. Endre utdataformatet eller fjern array_fields." "message": "array_fields kan kun brukes med output_format: array_of_arrays. Endre utdataformatet eller fjern array_fields."
}, },
"invalid_array_fields": { "invalid_array_fields": {
"message": "Verdien {template} for array_fields er ugyldig. Feltnavn må omsluttes av krøllparenteser, f.eks. '{start_time}, {price_per_kwh}, {level}'." "message": "Ugyldig array_fields-mal. Bruk feltnavn i krøllparenteser, f.eks. '{start_time}, {price_per_kwh}, {level}'."
},
"min_segment_exceeds_duration": {
"message": "min_segment_duration ({min_segment_minutes} min) kan ikke overstige total varighet ({duration_minutes} min). Reduser min_segment_duration eller øk duration."
},
"start_time_conflict": {
"message": "search_start og search_start_time angir begge starttidspunktet — bruk bare én. Velg search_start for eksakt dato/klokkeslett eller search_start_time for et tidspunkt på dagen."
},
"end_time_conflict": {
"message": "search_end og search_end_time angir begge sluttidspunktet — bruk bare én. Velg search_end for eksakt dato/klokkeslett eller search_end_time for et tidspunkt på dagen."
},
"insert_nulls_all_with_period_filter": {
"message": "insert_nulls: all støttes ikke med period_filter. Bruk insert_nulls: segments i stedet — dette legger til tomrom mellom separate perioder i diagrammet."
},
"connect_segments_with_period_filter": {
"message": "connect_segments kan ikke brukes med period_filter. Perioder er allerede sammenhengende — connect_segments har bare effekt med level_filter eller rating_level_filter."
},
"duplicate_task_names": {
"message": "Oppgavenavn må være unike. Duplikat: {names}. Gi hver oppgave et unikt navn slik at resultatene kan matches til riktig oppgave."
},
"tasks_exceed_search_window": {
"message": "Total oppgavetid inkludert pauser ({total_minutes} min) overstiger søkevinduet ({window_minutes} min). Reduser oppgavevarighetene, senk gap_minutes, eller utvid søkeperioden."
} }
}, },
"services": { "services": {

View file

@ -1175,7 +1175,7 @@
"message": "Kon de tijdzone van het huis niet bepalen. Controleer de huisconfiguratie in je Tibber-account." "message": "Kon de tijdzone van het huis niet bepalen. Controleer de huisconfiguratie in je Tibber-account."
}, },
"end_before_start": { "end_before_start": {
"message": "Het eindtijdstip ({search_end}) moet na het starttijdstip ({search_start}) liggen. Controleer je tijdparameters en eventuele day-offsets." "message": "Eindtijd moet na de starttijd liggen."
}, },
"price_fetch_failed": { "price_fetch_failed": {
"message": "Kon prijsgegevens niet ophalen bij de Tibber API. Probeer het later opnieuw." "message": "Kon prijsgegevens niet ophalen bij de Tibber API. Probeer het later opnieuw."
@ -1208,28 +1208,7 @@
"message": "array_fields kan alleen gebruikt worden met output_format: array_of_arrays. Wijzig het uitvoerformaat of verwijder array_fields." "message": "array_fields kan alleen gebruikt worden met output_format: array_of_arrays. Wijzig het uitvoerformaat of verwijder array_fields."
}, },
"invalid_array_fields": { "invalid_array_fields": {
"message": "De waarde {template} voor array_fields is ongeldig. Veldnamen moeten tussen accolades staan, bijv. '{start_time}, {price_per_kwh}, {level}'." "message": "Ongeldig array_fields-sjabloon. Gebruik veldnamen tussen accolades, bijv. '{start_time}, {price_per_kwh}, {level}'."
},
"min_segment_exceeds_duration": {
"message": "min_segment_duration ({min_segment_minutes} min) mag de totale duur ({duration_minutes} min) niet overschrijden. Verklein min_segment_duration of vergroot duration."
},
"start_time_conflict": {
"message": "search_start en search_start_time specificeren beide het starttijdstip — gebruik slechts één van beide. Kies search_start voor een exacte datum/tijd of search_start_time voor een tijdstip op de dag."
},
"end_time_conflict": {
"message": "search_end en search_end_time specificeren beide het eindtijdstip — gebruik slechts één van beide. Kies search_end voor een exacte datum/tijd of search_end_time voor een tijdstip op de dag."
},
"insert_nulls_all_with_period_filter": {
"message": "insert_nulls: all wordt niet ondersteund met period_filter. Gebruik insert_nulls: segments — dit voegt lege ruimtes in tussen afzonderlijke perioden in de grafiek."
},
"connect_segments_with_period_filter": {
"message": "connect_segments kan niet worden gebruikt met period_filter. Perioden zijn al aaneengesloten — connect_segments heeft alleen effect met level_filter of rating_level_filter."
},
"duplicate_task_names": {
"message": "Taaknamen moeten uniek zijn. Duplicaat: {names}. Geef elke taak een andere naam zodat de resultaten aan de juiste taak gekoppeld kunnen worden."
},
"tasks_exceed_search_window": {
"message": "De totale taaktijd inclusief pauzes ({total_minutes} min) overschrijdt het zoekvenster ({window_minutes} min). Verklein de taakduur, verlaag gap_minutes of vergroot het zoekbereik."
} }
}, },
"services": { "services": {

View file

@ -1175,7 +1175,7 @@
"message": "Kunde inte fastställa hemmets tidszon. Kontrollera hemkonfigurationen i ditt Tibber-konto." "message": "Kunde inte fastställa hemmets tidszon. Kontrollera hemkonfigurationen i ditt Tibber-konto."
}, },
"end_before_start": { "end_before_start": {
"message": "Sluttidpunkten ({search_end}) måste vara efter starttidpunkten ({search_start}). Kontrollera tidsparametrarna och eventuella day-offsets." "message": "Sluttid måste vara efter starttid."
}, },
"price_fetch_failed": { "price_fetch_failed": {
"message": "Kunde inte hämta prisdata från Tibber API. Försök igen senare." "message": "Kunde inte hämta prisdata från Tibber API. Försök igen senare."
@ -1208,28 +1208,7 @@
"message": "array_fields kan bara användas med output_format: array_of_arrays. Ändra utdataformatet eller ta bort array_fields." "message": "array_fields kan bara användas med output_format: array_of_arrays. Ändra utdataformatet eller ta bort array_fields."
}, },
"invalid_array_fields": { "invalid_array_fields": {
"message": "Värdet {template} för array_fields är ogiltigt. Fältnamn måste omges av klammerparenteser, t.ex. '{start_time}, {price_per_kwh}, {level}'." "message": "Ogiltig array_fields-mall. Använd fältnamn inom klammerparenteser, t.ex. '{start_time}, {price_per_kwh}, {level}'."
},
"min_segment_exceeds_duration": {
"message": "min_segment_duration ({min_segment_minutes} min) får inte överstiga den totala varaktigheten ({duration_minutes} min). Minska min_segment_duration eller öka duration."
},
"start_time_conflict": {
"message": "search_start och search_start_time anger båda starttidpunkten — använd bara en. Välj search_start för exakt datum/tid eller search_start_time för en tid på dagen."
},
"end_time_conflict": {
"message": "search_end och search_end_time anger båda sluttidpunkten — använd bara en. Välj search_end för exakt datum/tid eller search_end_time för en tid på dagen."
},
"insert_nulls_all_with_period_filter": {
"message": "insert_nulls: all stöds inte med period_filter. Använd insert_nulls: segments istället — det lägger till tomrum mellan separata perioder i diagrammet."
},
"connect_segments_with_period_filter": {
"message": "connect_segments kan inte användas med period_filter. Perioder är redan sammanhängande — connect_segments har bara effekt med level_filter eller rating_level_filter."
},
"duplicate_task_names": {
"message": "Uppgiftsnamn måste vara unika. Duplikat: {names}. Ge varje uppgift ett unikt namn så att resultaten kan matchas till rätt uppgift."
},
"tasks_exceed_search_window": {
"message": "Total uppgiftstid inklusive pauser ({total_minutes} min) överstiger sökfönstret ({window_minutes} min). Minska uppgiftslängderna, sänk gap_minutes eller utöka sökintervallet."
} }
}, },
"services": { "services": {