Compare commits

..

No commits in common. "e39d0be162007dd8f445ff796c7368c6fe758e9d" and "6937068a7a93d7bbc3bd9403e9845f1bb78761be" have entirely different histories.

13 changed files with 41 additions and 151 deletions

View file

@ -1460,7 +1460,7 @@ plan_charging:
number: number:
min: 0 min: 0
max: 100000 max: 100000
step: any step: 0.0001
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: any step: 0.0001
mode: box mode: box
output: output:
collapsed: true collapsed: true

View file

@ -267,11 +267,8 @@ 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
# Note: rebind to coordinator_data — `data` (the resolved service call entry, coordinator, data = get_entry_and_data(hass, entry_id)
# data) is still needed below for validate_search_params() and rating_lookup = build_rating_lookup(data)
# 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,11 +329,8 @@ 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
# Note: rebind to coordinator_data — `data` (the resolved service call entry, coordinator, data = get_entry_and_data(hass, entry_id)
# data) is still needed below for validate_search_params() and rating_lookup = build_rating_lookup(data)
# 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, coordinator_data = get_entry_and_data(hass, entry_id) entry, coordinator, data = get_entry_and_data(hass, entry_id)
rating_lookup = build_rating_lookup(coordinator_data) rating_lookup = build_rating_lookup(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.4", "homeassistant": "2026.4.0",
"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.325 pytest-homeassistant-custom-component==0.13.322

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.4"} HA_VERSION=${HA_VERSION:-"2026.4.1"}
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,107 +297,3 @@ 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()