refactor(services): enhance validation for service parameters and error messages

Improved validation logic for service parameters in find_cheapest_hours, find_cheapest_schedule, and chartdata services. Added checks for unique task names, ensured that segment durations do not exceed total duration, and clarified error messages for better user understanding.

Impact: Users will receive clearer error messages and improved validation when using the services, leading to a more robust experience.
This commit is contained in:
Julian Pawlowski 2026-04-13 12:02:19 +00:00
parent 9042ea6efb
commit 729bf307ca
9 changed files with 232 additions and 11 deletions

View file

@ -202,7 +202,7 @@ def _build_found_response( # noqa: PLR0913
}
async def _handle_find_hours(
async def _handle_find_hours( # noqa: PLR0915
call: ServiceCall,
*,
reverse: bool = False,
@ -258,6 +258,15 @@ async def _handle_find_hours(
min_segment_intervals = min_segment_minutes // INTERVAL_MINUTES
# 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_power_profile_length(power_profile, total_intervals)

View file

@ -226,6 +226,16 @@ async def handle_find_cheapest_schedule(call: ServiceCall) -> ServiceResponse:
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
# 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
gap_intervals = math.ceil(gap_minutes / INTERVAL_MINUTES) if gap_minutes > 0 else 0
@ -269,6 +279,21 @@ async def handle_find_cheapest_schedule(call: ServiceCall) -> ServiceResponse:
# Validate parameter combinations
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(
"%s called: %d tasks, gap=%dmin, range=%s to %s",
service_label,

View file

@ -409,6 +409,13 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: PLR091
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)
if connect_segments and insert_nulls != "segments":
raise ServiceValidationError(
@ -416,6 +423,13 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: PLR091
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
if call.data.get("array_fields") and output_format != "array_of_arrays":
raise ServiceValidationError(
@ -676,6 +690,58 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: PLR091
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):
# Mode 'segments': Add NULL points at segment boundaries for clean gaps
# Process ALL intervals as one continuous list - no special midnight handling needed

View file

@ -95,6 +95,18 @@ def validate_search_params(call_data: dict[str, Any]) -> None:
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
# 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.
@ -488,6 +500,10 @@ def resolve_search_range(
raise ServiceValidationError(
translation_domain=DOMAIN,
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

View file

@ -1175,7 +1175,7 @@
"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."
"message": "Der Endzeitpunkt ({search_end}) muss nach dem Startzeitpunkt ({search_start}) liegen. Überprüfe die Zeit-Parameter und eventuelle Day-Offsets."
},
"price_fetch_failed": {
"message": "Preisdaten konnten nicht von der Tibber-API abgerufen werden. Bitte versuche es später erneut."
@ -1208,7 +1208,28 @@
"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}'."
"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}'."
},
"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": {

View file

@ -1175,7 +1175,7 @@
"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."
"message": "End time ({search_end}) must be after start time ({search_start}). Check your time parameters and any day offsets."
},
"price_fetch_failed": {
"message": "Failed to fetch price data from the Tibber API. Please try again later."
@ -1208,7 +1208,28 @@
"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}'."
"message": "The array_fields value '{template}' is invalid. Field names must be wrapped 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": {

View file

@ -1175,7 +1175,7 @@
"message": "Kunne ikke bestemme hjemmets tidssone. Vennligst sjekk hjemmekonfigurasjonen i Tibber-kontoen din."
},
"end_before_start": {
"message": "Sluttid må være etter starttid."
"message": "Sluttidspunktet ({search_end}) må være etter starttidspunktet ({search_start}). Sjekk tid-parameterne og eventuelle day-offsets."
},
"price_fetch_failed": {
"message": "Kunne ikke hente prisdata fra Tibber API. Vennligst prøv igjen senere."
@ -1208,7 +1208,28 @@
"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}'."
"message": "Verdien '{template}' for array_fields er ugyldig. Feltnavn må omsluttes av 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": {

View file

@ -1175,7 +1175,7 @@
"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."
"message": "Het eindtijdstip ({search_end}) moet na het starttijdstip ({search_start}) liggen. Controleer je tijdparameters en eventuele day-offsets."
},
"price_fetch_failed": {
"message": "Kon prijsgegevens niet ophalen bij de Tibber API. Probeer het later opnieuw."
@ -1208,7 +1208,28 @@
"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}'."
"message": "De waarde '{template}' voor array_fields is ongeldig. Veldnamen moeten tussen accolades staan, 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": {

View file

@ -1175,7 +1175,7 @@
"message": "Kunde inte fastställa hemmets tidszon. Kontrollera hemkonfigurationen i ditt Tibber-konto."
},
"end_before_start": {
"message": "Sluttid måste vara efter starttid."
"message": "Sluttidpunkten ({search_end}) måste vara efter starttidpunkten ({search_start}). Kontrollera tidsparametrarna och eventuella day-offsets."
},
"price_fetch_failed": {
"message": "Kunde inte hämta prisdata från Tibber API. Försök igen senare."
@ -1208,7 +1208,28 @@
"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}'."
"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}'."
},
"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": {