Compare commits

...

5 commits

Author SHA1 Message Date
Julian Pawlowski
e39d0be162 fix(translations): correct message formatting in multiple language files
Some checks are pending
Lint / Ruff (push) Waiting to run
Validate / Hassfest validation (push) Waiting to run
Validate / HACS validation (push) Waiting to run
Updated error messages in German, English, Norwegian, Dutch, and Swedish translations to remove unnecessary quotes around placeholders. This improves clarity and consistency in error reporting.

Impact: Users will see clearer error messages without quotes around variable placeholders.
2026-06-01 11:31:18 +00:00
Julian Pawlowski
bcbaf3a1cd chore(bootstrap): update Home Assistant version to 2026.4.4
Updated the default Home Assistant version in the bootstrap script and requirements file to ensure compatibility with the latest features and fixes.

Impact: Users will benefit from improved stability and new features available in Home Assistant 2026.4.4.
2026-06-01 11:22:45 +00:00
Julian Pawlowski
3a44bc8267 Merge branch 'main' of https://github.com/jpawlowski/hass.tibber_prices 2026-06-01 11:16:23 +00:00
Julian Pawlowski
5ea6e75295 refactor(services): change step value to 'any' for plan_charging fields
Updated the step value for number fields in the plan_charging service to allow any value instead of a fixed increment. This change enhances flexibility in user input.

Impact: Users can now specify any value within the defined range for charging plans.

---
refactor(translations): update error message formatting for entity attributes

Modified the error messages in multiple translation files to remove curly braces around entity attributes. This improves consistency in message formatting across languages.

