From 729bf307ca4d9b202d29d0c6714103b867fcd7f7 Mon Sep 17 00:00:00 2001 From: Julian Pawlowski Date: Mon, 13 Apr 2026 12:02:19 +0000 Subject: [PATCH] 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. --- .../services/find_cheapest_hours.py | 11 +++- .../services/find_cheapest_schedule.py | 25 +++++++ .../tibber_prices/services/get_chartdata.py | 66 +++++++++++++++++++ .../tibber_prices/services/helpers.py | 16 +++++ .../tibber_prices/translations/de.json | 25 ++++++- .../tibber_prices/translations/en.json | 25 ++++++- .../tibber_prices/translations/nb.json | 25 ++++++- .../tibber_prices/translations/nl.json | 25 ++++++- .../tibber_prices/translations/sv.json | 25 ++++++- 9 files changed, 232 insertions(+), 11 deletions(-) diff --git a/custom_components/tibber_prices/services/find_cheapest_hours.py b/custom_components/tibber_prices/services/find_cheapest_hours.py index 94c3a37..ccf4bc8 100644 --- a/custom_components/tibber_prices/services/find_cheapest_hours.py +++ b/custom_components/tibber_prices/services/find_cheapest_hours.py @@ -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) diff --git a/custom_components/tibber_prices/services/find_cheapest_schedule.py b/custom_components/tibber_prices/services/find_cheapest_schedule.py index a5e7d03..88ab619 100644 --- a/custom_components/tibber_prices/services/find_cheapest_schedule.py +++ b/custom_components/tibber_prices/services/find_cheapest_schedule.py @@ -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, diff --git a/custom_components/tibber_prices/services/get_chartdata.py b/custom_components/tibber_prices/services/get_chartdata.py index cbb0b5e..6997a79 100644 --- a/custom_components/tibber_prices/services/get_chartdata.py +++ b/custom_components/tibber_prices/services/get_chartdata.py @@ -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 diff --git a/custom_components/tibber_prices/services/helpers.py b/custom_components/tibber_prices/services/helpers.py index b8015ad..1ff19a9 100644 --- a/custom_components/tibber_prices/services/helpers.py +++ b/custom_components/tibber_prices/services/helpers.py @@ -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 diff --git a/custom_components/tibber_prices/translations/de.json b/custom_components/tibber_prices/translations/de.json index 457bad9..c027853 100644 --- a/custom_components/tibber_prices/translations/de.json +++ b/custom_components/tibber_prices/translations/de.json @@ -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": { diff --git a/custom_components/tibber_prices/translations/en.json b/custom_components/tibber_prices/translations/en.json index 59e3f29..30272fa 100644 --- a/custom_components/tibber_prices/translations/en.json +++ b/custom_components/tibber_prices/translations/en.json @@ -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": { diff --git a/custom_components/tibber_prices/translations/nb.json b/custom_components/tibber_prices/translations/nb.json index a17c4ed..677e302 100644 --- a/custom_components/tibber_prices/translations/nb.json +++ b/custom_components/tibber_prices/translations/nb.json @@ -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": { diff --git a/custom_components/tibber_prices/translations/nl.json b/custom_components/tibber_prices/translations/nl.json index a2be03b..b69c7ab 100644 --- a/custom_components/tibber_prices/translations/nl.json +++ b/custom_components/tibber_prices/translations/nl.json @@ -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": { diff --git a/custom_components/tibber_prices/translations/sv.json b/custom_components/tibber_prices/translations/sv.json index a827fc6..a11232e 100644 --- a/custom_components/tibber_prices/translations/sv.json +++ b/custom_components/tibber_prices/translations/sv.json @@ -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": {