Impact: Users will see cleaner error messages when attributes are not found.
2026-06-01 11:16:21 +00:00
WouterGithb
db5d172fb8
fix(services): preserve service call data through coordinator data fetch (#151)
* fix(services): preserve service call data through coordinator data fetch

In `_handle_find_block` and `_handle_find_hours`, the local `data`
variable holding the resolved service call data was rebound to the
coordinator data dict returned by `get_entry_and_data()`. As a result,
the subsequent calls to `validate_search_params(data)`,
`apply_must_finish_by(data, ...)` and `resolve_search_range(...)` read
search-range parameters from coordinator data instead of from the
service call, silently ignoring:

- must_finish_by
- search_scope
- search_start, search_end
- search_start_time, search_end_time
- search_start_day_offset, search_end_day_offset
- search_start_offset_minutes, search_end_offset_minutes
- include_current_interval

The functions fell back to the default range ("now → end of tomorrow")
for every call that depended on these parameters.

Rename the third return value of `get_entry_and_data()` to
`coordinator_data` so the service call `data` survives, restoring
deadline and search-scope semantics. `find_cheapest_schedule.py`
already uses `data_dict` for the same purpose and was not affected.

Verified locally against v0.31.0: a call with
`must_finish_by: 2026-06-01T20:00:00+02:00` now correctly produces
`search_end: 2026-06-01T20:00:00+02:00` (was end-of-tomorrow before).

* refactor(services): update data handling in find_cheapest_schedule service

Refactor the data retrieval process to use coordinator data instead of entry data for improved clarity and consistency.

Impact: Enhances maintainability of the service code without altering user-facing functionality.

---------

Co-authored-by: “WouterK” <kwaken.geringd0w@icloud.com”git config --global user.name “WouterK”git config --global user.email kwaken.geringd0w@icloud.com”>
Co-authored-by: Julian Pawlowski <jpawlowski@users.noreply.github.com>
2026-06-01 12:45:10 +02:00
13 changed files with 151 additions and 41 deletions

View file

@ -1460,7 +1460,7 @@ plan_charging:
number: number:
min: 0 min: 0
max: 100000 max: 100000
step: 0.0001 step: any
mode: box mode: box
reserve_for_discharge: reserve_for_discharge:
required: false required: false
@ -1474,7 +1474,7 @@ plan_charging:
number: number:
min: 0 min: 0
max: 100000 max: 100000
step: 0.0001 step: any
mode: box mode: box
output: output:
collapsed: true collapsed: true

View file

@ -267,8 +267,11 @@ async def _handle_find_block(
# Round up to nearest quarter-hour interval # Round up to nearest quarter-hour interval
duration_minutes = math.ceil(duration_minutes_requested / INTERVAL_MINUTES) * INTERVAL_MINUTES duration_minutes = math.ceil(duration_minutes_requested / INTERVAL_MINUTES) * INTERVAL_MINUTES
entry, coordinator, data = get_entry_and_data(hass, entry_id) # Note: rebind to coordinator_data — `data` (the resolved service call
rating_lookup = build_rating_lookup(data) # data) is still needed below for validate_search_params() and
# apply_must_finish_by(), which read search-range parameters from it.
entry, coordinator, coordinator_data = get_entry_and_data(hass, entry_id)
rating_lookup = build_rating_lookup(coordinator_data)
home_id = entry.data.get("home_id") home_id = entry.data.get("home_id")
if not home_id: if not home_id:

View file

@ -329,8 +329,11 @@ async def _handle_find_hours(
total_minutes = math.ceil(total_minutes_requested / INTERVAL_MINUTES) * INTERVAL_MINUTES total_minutes = math.ceil(total_minutes_requested / INTERVAL_MINUTES) * INTERVAL_MINUTES
min_segment_minutes = math.ceil(min_segment_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) # Note: rebind to coordinator_data — `data` (the resolved service call
rating_lookup = build_rating_lookup(data) # data) is still needed below for validate_search_params() and
# apply_must_finish_by(), which read search-range parameters from it.
entry, coordinator, coordinator_data = get_entry_and_data(hass, entry_id)
rating_lookup = build_rating_lookup(coordinator_data)
home_id = entry.data.get("home_id") home_id = entry.data.get("home_id")
if not home_id: if not home_id:

View file

@ -406,8 +406,8 @@ async def handle_find_cheapest_schedule(call: ServiceCall) -> ServiceResponse:
# 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
entry, coordinator, data = get_entry_and_data(hass, entry_id) entry, coordinator, coordinator_data = get_entry_and_data(hass, entry_id)
rating_lookup = build_rating_lookup(data) rating_lookup = build_rating_lookup(coordinator_data)
home_id = entry.data.get("home_id") home_id = entry.data.get("home_id")
if not home_id: if not home_id:

View file

@ -1271,7 +1271,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": "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": { "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." "message": "min_segment_duration ({min_segment_minutes} Min.) darf die Gesamtdauer ({duration_minutes} Min.) nicht überschreiten. Reduziere min_segment_duration oder erhöhe duration."
@ -1298,19 +1298,19 @@
"message": "must_finish_by kann nicht mit Endgrenz-Parametern ({params}) kombiniert werden. Verwende must_finish_by allein — es setzt das Suchende automatisch auf die Deadline." "message": "must_finish_by kann nicht mit Endgrenz-Parametern ({params}) kombiniert werden. Verwende must_finish_by allein — es setzt das Suchende automatisch auf die Deadline."
}, },
"invalid_entity_reference": { "invalid_entity_reference": {
"message": "'{reference}' ist keine gültige Entity-Referenz. Verwende das Format 'domain.entity_id' oder 'domain.entity_id@attribut'." "message": "{reference} ist keine gültige Entity-Referenz. Verwende das Format 'domain.entity_id' oder 'domain.entity_id@attribut'."
}, },
"entity_not_found": { "entity_not_found": {
"message": "Entity '{entity_id}' nicht gefunden. Überprüfe, ob die Entity existiert und verfügbar ist." "message": "Entity {entity_id} nicht gefunden. Überprüfe, ob die Entity existiert und verfügbar ist."
}, },
"entity_attribute_not_found": { "entity_attribute_not_found": {
"message": "Entity '{entity_id}' hat kein Attribut '{attribute}'." "message": "Entity {entity_id} hat kein Attribut {attribute}."
}, },
"entity_state_unavailable": { "entity_state_unavailable": {
"message": "Entity '{entity_id}' hat den Status '{state}'. Die Entity muss einen gültigen Statuswert haben." "message": "Entity {entity_id} hat den Status {state}. Die Entity muss einen gültigen Statuswert haben."
}, },
"entity_value_conversion_failed": { "entity_value_conversion_failed": {
"message": "Wert '{raw_value}' von '{entity_id}' ({attribute}) kann nicht in {expected_type} konvertiert werden. Überprüfe, ob die Entity einen kompatiblen Wert liefert." "message": "Wert {raw_value} von {entity_id} ({attribute}) kann nicht in {expected_type} konvertiert werden. Überprüfe, ob die Entity einen kompatiblen Wert liefert."
}, },
"capacity_required_for_percent": { "capacity_required_for_percent": {
"message": "battery_capacity_kwh ist erforderlich, wenn der aktuelle oder Ziel-SoC in Prozent angegeben wird." "message": "battery_capacity_kwh ist erforderlich, wenn der aktuelle oder Ziel-SoC in Prozent angegeben wird."

View file

@ -1271,7 +1271,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": "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": { "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." "message": "min_segment_duration ({min_segment_minutes} min) cannot exceed the total duration ({duration_minutes} min). Reduce min_segment_duration or increase duration."
@ -1298,19 +1298,19 @@
"message": "must_finish_by cannot be combined with end-boundary parameters ({params}). Use must_finish_by alone — it sets the search end to the deadline automatically." "message": "must_finish_by cannot be combined with end-boundary parameters ({params}). Use must_finish_by alone — it sets the search end to the deadline automatically."
}, },
"invalid_entity_reference": { "invalid_entity_reference": {
"message": "'{reference}' is not a valid entity reference. Use the format 'domain.entity_id' or 'domain.entity_id@attribute'." "message": "{reference} is not a valid entity reference. Use the format 'domain.entity_id' or 'domain.entity_id@attribute'."
}, },
"entity_not_found": { "entity_not_found": {
"message": "Entity '{entity_id}' not found. Verify the entity exists and is available." "message": "Entity {entity_id} not found. Verify the entity exists and is available."
}, },
"entity_attribute_not_found": { "entity_attribute_not_found": {
"message": "Entity '{entity_id}' does not have attribute '{attribute}'." "message": "Entity {entity_id} does not have attribute {attribute}."
}, },
"entity_state_unavailable": { "entity_state_unavailable": {
"message": "Entity '{entity_id}' state is '{state}'. The entity must have a valid state value." "message": "Entity {entity_id} state is {state}. The entity must have a valid state value."
}, },
"entity_value_conversion_failed": { "entity_value_conversion_failed": {
"message": "Cannot convert value '{raw_value}' from '{entity_id}' ({attribute}) to {expected_type}. Verify the entity provides a compatible value." "message": "Cannot convert value {raw_value} from {entity_id} ({attribute}) to {expected_type}. Verify the entity provides a compatible value."
}, },
"capacity_required_for_percent": { "capacity_required_for_percent": {
"message": "battery_capacity_kwh is required when current or target SoC is provided as a percentage." "message": "battery_capacity_kwh is required when current or target SoC is provided as a percentage."

View file

@ -1271,7 +1271,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": "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": { "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." "message": "min_segment_duration ({min_segment_minutes} min) kan ikke overstige total varighet ({duration_minutes} min). Reduser min_segment_duration eller øk duration."
@ -1298,19 +1298,19 @@
"message": "must_finish_by kan ikke kombineres med sluttgrenseparametere ({params}). Bruk must_finish_by alene — det setter søkeslutt til fristen automatisk." "message": "must_finish_by kan ikke kombineres med sluttgrenseparametere ({params}). Bruk must_finish_by alene — det setter søkeslutt til fristen automatisk."
}, },
"invalid_entity_reference": { "invalid_entity_reference": {
"message": "'{reference}' er ikke en gyldig entitetsreferanse. Bruk formatet 'domain.entity_id' eller 'domain.entity_id@attributt'." "message": "{reference} er ikke en gyldig entitetsreferanse. Bruk formatet 'domain.entity_id' eller 'domain.entity_id@attributt'."
}, },
"entity_not_found": { "entity_not_found": {
"message": "Entitet '{entity_id}' ble ikke funnet. Kontroller at entiteten finnes og er tilgjengelig." "message": "Entitet {entity_id} ble ikke funnet. Kontroller at entiteten finnes og er tilgjengelig."
}, },
"entity_attribute_not_found": { "entity_attribute_not_found": {
"message": "Entitet '{entity_id}' har ikke attributtet '{attribute}'." "message": "Entitet {entity_id} har ikke attributtet {attribute}."
}, },
"entity_state_unavailable": { "entity_state_unavailable": {
"message": "Entitet '{entity_id}' har tilstanden '{state}'. Entiteten må ha en gyldig tilstandsverdi." "message": "Entitet {entity_id} har tilstanden {state}. Entiteten må ha en gyldig tilstandsverdi."
}, },
"entity_value_conversion_failed": { "entity_value_conversion_failed": {
"message": "Kan ikke konvertere verdi '{raw_value}' fra '{entity_id}' ({attribute}) til {expected_type}. Kontroller at entiteten gir en kompatibel verdi." "message": "Kan ikke konvertere verdi {raw_value} fra {entity_id} ({attribute}) til {expected_type}. Kontroller at entiteten gir en kompatibel verdi."
}, },
"capacity_required_for_percent": { "capacity_required_for_percent": {
"message": "battery_capacity_kwh er påkrevd når nåværende eller mål-SoC oppgis i prosent." "message": "battery_capacity_kwh er påkrevd når nåværende eller mål-SoC oppgis i prosent."

View file

@ -1271,7 +1271,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": "De waarde {template} voor array_fields is ongeldig. Veldnamen moeten tussen accolades staan, bijv. {start_time}, {price_per_kwh}, {level}."
}, },
"min_segment_exceeds_duration": { "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." "message": "min_segment_duration ({min_segment_minutes} min) mag de totale duur ({duration_minutes} min) niet overschrijden. Verklein min_segment_duration of vergroot duration."
@ -1298,19 +1298,19 @@
"message": "must_finish_by kan niet worden gecombineerd met eindgrensparameters ({params}). Gebruik must_finish_by alleen — het stelt het zoekeinde automatisch in op de deadline." "message": "must_finish_by kan niet worden gecombineerd met eindgrensparameters ({params}). Gebruik must_finish_by alleen — het stelt het zoekeinde automatisch in op de deadline."
}, },
"invalid_entity_reference": { "invalid_entity_reference": {
"message": "'{reference}' is geen geldige entiteitsreferentie. Gebruik het formaat 'domain.entity_id' of 'domain.entity_id@attribuut'." "message": "{reference} is geen geldige entiteitsreferentie. Gebruik het formaat 'domain.entity_id' of 'domain.entity_id@attribuut'."
}, },
"entity_not_found": { "entity_not_found": {
"message": "Entiteit '{entity_id}' niet gevonden. Controleer of de entiteit bestaat en beschikbaar is." "message": "Entiteit {entity_id} niet gevonden. Controleer of de entiteit bestaat en beschikbaar is."
}, },
"entity_attribute_not_found": { "entity_attribute_not_found": {
"message": "Entiteit '{entity_id}' heeft geen attribuut '{attribute}'." "message": "Entiteit {entity_id} heeft geen attribuut {attribute}."
}, },
"entity_state_unavailable": { "entity_state_unavailable": {
"message": "Entiteit '{entity_id}' heeft status '{state}'. De entiteit moet een geldige statuswaarde hebben." "message": "Entiteit {entity_id} heeft status {state}. De entiteit moet een geldige statuswaarde hebben."
}, },
"entity_value_conversion_failed": { "entity_value_conversion_failed": {
"message": "Kan waarde '{raw_value}' van '{entity_id}' ({attribute}) niet converteren naar {expected_type}. Controleer of de entiteit een compatibele waarde biedt." "message": "Kan waarde {raw_value} van {entity_id} ({attribute}) niet converteren naar {expected_type}. Controleer of de entiteit een compatibele waarde biedt."
}, },
"capacity_required_for_percent": { "capacity_required_for_percent": {
"message": "battery_capacity_kwh is vereist wanneer huidige of doel-SoC in procenten wordt opgegeven." "message": "battery_capacity_kwh is vereist wanneer huidige of doel-SoC in procenten wordt opgegeven."

View file

@ -1271,7 +1271,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": "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": { "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." "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."
@ -1298,19 +1298,19 @@
"message": "must_finish_by kan inte kombineras med slutgränsparametrar ({params}). Använd must_finish_by ensamt — det sätter sökslutet till deadline automatiskt." "message": "must_finish_by kan inte kombineras med slutgränsparametrar ({params}). Använd must_finish_by ensamt — det sätter sökslutet till deadline automatiskt."
}, },
"invalid_entity_reference": { "invalid_entity_reference": {
"message": "'{reference}' är inte en giltig entitetsreferens. Använd formatet 'domain.entity_id' eller 'domain.entity_id@attribut'." "message": "{reference} är inte en giltig entitetsreferens. Använd formatet 'domain.entity_id' eller 'domain.entity_id@attribut'."
}, },
"entity_not_found": { "entity_not_found": {
"message": "Entitet '{entity_id}' hittades inte. Kontrollera att entiteten finns och är tillgänglig." "message": "Entitet {entity_id} hittades inte. Kontrollera att entiteten finns och är tillgänglig."
}, },
"entity_attribute_not_found": { "entity_attribute_not_found": {
"message": "Entitet '{entity_id}' har inte attributet '{attribute}'." "message": "Entitet {entity_id} har inte attributet {attribute}."
}, },
"entity_state_unavailable": { "entity_state_unavailable": {
"message": "Entitet '{entity_id}' har tillståndet '{state}'. Entiteten måste ha ett giltigt tillståndsvärde." "message": "Entitet {entity_id} har tillståndet {state}. Entiteten måste ha ett giltigt tillståndsvärde."
}, },
"entity_value_conversion_failed": { "entity_value_conversion_failed": {
"message": "Kan inte konvertera värdet '{raw_value}' från '{entity_id}' ({attribute}) till {expected_type}. Kontrollera att entiteten ger ett kompatibelt värde." "message": "Kan inte konvertera värdet {raw_value} från {entity_id} ({attribute}) till {expected_type}. Kontrollera att entiteten ger ett kompatibelt värde."
}, },
"capacity_required_for_percent": { "capacity_required_for_percent": {
"message": "battery_capacity_kwh krävs när nuvarande eller mål-SoC anges i procent." "message": "battery_capacity_kwh krävs när nuvarande eller mål-SoC anges i procent."

View file

@ -1,5 +1,5 @@
{ {
"name": "Tibber Price Information & Ratings", "name": "Tibber Price Information & Ratings",
"homeassistant": "2026.4.0", "homeassistant": "2026.4.4",
"hacs": "2.0.5" "hacs": "2.0.5"
} }

View file

@ -5,4 +5,4 @@ ruff>=0.15.15,<0.16.0
zlib_ng>=1.0.0,<1.1.0 zlib_ng>=1.0.0,<1.1.0
isal>=1.8.0,<1.9.0 isal>=1.8.0,<1.9.0
# Must match HA_VERSION in scripts/setup/bootstrap exactly (1:1 mapping) # Must match HA_VERSION in scripts/setup/bootstrap exactly (1:1 mapping)
pytest-homeassistant-custom-component==0.13.322 pytest-homeassistant-custom-component==0.13.325

View file

@ -60,7 +60,7 @@ uv pip install --requirement requirements.txt
# HA_VERSION can be overridden from the environment, e.g.: # HA_VERSION can be overridden from the environment, e.g.:
# HA_VERSION=2026.4.1 script/bootstrap # HA_VERSION=2026.4.1 script/bootstrap
HA_VERSION=${HA_VERSION:-"2026.4.1"} HA_VERSION=${HA_VERSION:-"2026.4.4"}
HA_CORE_BASE_URL="https://raw.githubusercontent.com/home-assistant/core/${HA_VERSION}" HA_CORE_BASE_URL="https://raw.githubusercontent.com/home-assistant/core/${HA_VERSION}"
HA_TMP_DIR="$HOME/.ha_requirements" HA_TMP_DIR="$HOME/.ha_requirements"

View file

@ -297,3 +297,107 @@ async def test_schedule_handler_adds_per_task_comparison_details(monkeypatch: py
assert "comparison_price_min" in comparison assert "comparison_price_min" in comparison
assert "comparison_price_max" in comparison assert "comparison_price_max" in comparison
assert "comparison_window_end" in comparison assert "comparison_window_end" in comparison
@pytest.mark.asyncio
async def test_block_handler_preserves_service_search_data(monkeypatch: pytest.MonkeyPatch) -> None:
"""Block handler must pass resolved call data (not coordinator data) into search helpers."""
intervals = _make_intervals([10.0, 11.0, 12.0, 13.0])
fake_tuple = _build_fake_entry_and_coordinator(intervals)
deadline = datetime(2026, 1, 1, 8, 0, tzinfo=UTC)
fixed_start = datetime(2026, 1, 1, 0, 0, tzinfo=UTC)
monkeypatch.setattr(block_module, "get_entry_and_data", lambda _hass, _entry_id: fake_tuple)
monkeypatch.setattr(block_module, "resolve_home_timezone", lambda _coord, _home_id: "UTC")
def _validate_search_params(call_data: dict[str, Any]) -> None:
assert call_data["must_finish_by"] == deadline
assert call_data["include_current_interval"] is False
def _apply_must_finish_by(call_data: dict[str, Any], _home_tz: Any) -> tuple[dict[str, Any], datetime]:
assert call_data["must_finish_by"] == deadline
modified = dict(call_data)
modified["search_end"] = deadline
modified.pop("must_finish_by", None)
return modified, deadline
def _resolve_search_range(call_data: dict[str, Any], _now: datetime, _home_tz: Any) -> tuple[datetime, datetime]:
assert call_data["include_current_interval"] is False
assert call_data["search_end"] == deadline
return fixed_start, deadline
async def _fetch_intervals(*_args: Any, **_kwargs: Any) -> tuple[list[dict[str, Any]], bool]:
return [], False
monkeypatch.setattr(block_module, "validate_search_params", _validate_search_params)
monkeypatch.setattr(block_module, "apply_must_finish_by", _apply_must_finish_by)
monkeypatch.setattr(block_module, "resolve_search_range", _resolve_search_range)
monkeypatch.setattr(block_module, "async_fetch_service_intervals", _fetch_intervals)
call = SimpleNamespace(
hass=object(),
data={
"duration": timedelta(hours=1),
"use_base_unit": True,
"must_finish_by": deadline,
"include_current_interval": False,
},
)
response = cast("dict[str, Any]", await handle_find_cheapest_block(cast("ServiceCall", call)))
assert response["success"] is False
assert response["search_start"] == fixed_start.isoformat()
assert response["search_end"] == deadline.isoformat()
assert response["must_finish_by"] == deadline.isoformat()
@pytest.mark.asyncio
async def test_hours_handler_preserves_service_search_data(monkeypatch: pytest.MonkeyPatch) -> None:
"""Hours handler must pass resolved call data (not coordinator data) into search helpers."""
intervals = _make_intervals([10.0, 11.0, 12.0, 13.0])
fake_tuple = _build_fake_entry_and_coordinator(intervals)
deadline = datetime(2026, 1, 1, 8, 0, tzinfo=UTC)
fixed_start = datetime(2026, 1, 1, 0, 0, tzinfo=UTC)
monkeypatch.setattr(hours_module, "get_entry_and_data", lambda _hass, _entry_id: fake_tuple)
monkeypatch.setattr(hours_module, "resolve_home_timezone", lambda _coord, _home_id: "UTC")
def _validate_search_params(call_data: dict[str, Any]) -> None:
assert call_data["must_finish_by"] == deadline
assert call_data["include_current_interval"] is False
def _apply_must_finish_by(call_data: dict[str, Any], _home_tz: Any) -> tuple[dict[str, Any], datetime]:
assert call_data["must_finish_by"] == deadline
modified = dict(call_data)
modified["search_end"] = deadline
modified.pop("must_finish_by", None)
return modified, deadline
def _resolve_search_range(call_data: dict[str, Any], _now: datetime, _home_tz: Any) -> tuple[datetime, datetime]:
assert call_data["include_current_interval"] is False
assert call_data["search_end"] == deadline
return fixed_start, deadline
async def _fetch_intervals(*_args: Any, **_kwargs: Any) -> tuple[list[dict[str, Any]], bool]:
return [], False
monkeypatch.setattr(hours_module, "validate_search_params", _validate_search_params)
monkeypatch.setattr(hours_module, "apply_must_finish_by", _apply_must_finish_by)
monkeypatch.setattr(hours_module, "resolve_search_range", _resolve_search_range)
monkeypatch.setattr(hours_module, "async_fetch_service_intervals", _fetch_intervals)
call = SimpleNamespace(
hass=object(),
data={
"duration": timedelta(hours=1),
"use_base_unit": True,
"must_finish_by": deadline,
"include_current_interval": False,
},
)
response = cast("dict[str, Any]", await handle_find_cheapest_hours(cast("ServiceCall", call)))
assert response["success"] is False
assert response["search_start"] == fixed_start.isoformat()
assert response["search_end"] == deadline.isoformat()
assert response["must_finish_by"] == deadline.isoformat()