Compare commits

...

6 commits

Author SHA1 Message Date
Julian Pawlowski
093e904329 docs: add blueprint import badges and update automation examples
Some checks are pending
Deploy Docusaurus Documentation (Dual Sites) / Build and Deploy Documentation Sites (push) Waiting to run
Lint / Ruff (push) Waiting to run
Validate / HACS validation (push) Waiting to run
Validate / Hassfest validation (push) Waiting to run
automation-examples.md:
- Add info callout blocks with "Blueprint available" badges and
  one-click import links for each appliance section (dishwasher,
  washing machine, dryer, EV charging, pipeline)
- Update YAML examples to reflect current service API (remove
  relaxation_applied template, fix indentation)

sensor-reference.md:
- Remove stale data-refs attributes from current_price_trend,
  next_price_trend_change, and today_volatility anchors

examples/:
- Add examples/scripts/ directory with placeholder for
  tibber_notify_residents.yaml script example

User-Impact: none
2026-04-20 18:45:20 +00:00
Julian Pawlowski
e75e0ed1dc feat(blueprints): add appliance scheduling blueprints with auto-install
Bundle automation and script blueprints into the integration and
install them automatically at HA startup via _install_blueprints().
Remove them cleanly when the last config entry is removed.

Automation blueprints (standalone):
- dishwasher, washing_machine, dryer — smart plug and Home Connect
  variants (HC: door/remote-start sensors; HC Alt: program selector)

Automation blueprints (pipeline):
- laundry_day_pipeline — chains washer → dryer for multiple loads,
  HC and HC Alt variants

Other automation blueprints:
- ev_charging, heat_pump_price_level, heat_pump_smart_boost,
  home_battery, water_heater, laundry_day_pipeline (smart plug)

Script blueprint:
- notify_residents — presence-aware dispatcher for up to 10 residents
  with auto-discovered mobile_app notify services, iOS/Android push
  settings, and per-resident notify overrides

Notification UX across all blueprints:
- Apple Watch-optimised titles (~25 chars) and messages (most
  important info first, middle-dot separators, emoji anchors)
- Customisable notification titles via blueprint inputs (standalone)
- Comma-separated notify services for simple multi-target delivery
- Advanced script path for presence filtering and platform push data

Impact: Users get ready-to-use blueprints installed automatically
with the integration for scheduling appliances during cheap Tibber
price windows. No manual import required.
2026-04-20 18:45:05 +00:00
Julian Pawlowski
2d2873f75f feat(entity): expose integration version as sw_version in device info
Read the version field from manifest.json at import time via a new
INTEGRATION_VERSION constant in const.py. Pass it as sw_version to
DeviceInfo in TibberPricesEntity so the current integration version
is visible in the HA device registry.

Impact: Device page in HA now shows the installed integration version
under firmware/software version.
2026-04-20 18:44:34 +00:00
Julian Pawlowski
e01cc5d447 feat(services): allow entity IDs as service parameter values
Add entity_resolver module that lets all service parameters accept
HA entity references in place of literal values. The entity's current
state (or a specific attribute via the @attr syntax) is resolved at
call time and coerced to the expected Python type.

Syntax:
  "sensor.washing_duration"           → uses entity state
  "sensor.washing_duration@run_minutes" → uses entity attribute

Apply or_entity_ref() and resolve_entity_references() to all five
service handlers (get_price, find_cheapest_block, find_cheapest_hours,
find_cheapest_schedule, get_chartdata) for every parameter where a
dynamic value from another entity is useful (duration, start/end times,
offsets, etc.).

Add five new translation keys for entity-resolution error messages
(invalid_entity_reference, entity_not_found, entity_attribute_not_found,
entity_state_unavailable, entity_value_conversion_failed) across all
five language files.

Fix pytest warning filter to suppress AsyncMock cleanup noise, and
update test_resource_cleanup to mock hass.config_entries.async_entries
so the blueprint-removal path in async_remove_entry does not raise.

Impact: Automations and scripts can pass sensor entity IDs as service
parameters (e.g. duration from a sensor) instead of having to use
template-based workarounds.
2026-04-20 18:44:24 +00:00
Julian Pawlowski
a8d1519a26 docs(scheduling): update docs for sequential scheduling parameter
Replace workaround recommendations (2× find_cheapest_block) with the
new sequential: true parameter. Rewrite washer→dryer example as a single
find_cheapest_schedule call. Update quick reference tables.

Release-Notes: skip
2026-04-19 14:17:41 +00:00
Julian Pawlowski
31fca73ccd feat(services): add sequential parameter to find_cheapest_schedule
When sequential: true, tasks are placed in declaration order instead of
being sorted by duration. Each task's search window starts after the
previous task ends (plus gap_minutes). If a task cannot be placed, all
subsequent tasks in the chain are also marked unscheduled.

Adds 12 tests covering ordering, chaining, gap enforcement, and
chain-breaking behavior.

Impact: Users can now schedule dependent appliances (e.g., washing
machine → dryer) in a single find_cheapest_schedule call with guaranteed
order, instead of chaining two find_cheapest_block calls.
2026-04-19 14:17:32 +00:00
40 changed files with 9900 additions and 237 deletions

View file

@ -7,6 +7,8 @@ https://github.com/jpawlowski/hass.tibber_prices
from __future__ import annotations
from pathlib import Path
import shutil
from typing import TYPE_CHECKING, Any
import voluptuous as vol
@ -93,6 +95,53 @@ CONFIG_SCHEMA = vol.Schema(
)
def _install_blueprints(config_dir: str) -> None:
"""Copy bundled blueprints to the HA config blueprints directory.
Always overwrites existing files so blueprints stay in sync with the
integration version. Removes orphan files that are no longer shipped.
Handles both automation and script blueprint domains.
"""
for bp_domain in ("automation", "script"):
src = Path(__file__).parent / "blueprints" / bp_domain
dst = Path(config_dir) / "blueprints" / bp_domain / DOMAIN
if not src.is_dir():
LOGGER.debug("No bundled %s blueprints directory found, skipping", bp_domain)
continue
dst.mkdir(parents=True, exist_ok=True)
shipped: set[str] = set()
for src_file in src.rglob("*.yaml"):
rel = src_file.relative_to(src)
# Only copy files from the tibber_prices sub-folder
if rel.parts[0] != DOMAIN:
continue
dest_file = Path(config_dir) / "blueprints" / bp_domain / rel
dest_file.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src_file, dest_file)
shipped.add(rel.parts[-1])
# Remove orphan blueprints no longer shipped with the integration
if dst.is_dir():
for existing in dst.glob("*.yaml"):
if existing.name not in shipped:
existing.unlink()
LOGGER.info("Removed orphan %s blueprint %s", bp_domain, existing.name)
LOGGER.debug("Installed %d bundled %s blueprints to %s", len(shipped), bp_domain, dst)
def _remove_blueprints(config_dir: str) -> None:
"""Remove all integration-managed blueprints from the config directory."""
for bp_domain in ("automation", "script"):
bp_dir = Path(config_dir) / "blueprints" / bp_domain / DOMAIN
if bp_dir.is_dir():
shutil.rmtree(bp_dir)
LOGGER.info("Removed bundled %s blueprints directory %s", bp_domain, bp_dir)
async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool:
"""Set up the Tibber Prices component from configuration.yaml."""
# Store chart export configuration in hass.data for sensor access
@ -120,6 +169,9 @@ async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool:
LOGGER.debug("No chart_metadata configuration found in configuration.yaml")
hass.data[DOMAIN][DATA_CHART_METADATA_CONFIG] = {}
# Install/update bundled blueprints
await hass.async_add_executor_job(_install_blueprints, hass.config.config_dir)
return True
@ -366,6 +418,11 @@ async def async_remove_entry(
await async_remove_pool_storage(hass, entry.entry_id)
LOGGER.debug(f"[tibber_prices] async_remove_entry removed interval pool storage for entry_id={entry.entry_id}")
# Remove bundled blueprints if this was the last config entry
remaining = [e for e in hass.config_entries.async_entries(DOMAIN) if e.entry_id != entry.entry_id]
if not remaining:
await hass.async_add_executor_job(_remove_blueprints, hass.config.config_dir)
async def async_reload_entry(
hass: HomeAssistant,

View file

@ -0,0 +1,295 @@
blueprint:
name: "Tibber Prices: Dishwasher (Smart Plug)"
description: >
**Companion blueprint for
[Tibber Prices](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration)
(HACS integration)** · Blueprint v1.0.0
Automatically run your dishwasher at the cheapest electricity price
overnight using a smart plug.
Open your
[Tibber Prices configuration](https://my.home-assistant.io/redirect/integration/?domain=tibber_prices)
to verify the integration is installed and set up.
**What it does:**
- Plans the cheapest 2-hour window overnight (every evening)
- Starts the dishwasher automatically at the cheapest time
- Sends a notification with the planned time and price
- Survives Home Assistant restarts (uses `input_datetime` helper)
**Prerequisites:**
- [Tibber Prices integration](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration) installed via HACS
- One helper (created in Settings → Helpers):
- Date & Time (`input_datetime`) — stores the planned start time
- Smart plug switch for the dishwasher
**How it works:**
1. Every evening at the configured time, the blueprint finds the
cheapest window overnight
2. The planned start time is saved to the helper (survives restarts)
3. At the planned time, the smart plug turns on
4. A notification confirms the plan and the start
**Other variants:**
[Home Connect](https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/dishwasher_home_connect.yaml)
·
[Home Connect Alt](https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/dishwasher_home_connect_alt.yaml)
domain: automation
author: jpawlowski
homeassistant:
min_version: "2024.6.0"
source_url: https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/dishwasher.yaml
input:
appliance:
name: Appliance
icon: mdi:dishwasher
description: Select the smart plug that controls your dishwasher.
input:
appliance_switch:
name: Dishwasher Smart Plug
description: The switch entity controlling the dishwasher.
selector:
entity:
filter:
domain: switch
schedule:
name: Schedule
icon: mdi:calendar-clock
description: Configure when to plan and the search window.
input:
plan_time:
name: Planning Time
description: >
When to search for the cheapest window each day.
Typically in the evening after loading the dishwasher.
default: "20:00:00"
selector:
time:
start_helper:
name: Start Time Helper
description: >
An `input_datetime` helper (type: Date and Time) that stores
the planned start time. Create in Settings → Helpers.
selector:
entity:
filter:
domain: input_datetime
duration:
name: Program Duration
description: >
Typical dishwasher program duration in minutes.
ECO 50°C ≈ 120 min, Auto ≈ 90 min, Intensive ≈ 150 min.
default: 120
selector:
number:
min: 30
max: 240
step: 5
unit_of_measurement: min
mode: slider
search_start:
name: Search Window Start
description: >
Earliest time the dishwasher may start.
Typically late evening after loading.
default: "22:00:00"
selector:
time:
search_end:
name: Search Window End
description: >
Latest time the dishwasher must finish by.
The program must complete before this time.
default: "06:00:00"
selector:
time:
runtime_overrides:
name: Runtime Overrides
icon: mdi:tune-vertical
collapsed: true
description: >
Optionally connect helpers to override settings from your
dashboard at runtime. When a helper is connected and has
a valid value, it takes priority over the fixed default.
Leave empty to always use the fixed defaults.
input:
duration_override:
name: "Override: Program Duration"
description: >
`input_number` helper to change the duration from your
dashboard without reconfiguring the blueprint.
**Create in Settings → Helpers → Number** with the same
min/max as the Duration slider above.
default: ""
selector:
entity:
filter:
domain: input_number
notifications:
name: Notifications
icon: mdi:bell-outline
collapsed: true
description: Optional mobile notifications for planning and start.
input:
notify_service:
name: Notification Service
description: >
One or more notify services, comma-separated
(e.g., `notify.mobile_app_yourphone` or
`notify.mobile_app_phone, notify.mobile_app_tablet`).
Leave empty to disable all notifications.
default: ""
selector:
text:
mode: single
max_exceeded: silent
triggers:
- trigger: time
at: !input plan_time
id: plan
- trigger: time
at: !input start_helper
id: execute
variables:
_blueprint_variant: "smart_plug"
appliance_switch: !input appliance_switch
start_helper: !input start_helper
_duration_default: !input duration
_duration_override: !input duration_override
duration: >
{% set o = _duration_override %}
{% if o and states(o) not in ['unknown', 'unavailable'] %}
{{ states(o) | int(_duration_default) }}
{% else %}
{{ _duration_default }}
{% endif %}
search_start: !input search_start
search_end: !input search_end
notify_service: !input notify_service
actions:
# Check: Tibber Prices integration installed?
- variables:
_tp_entities: "{{ integration_entities('tibber_prices') | list }}"
- if:
- condition: template
value_template: "{{ _tp_entities | length == 0 }}"
then:
- if:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
then:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "🍽️ Dishwasher — Setup Required"
message: >
The Tibber Prices integration is not installed or not
configured. Install it via HACS and set up your Tibber
account before using this blueprint.
- stop: "Tibber Prices integration not found"
# ════════════════════════════════════════════════════════
# PLAN: Find cheapest window
# ════════════════════════════════════════════════════════
- choose:
- conditions:
- condition: trigger
id: plan
sequence:
- action: tibber_prices.find_cheapest_block
data:
duration: >
{{ '%02d:%02d:00' | format(
(duration | int) // 60,
(duration | int) % 60) }}
search_start_time: "{{ search_start }}"
search_end_time: "{{ search_end }}"
search_end_day_offset: 1
response_variable: result
- if:
- condition: template
value_template: "{{ result.window_found }}"
then:
- action: input_datetime.set_datetime
target:
entity_id: "{{ start_helper }}"
data:
datetime: "{{ result.window.start }}"
- if:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
then:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "🍽️ Dishwasher Planned"
message: >
Start at {{ result.window.start | as_datetime
| as_local | as_timestamp
| timestamp_custom('%H:%M') }}.
Avg price: {{ result.window.price_mean | round(1) }}
{{ result.price_unit }}/kWh.
{% if result.relaxation_applied | default(false) %}
(Filters relaxed to find window.)
{% endif %}
else:
- if:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
then:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "🍽️ Dishwasher"
message: >
No cheap window found overnight. Consider running
manually or adjusting the search window.
# ════════════════════════════════════════════════════
# EXECUTE: Start dishwasher
# ════════════════════════════════════════════════════
- conditions:
- condition: trigger
id: execute
sequence:
- action: switch.turn_on
target:
entity_id: "{{ appliance_switch }}"
- if:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
then:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "🍽️ Dishwasher Started"
message: >
Smart plug turned on. Program should finish in
~{{ duration }} minutes.

View file

@ -0,0 +1,445 @@
blueprint:
name: "Tibber Prices: Dishwasher (Home Connect)"
description: >
**Companion blueprint for
[Tibber Prices](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration)
(HACS integration)** · Blueprint v2.0.0
**Device-driven** dishwasher automation with electricity price
optimization using the **Home Connect** integration (HA Core).
**How it works:**
1. Select your program on the dishwasher
2. Close the door and enable Remote Start
3. The blueprint reads the estimated duration from the device
4. Finds the cheapest electricity window before your deadline
5. Tells the dishwasher when to start via `StartInRelative`
6. The dishwasher manages the countdown internally — no HA timers
**No scheduling needed** — the dishwasher handles the delayed start
itself. No `input_datetime` helpers required. Survives HA restarts
because the countdown runs on the appliance.
**Prerequisites:**
- [Tibber Prices integration](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration) installed via HACS
- [Home Connect](https://www.home-assistant.io/integrations/home_connect/) integration configured
- **Remote Start** enabled on the dishwasher
**Other variants:**
[Smart Plug](https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/dishwasher.yaml)
·
[Home Connect Alt](https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/dishwasher_home_connect_alt.yaml)
domain: automation
author: jpawlowski
homeassistant:
min_version: "2024.11.0"
source_url: https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/dishwasher_home_connect.yaml
input:
appliance:
name: Appliance
icon: mdi:dishwasher
description: >
Select your Home Connect dishwasher device and entities.
input:
appliance_device:
name: Dishwasher Device
description: >
Your dishwasher from the Home Connect integration.
Used to target the start command.
selector:
device:
filter:
integration: home_connect
door_sensor:
name: Door Sensor
description: >
The door sensor of your dishwasher
(e.g., `binary_sensor.dishwasher_door`).
selector:
entity:
filter:
integration: home_connect
domain: binary_sensor
device_class: door
remote_start_sensor:
name: Remote Control Sensor
description: >
The "Remote Control Active" binary sensor
(e.g., `binary_sensor.dishwasher_remote_control`).
Must be **on** for the automation to proceed.
selector:
entity:
filter:
integration: home_connect
domain: binary_sensor
estimated_duration_entity:
name: Estimated Program Duration
description: >
The "Estimated Total Program Time" sensor.
If unavailable, the fallback duration is used instead.
selector:
entity:
filter:
integration: home_connect
domain: sensor
operation_state_entity:
name: Operation State
description: >
The "Operation State" sensor.
Used to verify the machine is ready before planning.
selector:
entity:
filter:
integration: home_connect
domain: sensor
schedule:
name: Schedule
icon: mdi:calendar-clock
description: >
Configure the deadline and fallback duration.
input:
must_finish_by:
name: Must Finish By
description: >
The program must be finished by this time.
If this time has already passed today, the deadline
automatically moves to tomorrow (overnight mode).
default: "06:00:00"
selector:
time:
duration_fallback:
name: Fallback Duration (minutes)
description: >
Used **only** if the device doesn't report the estimated
duration. Normally the duration is read automatically.
ECO 50°C ≈ 120 min, Auto ≈ 90 min, Intensive ≈ 150 min.
default: 120
selector:
number:
min: 30
max: 240
step: 5
unit_of_measurement: min
mode: slider
notifications:
name: Notifications
icon: mdi:bell-outline
collapsed: true
description: >
Optional notifications. Use **simple mode** (just a service)
or point to an **advanced script** for multi-target,
presence-aware, and platform-specific notifications.
input:
notify_service:
name: Quick Notification (Simple)
description: >
One or more notify services, comma-separated
(e.g., `notify.mobile_app_yourphone` or
`notify.mobile_app_phone, notify.mobile_app_tablet`).
Ignored when the advanced script is set.
default: ""
selector:
text:
notification_script:
name: Notification Script (Advanced)
description: >
A `script.*` entity for advanced notifications
(multiple recipients, presence filtering, iOS/Android).
When set, replaces the simple notification.
Receives structured variables (event_type, appliance,
title, message, and context data).
default: ""
selector:
entity:
filter:
domain: script
title_setup_required:
name: "Title: Setup Required"
default: "🍽️ Dishwasher — Setup Required"
selector:
text:
title_not_ready:
name: "Title: Not Ready"
default: "🍽️ Dishwasher — Not Ready"
selector:
text:
title_no_cheap_slot:
name: "Title: No Cheap Slot"
default: "🍽️ Dishwasher — No Cheap Slot"
selector:
text:
title_planned:
name: "Title: Planned"
default: "🍽️ Dishwasher — Planned!"
selector:
text:
mode: single
max_exceeded: silent
triggers:
- trigger: state
entity_id: !input door_sensor
to: "off"
- trigger: state
entity_id: !input remote_start_sensor
to: "on"
conditions:
- condition: state
entity_id: !input door_sensor
state: "off"
- condition: state
entity_id: !input remote_start_sensor
state: "on"
variables:
_blueprint_variant: "home_connect"
appliance_device: !input appliance_device
door_sensor: !input door_sensor
remote_start_sensor: !input remote_start_sensor
estimated_duration_entity: !input estimated_duration_entity
operation_state_entity: !input operation_state_entity
must_finish_by_time: !input must_finish_by
duration_fallback: !input duration_fallback
notify_service: !input notify_service
notification_script: !input notification_script
title_setup_required: !input title_setup_required
title_not_ready: !input title_not_ready
title_no_cheap_slot: !input title_no_cheap_slot
title_planned: !input title_planned
actions:
# ════════════════════════════════════════════════════════
# PREFLIGHT CHECKS
# ════════════════════════════════════════════════════════
- variables:
_tp_entities: "{{ integration_entities('tibber_prices') | list }}"
- if:
- condition: template
value_template: "{{ _tp_entities | length == 0 }}"
then:
- variables:
_n_title: "{{ title_setup_required }}"
_n_message: >
Install the Tibber Prices integration via HACS and
configure your Tibber account.
- choose:
- conditions:
- condition: template
value_template: "{{ notification_script | length > 0 }}"
sequence:
- action: script.turn_on
target:
entity_id: "{{ notification_script }}"
data:
variables:
event_type: setup_required
appliance: dishwasher
title: "{{ _n_title }}"
message: "{{ _n_message }}"
- conditions:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
sequence:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "{{ _n_title }}"
message: "{{ _n_message }}"
- stop: "Tibber Prices integration not found"
- if:
- condition: template
value_template: >
{% set op = states(operation_state_entity) %}
{{ op not in ['unknown', 'unavailable']
and 'Ready' not in op
and 'Inactive' not in op }}
then:
- variables:
_n_title: "{{ title_not_ready }}"
_n_message: >
State: {{ states(operation_state_entity) }}.
Ensure it's idle with Remote Start enabled.
- choose:
- conditions:
- condition: template
value_template: "{{ notification_script | length > 0 }}"
sequence:
- action: script.turn_on
target:
entity_id: "{{ notification_script }}"
data:
variables:
event_type: not_ready
appliance: dishwasher
title: "{{ _n_title }}"
message: "{{ _n_message }}"
- conditions:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
sequence:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "{{ _n_title }}"
message: "{{ _n_message }}"
- stop: "Machine not ready"
# ════════════════════════════════════════════════════════
# READ DEVICE DATA
# ════════════════════════════════════════════════════════
- variables:
_raw_duration: "{{ states(estimated_duration_entity) }}"
duration: >
{% set raw = states(estimated_duration_entity) %}
{% if raw not in ['unknown', 'unavailable', 'None', '']
and ':' in raw %}
{% set parts = raw.split(':') %}
{{ (parts[0] | int(0)) * 60 + (parts[1] | int(0)) }}
{% elif raw not in ['unknown', 'unavailable', 'None', '']
and raw | int(0) > 0 %}
{{ raw | int }}
{% else %}
{{ duration_fallback }}
{% endif %}
deadline: >
{% set dl = today_at(must_finish_by_time) %}
{% if dl <= now() %}
{{ (dl + timedelta(days=1)).isoformat() }}
{% else %}
{{ dl.isoformat() }}
{% endif %}
# ════════════════════════════════════════════════════════
# FIND CHEAPEST WINDOW
# ════════════════════════════════════════════════════════
- action: tibber_prices.find_cheapest_block
data:
duration: >
{{ '%02d:%02d:00' | format(
(duration | int) // 60,
(duration | int) % 60) }}
must_finish_by: "{{ deadline }}"
response_variable: result
- if:
- condition: template
value_template: "{{ not result.window_found }}"
then:
- variables:
_n_title: "{{ title_no_cheap_slot }}"
_n_message: >
No cheap slot before
{{ deadline | as_datetime | as_local
| as_timestamp | timestamp_custom('%H:%M') }}
for {{ duration }} min.
Run manually or extend the deadline.
- choose:
- conditions:
- condition: template
value_template: "{{ notification_script | length > 0 }}"
sequence:
- action: script.turn_on
target:
entity_id: "{{ notification_script }}"
data:
variables:
event_type: no_window
appliance: dishwasher
title: "{{ _n_title }}"
message: "{{ _n_message }}"
deadline: "{{ deadline }}"
duration_minutes: "{{ duration | int }}"
- conditions:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
sequence:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "{{ _n_title }}"
message: "{{ _n_message }}"
- stop: "No cheap window found"
# ════════════════════════════════════════════════════════
# START WITH DELAY (device manages countdown)
# ════════════════════════════════════════════════════════
- variables:
_window_start: "{{ result.window.start | as_datetime }}"
start_in_relative: >
{{ [0, ((_window_start - now()).total_seconds()) | int] | max }}
# Dishwashers use StartInRelative = seconds until start
- action: home_connect.start_selected_program
target:
device_id: "{{ appliance_device }}"
data:
b_s_h_common_option_start_in_relative: "{{ start_in_relative }}"
- variables:
_n_title: "{{ title_planned }}"
_n_message: >
{% if start_in_relative | int > 0 %}
⏰ {{ _window_start | as_local
| as_timestamp | timestamp_custom('%H:%M') }}
(in {{ (start_in_relative | int / 3600) | round(1) }} h)
· ~{{ duration }} min
· {{ result.window.price_mean | round(1) }} {{ result.price_unit }}/kWh
{% else %}
▶️ Starting now!
· ~{{ duration }} min
· {{ result.window.price_mean | round(1) }} {{ result.price_unit }}/kWh
{% endif %}
{% if _raw_duration in ['unknown', 'unavailable', 'None', ''] %}
· ⚠️ Duration estimated
{% endif %}
- choose:
- conditions:
- condition: template
value_template: "{{ notification_script | length > 0 }}"
sequence:
- action: script.turn_on
target:
entity_id: "{{ notification_script }}"
data:
variables:
event_type: planned
appliance: dishwasher
title: "{{ _n_title }}"
message: "{{ _n_message }}"
start_time: "{{ _window_start | as_local | as_timestamp | timestamp_custom('%Y-%m-%dT%H:%M:%S') }}"
duration_minutes: "{{ duration | int }}"
price_mean: "{{ result.window.price_mean | round(1) }}"
price_unit: "{{ result.price_unit }}"
using_fallback_duration: "{{ _raw_duration in ['unknown', 'unavailable', 'None', ''] }}"
- conditions:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
sequence:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "{{ _n_title }}"
message: "{{ _n_message }}"

View file

@ -0,0 +1,507 @@
blueprint:
name: "Tibber Prices: Dishwasher (Home Connect Alt)"
description: >
**Companion blueprint for
[Tibber Prices](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration)
(HACS integration)** · Blueprint v2.0.0
**Device-driven** dishwasher automation with electricity price
optimization using **Home Connect Alt**
([HACS integration by ekutner](https://my.home-assistant.io/redirect/hacs_repository/?owner=ekutner&repository=home-connect-hass&category=integration)).
**How it works:**
1. Select your program on the dishwasher
2. Close the door and enable Remote Start
3. The blueprint reads the program and estimated duration from the
device automatically
4. Finds the cheapest electricity window before your deadline
5. Tells the dishwasher when to start via `StartInRelative`
6. The dishwasher manages the countdown internally — no HA timers
**No scheduling needed** — the dishwasher handles the delayed start
itself. No `input_datetime` helpers required. Survives HA restarts
because the countdown runs on the appliance.
**Prerequisites:**
- [Tibber Prices integration](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration) installed via HACS
- [Home Connect Alt](https://my.home-assistant.io/redirect/hacs_repository/?owner=ekutner&repository=home-connect-hass&category=integration) integration configured
- **Remote Start** enabled on the dishwasher
**Other variants:**
[Smart Plug](https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/dishwasher.yaml)
·
[Home Connect](https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/dishwasher_home_connect.yaml)
domain: automation
author: jpawlowski
homeassistant:
min_version: "2024.11.0"
source_url: https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/dishwasher_home_connect_alt.yaml
input:
appliance:
name: Appliance Entities
icon: mdi:dishwasher
description: >
Select your Home Connect Alt dishwasher entities.
All entities belong to the same appliance device.
input:
program_entity:
name: Program Select Entity
description: >
The **Programs** select entity of your dishwasher
(e.g., `select.dishwasher_programs`).
Used to read the selected program and as target for starting.
selector:
entity:
filter:
integration: home_connect_alt
domain: select
door_sensor:
name: Door Sensor
description: >
The door sensor of your dishwasher
(e.g., `binary_sensor.dishwasher_door`).
selector:
entity:
filter:
integration: home_connect_alt
domain: binary_sensor
device_class: door
remote_start_sensor:
name: Remote Control Sensor
description: >
The "Remote Control Active" binary sensor
(e.g., `binary_sensor.dishwasher_remote_control_active`).
Must be **on** for the automation to proceed.
selector:
entity:
filter:
integration: home_connect_alt
domain: binary_sensor
estimated_duration_entity:
name: Estimated Program Duration
description: >
The "Estimated Total Program Time" sensor
(e.g., `sensor.dishwasher_estimated_total_program_time`).
Shows the expected duration in `H:MM` format.
If unavailable, the fallback duration is used instead.
selector:
entity:
filter:
integration: home_connect_alt
domain: sensor
operation_state_entity:
name: Operation State
description: >
The "Operation State" sensor
(e.g., `sensor.dishwasher_operation_state`).
Used to verify the machine is ready before planning.
selector:
entity:
filter:
integration: home_connect_alt
domain: sensor
schedule:
name: Schedule
icon: mdi:calendar-clock
description: >
Configure the deadline and fallback duration.
input:
must_finish_by:
name: Must Finish By
description: >
The program must be finished by this time.
If this time has already passed today, the deadline
automatically moves to tomorrow (overnight mode).
default: "06:00:00"
selector:
time:
duration_fallback:
name: Fallback Duration (minutes)
description: >
Used **only** if the device doesn't report the estimated
duration (e.g., program not yet fully selected on the
appliance). Normally the duration is read automatically.
ECO 50°C ≈ 120 min, Auto ≈ 90 min, Intensive ≈ 150 min.
default: 120
selector:
number:
min: 30
max: 240
step: 5
unit_of_measurement: min
mode: slider
notifications:
name: Notifications
icon: mdi:bell-outline
collapsed: true
description: >
Optional notifications. Use **simple mode** (just a service)
or point to an **advanced script** for multi-target,
presence-aware, and platform-specific notifications.
input:
notify_service:
name: Quick Notification (Simple)
description: >
One or more notify services, comma-separated
(e.g., `notify.mobile_app_yourphone` or
`notify.mobile_app_phone, notify.mobile_app_tablet`).
Ignored when the advanced script is set.
default: ""
selector:
text:
notification_script:
name: Notification Script (Advanced)
description: >
A `script.*` entity for advanced notifications
(multiple recipients, presence filtering, iOS/Android).
When set, replaces the simple notification.
Receives structured variables (event_type, appliance,
title, message, and context data).
default: ""
selector:
entity:
filter:
domain: script
title_setup_required:
name: "Title: Setup Required"
default: "🍽️ Dishwasher — Setup Required"
selector:
text:
title_not_ready:
name: "Title: Not Ready"
default: "🍽️ Dishwasher — Not Ready"
selector:
text:
title_no_program:
name: "Title: No Program"
default: "🍽️ Dishwasher — No Program"
selector:
text:
title_no_cheap_slot:
name: "Title: No Cheap Slot"
default: "🍽️ Dishwasher — No Cheap Slot"
selector:
text:
title_planned:
name: "Title: Planned"
default: "🍽️ Dishwasher — Planned!"
selector:
text:
mode: single
max_exceeded: silent
triggers:
# Fire when door closes OR remote start becomes active
- trigger: state
entity_id: !input door_sensor
to: "off"
- trigger: state
entity_id: !input remote_start_sensor
to: "on"
conditions:
# Both conditions must be true regardless of which trigger fired
- condition: state
entity_id: !input door_sensor
state: "off"
- condition: state
entity_id: !input remote_start_sensor
state: "on"
variables:
_blueprint_variant: "home_connect_alt"
program_entity: !input program_entity
door_sensor: !input door_sensor
remote_start_sensor: !input remote_start_sensor
estimated_duration_entity: !input estimated_duration_entity
operation_state_entity: !input operation_state_entity
must_finish_by_time: !input must_finish_by
duration_fallback: !input duration_fallback
notify_service: !input notify_service
notification_script: !input notification_script
title_setup_required: !input title_setup_required
title_not_ready: !input title_not_ready
title_no_program: !input title_no_program
title_no_cheap_slot: !input title_no_cheap_slot
title_planned: !input title_planned
actions:
# ════════════════════════════════════════════════════════
# PREFLIGHT CHECKS
# ════════════════════════════════════════════════════════
# Check: Tibber Prices integration installed?
- variables:
_tp_entities: "{{ integration_entities('tibber_prices') | list }}"
- if:
- condition: template
value_template: "{{ _tp_entities | length == 0 }}"
then:
- variables:
_n_title: "{{ title_setup_required }}"
_n_message: >
Install the Tibber Prices integration via HACS and
configure your Tibber account.
- choose:
- conditions:
- condition: template
value_template: "{{ notification_script | length > 0 }}"
sequence:
- action: script.turn_on
target:
entity_id: "{{ notification_script }}"
data:
variables:
event_type: setup_required
appliance: dishwasher
title: "{{ _n_title }}"
message: "{{ _n_message }}"
- conditions:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
sequence:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "{{ _n_title }}"
message: "{{ _n_message }}"
- stop: "Tibber Prices integration not found"
# Check: Machine is ready (not already running)?
- if:
- condition: template
value_template: >
{% set op = states(operation_state_entity) %}
{{ op not in ['unknown', 'unavailable']
and 'Ready' not in op
and 'Inactive' not in op }}
then:
- variables:
_n_title: "{{ title_not_ready }}"
_n_message: >
State: {{ states(operation_state_entity) }}.
Ensure it's idle with Remote Start enabled.
- choose:
- conditions:
- condition: template
value_template: "{{ notification_script | length > 0 }}"
sequence:
- action: script.turn_on
target:
entity_id: "{{ notification_script }}"
data:
variables:
event_type: not_ready
appliance: dishwasher
title: "{{ _n_title }}"
message: "{{ _n_message }}"
- conditions:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
sequence:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "{{ _n_title }}"
message: "{{ _n_message }}"
- stop: "Machine not ready"
# ════════════════════════════════════════════════════════
# READ DEVICE DATA
# ════════════════════════════════════════════════════════
- variables:
# Read selected program from device
selected_program: "{{ states(program_entity) }}"
# Read estimated duration from device (H:MM format → minutes)
_raw_duration: "{{ states(estimated_duration_entity) }}"
duration: >
{% set raw = states(estimated_duration_entity) %}
{% if raw not in ['unknown', 'unavailable', 'None', '']
and ':' in raw %}
{% set parts = raw.split(':') %}
{{ (parts[0] | int(0)) * 60 + (parts[1] | int(0)) }}
{% else %}
{{ duration_fallback }}
{% endif %}
# Compute deadline (auto-detect overnight)
deadline: >
{% set dl = today_at(must_finish_by_time) %}
{% if dl <= now() %}
{{ (dl + timedelta(days=1)).isoformat() }}
{% else %}
{{ dl.isoformat() }}
{% endif %}
# Validate program is selected
- if:
- condition: template
value_template: >
{{ selected_program in ['unknown', 'unavailable', 'None', ''] }}
then:
- variables:
_n_title: "{{ title_no_program }}"
_n_message: >
Select a program, close the door, and enable
Remote Start.
- choose:
- conditions:
- condition: template
value_template: "{{ notification_script | length > 0 }}"
sequence:
- action: script.turn_on
target:
entity_id: "{{ notification_script }}"
data:
variables:
event_type: no_program
appliance: dishwasher
title: "{{ _n_title }}"
message: "{{ _n_message }}"
- conditions:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
sequence:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "{{ _n_title }}"
message: "{{ _n_message }}"
- stop: "No program selected"
# ════════════════════════════════════════════════════════
# FIND CHEAPEST WINDOW
# ════════════════════════════════════════════════════════
- action: tibber_prices.find_cheapest_block
data:
duration: >
{{ '%02d:%02d:00' | format(
(duration | int) // 60,
(duration | int) % 60) }}
must_finish_by: "{{ deadline }}"
response_variable: result
- if:
- condition: template
value_template: "{{ not result.window_found }}"
then:
- variables:
_n_title: "{{ title_no_cheap_slot }}"
_n_message: >
No cheap slot before
{{ deadline | as_datetime | as_local
| as_timestamp | timestamp_custom('%H:%M') }}
for {{ duration }} min.
Run manually or extend the deadline.
- choose:
- conditions:
- condition: template
value_template: "{{ notification_script | length > 0 }}"
sequence:
- action: script.turn_on
target:
entity_id: "{{ notification_script }}"
data:
variables:
event_type: no_window
appliance: dishwasher
title: "{{ _n_title }}"
message: "{{ _n_message }}"
deadline: "{{ deadline }}"
duration_minutes: "{{ duration | int }}"
- conditions:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
sequence:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "{{ _n_title }}"
message: "{{ _n_message }}"
- stop: "No cheap window found"
# ════════════════════════════════════════════════════════
# START WITH DELAY (device manages countdown)
# ════════════════════════════════════════════════════════
- variables:
_window_start: "{{ result.window.start | as_datetime }}"
# Dishwashers use StartInRelative (seconds until program starts)
start_in_relative: >
{{ [0, ((_window_start - now()).total_seconds()) | int] | max }}
- action: home_connect_alt.start_program
target:
entity_id: "{{ program_entity }}"
data:
program: "{{ selected_program }}"
options:
- key: BSH.Common.Option.StartInRelative
value: "{{ start_in_relative }}"
- variables:
_n_title: "{{ title_planned }}"
_n_message: >
{{ selected_program.split('.')[-1] }}
{% if start_in_relative | int > 0 %}
· ⏰ {{ _window_start | as_local
| as_timestamp | timestamp_custom('%H:%M') }}
(in {{ (start_in_relative | int / 3600) | round(1) }} h)
{% else %}
· ▶️ Starting now!
{% endif %}
· ~{{ duration }} min
· {{ result.window.price_mean | round(1) }} {{ result.price_unit }}/kWh
{% if _raw_duration in ['unknown', 'unavailable', 'None', ''] %}
· ⚠️ Duration estimated
{% endif %}
- choose:
- conditions:
- condition: template
value_template: "{{ notification_script | length > 0 }}"
sequence:
- action: script.turn_on
target:
entity_id: "{{ notification_script }}"
data:
variables:
event_type: planned
appliance: dishwasher
title: "{{ _n_title }}"
message: "{{ _n_message }}"
start_time: "{{ _window_start | as_local | as_timestamp | timestamp_custom('%Y-%m-%dT%H:%M:%S') }}"
duration_minutes: "{{ duration | int }}"
price_mean: "{{ result.window.price_mean | round(1) }}"
price_unit: "{{ result.price_unit }}"
selected_program: "{{ selected_program }}"
using_fallback_duration: "{{ _raw_duration in ['unknown', 'unavailable', 'None', ''] }}"
- conditions:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
sequence:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "{{ _n_title }}"
message: "{{ _n_message }}"

View file

@ -0,0 +1,261 @@
blueprint:
name: "Tibber Prices: Dryer (Smart Plug)"
description: >
**Companion blueprint for
[Tibber Prices](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration)
(HACS integration)** · Blueprint v1.0.0
Automatically run your dryer at the cheapest electricity price
overnight using a smart plug.
**Prerequisites:**
- [Tibber Prices integration](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration) installed via HACS
- One helper: Date & Time (`input_datetime`) — stores the planned start time
- Smart plug switch for the dryer
**Tip:** For multiple wash + dry cycles, use the
[Laundry Day Pipeline](https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/laundry_day_pipeline.yaml)
blueprint instead.
**Other variants:**
[Home Connect](https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/dryer_home_connect.yaml)
·
[Home Connect Alt](https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/dryer_home_connect_alt.yaml)
domain: automation
author: jpawlowski
homeassistant:
min_version: "2024.6.0"
source_url: https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/dryer.yaml
input:
appliance:
name: Appliance
icon: mdi:tumble-dryer
description: Select the smart plug that controls your dryer.
input:
appliance_switch:
name: Dryer Smart Plug
description: The switch entity controlling the dryer.
selector:
entity:
filter:
domain: switch
schedule:
name: Schedule
icon: mdi:calendar-clock
description: Configure when to plan and the search window.
input:
plan_time:
name: Planning Time
description: >
When to search for the cheapest window each day.
Typically in the evening after loading the dryer.
default: "20:00:00"
selector:
time:
start_helper:
name: Start Time Helper
description: >
An `input_datetime` helper (type: Date and Time) that stores
the planned start time. Create in Settings → Helpers.
selector:
entity:
filter:
domain: input_datetime
duration:
name: Program Duration
description: >
Typical dry program duration in minutes.
Cotton Dry ≈ 60 min, Extra Dry ≈ 75 min, Gentle ≈ 90 min.
default: 65
selector:
number:
min: 15
max: 180
step: 5
unit_of_measurement: min
mode: slider
search_start:
name: Search Window Start
description: >
Earliest time the dryer may start.
Typically late evening.
default: "22:00:00"
selector:
time:
search_end:
name: Search Window End
description: >
Latest time the dryer must finish by.
The program must complete before this time.
default: "06:00:00"
selector:
time:
runtime_overrides:
name: Runtime Overrides
icon: mdi:tune-vertical
collapsed: true
description: >
Optionally connect helpers to override settings from your
dashboard at runtime. When a helper is connected and has
a valid value, it takes priority over the fixed default.
Leave empty to always use the fixed defaults.
input:
duration_override:
name: "Override: Program Duration"
description: >
`input_number` helper to change the duration from your
dashboard without reconfiguring the blueprint.
**Create in Settings → Helpers → Number** with the same
min/max as the Duration slider above.
default: ""
selector:
entity:
filter:
domain: input_number
notifications:
name: Notifications
icon: mdi:bell-outline
collapsed: true
description: >
Optional mobile notifications for planning and start events.
input:
notify_service:
name: Notification Service
description: >
One or more notify services, comma-separated
(e.g., `notify.mobile_app_yourphone` or
`notify.mobile_app_phone, notify.mobile_app_tablet`).
Leave empty to disable all notifications.
default: ""
selector:
text:
mode: single
max_exceeded: silent
triggers:
- trigger: time
at: !input plan_time
id: plan
- trigger: time
at: !input start_helper
id: execute
variables:
_blueprint_variant: "smart_plug"
appliance_switch: !input appliance_switch
start_helper: !input start_helper
_duration_default: !input duration
_duration_override: !input duration_override
duration: >
{% set o = _duration_override %}
{% if o and states(o) not in ['unknown', 'unavailable'] %}
{{ states(o) | int(_duration_default) }}
{% else %}
{{ _duration_default }}
{% endif %}
search_start: !input search_start
search_end: !input search_end
notify_service: !input notify_service
actions:
- variables:
_tp_entities: "{{ integration_entities('tibber_prices') | list }}"
- if:
- condition: template
value_template: "{{ _tp_entities | length == 0 }}"
then:
- if:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
then:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "🌀 Dryer — Setup Required"
message: >
The Tibber Prices integration is not installed.
- stop: "Tibber Prices integration not found"
- choose:
- conditions:
- condition: trigger
id: plan
sequence:
- action: tibber_prices.find_cheapest_block
data:
duration: >
{{ '%02d:%02d:00' | format(
(duration | int) // 60,
(duration | int) % 60) }}
search_start_time: "{{ search_start }}"
search_end_time: "{{ search_end }}"
search_end_day_offset: 1
response_variable: result
- if:
- condition: template
value_template: "{{ result.window_found }}"
then:
- action: input_datetime.set_datetime
target:
entity_id: "{{ start_helper }}"
data:
datetime: "{{ result.window.start }}"
- if:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
then:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "🌀 Dryer Planned"
message: >
Start at {{ result.window.start | as_datetime
| as_local | as_timestamp
| timestamp_custom('%H:%M') }}.
Avg price: {{ result.window.price_mean | round(1) }}
{{ result.price_unit }}/kWh.
else:
- if:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
then:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "🌀 Dryer"
message: No cheap window found. Consider running manually.
- conditions:
- condition: trigger
id: execute
sequence:
- action: switch.turn_on
target:
entity_id: "{{ appliance_switch }}"
- if:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
then:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "🌀 Dryer Started"
message: >
Smart plug turned on. Program should finish in
~{{ duration }} minutes.

View file

@ -0,0 +1,458 @@
blueprint:
name: "Tibber Prices: Dryer (Home Connect)"
description: >
**Companion blueprint for
[Tibber Prices](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration)
(HACS integration)** · Blueprint v2.0.0
**Device-driven** dryer automation with electricity price
optimization using the **Home Connect** integration (HA Core).
**How it works:**
1. Select your program on the dryer
2. Close the door and enable Remote Start
3. The blueprint reads the estimated duration from the device
4. Finds the cheapest electricity window before your deadline
5. Tells the dryer when to finish via `FinishInRelative`
6. The dryer calculates when to start and manages the countdown
internally — no HA timers
**Important:** Dryers use `FinishInRelative` (like washing machines).
The appliance receives the deadline and calculates the optimal start
time itself.
**No scheduling needed** — the dryer handles the delayed start
itself. No `input_datetime` helpers required. Survives HA restarts
because the countdown runs on the appliance.
**Prerequisites:**
- [Tibber Prices integration](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration) installed via HACS
- [Home Connect](https://www.home-assistant.io/integrations/home_connect/) integration configured
- **Remote Start** enabled on the dryer
**Tip:** For multiple wash + dry cycles, use the
[Laundry Day Pipeline](https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/laundry_day_pipeline_home_connect.yaml)
blueprint instead.
**Other variants:**
[Smart Plug](https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/dryer.yaml)
·
[Home Connect Alt](https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/dryer_home_connect_alt.yaml)
domain: automation
author: jpawlowski
homeassistant:
min_version: "2024.11.0"
source_url: https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/dryer_home_connect.yaml
input:
appliance:
name: Appliance
icon: mdi:tumble-dryer
description: >
Select your Home Connect dryer device and entities.
input:
appliance_device:
name: Dryer Device
description: >
Your dryer from the Home Connect integration.
Used to target the start command.
selector:
device:
filter:
integration: home_connect
door_sensor:
name: Door Sensor
description: >
The door sensor of your dryer.
selector:
entity:
filter:
integration: home_connect
domain: binary_sensor
device_class: door
remote_start_sensor:
name: Remote Control Sensor
description: >
The "Remote Control Active" binary sensor.
Must be **on** for the automation to proceed.
selector:
entity:
filter:
integration: home_connect
domain: binary_sensor
estimated_duration_entity:
name: Estimated Program Duration
description: >
The "Estimated Total Program Time" sensor.
If unavailable, the fallback duration is used instead.
selector:
entity:
filter:
integration: home_connect
domain: sensor
operation_state_entity:
name: Operation State
description: >
The "Operation State" sensor.
Used to verify the machine is ready before planning.
selector:
entity:
filter:
integration: home_connect
domain: sensor
schedule:
name: Schedule
icon: mdi:calendar-clock
description: >
Configure the deadline and fallback duration.
input:
must_finish_by:
name: Must Finish By
description: >
The program must be finished by this time.
If this time has already passed today, the deadline
automatically moves to tomorrow (overnight mode).
default: "06:00:00"
selector:
time:
duration_fallback:
name: Fallback Duration (minutes)
description: >
Used **only** if the device doesn't report the estimated
duration. Normally the duration is read automatically.
Cotton Dry ≈ 60 min, Extra Dry ≈ 75 min, Gentle ≈ 90 min.
default: 65
selector:
number:
min: 15
max: 180
step: 5
unit_of_measurement: min
mode: slider
notifications:
name: Notifications
icon: mdi:bell-outline
collapsed: true
description: >
Optional notifications. Use **simple mode** (just a service)
or point to an **advanced script** for multi-target,
presence-aware, and platform-specific notifications.
input:
notify_service:
name: Quick Notification (Simple)
description: >
One or more notify services, comma-separated
(e.g., `notify.mobile_app_yourphone` or
`notify.mobile_app_phone, notify.mobile_app_tablet`).
Ignored when the advanced script is set.
default: ""
selector:
text:
notification_script:
name: Notification Script (Advanced)
description: >
A `script.*` entity for advanced notifications
(multiple recipients, presence filtering, iOS/Android).
When set, replaces the simple notification.
Receives structured variables (event_type, appliance,
title, message, and context data).
default: ""
selector:
entity:
filter:
domain: script
title_setup_required:
name: "Title: Setup Required"
default: "🌀 Dryer — Setup Required"
selector:
text:
title_not_ready:
name: "Title: Not Ready"
default: "🌀 Dryer — Not Ready"
selector:
text:
title_no_cheap_slot:
name: "Title: No Cheap Slot"
default: "🌀 Dryer — No Cheap Slot"
selector:
text:
title_planned:
name: "Title: Planned"
default: "🌀 Dryer — Planned!"
selector:
text:
mode: single
max_exceeded: silent
triggers:
- trigger: state
entity_id: !input door_sensor
to: "off"
- trigger: state
entity_id: !input remote_start_sensor
to: "on"
conditions:
- condition: state
entity_id: !input door_sensor
state: "off"
- condition: state
entity_id: !input remote_start_sensor
state: "on"
variables:
_blueprint_variant: "home_connect"
appliance_device: !input appliance_device
door_sensor: !input door_sensor
remote_start_sensor: !input remote_start_sensor
estimated_duration_entity: !input estimated_duration_entity
operation_state_entity: !input operation_state_entity
must_finish_by_time: !input must_finish_by
duration_fallback: !input duration_fallback
notify_service: !input notify_service
notification_script: !input notification_script
title_setup_required: !input title_setup_required
title_not_ready: !input title_not_ready
title_no_cheap_slot: !input title_no_cheap_slot
title_planned: !input title_planned
actions:
# ════════════════════════════════════════════════════════
# PREFLIGHT CHECKS
# ════════════════════════════════════════════════════════
- variables:
_tp_entities: "{{ integration_entities('tibber_prices') | list }}"
- if:
- condition: template
value_template: "{{ _tp_entities | length == 0 }}"
then:
- variables:
_n_title: "{{ title_setup_required }}"
_n_message: >
Install the Tibber Prices integration via HACS and
configure your Tibber account.
- choose:
- conditions:
- condition: template
value_template: "{{ notification_script | length > 0 }}"
sequence:
- action: script.turn_on
target:
entity_id: "{{ notification_script }}"
data:
variables:
event_type: setup_required
appliance: dryer
title: "{{ _n_title }}"
message: "{{ _n_message }}"
- conditions:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
sequence:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "{{ _n_title }}"
message: "{{ _n_message }}"
- stop: "Tibber Prices integration not found"
- if:
- condition: template
value_template: >
{% set op = states(operation_state_entity) %}
{{ op not in ['unknown', 'unavailable']
and 'Ready' not in op
and 'Inactive' not in op }}
then:
- variables:
_n_title: "{{ title_not_ready }}"
_n_message: >
State: {{ states(operation_state_entity) }}.
Ensure it's idle with Remote Start enabled.
- choose:
- conditions:
- condition: template
value_template: "{{ notification_script | length > 0 }}"
sequence:
- action: script.turn_on
target:
entity_id: "{{ notification_script }}"
data:
variables:
event_type: not_ready
appliance: dryer
title: "{{ _n_title }}"
message: "{{ _n_message }}"
- conditions:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
sequence:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "{{ _n_title }}"
message: "{{ _n_message }}"
- stop: "Machine not ready"
# ════════════════════════════════════════════════════════
# READ DEVICE DATA
# ════════════════════════════════════════════════════════
- variables:
_raw_duration: "{{ states(estimated_duration_entity) }}"
duration: >
{% set raw = states(estimated_duration_entity) %}
{% if raw not in ['unknown', 'unavailable', 'None', '']
and ':' in raw %}
{% set parts = raw.split(':') %}
{{ (parts[0] | int(0)) * 60 + (parts[1] | int(0)) }}
{% elif raw not in ['unknown', 'unavailable', 'None', '']
and raw | int(0) > 0 %}
{{ raw | int }}
{% else %}
{{ duration_fallback }}
{% endif %}
deadline: >
{% set dl = today_at(must_finish_by_time) %}
{% if dl <= now() %}
{{ (dl + timedelta(days=1)).isoformat() }}
{% else %}
{{ dl.isoformat() }}
{% endif %}
# ════════════════════════════════════════════════════════
# FIND CHEAPEST WINDOW
# ════════════════════════════════════════════════════════
- action: tibber_prices.find_cheapest_block
data:
duration: >
{{ '%02d:%02d:00' | format(
(duration | int) // 60,
(duration | int) % 60) }}
must_finish_by: "{{ deadline }}"
response_variable: result
- if:
- condition: template
value_template: "{{ not result.window_found }}"
then:
- variables:
_n_title: "{{ title_no_cheap_slot }}"
_n_message: >
No cheap slot before
{{ deadline | as_datetime | as_local
| as_timestamp | timestamp_custom('%H:%M') }}
for {{ duration }} min.
Run manually or extend the deadline.
- choose:
- conditions:
- condition: template
value_template: "{{ notification_script | length > 0 }}"
sequence:
- action: script.turn_on
target:
entity_id: "{{ notification_script }}"
data:
variables:
event_type: no_window
appliance: dryer
title: "{{ _n_title }}"
message: "{{ _n_message }}"
deadline: "{{ deadline }}"
duration_minutes: "{{ duration | int }}"
- conditions:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
sequence:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "{{ _n_title }}"
message: "{{ _n_message }}"
- stop: "No cheap window found"
# ════════════════════════════════════════════════════════
# START WITH DELAY (device manages countdown)
# ════════════════════════════════════════════════════════
- variables:
_window_start: "{{ result.window.start | as_datetime }}"
_window_end: >
{{ (_window_start + timedelta(minutes=duration | int)).isoformat() }}
finish_in_relative: >
{% set window_end = _window_start + timedelta(minutes=duration | int) %}
{% set seconds_until_end = ((window_end - now()).total_seconds()) | int %}
{{ [duration | int * 60, seconds_until_end] | max }}
# Dryers use FinishInRelative
- action: home_connect.set_program_and_options
target:
device_id: "{{ appliance_device }}"
data:
affects_to: active_program
b_s_h_common_option_finish_in_relative: "{{ finish_in_relative }}"
- variables:
_n_title: "{{ title_planned }}"
_n_message: >
{% set delay = finish_in_relative | int - (duration | int * 60) %}
{% if delay > 0 %}
⏰ ~{{ _window_start | as_local
| as_timestamp | timestamp_custom('%H:%M') }}
(in {{ (delay / 3600) | round(1) }} h)
· ~{{ duration }} min
· {{ result.window.price_mean | round(1) }} {{ result.price_unit }}/kWh
{% else %}
▶️ Starting now!
· ~{{ duration }} min
· {{ result.window.price_mean | round(1) }} {{ result.price_unit }}/kWh
{% endif %}
{% if _raw_duration in ['unknown', 'unavailable', 'None', ''] %}
· ⚠️ Duration estimated
{% endif %}
- choose:
- conditions:
- condition: template
value_template: "{{ notification_script | length > 0 }}"
sequence:
- action: script.turn_on
target:
entity_id: "{{ notification_script }}"
data:
variables:
event_type: planned
appliance: dryer
title: "{{ _n_title }}"
message: "{{ _n_message }}"
start_time: "{{ _window_start | as_local | as_timestamp | timestamp_custom('%Y-%m-%dT%H:%M:%S') }}"
duration_minutes: "{{ duration | int }}"
price_mean: "{{ result.window.price_mean | round(1) }}"
price_unit: "{{ result.price_unit }}"
using_fallback_duration: "{{ _raw_duration in ['unknown', 'unavailable', 'None', ''] }}"
- conditions:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
sequence:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "{{ _n_title }}"
message: "{{ _n_message }}"

View file

@ -0,0 +1,510 @@
blueprint:
name: "Tibber Prices: Dryer (Home Connect Alt)"
description: >
**Companion blueprint for
[Tibber Prices](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration)
(HACS integration)** · Blueprint v2.0.0
**Device-driven** dryer automation with electricity price
optimization using **Home Connect Alt**
([HACS integration by ekutner](https://my.home-assistant.io/redirect/hacs_repository/?owner=ekutner&repository=home-connect-hass&category=integration)).
**How it works:**
1. Select your program on the dryer
2. Close the door and enable Remote Start
3. The blueprint reads the program and estimated duration from the
device automatically
4. Finds the cheapest electricity window before your deadline
5. Tells the dryer when to finish via `FinishInRelative`
6. The dryer calculates when to start and manages the countdown
internally — no HA timers
**Important:** Dryers use `FinishInRelative` (like washing machines).
The appliance receives the deadline and calculates the optimal start
time itself.
**No scheduling needed** — the dryer handles the delayed start
itself. No `input_datetime` helpers required. Survives HA restarts
because the countdown runs on the appliance.
**Prerequisites:**
- [Tibber Prices integration](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration) installed via HACS
- [Home Connect Alt](https://my.home-assistant.io/redirect/hacs_repository/?owner=ekutner&repository=home-connect-hass&category=integration) integration configured
- **Remote Start** enabled on the dryer
**Tip:** For multiple wash + dry cycles, use the
[Laundry Day Pipeline](https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/laundry_day_pipeline_home_connect_alt.yaml)
blueprint instead.
**Other variants:**
[Smart Plug](https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/dryer.yaml)
·
[Home Connect](https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/dryer_home_connect.yaml)
domain: automation
author: jpawlowski
homeassistant:
min_version: "2024.11.0"
source_url: https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/dryer_home_connect_alt.yaml
input:
appliance:
name: Appliance Entities
icon: mdi:tumble-dryer
description: >
Select your Home Connect Alt dryer entities.
All entities belong to the same appliance device.
input:
program_entity:
name: Program Select Entity
description: >
The **Programs** select entity of your dryer
(e.g., `select.dryer_programs`).
Used to read the selected program and as target for starting.
selector:
entity:
filter:
integration: home_connect_alt
domain: select
door_sensor:
name: Door Sensor
description: >
The door sensor of your dryer
(e.g., `binary_sensor.dryer_door`).
selector:
entity:
filter:
integration: home_connect_alt
domain: binary_sensor
device_class: door
remote_start_sensor:
name: Remote Control Sensor
description: >
The "Remote Control Active" binary sensor
(e.g., `binary_sensor.dryer_remote_control_active`).
Must be **on** for the automation to proceed.
selector:
entity:
filter:
integration: home_connect_alt
domain: binary_sensor
estimated_duration_entity:
name: Estimated Program Duration
description: >
The "Estimated Total Program Time" sensor
(e.g., `sensor.dryer_estimated_total_program_time`).
Shows the expected duration in `H:MM` format.
If unavailable, the fallback duration is used instead.
selector:
entity:
filter:
integration: home_connect_alt
domain: sensor
operation_state_entity:
name: Operation State
description: >
The "Operation State" sensor
(e.g., `sensor.dryer_operation_state`).
Used to verify the machine is ready before planning.
selector:
entity:
filter:
integration: home_connect_alt
domain: sensor
schedule:
name: Schedule
icon: mdi:calendar-clock
description: >
Configure the deadline and fallback duration.
input:
must_finish_by:
name: Must Finish By
description: >
The program must be finished by this time.
If this time has already passed today, the deadline
automatically moves to tomorrow (overnight mode).
default: "06:00:00"
selector:
time:
duration_fallback:
name: Fallback Duration (minutes)
description: >
Used **only** if the device doesn't report the estimated
duration (e.g., program not yet fully selected on the
appliance). Normally the duration is read automatically.
Cotton Dry ≈ 60 min, Extra Dry ≈ 75 min, Gentle ≈ 90 min.
default: 65
selector:
number:
min: 15
max: 180
step: 5
unit_of_measurement: min
mode: slider
notifications:
name: Notifications
icon: mdi:bell-outline
collapsed: true
description: >
Optional notifications. Use **simple mode** (just a service)
or point to an **advanced script** for multi-target,
presence-aware, and platform-specific notifications.
input:
notify_service:
name: Quick Notification (Simple)
description: >
One or more notify services, comma-separated
(e.g., `notify.mobile_app_yourphone` or
`notify.mobile_app_phone, notify.mobile_app_tablet`).
Ignored when the advanced script is set.
default: ""
selector:
text:
notification_script:
name: Notification Script (Advanced)
description: >
A `script.*` entity for advanced notifications
(multiple recipients, presence filtering, iOS/Android).
When set, replaces the simple notification.
Receives structured variables (event_type, appliance,
title, message, and context data).
default: ""
selector:
entity:
filter:
domain: script
title_setup_required:
name: "Title: Setup Required"
default: "🌀 Dryer — Setup Required"
selector:
text:
title_not_ready:
name: "Title: Not Ready"
default: "🌀 Dryer — Not Ready"
selector:
text:
title_no_program:
name: "Title: No Program"
default: "🌀 Dryer — No Program"
selector:
text:
title_no_cheap_slot:
name: "Title: No Cheap Slot"
default: "🌀 Dryer — No Cheap Slot"
selector:
text:
title_planned:
name: "Title: Planned"
default: "🌀 Dryer — Planned!"
selector:
text:
mode: single
max_exceeded: silent
triggers:
- trigger: state
entity_id: !input door_sensor
to: "off"
- trigger: state
entity_id: !input remote_start_sensor
to: "on"
conditions:
- condition: state
entity_id: !input door_sensor
state: "off"
- condition: state
entity_id: !input remote_start_sensor
state: "on"
variables:
_blueprint_variant: "home_connect_alt"
program_entity: !input program_entity
door_sensor: !input door_sensor
remote_start_sensor: !input remote_start_sensor
estimated_duration_entity: !input estimated_duration_entity
operation_state_entity: !input operation_state_entity
must_finish_by_time: !input must_finish_by
duration_fallback: !input duration_fallback
notify_service: !input notify_service
notification_script: !input notification_script
title_setup_required: !input title_setup_required
title_not_ready: !input title_not_ready
title_no_program: !input title_no_program
title_no_cheap_slot: !input title_no_cheap_slot
title_planned: !input title_planned
actions:
# ════════════════════════════════════════════════════════
# PREFLIGHT CHECKS
# ════════════════════════════════════════════════════════
- variables:
_tp_entities: "{{ integration_entities('tibber_prices') | list }}"
- if:
- condition: template
value_template: "{{ _tp_entities | length == 0 }}"
then:
- variables:
_n_title: "{{ title_setup_required }}"
_n_message: >
Install the Tibber Prices integration via HACS and
configure your Tibber account.
- choose:
- conditions:
- condition: template
value_template: "{{ notification_script | length > 0 }}"
sequence:
- action: script.turn_on
target:
entity_id: "{{ notification_script }}"
data:
variables:
event_type: setup_required
appliance: dryer
title: "{{ _n_title }}"
message: "{{ _n_message }}"
- conditions:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
sequence:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "{{ _n_title }}"
message: "{{ _n_message }}"
- stop: "Tibber Prices integration not found"
- if:
- condition: template
value_template: >
{% set op = states(operation_state_entity) %}
{{ op not in ['unknown', 'unavailable']
and 'Ready' not in op
and 'Inactive' not in op }}
then:
- variables:
_n_title: "{{ title_not_ready }}"
_n_message: >
State: {{ states(operation_state_entity) }}.
Ensure it's idle with Remote Start enabled.
- choose:
- conditions:
- condition: template
value_template: "{{ notification_script | length > 0 }}"
sequence:
- action: script.turn_on
target:
entity_id: "{{ notification_script }}"
data:
variables:
event_type: not_ready
appliance: dryer
title: "{{ _n_title }}"
message: "{{ _n_message }}"
- conditions:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
sequence:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "{{ _n_title }}"
message: "{{ _n_message }}"
- stop: "Machine not ready"
# ════════════════════════════════════════════════════════
# READ DEVICE DATA
# ════════════════════════════════════════════════════════
- variables:
selected_program: "{{ states(program_entity) }}"
_raw_duration: "{{ states(estimated_duration_entity) }}"
duration: >
{% set raw = states(estimated_duration_entity) %}
{% if raw not in ['unknown', 'unavailable', 'None', '']
and ':' in raw %}
{% set parts = raw.split(':') %}
{{ (parts[0] | int(0)) * 60 + (parts[1] | int(0)) }}
{% else %}
{{ duration_fallback }}
{% endif %}
deadline: >
{% set dl = today_at(must_finish_by_time) %}
{% if dl <= now() %}
{{ (dl + timedelta(days=1)).isoformat() }}
{% else %}
{{ dl.isoformat() }}
{% endif %}
- if:
- condition: template
value_template: >
{{ selected_program in ['unknown', 'unavailable', 'None', ''] }}
then:
- variables:
_n_title: "{{ title_no_program }}"
_n_message: >
Select a program, close the door, and enable
Remote Start.
- choose:
- conditions:
- condition: template
value_template: "{{ notification_script | length > 0 }}"
sequence:
- action: script.turn_on
target:
entity_id: "{{ notification_script }}"
data:
variables:
event_type: no_program
appliance: dryer
title: "{{ _n_title }}"
message: "{{ _n_message }}"
- conditions:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
sequence:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "{{ _n_title }}"
message: "{{ _n_message }}"
- stop: "No program selected"
# ════════════════════════════════════════════════════════
# FIND CHEAPEST WINDOW
# ════════════════════════════════════════════════════════
- action: tibber_prices.find_cheapest_block
data:
duration: >
{{ '%02d:%02d:00' | format(
(duration | int) // 60,
(duration | int) % 60) }}
must_finish_by: "{{ deadline }}"
response_variable: result
- if:
- condition: template
value_template: "{{ not result.window_found }}"
then:
- variables:
_n_title: "{{ title_no_cheap_slot }}"
_n_message: >
No cheap slot before
{{ deadline | as_datetime | as_local
| as_timestamp | timestamp_custom('%H:%M') }}
for {{ duration }} min.
Run manually or extend the deadline.
- choose:
- conditions:
- condition: template
value_template: "{{ notification_script | length > 0 }}"
sequence:
- action: script.turn_on
target:
entity_id: "{{ notification_script }}"
data:
variables:
event_type: no_window
appliance: dryer
title: "{{ _n_title }}"
message: "{{ _n_message }}"
deadline: "{{ deadline }}"
duration_minutes: "{{ duration | int }}"
- conditions:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
sequence:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "{{ _n_title }}"
message: "{{ _n_message }}"
- stop: "No cheap window found"
# ════════════════════════════════════════════════════════
# START WITH DELAY (device manages countdown)
# ════════════════════════════════════════════════════════
- variables:
_window_start: "{{ result.window.start | as_datetime }}"
_window_end: >
{{ (_window_start + timedelta(minutes=duration | int)).isoformat() }}
finish_in_relative: >
{% set window_end = _window_start + timedelta(minutes=duration | int) %}
{% set seconds_until_end = ((window_end - now()).total_seconds()) | int %}
{{ [duration | int * 60, seconds_until_end] | max }}
- action: home_connect_alt.start_program
target:
entity_id: "{{ program_entity }}"
data:
program: "{{ selected_program }}"
options:
- key: BSH.Common.Option.FinishInRelative
value: "{{ finish_in_relative }}"
- variables:
_n_title: "{{ title_planned }}"
_n_message: >
{{ selected_program.split('.')[-1] }}
{% set delay = finish_in_relative | int - (duration | int * 60) %}
{% if delay > 0 %}
· ⏰ ~{{ _window_start | as_local
| as_timestamp | timestamp_custom('%H:%M') }}
(in {{ (delay / 3600) | round(1) }} h)
{% else %}
· ▶️ Starting now!
{% endif %}
· ~{{ duration }} min
· {{ result.window.price_mean | round(1) }} {{ result.price_unit }}/kWh
{% if _raw_duration in ['unknown', 'unavailable', 'None', ''] %}
· ⚠️ Duration estimated
{% endif %}
- choose:
- conditions:
- condition: template
value_template: "{{ notification_script | length > 0 }}"
sequence:
- action: script.turn_on
target:
entity_id: "{{ notification_script }}"
data:
variables:
event_type: planned
appliance: dryer
title: "{{ _n_title }}"
message: "{{ _n_message }}"
start_time: "{{ _window_start | as_local | as_timestamp | timestamp_custom('%Y-%m-%dT%H:%M:%S') }}"
duration_minutes: "{{ duration | int }}"
price_mean: "{{ result.window.price_mean | round(1) }}"
price_unit: "{{ result.price_unit }}"
using_fallback_duration: "{{ _raw_duration in ['unknown', 'unavailable', 'None', ''] }}"
- conditions:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
sequence:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "{{ _n_title }}"
message: "{{ _n_message }}"

View file

@ -0,0 +1,301 @@
blueprint:
name: "Tibber Prices: EV Charging — Cheapest Hours Overnight"
description: >
**Companion blueprint for
[Tibber Prices](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration)
(HACS integration)** · Blueprint v1.0.0
Automatically charge your electric vehicle during the cheapest hours
overnight. Uses `find_cheapest_hours` to select the cheapest
individual 15-minute intervals — the charger may pause and resume
between segments.
**What it does:**
- Finds the cheapest intervals within a configurable search window
- Stores the first segment's start time in a helper
- Turns the charger on/off based on an interval schedule
- Optional: Skips planning if battery is already above a threshold
**Prerequisites:**
- [Tibber Prices integration](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration) installed via HACS
- One helper: Date & Time (`input_datetime`) for the charge start
- A smart plug or charger switch entity
**Alternative:** If your charger can't pause/resume, use
`find_cheapest_block` instead (see the Dishwasher Smart Plug
blueprint for a contiguous-window example).
domain: automation
author: jpawlowski
homeassistant:
min_version: "2024.6.0"
source_url: https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/ev_charging.yaml
input:
vehicle:
name: Vehicle / Charger
icon: mdi:ev-station
description: Configure your EV charger switch and optional battery sensor.
input:
charger_switch:
name: Charger Switch
description: >
The switch entity that controls your EV charger
(smart plug or charger integration).
selector:
entity:
filter:
domain: switch
battery_sensor:
name: Battery Level Sensor (optional)
description: >
If provided, charging is only planned when the battery
is below the threshold. Leave empty to always plan.
default: ""
selector:
entity:
filter:
domain: sensor
device_class: battery
battery_threshold:
name: Battery Threshold
description: >
Only plan charging if battery level is below this
percentage. Ignored if no battery sensor is selected.
default: 80
selector:
number:
min: 10
max: 100
step: 5
unit_of_measurement: "%"
schedule:
name: Schedule
icon: mdi:calendar-clock
description: Configure charging times and the overnight search window.
input:
plan_time:
name: Planning Time
description: >
When to search for the cheapest hours each day.
Should be before the search window starts.
default: "18:00:00"
selector:
time:
charge_duration:
name: Total Charging Duration
description: >
How many hours of cheap charging to find.
default: "04:00:00"
selector:
time:
min_segment:
name: Minimum Segment Duration
description: >
Shortest uninterrupted charging segment. Prevents
very short on/off cycles that stress the charger.
default: "00:30:00"
selector:
time:
search_start:
name: Search Window Start
description: >
Earliest time charging may begin.
default: "18:00:00"
selector:
time:
search_end:
name: Search Window End
description: >
Latest time charging must finish by.
The vehicle should be ready by this time.
default: "07:00:00"
selector:
time:
runtime_overrides:
name: Runtime Overrides
icon: mdi:tune-vertical
collapsed: true
description: >
Optionally connect helpers to override settings from your
dashboard at runtime. When a helper is connected and has
a valid value, it takes priority over the fixed default.
Leave empty to always use the fixed defaults.
input:
charge_duration_override:
name: "Override: Charging Duration"
description: >
`input_number` helper to change the charging duration
(in hours) from your dashboard. Useful when daily
charging needs vary.
**Create in Settings → Helpers → Number**
(min: 0.5, max: 12, step: 0.5, unit: h).
default: ""
selector:
entity:
filter:
domain: input_number
notifications:
name: Notifications
icon: mdi:bell-outline
collapsed: true
description: >
Optional mobile notifications for charging schedule
and start/stop events.
input:
notify_service:
name: Notification Service
description: >
One or more notify services, comma-separated
(e.g., `notify.mobile_app_yourphone` or
`notify.mobile_app_phone, notify.mobile_app_tablet`).
Leave empty to disable all notifications.
default: ""
selector:
text:
mode: single
max_exceeded: silent
triggers:
- trigger: time
at: !input plan_time
id: plan
variables:
_blueprint_variant: "ev_charging"
charger_switch: !input charger_switch
battery_sensor: !input battery_sensor
battery_threshold: !input battery_threshold
_charge_duration_default: !input charge_duration
_charge_duration_override: !input charge_duration_override
charge_duration: >
{% set o = _charge_duration_override %}
{% if o and states(o) not in ['unknown', 'unavailable'] %}
{% set hours = states(o) | float(4) %}
{{ '%02d:%02d:00' | format(hours | int, ((hours % 1) * 60) | int) }}
{% else %}
{{ _charge_duration_default }}
{% endif %}
min_segment: !input min_segment
search_start: !input search_start
search_end: !input search_end
notify_service: !input notify_service
actions:
# Check: Tibber Prices integration installed?
- variables:
_tp_entities: "{{ integration_entities('tibber_prices') | list }}"
- if:
- condition: template
value_template: "{{ _tp_entities | length == 0 }}"
then:
- if:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
then:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "🔌 EV Charging — Setup Required"
message: The Tibber Prices integration is not installed.
- stop: "Tibber Prices integration not found"
# ════════════════════════════════════════════════════════
# BATTERY CHECK
# ════════════════════════════════════════════════════════
- if:
- condition: template
value_template: >
{{ battery_sensor | length > 0
and states(battery_sensor) | int(0) >= battery_threshold | int }}
then:
- if:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
then:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "🔌 EV Charging Skipped"
message: >
Battery at {{ states(battery_sensor) }}% (threshold:
{{ battery_threshold }}%). No charging needed.
- stop: "Battery above threshold"
# ════════════════════════════════════════════════════════
# FIND CHEAPEST HOURS
# ════════════════════════════════════════════════════════
- action: tibber_prices.find_cheapest_hours
data:
duration: "{{ charge_duration }}"
min_segment_duration: "{{ min_segment }}"
search_start_time: "{{ search_start }}"
search_end_time: "{{ search_end }}"
search_end_day_offset: 1
response_variable: result
- if:
- condition: template
value_template: "{{ result.intervals_found }}"
then:
- if:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
then:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "🔌 EV Charging Planned"
message: >
{{ result.schedule.segment_count }} charging sessions:
{% for seg in result.schedule.segments %}
• {{ seg.start | as_datetime | as_local | as_timestamp
| timestamp_custom('%H:%M') }}{{ seg.end | as_datetime
| as_local | as_timestamp | timestamp_custom('%H:%M') }}
({{ seg.price_mean | round(1) }} {{ result.price_unit }})
{% endfor %}
# Turn on/off charger for each segment
- repeat:
for_each: "{{ result.schedule.segments }}"
sequence:
- delay: >
{{ ((repeat.item.start | as_datetime | as_local
| as_timestamp) - (now() | as_timestamp)) | int }}
- action: switch.turn_on
target:
entity_id: "{{ charger_switch }}"
- delay: >
{{ ((repeat.item.end | as_datetime | as_local
| as_timestamp) - (repeat.item.start | as_datetime
| as_local | as_timestamp)) | int }}
- action: switch.turn_off
target:
entity_id: "{{ charger_switch }}"
else:
- if:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
then:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "🔌 EV Charging"
message: No cheap intervals found in the search window.

View file

@ -0,0 +1,235 @@
blueprint:
name: "Tibber Prices: Heat Pump — Temperature by Price Level"
description: >
**Companion blueprint for
[Tibber Prices](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration)
(HACS integration)** · Blueprint v1.0.0
Adjust your heat pump target temperature based on the current
electricity price rating. Higher target when cheap, lower when
expensive — the simplest real-time heat pump optimization.
**What it does:**
- Reacts every 15 minutes when the price sensor updates
- Sets one of 5 target temperatures based on `rating_level`
(VERY_CHEAP, CHEAP, NORMAL, EXPENSIVE, VERY_EXPENSIVE)
- No helpers needed — pure sensor-based
**Prerequisites:**
- [Tibber Prices integration](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration) installed via HACS
- A `climate.*` entity for your heat pump
**See also:**
[Heat Pump Smart Boost](https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/heat_pump_smart_boost.yaml)
— a more advanced variant that extends boost during V-shaped
price valleys using trend awareness.
domain: automation
author: jpawlowski
homeassistant:
min_version: "2024.6.0"
source_url: https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/heat_pump_price_level.yaml
input:
devices:
name: Devices
icon: mdi:heat-pump-outline
description: Select your heat pump and the Tibber Prices sensor.
input:
price_sensor:
name: Current Price Sensor
description: >
The `sensor.<home>_current_electricity_price` from
Tibber Prices. Must have `rating_level` attribute.
selector:
entity:
filter:
domain: sensor
integration: tibber_prices
heat_pump_entity:
name: Heat Pump
description: Your heat pump climate entity.
selector:
entity:
filter:
domain: climate
temperatures:
name: Target Temperatures
icon: mdi:thermometer
description: >
Set the target temperature for each price level.
Temperatures are in °C.
input:
temp_very_cheap:
name: VERY_CHEAP Temperature
description: Maximum comfort when prices are very low.
default: 23.0
selector:
number:
min: 15
max: 30
step: 0.5
unit_of_measurement: °C
temp_cheap:
name: CHEAP Temperature
description: Slightly above normal for moderate savings.
default: 22.0
selector:
number:
min: 15
max: 30
step: 0.5
unit_of_measurement: °C
temp_normal:
name: NORMAL Temperature
description: Baseline comfort temperature for average prices.
default: 20.5
selector:
number:
min: 15
max: 30
step: 0.5
unit_of_measurement: °C
temp_expensive:
name: EXPENSIVE Temperature
description: Reduced temperature to save during high prices.
default: 19.0
selector:
number:
min: 15
max: 30
step: 0.5
unit_of_measurement: °C
temp_very_expensive:
name: VERY_EXPENSIVE Temperature
description: Minimum to save energy during peak prices.
default: 18.0
selector:
number:
min: 15
max: 30
step: 0.5
unit_of_measurement: °C
runtime_overrides:
name: Runtime Overrides
icon: mdi:tune-vertical
collapsed: true
description: >
Optionally connect a helper to shift all target temperatures
at once (e.g., +2°C comfort boost in winter, 1°C in summer).
Leave empty to always use the fixed defaults.
input:
temperature_offset_override:
name: "Override: Temperature Offset"
description: >
`input_number` helper to shift ALL target temperatures
up or down from your dashboard.
**Create in Settings → Helpers → Number**
(min: 5, max: 5, step: 0.5, unit: °C).
default: ""
selector:
entity:
filter:
domain: input_number
notifications:
name: Notifications
icon: mdi:bell-outline
collapsed: true
description: >
Optional mobile notifications for temperature adjustments.
input:
notify_service:
name: Notification Service
description: >
One or more notify services, comma-separated
(e.g., `notify.mobile_app_yourphone` or
`notify.mobile_app_phone, notify.mobile_app_tablet`).
Leave empty to disable all notifications.
default: ""
selector:
text:
mode: restart
triggers:
- trigger: state
entity_id: !input price_sensor
variables:
_blueprint_variant: "heat_pump_price_level"
price_sensor: !input price_sensor
heat_pump_entity: !input heat_pump_entity
_temp_vc: !input temp_very_cheap
_temp_c: !input temp_cheap
_temp_n: !input temp_normal
_temp_e: !input temp_expensive
_temp_ve: !input temp_very_expensive
_temp_offset_override: !input temperature_offset_override
_temp_offset: >
{% set o = _temp_offset_override %}
{% if o and states(o) not in ['unknown', 'unavailable'] %}
{{ states(o) | float(0) }}
{% else %}
0
{% endif %}
temp_very_cheap: "{{ (_temp_vc | float) + (_temp_offset | float) }}"
temp_cheap: "{{ (_temp_c | float) + (_temp_offset | float) }}"
temp_normal: "{{ (_temp_n | float) + (_temp_offset | float) }}"
temp_expensive: "{{ (_temp_e | float) + (_temp_offset | float) }}"
temp_very_expensive: "{{ (_temp_ve | float) + (_temp_offset | float) }}"
notify_service: !input notify_service
level: >
{{ state_attr(price_sensor, 'rating_level') | default('NORMAL') }}
actions:
# Check: Tibber Prices integration installed?
- variables:
_tp_entities: "{{ integration_entities('tibber_prices') | list }}"
- if:
- condition: template
value_template: "{{ _tp_entities | length == 0 }}"
then:
- stop: "Tibber Prices integration not found"
# ════════════════════════════════════════════════════════
# SET TEMPERATURE BASED ON PRICE LEVEL
# ════════════════════════════════════════════════════════
- variables:
target_temp: >
{% if level == 'VERY_CHEAP' %}
{{ temp_very_cheap }}
{% elif level == 'CHEAP' %}
{{ temp_cheap }}
{% elif level == 'EXPENSIVE' %}
{{ temp_expensive }}
{% elif level == 'VERY_EXPENSIVE' %}
{{ temp_very_expensive }}
{% else %}
{{ temp_normal }}
{% endif %}
- action: climate.set_temperature
target:
entity_id: "{{ heat_pump_entity }}"
data:
temperature: "{{ target_temp | float }}"
- if:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
then:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "🌡️ Heat Pump Adjusted"
message: >
Price level: {{ level }}. Target temperature set to
{{ target_temp }}°C.

View file

@ -0,0 +1,282 @@
blueprint:
name: "Tibber Prices: Heat Pump — Smart Boost with Trend Awareness"
description: >
**Companion blueprint for
[Tibber Prices](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration)
(HACS integration)** · Blueprint v1.0.0
Advanced heat pump optimization that extends the boost window
beyond the detected Best Price Period using trend sensors.
**Why?** On V-shaped price days, the Best Price Period may cover
only 12 hours, but prices remain favorable for 46 hours. By
checking the price level AND the trend, you can safely boost
during the entire cheap valley.
**Logic:**
- **Boost** when EITHER: (a) inside a Best Price Period, OR
(b) price is CHEAP/VERY_CHEAP AND trend is stable/falling
- **Return to normal** when NEITHER condition is true
**Prerequisites:**
- [Tibber Prices integration](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration) installed via HACS
- A `climate.*` entity for your heat pump
**See also:**
[Heat Pump Price Level](https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/heat_pump_price_level.yaml)
— simpler variant that adjusts to 5 different temperatures per
price level without trend awareness.
domain: automation
author: jpawlowski
homeassistant:
min_version: "2024.6.0"
source_url: https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/heat_pump_smart_boost.yaml
input:
devices:
name: Devices
icon: mdi:heat-pump-outline
description: >
Select your heat pump and the Tibber Prices sensors.
input:
period_sensor:
name: Best Price Period Sensor
description: >
The `binary_sensor.<home>_best_price_period` from
Tibber Prices.
selector:
entity:
filter:
domain: binary_sensor
integration: tibber_prices
price_sensor:
name: Current Price Sensor
description: >
The `sensor.<home>_current_electricity_price` from
Tibber Prices. Must have `rating_level` attribute.
selector:
entity:
filter:
domain: sensor
integration: tibber_prices
trend_sensor:
name: Price Outlook Sensor (1h)
description: >
The `sensor.<home>_price_outlook_1h` from Tibber Prices.
Must have `trend_value` attribute. `rising` means current
price is LOWER than the future average — so it's actually
a good time to boost.
selector:
entity:
filter:
domain: sensor
integration: tibber_prices
heat_pump_entity:
name: Heat Pump
description: Your heat pump climate entity.
selector:
entity:
filter:
domain: climate
temperatures:
name: Temperatures
icon: mdi:thermometer
description: Boost and normal target temperatures.
input:
boost_temperature:
name: Boost Temperature
description: Target during the extended cheap window.
default: 22.0
selector:
number:
min: 15
max: 30
step: 0.5
unit_of_measurement: °C
normal_temperature:
name: Normal Temperature
description: Target when no cheap conditions apply.
default: 20.5
selector:
number:
min: 15
max: 30
step: 0.5
unit_of_measurement: °C
runtime_overrides:
name: Runtime Overrides
icon: mdi:tune-vertical
collapsed: true
description: >
Optionally connect helpers to override settings from your
dashboard at runtime. When a helper is connected and has
a valid value, it takes priority over the fixed default.
Leave empty to always use the fixed defaults.
input:
boost_temperature_override:
name: "Override: Boost Temperature"
description: >
`input_number` helper to change the boost temperature
from your dashboard.
**Create in Settings → Helpers → Number**
(min: 15, max: 30, step: 0.5, unit: °C).
default: ""
selector:
entity:
filter:
domain: input_number
normal_temperature_override:
name: "Override: Normal Temperature"
description: >
`input_number` helper to change the normal temperature
from your dashboard.
**Create in Settings → Helpers → Number**
(min: 15, max: 30, step: 0.5, unit: °C).
default: ""
selector:
entity:
filter:
domain: input_number
notifications:
name: Notifications
icon: mdi:bell-outline
collapsed: true
description: >
Optional mobile notifications for boost start/stop events.
input:
notify_service:
name: Notification Service
description: >
One or more notify services, comma-separated
(e.g., `notify.mobile_app_yourphone` or
`notify.mobile_app_phone, notify.mobile_app_tablet`).
Leave empty to disable all notifications.
default: ""
selector:
text:
mode: restart
triggers:
# Best price period starts/stops
- trigger: state
entity_id: !input period_sensor
to: "on"
id: period_start
- trigger: state
entity_id: !input period_sensor
to: "off"
id: period_end
# Price updates every 15 minutes
- trigger: state
entity_id: !input price_sensor
id: price_update
variables:
_blueprint_variant: "heat_pump_smart_boost"
period_sensor: !input period_sensor
price_sensor: !input price_sensor
trend_sensor: !input trend_sensor
heat_pump_entity: !input heat_pump_entity
_boost_temp_default: !input boost_temperature
_boost_temp_override: !input boost_temperature_override
boost_temperature: >
{% set o = _boost_temp_override %}
{% if o and states(o) not in ['unknown', 'unavailable'] %}
{{ states(o) | float(_boost_temp_default) }}
{% else %}
{{ _boost_temp_default }}
{% endif %}
_normal_temp_default: !input normal_temperature
_normal_temp_override: !input normal_temperature_override
normal_temperature: >
{% set o = _normal_temp_override %}
{% if o and states(o) not in ['unknown', 'unavailable'] %}
{{ states(o) | float(_normal_temp_default) }}
{% else %}
{{ _normal_temp_default }}
{% endif %}
notify_service: !input notify_service
actions:
# Check: Tibber Prices integration installed?
- variables:
_tp_entities: "{{ integration_entities('tibber_prices') | list }}"
- if:
- condition: template
value_template: "{{ _tp_entities | length == 0 }}"
then:
- stop: "Tibber Prices integration not found"
# ════════════════════════════════════════════════════════
# EVALUATE BOOST CONDITIONS
# ════════════════════════════════════════════════════════
- variables:
in_period: >
{{ is_state(period_sensor, 'on') }}
is_cheap: >
{{ state_attr(price_sensor, 'rating_level')
| default('NORMAL') in ['VERY_CHEAP', 'CHEAP'] }}
trend_ok: >
{{ state_attr(trend_sensor, 'trend_value')
| int(0) <= 0 }}
should_boost: >
{{ in_period or (is_cheap and trend_ok) }}
- choose:
# ── BOOST ──
- conditions:
- condition: template
value_template: "{{ should_boost }}"
sequence:
- action: climate.set_temperature
target:
entity_id: "{{ heat_pump_entity }}"
data:
temperature: "{{ boost_temperature | float }}"
- if:
- condition: template
value_template: >
{{ notify_service | length > 0
and trigger.id == 'period_start' }}
then:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "🌡️ Heat Pump — Boost Active"
message: >
{% if in_period %}Best price period started.
{% else %}Price is cheap and trend is favorable.
{% endif %}
Target set to {{ boost_temperature }}°C.
# ── RETURN TO NORMAL ──
default:
- action: climate.set_temperature
target:
entity_id: "{{ heat_pump_entity }}"
data:
temperature: "{{ normal_temperature | float }}"
- if:
- condition: template
value_template: >
{{ notify_service | length > 0
and trigger.id == 'period_end' }}
then:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "🌡️ Heat Pump — Normal Mode"
message: >
Cheap window ended. Target back to
{{ normal_temperature }}°C.

View file

@ -0,0 +1,390 @@
blueprint:
name: "Tibber Prices: Home Battery — Charge Cheap, Discharge Expensive"
description: >
**Companion blueprint for
[Tibber Prices](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration)
(HACS integration)** · Blueprint v1.0.0
Optimize your home battery by charging from the grid during cheap
prices and discharging during expensive periods.
**What it does:**
- **Best Price Period ON** → Charge from grid (if SOC below threshold)
- **Peak Price Period ON** → Discharge to grid (if SOC above threshold)
- **Both OFF** → Stop grid charging/discharging (solar-only mode)
- Optional: Volatility check — skip charging on flat-price days
**Prerequisites:**
- [Tibber Prices integration](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration) installed via HACS
- Switch entities for grid charging and grid discharge
- Optional: Battery SOC sensor for threshold logic
domain: automation
author: jpawlowski
homeassistant:
min_version: "2024.6.0"
source_url: https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/home_battery.yaml
input:
sensors:
name: Tibber Prices Sensors
icon: mdi:chart-timeline-variant-shimmer
description: Select the period sensors from Tibber Prices.
input:
best_price_sensor:
name: Best Price Period Sensor
description: >
`binary_sensor.<home>_best_price_period` — triggers
charging.
selector:
entity:
filter:
domain: binary_sensor
integration: tibber_prices
peak_price_sensor:
name: Peak Price Period Sensor
description: >
`binary_sensor.<home>_peak_price_period` — triggers
discharging.
selector:
entity:
filter:
domain: binary_sensor
integration: tibber_prices
battery:
name: Battery
icon: mdi:battery-charging-60
description: Configure your battery switches and thresholds.
input:
charge_switch:
name: Grid Charging Switch
description: >
Switch that enables charging from the grid.
selector:
entity:
filter:
domain: switch
discharge_switch:
name: Grid Discharge Switch
description: >
Switch that enables discharging to grid / home.
selector:
entity:
filter:
domain: switch
soc_sensor:
name: Battery SOC Sensor (optional)
description: >
State of Charge sensor (0100%). Leave empty to skip
SOC checks.
default: ""
selector:
entity:
filter:
domain: sensor
device_class: battery
charge_max_soc:
name: Max SOC for Charging
description: >
Only charge from grid if SOC is below this level.
default: 90
selector:
number:
min: 50
max: 100
step: 5
unit_of_measurement: "%"
discharge_min_soc:
name: Min SOC for Discharging
description: >
Only discharge if SOC is above this level.
default: 20
selector:
number:
min: 5
max: 50
step: 5
unit_of_measurement: "%"
check_volatility:
name: Skip Charging on Flat-Price Days
description: >
When enabled, grid charging is skipped when volatility
is "low" (charging from grid wouldn't save much money).
default: true
selector:
boolean:
runtime_overrides:
name: Runtime Overrides
icon: mdi:tune-vertical
collapsed: true
description: >
Optionally connect helpers to override settings from your
dashboard at runtime. When a helper is connected and has
a valid value, it takes priority over the fixed default.
Leave empty to always use the fixed defaults.
input:
charge_max_soc_override:
name: "Override: Max SOC for Charging"
description: >
`input_number` helper to adjust the charge threshold
from your dashboard (e.g., before travel or bad weather).
**Create in Settings → Helpers → Number**
(min: 50, max: 100, step: 5, unit: %).
default: ""
selector:
entity:
filter:
domain: input_number
discharge_min_soc_override:
name: "Override: Min SOC for Discharging"
description: >
`input_number` helper to adjust the discharge threshold
from your dashboard.
**Create in Settings → Helpers → Number**
(min: 5, max: 50, step: 5, unit: %).
default: ""
selector:
entity:
filter:
domain: input_number
notifications:
name: Notifications
icon: mdi:bell-outline
collapsed: true
description: >
Optional mobile notifications for charge/discharge events.
input:
notify_service:
name: Notification Service
description: >
One or more notify services, comma-separated
(e.g., `notify.mobile_app_yourphone` or
`notify.mobile_app_phone, notify.mobile_app_tablet`).
Leave empty to disable all notifications.
default: ""
selector:
text:
mode: restart
triggers:
- trigger: state
entity_id: !input best_price_sensor
to: "on"
id: charge_start
- trigger: state
entity_id: !input best_price_sensor
to: "off"
id: charge_end
- trigger: state
entity_id: !input peak_price_sensor
to: "on"
id: discharge_start
- trigger: state
entity_id: !input peak_price_sensor
to: "off"
id: discharge_end
variables:
_blueprint_variant: "home_battery"
best_price_sensor: !input best_price_sensor
peak_price_sensor: !input peak_price_sensor
charge_switch: !input charge_switch
discharge_switch: !input discharge_switch
soc_sensor: !input soc_sensor
_charge_max_soc_default: !input charge_max_soc
_charge_max_soc_override: !input charge_max_soc_override
charge_max_soc: >
{% set o = _charge_max_soc_override %}
{% if o and states(o) not in ['unknown', 'unavailable'] %}
{{ states(o) | int(_charge_max_soc_default) }}
{% else %}
{{ _charge_max_soc_default }}
{% endif %}
_discharge_min_soc_default: !input discharge_min_soc
_discharge_min_soc_override: !input discharge_min_soc_override
discharge_min_soc: >
{% set o = _discharge_min_soc_override %}
{% if o and states(o) not in ['unknown', 'unavailable'] %}
{{ states(o) | int(_discharge_min_soc_default) }}
{% else %}
{{ _discharge_min_soc_default }}
{% endif %}
check_volatility: !input check_volatility
notify_service: !input notify_service
actions:
# Check: Tibber Prices integration installed?
- variables:
_tp_entities: "{{ integration_entities('tibber_prices') | list }}"
- if:
- condition: template
value_template: "{{ _tp_entities | length == 0 }}"
then:
- stop: "Tibber Prices integration not found"
# ════════════════════════════════════════════════════════
# CHARGE / DISCHARGE / STOP
# ════════════════════════════════════════════════════════
- choose:
# ── CHARGE during Best Price Period ──
- conditions:
- condition: trigger
id: charge_start
sequence:
# Volatility check
- if:
- condition: template
value_template: >
{{ check_volatility
and state_attr(best_price_sensor, 'volatility')
| default('normal') == 'low' }}
then:
- if:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
then:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "🔋 Battery — Skipped (Low Volatility)"
message: >
Prices are flat today. Grid charging skipped
(savings would be minimal).
- stop: "Low volatility — skipping grid charge"
# SOC check
- if:
- condition: template
value_template: >
{{ soc_sensor | length > 0
and states(soc_sensor) | int(0) >= charge_max_soc | int }}
then:
- if:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
then:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "🔋 Battery — Already Charged"
message: >
SOC at {{ states(soc_sensor) }}% (max:
{{ charge_max_soc }}%). Skipping.
- stop: "SOC above charge threshold"
# Start charging
- action: switch.turn_on
target:
entity_id: "{{ charge_switch }}"
- if:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
then:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "🔋 Battery — Grid Charging"
message: >
Best price period started. Charging from grid.
{% if soc_sensor | length > 0 %}
SOC: {{ states(soc_sensor) }}%.
{% endif %}
# ── DISCHARGE during Peak Price Period ──
- conditions:
- condition: trigger
id: discharge_start
sequence:
# SOC check
- if:
- condition: template
value_template: >
{{ soc_sensor | length > 0
and states(soc_sensor) | int(0) <= discharge_min_soc | int }}
then:
- if:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
then:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "🔋 Battery — Too Low to Discharge"
message: >
SOC at {{ states(soc_sensor) }}% (min:
{{ discharge_min_soc }}%). Skipping.
- stop: "SOC below discharge threshold"
# Start discharging
- action: switch.turn_on
target:
entity_id: "{{ discharge_switch }}"
- if:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
then:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "🔋 Battery — Discharging"
message: >
Peak price period started. Discharging battery.
{% if soc_sensor | length > 0 %}
SOC: {{ states(soc_sensor) }}%.
{% endif %}
# ── STOP charging when best price ends ──
- conditions:
- condition: trigger
id: charge_end
sequence:
- action: switch.turn_off
target:
entity_id: "{{ charge_switch }}"
- if:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
then:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "🔋 Battery — Charge Stopped"
message: Best price period ended. Grid charging off.
# ── STOP discharging when peak price ends ──
- conditions:
- condition: trigger
id: discharge_end
sequence:
- action: switch.turn_off
target:
entity_id: "{{ discharge_switch }}"
- if:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
then:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "🔋 Battery — Discharge Stopped"
message: Peak price period ended. Grid discharge off.

View file

@ -0,0 +1,588 @@
blueprint:
name: "Tibber Prices: Laundry Day Pipeline (Smart Plug)"
description: >
**Companion blueprint for
[Tibber Prices](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration)
(HACS integration)** · Blueprint v1.0.0
Schedule multiple wash + dry cycles at the cheapest electricity prices
using smart plug switches.
Open your
[Tibber Prices configuration](https://my.home-assistant.io/redirect/integration/?domain=tibber_prices)
to verify the integration is installed and set up.
**What it does:**
- Plans 15 wash + dry cycles with automatic price optimization
- Finds the cheapest time windows for each appliance cycle
- Sends mobile notifications for laundry transfer reminders
- Optional pipeline mode: next wash starts while dryer runs
**Prerequisites:**
- [Tibber Prices integration](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration) installed via HACS
- Two helpers (created in Settings → Helpers):
- Toggle (`input_boolean`) — starts laundry day when turned on
- Number (`input_number`, min 1, max 5, step 1) — how many loads
- Smart plug switches for washer and dryer
**How it works:**
```
Load 1: [══ Wash 1 ══] → transfer → [══ Dry 1 ══]
Load 2: (pipeline) [══ Wash 2 ══] → transfer → [══ Dry 2 ══]
```
1. Turn on the toggle to start laundry day
2. Each wash + dry cycle is planned at the cheapest available price
3. You receive notifications when it's time to transfer laundry
4. The toggle turns off automatically when all loads are done
**Pipeline mode** (optional): When your wash cycle takes longer than
your dry cycle, the next wash can start while the dryer is still
running. This significantly reduces total laundry time.
**Other variants:**
[Home Connect](https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/laundry_day_pipeline_home_connect.yaml)
·
[Home Connect Alt](https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/laundry_day_pipeline_home_connect_alt.yaml)
domain: automation
author: jpawlowski
homeassistant:
min_version: "2024.6.0"
source_url: https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/laundry_day_pipeline.yaml
input:
appliances:
name: Appliances
icon: mdi:washing-machine
description: Configure your washing machine and dryer.
input:
washer_switch:
name: Washing Machine Switch
description: Smart plug controlling the washing machine.
selector:
entity:
filter:
domain: switch
include_dryer:
name: Include Dryer
description: >
Enable to schedule dryer cycles after each wash.
Disable if you hang laundry to dry.
default: true
selector:
boolean:
dryer_switch:
name: Dryer Switch
description: >
Smart plug controlling the dryer.
Only used when "Include Dryer" is enabled.
selector:
entity:
filter:
domain: switch
durations:
name: Program Durations
icon: mdi:timer-outline
description: >
Set typical program durations for your appliances.
Include a small buffer (~5 min) for cycle-to-cycle variation.
input:
washer_duration:
name: Wash Cycle Duration
description: >
Typical wash program duration in minutes.
ECO 40-60 ≈ 90 min, Cotton 60°C ≈ 120 min, Quick ≈ 45 min.
default: 95
selector:
number:
min: 15
max: 240
step: 5
unit_of_measurement: min
mode: slider
dryer_duration:
name: Dry Cycle Duration
description: >
Typical dry program duration in minutes.
Cotton Dry ≈ 60 min, Extra Dry ≈ 75 min, Gentle ≈ 90 min.
default: 65
selector:
number:
min: 15
max: 180
step: 5
unit_of_measurement: min
mode: slider
transfer_time:
name: Transfer Time
description: >
Minutes to transfer laundry from washer to dryer.
You'll get a notification when it's time.
default: 15
selector:
number:
min: 5
max: 60
step: 5
unit_of_measurement: min
mode: slider
schedule:
name: Schedule
icon: mdi:calendar-clock
description: Configure the trigger, load count, and deadline.
input:
trigger_entity:
name: Laundry Day Toggle
description: >
An `input_boolean` helper that starts laundry day when turned on.
Create in Settings → Helpers → Toggle.
selector:
entity:
filter:
domain: input_boolean
loads_entity:
name: Number of Loads
description: >
An `input_number` helper (15) for how many wash cycles to run.
Create in Settings → Helpers → Number (min: 1, max: 5, step: 1).
selector:
entity:
filter:
domain: input_number
deadline_time:
name: Must Finish By
description: >
All laundry must be finished by this time today.
The scheduler only looks for cheap windows before this deadline.
default: "22:00:00"
selector:
time:
advanced:
name: Advanced
icon: mdi:cog
collapsed: true
description: Pipeline mode and fine-tuning options.
input:
pipeline_mode:
name: Pipeline Mode
description: >
When enabled, the next wash starts immediately after the dryer
begins — without waiting for the dryer to finish. This creates
a pipeline where washer and dryer overlap, cutting total time
by roughly one dry cycle per load.
**Only safe when wash duration ≥ dryer duration.**
If your dryer takes longer than your washer, leave this off.
default: false
selector:
boolean:
runtime_overrides:
name: Runtime Overrides
icon: mdi:tune-vertical
collapsed: true
description: >
Optionally connect helpers to override durations from your
dashboard at runtime. When a helper is connected and has
a valid value, it takes priority over the fixed default.
Leave empty to always use the fixed defaults.
input:
washer_duration_override:
name: "Override: Wash Cycle Duration"
description: >
`input_number` helper to change the wash duration from
your dashboard (e.g., ECO vs. Quick program).
**Create in Settings → Helpers → Number**
(min: 15, max: 240, step: 5, unit: min).
default: ""
selector:
entity:
filter:
domain: input_number
dryer_duration_override:
name: "Override: Dry Cycle Duration"
description: >
`input_number` helper to change the dry duration from
your dashboard.
**Create in Settings → Helpers → Number**
(min: 15, max: 180, step: 5, unit: min).
default: ""
selector:
entity:
filter:
domain: input_number
notifications:
name: Notifications
icon: mdi:bell-outline
collapsed: true
description: Optional mobile notifications for transfer reminders and progress.
input:
notify_service:
name: Notification Service
description: >
One or more notify services, comma-separated
(e.g., `notify.mobile_app_yourphone` or
`notify.mobile_app_phone, notify.mobile_app_tablet`).
Leave empty to disable all notifications.
default: ""
selector:
text:
# Only one laundry day at a time
mode: single
max_exceeded: warning
triggers:
- trigger: state
entity_id: !input trigger_entity
to: "on"
# Expose inputs as template variables
variables:
# Blueprint versioning — for compatibility checks
_blueprint_variant: "smart_plug"
# Input variables
washer_switch: !input washer_switch
dryer_switch: !input dryer_switch
include_dryer: !input include_dryer
_washer_duration_default: !input washer_duration
_washer_duration_override: !input washer_duration_override
washer_duration: >
{% set o = _washer_duration_override %}
{% if o and states(o) not in ['unknown', 'unavailable'] %}
{{ states(o) | int(_washer_duration_default) }}
{% else %}
{{ _washer_duration_default }}
{% endif %}
_dryer_duration_default: !input dryer_duration
_dryer_duration_override: !input dryer_duration_override
dryer_duration: >
{% set o = _dryer_duration_override %}
{% if o and states(o) not in ['unknown', 'unavailable'] %}
{{ states(o) | int(_dryer_duration_default) }}
{% else %}
{{ _dryer_duration_default }}
{% endif %}
transfer_time: !input transfer_time
loads_entity: !input loads_entity
deadline_time: !input deadline_time
pipeline_mode: !input pipeline_mode
notify_service: !input notify_service
total_loads: "{{ states(loads_entity) | int(1) }}"
trigger_entity: !input trigger_entity
actions:
# Check: Tibber Prices integration installed?
- variables:
_tp_entities: "{{ integration_entities('tibber_prices') | list }}"
# Check: Integration installed?
- if:
- condition: template
value_template: "{{ _tp_entities | length == 0 }}"
then:
- if:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
then:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "🧺 Laundry Day — Setup Required"
message: >
The Tibber Prices integration is not installed or not
configured. Install it via HACS and set up your Tibber
account before using this blueprint.
- action: input_boolean.turn_off
target:
entity_id: "{{ trigger_entity }}"
- stop: "Tibber Prices integration not found"
# ════════════════════════════════════════════════════════
# VALIDATION
# ════════════════════════════════════════════════════════
- if:
- condition: template
value_template: "{{ total_loads < 1 or total_loads > 5 }}"
then:
- if:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
then:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "🧺 Laundry Day"
message: >
Invalid number of loads: {{ total_loads }}.
Set {{ loads_entity }} between 1 and 5.
- action: input_boolean.turn_off
target:
entity_id: "{{ trigger_entity }}"
- stop: "Invalid load count"
# ════════════════════════════════════════════════════════
# START NOTIFICATION
# ════════════════════════════════════════════════════════
- if:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
then:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "🧺 Laundry Day Started"
message: >
Planning {{ total_loads }}
load{{ 's' if total_loads | int > 1 else '' }}
(wash {{ washer_duration }} min
{{ '+ dry ' ~ dryer_duration ~ ' min' if include_dryer else '' }}).
Must finish by {{ deadline_time[:5] }}.
# ════════════════════════════════════════════════════════
# MAIN PIPELINE LOOP
# ════════════════════════════════════════════════════════
- repeat:
count: "{{ total_loads }}"
sequence:
# Check if user cancelled (turned off the toggle)
- if:
- condition: template
value_template: "{{ is_state(trigger_entity, 'off') }}"
then:
- if:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
then:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "🧺 Laundry Day Cancelled"
message: >
Stopped after {{ repeat.index - 1 }}
of {{ total_loads }} loads.
- stop: "Cancelled by user"
# ── PLAN WASH ──────────────────────────────────
- action: tibber_prices.find_cheapest_block
data:
duration: >
{{ '%02d:%02d:00' | format(
(washer_duration | int) // 60,
(washer_duration | int) % 60) }}
must_finish_by: >
{{ today_at(deadline_time).isoformat() }}
response_variable: wash_result
- if:
- condition: template
value_template: "{{ not wash_result.window_found }}"
then:
- if:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
then:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "🧺 Laundry Day — Problem"
message: >
No cheap window found for wash {{ repeat.index }}/{{ total_loads }}.
{{ wash_result.reason | default('Not enough time before deadline?') }}
- action: input_boolean.turn_off
target:
entity_id: "{{ trigger_entity }}"
- stop: "No wash window found"
# ── WAIT UNTIL WASH START ──────────────────────
- delay:
seconds: >
{{ max(0,
((wash_result.window.start | as_datetime) - now())
.total_seconds() | int) }}
# ── START WASH ─────────────────────────────────
- action: switch.turn_on
target:
entity_id: "{{ washer_switch }}"
- if:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
then:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: >
👕 Wash {{ repeat.index }}/{{ total_loads }} Started
message: >
Running until
~{{ (now() + timedelta(minutes=washer_duration | int))
| as_timestamp | timestamp_custom('%H:%M') }}.
Price: {{ wash_result.window.price_mean | round(1) }}
{{ wash_result.price_unit }}/kWh avg.
{% if wash_result.relaxation_applied | default(false) %}
(Filters relaxed to find window.)
{% endif %}
# ── WAIT FOR WASH TO COMPLETE ──────────────────
- delay:
minutes: "{{ washer_duration }}"
# ── WASH DONE ─────────────────────────────────
- if:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
then:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: >
✅ Wash {{ repeat.index }}/{{ total_loads }} Done!
message: >
{% if include_dryer %}
Transfer laundry to the dryer!
{% endif %}
{% if repeat.index | int < total_loads | int %}
{% if include_dryer %}Then load{% else %}Load{% endif %}
the washer for load {{ repeat.index + 1 }}.
{% endif %}
# ── DRYER (if enabled) ─────────────────────────
- if:
- condition: template
value_template: "{{ include_dryer }}"
then:
# Wait for transfer
- delay:
minutes: "{{ transfer_time }}"
# Plan dryer
- action: tibber_prices.find_cheapest_block
data:
duration: >
{{ '%02d:%02d:00' | format(
(dryer_duration | int) // 60,
(dryer_duration | int) % 60) }}
must_finish_by: >
{{ today_at(deadline_time).isoformat() }}
response_variable: dry_result
- if:
- condition: template
value_template: "{{ not dry_result.window_found }}"
then:
- if:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
then:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "⚠️ Dryer {{ repeat.index }} — No Window"
message: >
No cheap window found for dryer {{ repeat.index }}.
Consider running the dryer manually.
{{ dry_result.reason | default('') }}
# Don't abort — continue with next wash cycle
- if:
- condition: template
value_template: >
{{ dry_result.window_found | default(false) }}
then:
# Wait until dryer start
- delay:
seconds: >
{{ max(0,
((dry_result.window.start | as_datetime) - now())
.total_seconds() | int) }}
# START DRYER
- action: switch.turn_on
target:
entity_id: "{{ dryer_switch }}"
- if:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
then:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: >
🌀 Dryer {{ repeat.index }}/{{ total_loads }}
Started
message: >
Running until
~{{ (now() + timedelta(minutes=dryer_duration | int))
| as_timestamp | timestamp_custom('%H:%M') }}.
{% if pipeline_mode
and repeat.index | int < total_loads | int %}
Next wash will be planned now —
dryer runs in parallel.
{% endif %}
# Wait for dryer to finish
# UNLESS pipeline mode AND more loads to come
- if:
- condition: template
value_template: >
{{ not (pipeline_mode
and repeat.index | int < total_loads | int) }}
then:
- delay:
minutes: "{{ dryer_duration }}"
# ════════════════════════════════════════════════════════
# ALL DONE
# ════════════════════════════════════════════════════════
- if:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
then:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "🎉 Laundry Day Complete!"
message: >
All {{ total_loads }}
load{{ 's' if total_loads | int > 1 else '' }}
washed{{ ' and dried' if include_dryer else '' }}.
Time to fold! 🧺
- action: input_boolean.turn_off
target:
entity_id: "{{ trigger_entity }}"

View file

@ -0,0 +1,284 @@
blueprint:
name: "Tibber Prices: Washing Machine (Smart Plug)"
description: >
**Companion blueprint for
[Tibber Prices](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration)
(HACS integration)** · Blueprint v1.0.0
Automatically run your washing machine at the cheapest electricity
price overnight using a smart plug.
Open your
[Tibber Prices configuration](https://my.home-assistant.io/redirect/integration/?domain=tibber_prices)
to verify the integration is installed and set up.
**What it does:**
- Plans the cheapest window overnight for one wash cycle
- Starts the washing machine automatically at the cheapest time
- Sends a notification with the planned time and price
- Survives Home Assistant restarts (uses `input_datetime` helper)
**Prerequisites:**
- [Tibber Prices integration](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration) installed via HACS
- One helper (created in Settings → Helpers):
- Date & Time (`input_datetime`) — stores the planned start time
- Smart plug switch for the washing machine
**Tip:** For multiple wash + dry cycles in one day, use the
[Laundry Day Pipeline](https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/laundry_day_pipeline.yaml)
blueprint instead.
**Other variants:**
[Home Connect](https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/washing_machine_home_connect.yaml)
·
[Home Connect Alt](https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/washing_machine_home_connect_alt.yaml)
domain: automation
author: jpawlowski
homeassistant:
min_version: "2024.6.0"
source_url: https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/washing_machine.yaml
input:
appliance:
name: Appliance
icon: mdi:washing-machine
description: Select the smart plug that controls your washing machine.
input:
appliance_switch:
name: Washing Machine Smart Plug
description: The switch entity controlling the washing machine.
selector:
entity:
filter:
domain: switch
schedule:
name: Schedule
icon: mdi:calendar-clock
description: Configure when to plan and the search window.
input:
plan_time:
name: Planning Time
description: >
When to search for the cheapest window each day.
default: "20:00:00"
selector:
time:
start_helper:
name: Start Time Helper
description: >
An `input_datetime` helper (type: Date and Time) that stores
the planned start time. Create in Settings → Helpers.
selector:
entity:
filter:
domain: input_datetime
duration:
name: Program Duration
description: >
Typical wash program duration in minutes.
ECO 40-60 ≈ 90 min, Cotton 60°C ≈ 120 min, Quick ≈ 45 min.
default: 95
selector:
number:
min: 15
max: 240
step: 5
unit_of_measurement: min
mode: slider
search_start:
name: Search Window Start
description: >
Earliest time the washing machine may start.
Typically late evening after loading.
default: "22:00:00"
selector:
time:
search_end:
name: Search Window End
description: >
Latest time the wash must finish by.
The program must complete before this time.
default: "06:00:00"
selector:
time:
runtime_overrides:
name: Runtime Overrides
icon: mdi:tune-vertical
collapsed: true
description: >
Optionally connect helpers to override settings from your
dashboard at runtime. When a helper is connected and has
a valid value, it takes priority over the fixed default.
Leave empty to always use the fixed defaults.
input:
duration_override:
name: "Override: Program Duration"
description: >
`input_number` helper to change the duration from your
dashboard without reconfiguring the blueprint.
**Create in Settings → Helpers → Number** with the same
min/max as the Duration slider above.
default: ""
selector:
entity:
filter:
domain: input_number
notifications:
name: Notifications
icon: mdi:bell-outline
collapsed: true
description: Optional mobile notifications.
input:
notify_service:
name: Notification Service
description: >
One or more notify services, comma-separated
(e.g., `notify.mobile_app_yourphone` or
`notify.mobile_app_phone, notify.mobile_app_tablet`).
Leave empty to disable all notifications.
default: ""
selector:
text:
mode: single
max_exceeded: silent
triggers:
- trigger: time
at: !input plan_time
id: plan
- trigger: time
at: !input start_helper
id: execute
variables:
_blueprint_variant: "smart_plug"
appliance_switch: !input appliance_switch
start_helper: !input start_helper
_duration_default: !input duration
_duration_override: !input duration_override
duration: >
{% set o = _duration_override %}
{% if o and states(o) not in ['unknown', 'unavailable'] %}
{{ states(o) | int(_duration_default) }}
{% else %}
{{ _duration_default }}
{% endif %}
search_start: !input search_start
search_end: !input search_end
notify_service: !input notify_service
actions:
# Check: Tibber Prices integration installed?
- variables:
_tp_entities: "{{ integration_entities('tibber_prices') | list }}"
- if:
- condition: template
value_template: "{{ _tp_entities | length == 0 }}"
then:
- if:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
then:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "👕 Washing Machine — Setup Required"
message: >
The Tibber Prices integration is not installed or not
configured. Install it via HACS and set up your Tibber
account before using this blueprint.
- stop: "Tibber Prices integration not found"
# ════════════════════════════════════════════════════════
# PLAN / EXECUTE
# ════════════════════════════════════════════════════════
- choose:
- conditions:
- condition: trigger
id: plan
sequence:
- action: tibber_prices.find_cheapest_block
data:
duration: >
{{ '%02d:%02d:00' | format(
(duration | int) // 60,
(duration | int) % 60) }}
search_start_time: "{{ search_start }}"
search_end_time: "{{ search_end }}"
search_end_day_offset: 1
response_variable: result
- if:
- condition: template
value_template: "{{ result.window_found }}"
then:
- action: input_datetime.set_datetime
target:
entity_id: "{{ start_helper }}"
data:
datetime: "{{ result.window.start }}"
- if:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
then:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "👕 Washing Machine Planned"
message: >
Start at {{ result.window.start | as_datetime
| as_local | as_timestamp
| timestamp_custom('%H:%M') }}.
Avg price: {{ result.window.price_mean | round(1) }}
{{ result.price_unit }}/kWh.
{% if result.relaxation_applied | default(false) %}
(Filters relaxed to find window.)
{% endif %}
else:
- if:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
then:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "👕 Washing Machine"
message: >
No cheap window found. Consider running manually
or adjusting the search window.
- conditions:
- condition: trigger
id: execute
sequence:
- action: switch.turn_on
target:
entity_id: "{{ appliance_switch }}"
- if:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
then:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "👕 Washing Machine Started"
message: >
Smart plug turned on. Program should finish in
~{{ duration }} minutes.

View file

@ -0,0 +1,458 @@
blueprint:
name: "Tibber Prices: Washing Machine (Home Connect)"
description: >
**Companion blueprint for
[Tibber Prices](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration)
(HACS integration)** · Blueprint v2.0.0
**Device-driven** washing machine automation with electricity price
optimization using the **Home Connect** integration (HA Core).
**How it works:**
1. Select your program on the washing machine
2. Close the door and enable Remote Start
3. The blueprint reads the estimated duration from the device
4. Finds the cheapest electricity window before your deadline
5. Tells the machine when to finish via `FinishInRelative`
6. The machine calculates when to start and manages the countdown
internally — no HA timers
**Important:** Washing machines use `FinishInRelative` (not
`StartInRelative` like dishwashers). The appliance receives the
deadline and calculates the optimal start time itself.
**No scheduling needed** — the machine handles the delayed start
itself. No `input_datetime` helpers required. Survives HA restarts
because the countdown runs on the appliance.
**Prerequisites:**
- [Tibber Prices integration](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration) installed via HACS
- [Home Connect](https://www.home-assistant.io/integrations/home_connect/) integration configured
- **Remote Start** enabled on the washing machine
**Tip:** For multiple wash + dry cycles, use the
[Laundry Day Pipeline](https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/laundry_day_pipeline_home_connect.yaml)
blueprint instead.
**Other variants:**
[Smart Plug](https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/washing_machine.yaml)
·
[Home Connect Alt](https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/washing_machine_home_connect_alt.yaml)
domain: automation
author: jpawlowski
homeassistant:
min_version: "2024.11.0"
source_url: https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/washing_machine_home_connect.yaml
input:
appliance:
name: Appliance
icon: mdi:washing-machine
description: >
Select your Home Connect washing machine device and entities.
input:
appliance_device:
name: Washing Machine Device
description: >
Your washing machine from the Home Connect integration.
Used to target the start command.
selector:
device:
filter:
integration: home_connect
door_sensor:
name: Door Sensor
description: >
The door sensor of your washing machine.
selector:
entity:
filter:
integration: home_connect
domain: binary_sensor
device_class: door
remote_start_sensor:
name: Remote Control Sensor
description: >
The "Remote Control Active" binary sensor.
Must be **on** for the automation to proceed.
selector:
entity:
filter:
integration: home_connect
domain: binary_sensor
estimated_duration_entity:
name: Estimated Program Duration
description: >
The "Estimated Total Program Time" sensor.
If unavailable, the fallback duration is used instead.
selector:
entity:
filter:
integration: home_connect
domain: sensor
operation_state_entity:
name: Operation State
description: >
The "Operation State" sensor.
Used to verify the machine is ready before planning.
selector:
entity:
filter:
integration: home_connect
domain: sensor
schedule:
name: Schedule
icon: mdi:calendar-clock
description: >
Configure the deadline and fallback duration.
input:
must_finish_by:
name: Must Finish By
description: >
The program must be finished by this time.
If this time has already passed today, the deadline
automatically moves to tomorrow (overnight mode).
default: "06:00:00"
selector:
time:
duration_fallback:
name: Fallback Duration (minutes)
description: >
Used **only** if the device doesn't report the estimated
duration. Normally the duration is read automatically.
ECO 40-60 ≈ 90 min, Cotton 60°C ≈ 120 min, Quick ≈ 45 min.
default: 95
selector:
number:
min: 15
max: 240
step: 5
unit_of_measurement: min
mode: slider
notifications:
name: Notifications
icon: mdi:bell-outline
collapsed: true
description: >
Optional notifications. Use **simple mode** (just a service)
or point to an **advanced script** for multi-target,
presence-aware, and platform-specific notifications.
input:
notify_service:
name: Quick Notification (Simple)
description: >
One or more notify services, comma-separated
(e.g., `notify.mobile_app_yourphone` or
`notify.mobile_app_phone, notify.mobile_app_tablet`).
Ignored when the advanced script is set.
default: ""
selector:
text:
notification_script:
name: Notification Script (Advanced)
description: >
A `script.*` entity for advanced notifications
(multiple recipients, presence filtering, iOS/Android).
When set, replaces the simple notification.
Receives structured variables (event_type, appliance,
title, message, and context data).
default: ""
selector:
entity:
filter:
domain: script
title_setup_required:
name: "Title: Setup Required"
default: "👕 Washing Machine — Setup Required"
selector:
text:
title_not_ready:
name: "Title: Not Ready"
default: "👕 Washing Machine — Not Ready"
selector:
text:
title_no_cheap_slot:
name: "Title: No Cheap Slot"
default: "👕 Washing Machine — No Cheap Slot"
selector:
text:
title_planned:
name: "Title: Planned"
default: "👕 Washing Machine — Planned!"
selector:
text:
mode: single
max_exceeded: silent
triggers:
- trigger: state
entity_id: !input door_sensor
to: "off"
- trigger: state
entity_id: !input remote_start_sensor
to: "on"
conditions:
- condition: state
entity_id: !input door_sensor
state: "off"
- condition: state
entity_id: !input remote_start_sensor
state: "on"
variables:
_blueprint_variant: "home_connect"
appliance_device: !input appliance_device
door_sensor: !input door_sensor
remote_start_sensor: !input remote_start_sensor
estimated_duration_entity: !input estimated_duration_entity
operation_state_entity: !input operation_state_entity
must_finish_by_time: !input must_finish_by
duration_fallback: !input duration_fallback
notify_service: !input notify_service
notification_script: !input notification_script
title_setup_required: !input title_setup_required
title_not_ready: !input title_not_ready
title_no_cheap_slot: !input title_no_cheap_slot
title_planned: !input title_planned
actions:
# ════════════════════════════════════════════════════════
# PREFLIGHT CHECKS
# ════════════════════════════════════════════════════════
- variables:
_tp_entities: "{{ integration_entities('tibber_prices') | list }}"
- if:
- condition: template
value_template: "{{ _tp_entities | length == 0 }}"
then:
- variables:
_n_title: "{{ title_setup_required }}"
_n_message: >
Install the Tibber Prices integration via HACS and
configure your Tibber account.
- choose:
- conditions:
- condition: template
value_template: "{{ notification_script | length > 0 }}"
sequence:
- action: script.turn_on
target:
entity_id: "{{ notification_script }}"
data:
variables:
event_type: setup_required
appliance: washing_machine
title: "{{ _n_title }}"
message: "{{ _n_message }}"
- conditions:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
sequence:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "{{ _n_title }}"
message: "{{ _n_message }}"
- stop: "Tibber Prices integration not found"
- if:
- condition: template
value_template: >
{% set op = states(operation_state_entity) %}
{{ op not in ['unknown', 'unavailable']
and 'Ready' not in op
and 'Inactive' not in op }}
then:
- variables:
_n_title: "{{ title_not_ready }}"
_n_message: >
State: {{ states(operation_state_entity) }}.
Ensure it's idle with Remote Start enabled.
- choose:
- conditions:
- condition: template
value_template: "{{ notification_script | length > 0 }}"
sequence:
- action: script.turn_on
target:
entity_id: "{{ notification_script }}"
data:
variables:
event_type: not_ready
appliance: washing_machine
title: "{{ _n_title }}"
message: "{{ _n_message }}"
- conditions:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
sequence:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "{{ _n_title }}"
message: "{{ _n_message }}"
- stop: "Machine not ready"
# ════════════════════════════════════════════════════════
# READ DEVICE DATA
# ════════════════════════════════════════════════════════
- variables:
_raw_duration: "{{ states(estimated_duration_entity) }}"
duration: >
{% set raw = states(estimated_duration_entity) %}
{% if raw not in ['unknown', 'unavailable', 'None', '']
and ':' in raw %}
{% set parts = raw.split(':') %}
{{ (parts[0] | int(0)) * 60 + (parts[1] | int(0)) }}
{% elif raw not in ['unknown', 'unavailable', 'None', '']
and raw | int(0) > 0 %}
{{ raw | int }}
{% else %}
{{ duration_fallback }}
{% endif %}
deadline: >
{% set dl = today_at(must_finish_by_time) %}
{% if dl <= now() %}
{{ (dl + timedelta(days=1)).isoformat() }}
{% else %}
{{ dl.isoformat() }}
{% endif %}
# ════════════════════════════════════════════════════════
# FIND CHEAPEST WINDOW
# ════════════════════════════════════════════════════════
- action: tibber_prices.find_cheapest_block
data:
duration: >
{{ '%02d:%02d:00' | format(
(duration | int) // 60,
(duration | int) % 60) }}
must_finish_by: "{{ deadline }}"
response_variable: result
- if:
- condition: template
value_template: "{{ not result.window_found }}"
then:
- variables:
_n_title: "{{ title_no_cheap_slot }}"
_n_message: >
No cheap slot before
{{ deadline | as_datetime | as_local
| as_timestamp | timestamp_custom('%H:%M') }}
for {{ duration }} min.
Run manually or extend the deadline.
- choose:
- conditions:
- condition: template
value_template: "{{ notification_script | length > 0 }}"
sequence:
- action: script.turn_on
target:
entity_id: "{{ notification_script }}"
data:
variables:
event_type: no_window
appliance: washing_machine
title: "{{ _n_title }}"
message: "{{ _n_message }}"
deadline: "{{ deadline }}"
duration_minutes: "{{ duration | int }}"
- conditions:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
sequence:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "{{ _n_title }}"
message: "{{ _n_message }}"
- stop: "No cheap window found"
# ════════════════════════════════════════════════════════
# START WITH DELAY (device manages countdown)
# ════════════════════════════════════════════════════════
- variables:
_window_start: "{{ result.window.start | as_datetime }}"
_window_end: >
{{ (_window_start + timedelta(minutes=duration | int)).isoformat() }}
finish_in_relative: >
{% set window_end = _window_start + timedelta(minutes=duration | int) %}
{% set seconds_until_end = ((window_end - now()).total_seconds()) | int %}
{{ [duration | int * 60, seconds_until_end] | max }}
# Washing machines use FinishInRelative
- action: home_connect.set_program_and_options
target:
device_id: "{{ appliance_device }}"
data:
affects_to: active_program
b_s_h_common_option_finish_in_relative: "{{ finish_in_relative }}"
- variables:
_n_title: "{{ title_planned }}"
_n_message: >
{% set delay = finish_in_relative | int - (duration | int * 60) %}
{% if delay > 0 %}
⏰ ~{{ _window_start | as_local
| as_timestamp | timestamp_custom('%H:%M') }}
(in {{ (delay / 3600) | round(1) }} h)
· ~{{ duration }} min
· {{ result.window.price_mean | round(1) }} {{ result.price_unit }}/kWh
{% else %}
▶️ Starting now!
· ~{{ duration }} min
· {{ result.window.price_mean | round(1) }} {{ result.price_unit }}/kWh
{% endif %}
{% if _raw_duration in ['unknown', 'unavailable', 'None', ''] %}
· ⚠️ Duration estimated
{% endif %}
- choose:
- conditions:
- condition: template
value_template: "{{ notification_script | length > 0 }}"
sequence:
- action: script.turn_on
target:
entity_id: "{{ notification_script }}"
data:
variables:
event_type: planned
appliance: washing_machine
title: "{{ _n_title }}"
message: "{{ _n_message }}"
start_time: "{{ _window_start | as_local | as_timestamp | timestamp_custom('%Y-%m-%dT%H:%M:%S') }}"
duration_minutes: "{{ duration | int }}"
price_mean: "{{ result.window.price_mean | round(1) }}"
price_unit: "{{ result.price_unit }}"
using_fallback_duration: "{{ _raw_duration in ['unknown', 'unavailable', 'None', ''] }}"
- conditions:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
sequence:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "{{ _n_title }}"
message: "{{ _n_message }}"

View file

@ -0,0 +1,513 @@
blueprint:
name: "Tibber Prices: Washing Machine (Home Connect Alt)"
description: >
**Companion blueprint for
[Tibber Prices](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration)
(HACS integration)** · Blueprint v2.0.0
**Device-driven** washing machine automation with electricity price
optimization using **Home Connect Alt**
([HACS integration by ekutner](https://my.home-assistant.io/redirect/hacs_repository/?owner=ekutner&repository=home-connect-hass&category=integration)).
**How it works:**
1. Select your program on the washing machine
2. Close the door and enable Remote Start
3. The blueprint reads the program and estimated duration from the
device automatically
4. Finds the cheapest electricity window before your deadline
5. Tells the washing machine when to finish via `FinishInRelative`
6. The machine calculates when to start and manages the countdown
internally — no HA timers
**Important:** Washing machines use `FinishInRelative` (not
`StartInRelative` like dishwashers). The appliance receives the
deadline and calculates the optimal start time itself.
**No scheduling needed** — the machine handles the delayed start
itself. No `input_datetime` helpers required. Survives HA restarts
because the countdown runs on the appliance.
**Prerequisites:**
- [Tibber Prices integration](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration) installed via HACS
- [Home Connect Alt](https://my.home-assistant.io/redirect/hacs_repository/?owner=ekutner&repository=home-connect-hass&category=integration) integration configured
- **Remote Start** enabled on the washing machine
**Tip:** For multiple wash + dry cycles, use the
[Laundry Day Pipeline](https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/laundry_day_pipeline_home_connect_alt.yaml)
blueprint instead.
**Other variants:**
[Smart Plug](https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/washing_machine.yaml)
·
[Home Connect](https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/washing_machine_home_connect.yaml)
domain: automation
author: jpawlowski
homeassistant:
min_version: "2024.11.0"
source_url: https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/washing_machine_home_connect_alt.yaml
input:
appliance:
name: Appliance Entities
icon: mdi:washing-machine
description: >
Select your Home Connect Alt washing machine entities.
All entities belong to the same appliance device.
input:
program_entity:
name: Program Select Entity
description: >
The **Programs** select entity of your washing machine
(e.g., `select.washer_programs`).
Used to read the selected program and as target for starting.
selector:
entity:
filter:
integration: home_connect_alt
domain: select
door_sensor:
name: Door Sensor
description: >
The door sensor of your washing machine
(e.g., `binary_sensor.washer_door`).
selector:
entity:
filter:
integration: home_connect_alt
domain: binary_sensor
device_class: door
remote_start_sensor:
name: Remote Control Sensor
description: >
The "Remote Control Active" binary sensor
(e.g., `binary_sensor.washer_remote_control_active`).
Must be **on** for the automation to proceed.
selector:
entity:
filter:
integration: home_connect_alt
domain: binary_sensor
estimated_duration_entity:
name: Estimated Program Duration
description: >
The "Estimated Total Program Time" sensor
(e.g., `sensor.washer_estimated_total_program_time`).
Shows the expected duration in `H:MM` format.
If unavailable, the fallback duration is used instead.
selector:
entity:
filter:
integration: home_connect_alt
domain: sensor
operation_state_entity:
name: Operation State
description: >
The "Operation State" sensor
(e.g., `sensor.washer_operation_state`).
Used to verify the machine is ready before planning.
selector:
entity:
filter:
integration: home_connect_alt
domain: sensor
schedule:
name: Schedule
icon: mdi:calendar-clock
description: >
Configure the deadline and fallback duration.
input:
must_finish_by:
name: Must Finish By
description: >
The program must be finished by this time.
If this time has already passed today, the deadline
automatically moves to tomorrow (overnight mode).
default: "06:00:00"
selector:
time:
duration_fallback:
name: Fallback Duration (minutes)
description: >
Used **only** if the device doesn't report the estimated
duration (e.g., program not yet fully selected on the
appliance). Normally the duration is read automatically.
ECO 40-60 ≈ 90 min, Cotton 60°C ≈ 120 min, Quick ≈ 45 min.
default: 95
selector:
number:
min: 15
max: 240
step: 5
unit_of_measurement: min
mode: slider
notifications:
name: Notifications
icon: mdi:bell-outline
collapsed: true
description: >
Optional notifications. Use **simple mode** (just a service)
or point to an **advanced script** for multi-target,
presence-aware, and platform-specific notifications.
input:
notify_service:
name: Quick Notification (Simple)
description: >
One or more notify services, comma-separated
(e.g., `notify.mobile_app_yourphone` or
`notify.mobile_app_phone, notify.mobile_app_tablet`).
Ignored when the advanced script is set.
default: ""
selector:
text:
notification_script:
name: Notification Script (Advanced)
description: >
A `script.*` entity for advanced notifications
(multiple recipients, presence filtering, iOS/Android).
When set, replaces the simple notification.
Receives structured variables (event_type, appliance,
title, message, and context data).
default: ""
selector:
entity:
filter:
domain: script
title_setup_required:
name: "Title: Setup Required"
default: "👕 Washing Machine — Setup Required"
selector:
text:
title_not_ready:
name: "Title: Not Ready"
default: "👕 Washing Machine — Not Ready"
selector:
text:
title_no_program:
name: "Title: No Program"
default: "👕 Washing Machine — No Program"
selector:
text:
title_no_cheap_slot:
name: "Title: No Cheap Slot"
default: "👕 Washing Machine — No Cheap Slot"
selector:
text:
title_planned:
name: "Title: Planned"
default: "👕 Washing Machine — Planned!"
selector:
text:
mode: single
max_exceeded: silent
triggers:
- trigger: state
entity_id: !input door_sensor
to: "off"
- trigger: state
entity_id: !input remote_start_sensor
to: "on"
conditions:
- condition: state
entity_id: !input door_sensor
state: "off"
- condition: state
entity_id: !input remote_start_sensor
state: "on"
variables:
_blueprint_variant: "home_connect_alt"
program_entity: !input program_entity
door_sensor: !input door_sensor
remote_start_sensor: !input remote_start_sensor
estimated_duration_entity: !input estimated_duration_entity
operation_state_entity: !input operation_state_entity
must_finish_by_time: !input must_finish_by
duration_fallback: !input duration_fallback
notify_service: !input notify_service
notification_script: !input notification_script
title_setup_required: !input title_setup_required
title_not_ready: !input title_not_ready
title_no_program: !input title_no_program
title_no_cheap_slot: !input title_no_cheap_slot
title_planned: !input title_planned
actions:
# ════════════════════════════════════════════════════════
# PREFLIGHT CHECKS
# ════════════════════════════════════════════════════════
- variables:
_tp_entities: "{{ integration_entities('tibber_prices') | list }}"
- if:
- condition: template
value_template: "{{ _tp_entities | length == 0 }}"
then:
- variables:
_n_title: "{{ title_setup_required }}"
_n_message: >
Install the Tibber Prices integration via HACS and
configure your Tibber account.
- choose:
- conditions:
- condition: template
value_template: "{{ notification_script | length > 0 }}"
sequence:
- action: script.turn_on
target:
entity_id: "{{ notification_script }}"
data:
variables:
event_type: setup_required
appliance: washing_machine
title: "{{ _n_title }}"
message: "{{ _n_message }}"
- conditions:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
sequence:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "{{ _n_title }}"
message: "{{ _n_message }}"
- stop: "Tibber Prices integration not found"
- if:
- condition: template
value_template: >
{% set op = states(operation_state_entity) %}
{{ op not in ['unknown', 'unavailable']
and 'Ready' not in op
and 'Inactive' not in op }}
then:
- variables:
_n_title: "{{ title_not_ready }}"
_n_message: >
State: {{ states(operation_state_entity) }}.
Ensure it's idle with Remote Start enabled.
- choose:
- conditions:
- condition: template
value_template: "{{ notification_script | length > 0 }}"
sequence:
- action: script.turn_on
target:
entity_id: "{{ notification_script }}"
data:
variables:
event_type: not_ready
appliance: washing_machine
title: "{{ _n_title }}"
message: "{{ _n_message }}"
- conditions:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
sequence:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "{{ _n_title }}"
message: "{{ _n_message }}"
- stop: "Machine not ready"
# ════════════════════════════════════════════════════════
# READ DEVICE DATA
# ════════════════════════════════════════════════════════
- variables:
selected_program: "{{ states(program_entity) }}"
_raw_duration: "{{ states(estimated_duration_entity) }}"
duration: >
{% set raw = states(estimated_duration_entity) %}
{% if raw not in ['unknown', 'unavailable', 'None', '']
and ':' in raw %}
{% set parts = raw.split(':') %}
{{ (parts[0] | int(0)) * 60 + (parts[1] | int(0)) }}
{% else %}
{{ duration_fallback }}
{% endif %}
deadline: >
{% set dl = today_at(must_finish_by_time) %}
{% if dl <= now() %}
{{ (dl + timedelta(days=1)).isoformat() }}
{% else %}
{{ dl.isoformat() }}
{% endif %}
- if:
- condition: template
value_template: >
{{ selected_program in ['unknown', 'unavailable', 'None', ''] }}
then:
- variables:
_n_title: "{{ title_no_program }}"
_n_message: >
Select a program, close the door, and enable
Remote Start.
- choose:
- conditions:
- condition: template
value_template: "{{ notification_script | length > 0 }}"
sequence:
- action: script.turn_on
target:
entity_id: "{{ notification_script }}"
data:
variables:
event_type: no_program
appliance: washing_machine
title: "{{ _n_title }}"
message: "{{ _n_message }}"
- conditions:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
sequence:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "{{ _n_title }}"
message: "{{ _n_message }}"
- stop: "No program selected"
# ════════════════════════════════════════════════════════
# FIND CHEAPEST WINDOW
# ════════════════════════════════════════════════════════
- action: tibber_prices.find_cheapest_block
data:
duration: >
{{ '%02d:%02d:00' | format(
(duration | int) // 60,
(duration | int) % 60) }}
must_finish_by: "{{ deadline }}"
response_variable: result
- if:
- condition: template
value_template: "{{ not result.window_found }}"
then:
- variables:
_n_title: "{{ title_no_cheap_slot }}"
_n_message: >
No cheap slot before
{{ deadline | as_datetime | as_local
| as_timestamp | timestamp_custom('%H:%M') }}
for {{ duration }} min.
Run manually or extend the deadline.
- choose:
- conditions:
- condition: template
value_template: "{{ notification_script | length > 0 }}"
sequence:
- action: script.turn_on
target:
entity_id: "{{ notification_script }}"
data:
variables:
event_type: no_window
appliance: washing_machine
title: "{{ _n_title }}"
message: "{{ _n_message }}"
deadline: "{{ deadline }}"
duration_minutes: "{{ duration | int }}"
- conditions:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
sequence:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "{{ _n_title }}"
message: "{{ _n_message }}"
- stop: "No cheap window found"
# ════════════════════════════════════════════════════════
# START WITH DELAY (device manages countdown)
# ════════════════════════════════════════════════════════
- variables:
_window_start: "{{ result.window.start | as_datetime }}"
# Washing machines use FinishInRelative
# (seconds from now until program must be finished)
_window_end: >
{{ (_window_start + timedelta(minutes=duration | int)).isoformat() }}
finish_in_relative: >
{% set window_end = _window_start + timedelta(minutes=duration | int) %}
{% set seconds_until_end = ((window_end - now()).total_seconds()) | int %}
{{ [duration | int * 60, seconds_until_end] | max }}
- action: home_connect_alt.start_program
target:
entity_id: "{{ program_entity }}"
data:
program: "{{ selected_program }}"
options:
- key: BSH.Common.Option.FinishInRelative
value: "{{ finish_in_relative }}"
- variables:
_n_title: "{{ title_planned }}"
_n_message: >
{{ selected_program.split('.')[-1] }}
{% set delay = finish_in_relative | int - (duration | int * 60) %}
{% if delay > 0 %}
· ⏰ ~{{ _window_start | as_local
| as_timestamp | timestamp_custom('%H:%M') }}
(in {{ (delay / 3600) | round(1) }} h)
{% else %}
· ▶️ Starting now!
{% endif %}
· ~{{ duration }} min
· {{ result.window.price_mean | round(1) }} {{ result.price_unit }}/kWh
{% if _raw_duration in ['unknown', 'unavailable', 'None', ''] %}
· ⚠️ Duration estimated
{% endif %}
- choose:
- conditions:
- condition: template
value_template: "{{ notification_script | length > 0 }}"
sequence:
- action: script.turn_on
target:
entity_id: "{{ notification_script }}"
data:
variables:
event_type: planned
appliance: washing_machine
title: "{{ _n_title }}"
message: "{{ _n_message }}"
start_time: "{{ _window_start | as_local | as_timestamp | timestamp_custom('%Y-%m-%dT%H:%M:%S') }}"
duration_minutes: "{{ duration | int }}"
price_mean: "{{ result.window.price_mean | round(1) }}"
price_unit: "{{ result.price_unit }}"
selected_program: "{{ selected_program }}"
using_fallback_duration: "{{ _raw_duration in ['unknown', 'unavailable', 'None', ''] }}"
- conditions:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
sequence:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "{{ _n_title }}"
message: "{{ _n_message }}"

View file

@ -0,0 +1,233 @@
blueprint:
name: "Tibber Prices: Water Heater — Boost During Cheap Prices"
description: >
**Companion blueprint for
[Tibber Prices](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration)
(HACS integration)** · Blueprint v1.0.0
Automatically boost your water heater during the cheapest price
periods and return to eco temperature when prices rise.
**What it does:**
- Raises the water heater temperature during the Best Price Period
- Lowers it back to eco when the period ends
- Real-time reaction — no planning or helpers needed
**Prerequisites:**
- [Tibber Prices integration](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration) installed via HACS
- A `water_heater` entity (or `climate` entity for heat-pump boilers)
domain: automation
author: jpawlowski
homeassistant:
min_version: "2024.6.0"
source_url: https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/water_heater.yaml
input:
devices:
name: Devices
icon: mdi:water-boiler
description: Select your water heater and the Tibber Prices period sensor.
input:
period_sensor:
name: Best Price Period Sensor
description: >
The `binary_sensor.<home>_best_price_period` from Tibber Prices.
selector:
entity:
filter:
domain: binary_sensor
integration: tibber_prices
water_heater_entity:
name: Water Heater
description: >
Your water heater entity. Works with `water_heater.*`
or `climate.*` (for heat-pump water heaters).
selector:
entity:
filter:
domain:
- water_heater
- climate
temperatures:
name: Temperatures
icon: mdi:thermometer
description: Configure boost and eco temperatures.
input:
boost_temperature:
name: Boost Temperature
description: Target temperature during cheap prices.
default: 60
selector:
number:
min: 40
max: 80
step: 1
unit_of_measurement: °C
eco_temperature:
name: Eco Temperature
description: Target temperature outside cheap periods.
default: 45
selector:
number:
min: 30
max: 60
step: 1
unit_of_measurement: °C
runtime_overrides:
name: Runtime Overrides
icon: mdi:tune-vertical
collapsed: true
description: >
Optionally connect helpers to override settings from your
dashboard at runtime. When a helper is connected and has
a valid value, it takes priority over the fixed default.
Leave empty to always use the fixed defaults.
input:
boost_temperature_override:
name: "Override: Boost Temperature"
description: >
`input_number` helper to change the boost temperature
from your dashboard.
**Create in Settings → Helpers → Number**
(min: 40, max: 80, step: 1, unit: °C).
default: ""
selector:
entity:
filter:
domain: input_number
eco_temperature_override:
name: "Override: Eco Temperature"
description: >
`input_number` helper to change the eco temperature
from your dashboard.
**Create in Settings → Helpers → Number**
(min: 30, max: 60, step: 1, unit: °C).
default: ""
selector:
entity:
filter:
domain: input_number
notifications:
name: Notifications
icon: mdi:bell-outline
collapsed: true
description: >
Optional mobile notifications for temperature changes.
input:
notify_service:
name: Notification Service
description: >
One or more notify services, comma-separated
(e.g., `notify.mobile_app_yourphone` or
`notify.mobile_app_phone, notify.mobile_app_tablet`).
Leave empty to disable all notifications.
default: ""
selector:
text:
mode: restart
triggers:
- trigger: state
entity_id: !input period_sensor
to: "on"
id: period_start
- trigger: state
entity_id: !input period_sensor
to: "off"
id: period_end
variables:
_blueprint_variant: "water_heater"
water_heater_entity: !input water_heater_entity
_boost_temp_default: !input boost_temperature
_boost_temp_override: !input boost_temperature_override
boost_temperature: >
{% set o = _boost_temp_override %}
{% if o and states(o) not in ['unknown', 'unavailable'] %}
{{ states(o) | float(_boost_temp_default) }}
{% else %}
{{ _boost_temp_default }}
{% endif %}
_eco_temp_default: !input eco_temperature
_eco_temp_override: !input eco_temperature_override
eco_temperature: >
{% set o = _eco_temp_override %}
{% if o and states(o) not in ['unknown', 'unavailable'] %}
{{ states(o) | float(_eco_temp_default) }}
{% else %}
{{ _eco_temp_default }}
{% endif %}
notify_service: !input notify_service
actions:
# Check: Tibber Prices integration installed?
- variables:
_tp_entities: "{{ integration_entities('tibber_prices') | list }}"
- if:
- condition: template
value_template: "{{ _tp_entities | length == 0 }}"
then:
- stop: "Tibber Prices integration not found"
# ════════════════════════════════════════════════════════
# BOOST / ECO
# ════════════════════════════════════════════════════════
- choose:
- conditions:
- condition: trigger
id: period_start
sequence:
# Determine the correct service based on domain
- variables:
target_domain: "{{ water_heater_entity.split('.')[0] }}"
- action: "{{ target_domain }}.set_temperature"
target:
entity_id: "{{ water_heater_entity }}"
data:
temperature: "{{ boost_temperature }}"
- if:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
then:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "🔥 Water Heater — Boost Active"
message: >
Target raised to {{ boost_temperature }}°C during
the best price period.
- conditions:
- condition: trigger
id: period_end
sequence:
- variables:
target_domain: "{{ water_heater_entity.split('.')[0] }}"
- action: "{{ target_domain }}.set_temperature"
target:
entity_id: "{{ water_heater_entity }}"
data:
temperature: "{{ eco_temperature }}"
- if:
- condition: template
value_template: "{{ notify_service | length > 0 }}"
then:
- repeat:
for_each: "{{ notify_service.split(',') | map('trim') | reject('eq', '') | list }}"
sequence:
- action: "{{ repeat.item }}"
data:
title: "🔥 Water Heater — Back to Eco"
message: >
Best price period ended. Target back to
{{ eco_temperature }}°C.

View file

@ -0,0 +1,507 @@
blueprint:
name: "Tibber Prices: Notify Residents"
description: >
**Companion script blueprint for
[Tibber Prices](https://my.home-assistant.io/redirect/hacs_repository/?owner=jpawlowski&repository=hass.tibber_prices&category=integration)
appliance blueprints** · Blueprint v2.0.0
Advanced notification dispatcher that replaces the simple
"Quick Notification" in any Tibber Prices appliance blueprint.
**Features:**
- Up to **10 residents** — just pick a person, devices are
discovered automatically
- **Auto-discovery** — finds all Mobile App notify services
from the person's device trackers and notifies every device
- **Presence filtering** — only notify people who are home
- **iOS and Android** platform-specific options (interruption
level, notification channel, priority)
- **Notify service override** — for Telegram, groups, or any
non-mobile-app service
- Notifications **grouped by appliance** with smart tag
replacement (new events replace old ones)
**How to use:**
1. Create a new script from this blueprint
2. Add your residents — just select their person entity
3. In any Tibber Prices appliance blueprint, select this script
as **Notification Script (Advanced)**
4. Done! The appliance blueprint passes all context automatically
**Auto-discovery explained:** For each person, the script reads
the assigned device trackers (e.g., `device_tracker.alice_iphone`)
and derives the matching `notify.mobile_app_*` service
automatically. All devices of a person get notified — no manual
service configuration needed.
**Override:** If a person should receive notifications via
Telegram, a group, or a custom service instead of (or in addition
to) their mobile devices, set the optional "Notify Service
Override" field. When set, only the override service is used.
**Taking control:** Click "Take control" in the script editor
for full YAML access. The 10-slot limit no longer applies.
domain: script
author: jpawlowski
homeassistant:
min_version: 2024.6.0
input:
presence_settings:
name: Presence Settings
icon: mdi:home-account
description: >
Control whether notifications are filtered by who is home.
input:
filter_by_presence:
name: Only notify people who are home
description: >
When enabled, only residents whose person entity shows
`home` will receive the notification.
Disabled = everyone gets notified regardless of location.
default: true
selector:
boolean:
resident_1:
name: "Resident 1"
icon: mdi:account
description: >
First notification recipient. Select the person entity —
their mobile devices are discovered automatically.
input:
resident_1_person:
name: "Resident 1 — Person"
description: >
Person entity (e.g., `person.alice`).
Leave empty to skip this slot.
default: ""
selector:
entity:
filter:
domain: person
resident_1_override:
name: "Resident 1 — Notify Service Override"
description: >
Optional: a specific notify service to use instead of
auto-discovered mobile devices (e.g.,
`notify.telegram_alice` or `notify.family_group`).
When set, auto-discovery is skipped for this resident.
default: ""
selector:
text:
resident_2:
name: "Resident 2"
icon: mdi:account
collapsed: true
description: "Second notification recipient."
input:
resident_2_person:
name: "Resident 2 — Person"
default: ""
selector:
entity:
filter:
domain: person
resident_2_override:
name: "Resident 2 — Notify Service Override"
default: ""
selector:
text:
resident_3:
name: "Resident 3"
icon: mdi:account
collapsed: true
description: "Third notification recipient."
input:
resident_3_person:
name: "Resident 3 — Person"
default: ""
selector:
entity:
filter:
domain: person
resident_3_override:
name: "Resident 3 — Notify Service Override"
default: ""
selector:
text:
resident_4:
name: "Resident 4"
icon: mdi:account
collapsed: true
description: "Fourth notification recipient."
input:
resident_4_person:
name: "Resident 4 — Person"
default: ""
selector:
entity:
filter:
domain: person
resident_4_override:
name: "Resident 4 — Notify Service Override"
default: ""
selector:
text:
resident_5:
name: "Resident 5"
icon: mdi:account
collapsed: true
description: "Fifth notification recipient."
input:
resident_5_person:
name: "Resident 5 — Person"
default: ""
selector:
entity:
filter:
domain: person
resident_5_override:
name: "Resident 5 — Notify Service Override"
default: ""
selector:
text:
resident_6:
name: "Resident 6"
icon: mdi:account
collapsed: true
description: "Sixth notification recipient."
input:
resident_6_person:
name: "Resident 6 — Person"
default: ""
selector:
entity:
filter:
domain: person
resident_6_override:
name: "Resident 6 — Notify Service Override"
default: ""
selector:
text:
resident_7:
name: "Resident 7"
icon: mdi:account
collapsed: true
description: "Seventh notification recipient."
input:
resident_7_person:
name: "Resident 7 — Person"
default: ""
selector:
entity:
filter:
domain: person
resident_7_override:
name: "Resident 7 — Notify Service Override"
default: ""
selector:
text:
resident_8:
name: "Resident 8"
icon: mdi:account
collapsed: true
description: "Eighth notification recipient."
input:
resident_8_person:
name: "Resident 8 — Person"
default: ""
selector:
entity:
filter:
domain: person
resident_8_override:
name: "Resident 8 — Notify Service Override"
default: ""
selector:
text:
resident_9:
name: "Resident 9"
icon: mdi:account
collapsed: true
description: "Ninth notification recipient."
input:
resident_9_person:
name: "Resident 9 — Person"
default: ""
selector:
entity:
filter:
domain: person
resident_9_override:
name: "Resident 9 — Notify Service Override"
default: ""
selector:
text:
resident_10:
name: "Resident 10"
icon: mdi:account
collapsed: true
description: "Tenth notification recipient."
input:
resident_10_person:
name: "Resident 10 — Person"
default: ""
selector:
entity:
filter:
domain: person
resident_10_override:
name: "Resident 10 — Notify Service Override"
default: ""
selector:
text:
# ════════════════════════════════════════════════════════════
# Script fields — received from the appliance blueprint
# via script.turn_on → data → variables
# ════════════════════════════════════════════════════════════
fields:
event_type:
name: Event Type
description: >
What happened. Values: setup_required, not_ready, no_program,
no_window, planned, started, prepare_washer, timeout,
invalid_loads, wash_planned, wash_done, dryer_planned,
dryer_skipped, cancelled, complete.
required: true
example: planned
selector:
text:
appliance:
name: Appliance
description: >
Which appliance sent this. Values: dishwasher,
washing_machine, dryer, laundry_pipeline.
required: true
example: dishwasher
selector:
text:
title:
name: Title
description: Default notification title (with emoji).
required: true
example: "🍽️ Dishwasher — Planned!"
selector:
text:
message:
name: Message
description: Default notification message body.
required: true
example: "Starts at 02:15 (in 3.2 h). Duration: ~120 min."
selector:
text:
start_time:
name: Start Time
description: >
ISO start time (planned/wash_planned/dryer_planned events).
example: "2025-01-15T02:15:00"
selector:
text:
duration_minutes:
name: Duration (minutes)
description: >
Program duration in minutes (planned/no_window events).
example: "120"
selector:
text:
price_mean:
name: Average Price
description: >
Mean price in the selected window (planned events).
example: "18.5"
selector:
text:
price_unit:
name: Price Unit
description: >
Currency unit (planned events), e.g., "ct/kWh" or "øre/kWh".
example: "ct/kWh"
selector:
text:
selected_program:
name: Selected Program
description: >
Appliance program name (planned events, Home Connect Alt only).
example: "Dishcare.Dishwasher.Program.Eco50"
selector:
text:
using_fallback_duration:
name: Using Fallback Duration
description: >
"True" if the duration is a fallback estimate (planned events).
example: "False"
selector:
text:
deadline:
name: Deadline
description: >
The deadline that was exceeded (no_window events).
example: "2025-01-15T08:00:00"
selector:
text:
load_index:
name: Load Index
description: >
Current load number (pipeline events).
example: "2"
selector:
text:
total_loads:
name: Total Loads
description: >
Total number of loads planned (pipeline events).
example: "3"
selector:
text:
# ════════════════════════════════════════════════════════════
# Variables — map blueprint inputs to template variables
# ════════════════════════════════════════════════════════════
variables:
filter_by_presence: !input filter_by_presence
r1_person: !input resident_1_person
r1_override: !input resident_1_override
r2_person: !input resident_2_person
r2_override: !input resident_2_override
r3_person: !input resident_3_person
r3_override: !input resident_3_override
r4_person: !input resident_4_person
r4_override: !input resident_4_override
r5_person: !input resident_5_person
r5_override: !input resident_5_override
r6_person: !input resident_6_person
r6_override: !input resident_6_override
r7_person: !input resident_7_person
r7_override: !input resident_7_override
r8_person: !input resident_8_person
r8_override: !input resident_8_override
r9_person: !input resident_9_person
r9_override: !input resident_9_override
r10_person: !input resident_10_person
r10_override: !input resident_10_override
# Build a flat list of {service, person} notification targets.
# For each resident with a person entity set:
# - If an override service is configured → use that
# - Otherwise → auto-discover mobile_app notify services
# from the person's device_trackers attribute
notify_targets: >
{% set slots = [
{'person': r1_person, 'override': r1_override},
{'person': r2_person, 'override': r2_override},
{'person': r3_person, 'override': r3_override},
{'person': r4_person, 'override': r4_override},
{'person': r5_person, 'override': r5_override},
{'person': r6_person, 'override': r6_override},
{'person': r7_person, 'override': r7_override},
{'person': r8_person, 'override': r8_override},
{'person': r9_person, 'override': r9_override},
{'person': r10_person, 'override': r10_override},
] %}
{% set ns = namespace(targets=[]) %}
{% for slot in slots if slot.person != '' %}
{% set override = slot.override | default('') %}
{% if override | length > 0 %}
{% set ns.targets = ns.targets
+ [{'service': override, 'person': slot.person}] %}
{% else %}
{% set trackers = state_attr(slot.person,
'device_trackers') or [] %}
{% for t in trackers %}
{% set dev_name = t.split('.')[1] %}
{% if services.notify is defined
and 'mobile_app_' ~ dev_name in services.notify %}
{% set ns.targets = ns.targets
+ [{'service': 'notify.mobile_app_' ~ dev_name,
'person': slot.person}] %}
{% endif %}
{% endfor %}
{% endif %}
{% endfor %}
{{ ns.targets }}
# Events that bypass presence filtering (always notify everyone)
critical_events:
- complete
- cancelled
- timeout
icon: mdi:bell-ring
mode: parallel
max: 10
sequence:
- repeat:
for_each: "{{ notify_targets }}"
sequence:
# ── Presence check ──────────────────────────────
- condition: template
value_template: >
{% set person_id = repeat.item.person %}
{% if not filter_by_presence %}
true
{% elif event_type in critical_events %}
true
{% else %}
{{ states(person_id) == 'home' }}
{% endif %}
# ── Send notification ───────────────────────────
- action: "{{ repeat.item.service }}"
data:
title: "{{ title }}"
message: "{{ message }}"
data:
# iOS — interruption level
push:
interruption-level: >
{% if event_type in ['planned', 'wash_planned',
'dryer_planned', 'complete'] %}
time-sensitive
{% else %}
active
{% endif %}
# Android — channel and priority
channel: tibber_prices
importance: >
{% if event_type in ['planned', 'wash_planned',
'dryer_planned', 'complete'] %}
high
{% else %}
default
{% endif %}
ttl: 0
priority: high
# Group & replace — new events replace old ones
group: "tibber_{{ appliance }}"
tag: "tibber_{{ appliance }}_{{ event_type }}"

View file

@ -20,6 +20,9 @@ if TYPE_CHECKING:
DOMAIN = "tibber_prices"
LOGGER = logging.getLogger(__package__)
# Integration version from manifest.json (used for DeviceInfo sw_version)
INTEGRATION_VERSION: str = json.loads((Path(__file__).parent / "manifest.json").read_text(encoding="utf-8"))["version"]
# Data storage keys
DATA_CHART_CONFIG = "chart_config" # Key for chart export config in hass.data
DATA_CHART_METADATA_CONFIG = "chart_metadata_config" # Key for chart metadata config in hass.data

View file

@ -5,7 +5,7 @@ from __future__ import annotations
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import ATTRIBUTION, DOMAIN, get_home_type_translation, get_translation
from .const import ATTRIBUTION, DOMAIN, INTEGRATION_VERSION, get_home_type_translation, get_translation
from .coordinator import TibberPricesDataUpdateCoordinator
@ -41,6 +41,7 @@ class TibberPricesEntity(CoordinatorEntity[TibberPricesDataUpdateCoordinator]):
manufacturer="Tibber",
model=translated_model,
serial_number=home_id or None,
sw_version=INTEGRATION_VERSION,
configuration_url="https://developer.tibber.com/explorer",
)

View file

@ -973,6 +973,11 @@ find_cheapest_schedule:
max: 120
unit_of_measurement: min
mode: box
sequential:
required: false
default: false
selector:
boolean:
search_scope:
required: false
selector:

View file

@ -0,0 +1,285 @@
"""
Entity reference resolution for service parameters.
Allows service parameters to accept Home Assistant entity IDs instead of
literal values. The entity's current state (or a specific attribute) is
resolved at call time and converted to the expected parameter type.
Syntax:
"sensor.washing_duration" uses entity state
"sensor.washing_duration@run_minutes" uses entity attribute
Supported target types: int, float, datetime, timedelta, time.
Usage in schemas:
vol.Required("duration"): or_entity_ref(
vol.All(cv.positive_time_period, vol.Range(...))
),
Usage in handlers:
data, resolved = resolve_entity_references(hass, call.data, PARAM_TYPES)
# 'data' is a mutable dict with entity refs replaced by resolved values
# 'resolved' is a dict of resolution details for the response
"""
from __future__ import annotations
from datetime import datetime, time as dt_time, timedelta
import re
from typing import TYPE_CHECKING, Any
import voluptuous as vol
from custom_components.tibber_prices.const import DOMAIN
from homeassistant.exceptions import ServiceValidationError
from homeassistant.util import dt as dt_util
if TYPE_CHECKING:
from homeassistant.core import HomeAssistant
# Entity ID pattern: domain.object_id with optional @attribute
# domain: lowercase letters + underscores, must start with letter
# object_id: lowercase letters, digits, underscores
# attribute: anything after @ (HA attributes can have varied names)
_ENTITY_REF_RE = re.compile(
r"^([a-z][a-z0-9_]*\.[a-z0-9_]+)" # entity_id
r"(?:@(.+))?$", # optional @attribute
)
def is_entity_reference(value: Any) -> bool:
"""Check if a value looks like an entity reference."""
return isinstance(value, str) and _ENTITY_REF_RE.match(value) is not None
def _validate_entity_ref(value: Any) -> str:
"""Voluptuous validator: accepts entity reference strings."""
if not isinstance(value, str):
raise vol.Invalid("Entity reference must be a string")
if not _ENTITY_REF_RE.match(value):
raise vol.Invalid(f"Not a valid entity reference: {value}")
return value
def or_entity_ref(validator: Any) -> vol.Any:
"""Wrap a voluptuous validator to also accept entity references.
The schema will first try the original validator (for literal values),
then fall back to accepting an entity reference string.
Example:
vol.Required("duration"): or_entity_ref(
vol.All(cv.positive_time_period, vol.Range(min=timedelta(minutes=1)))
),
"""
return vol.Any(validator, _validate_entity_ref)
def _resolve_raw_value(hass: HomeAssistant, ref: str) -> tuple[str, str, str | None]:
"""Resolve an entity reference to its raw string value.
Returns:
Tuple of (raw_value, entity_id, attribute_name_or_none).
Raises:
ServiceValidationError: If entity not found, attribute missing, or state unavailable.
"""
match = _ENTITY_REF_RE.match(ref)
if not match:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_entity_reference",
translation_placeholders={"reference": ref},
)
entity_id = match.group(1)
attribute = match.group(2)
state_obj = hass.states.get(entity_id)
if state_obj is None:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="entity_not_found",
translation_placeholders={"entity_id": entity_id},
)
if attribute:
if attribute not in state_obj.attributes:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="entity_attribute_not_found",
translation_placeholders={"entity_id": entity_id, "attribute": attribute},
)
raw = state_obj.attributes[attribute]
else:
raw = state_obj.state
if raw in ("unknown", "unavailable"):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="entity_state_unavailable",
translation_placeholders={"entity_id": entity_id, "state": raw},
)
return str(raw), entity_id, attribute
# ---------------------------------------------------------------------------
# Type converters convert raw string values to expected Python types
# ---------------------------------------------------------------------------
def _convert_to_timedelta(raw: str) -> timedelta:
"""Convert a raw string to timedelta.
Accepts:
- Numeric value interpreted as minutes (e.g., "90" 1h30m)
- "HH:MM" hours and minutes
- "HH:MM:SS" hours, minutes, seconds
"""
# Try numeric (minutes)
try:
minutes = float(raw)
return timedelta(minutes=minutes)
except ValueError:
pass
# Try HH:MM or HH:MM:SS
parts = raw.split(":")
if len(parts) == 2:
return timedelta(hours=int(parts[0]), minutes=int(parts[1]))
if len(parts) == 3:
return timedelta(hours=int(parts[0]), minutes=int(parts[1]), seconds=int(parts[2]))
msg = f"Cannot convert '{raw}' to duration (expected minutes as number or HH:MM:SS)"
raise ValueError(msg)
def _convert_to_datetime(raw: str) -> datetime:
"""Convert a raw string to datetime using HA's parser."""
dt = dt_util.parse_datetime(raw)
if dt is not None:
return dt
msg = f"Cannot convert '{raw}' to datetime"
raise ValueError(msg)
def _convert_to_time(raw: str) -> dt_time:
"""Convert a raw string to time-of-day using HA's parser."""
t = dt_util.parse_time(raw)
if t is not None:
return t
msg = f"Cannot convert '{raw}' to time"
raise ValueError(msg)
_CONVERTERS: dict[type, Any] = {
int: lambda raw: int(float(raw)),
float: float,
timedelta: _convert_to_timedelta,
datetime: _convert_to_datetime,
dt_time: _convert_to_time,
}
def resolve_entity_references(
hass: HomeAssistant,
data: dict[str, Any] | Any,
param_types: dict[str, type],
) -> tuple[dict[str, Any], dict[str, dict[str, str | None]]]:
"""Resolve entity references in service call data.
Creates a mutable copy of the data dict and replaces any entity reference
strings with their resolved and type-converted values.
Args:
hass: HomeAssistant instance.
data: Service call data (typically call.data, may be immutable).
param_types: Map of parameter name expected Python type.
Only parameters listed here are checked for entity references.
Returns:
Tuple of (resolved_data_dict, resolved_info_dict).
resolved_data_dict: Mutable dict with entity refs replaced.
resolved_info_dict: Details of resolved references (empty if none).
Keys are parameter names; values contain entity_id, attribute,
raw_value, and resolved_value for the service response.
Raises:
ServiceValidationError: If entity not found, attribute missing,
state unavailable, or value cannot be converted.
"""
resolved_data = dict(data)
resolved_info: dict[str, dict[str, str | None]] = {}
for param_name, expected_type in param_types.items():
value = resolved_data.get(param_name)
if value is None or not is_entity_reference(value):
continue
raw_value, entity_id, attribute = _resolve_raw_value(hass, value)
converter = _CONVERTERS.get(expected_type)
if converter is None:
converted = raw_value
else:
try:
converted = converter(raw_value)
except (ValueError, TypeError) as err:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="entity_value_conversion_failed",
translation_placeholders={
"entity_id": entity_id,
"attribute": attribute or "state",
"raw_value": raw_value,
"expected_type": expected_type.__name__,
},
) from err
resolved_data[param_name] = converted
resolved_info[param_name] = {
"entity_id": entity_id,
"attribute": attribute,
"raw_value": raw_value,
"resolved_value": str(converted),
}
return resolved_data, resolved_info
def resolve_task_entity_references(
hass: HomeAssistant,
tasks: list[dict[str, Any]],
) -> tuple[list[dict[str, Any]], dict[str, dict[str, str | None]]]:
"""Resolve entity references in schedule task list.
Handles entity references in task-level parameters (currently: duration).
Args:
hass: HomeAssistant instance.
tasks: List of task dicts from service call data.
Returns:
Tuple of (resolved_tasks, resolved_info).
resolved_tasks: New list with entity refs replaced in task dicts.
resolved_info: Details keyed as "tasks[i].param_name".
"""
task_param_types: dict[str, type] = {
"duration": timedelta,
}
resolved_tasks = []
all_resolved: dict[str, dict[str, str | None]] = {}
for i, task in enumerate(tasks):
resolved_task, task_resolved = resolve_entity_references(hass, task, task_param_types)
resolved_tasks.append(resolved_task)
for param_name, info in task_resolved.items():
all_resolved[f"tasks[{i}].{param_name}"] = info
return resolved_tasks, all_resolved

View file

@ -8,7 +8,7 @@ machine, dryer).
from __future__ import annotations
from datetime import datetime, timedelta
from datetime import datetime, time as dt_time, timedelta
import logging
import math
from typing import TYPE_CHECKING, Any
@ -24,6 +24,7 @@ from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv
from homeassistant.util import dt as dt_util
from .entity_resolver import or_entity_ref, resolve_entity_references
from .helpers import (
INTERVAL_MINUTES,
PRICE_LEVEL_ORDER,
@ -58,20 +59,43 @@ _LOGGER = logging.getLogger(__name__)
FIND_CHEAPEST_BLOCK_SERVICE_NAME = "find_cheapest_block"
# Parameter types for entity reference resolution (param_name → expected Python type)
COMMON_BLOCK_ENTITY_PARAMS: dict[str, type] = {
"duration": timedelta,
"search_start": datetime,
"search_end": datetime,
"search_start_time": dt_time,
"search_end_time": dt_time,
"search_start_day_offset": int,
"search_end_day_offset": int,
"search_start_offset_minutes": int,
"search_end_offset_minutes": int,
"min_distance_from_avg": float,
"duration_flexibility_minutes": int,
"must_finish_by": datetime,
}
_COMMON_BLOCK_SCHEMA = {
vol.Optional("entry_id", default=""): cv.string,
vol.Required("duration"): vol.All(
cv.positive_time_period,
vol.Range(min=timedelta(minutes=1), max=timedelta(hours=12)),
vol.Required("duration"): or_entity_ref(
vol.All(cv.positive_time_period, vol.Range(min=timedelta(minutes=1), max=timedelta(hours=12))),
),
vol.Optional("search_start"): or_entity_ref(cv.datetime),
vol.Optional("search_end"): or_entity_ref(cv.datetime),
vol.Optional("search_start_time"): or_entity_ref(cv.time),
vol.Optional("search_start_day_offset", default=0): or_entity_ref(
vol.All(vol.Coerce(int), vol.Range(min=-7, max=2)),
),
vol.Optional("search_end_time"): or_entity_ref(cv.time),
vol.Optional("search_end_day_offset", default=0): or_entity_ref(
vol.All(vol.Coerce(int), vol.Range(min=-7, max=2)),
),
vol.Optional("search_start_offset_minutes"): or_entity_ref(
vol.All(vol.Coerce(int), vol.Range(min=-10080, max=10080)),
),
vol.Optional("search_end_offset_minutes"): or_entity_ref(
vol.All(vol.Coerce(int), vol.Range(min=-10080, max=10080)),
),
vol.Optional("search_start"): cv.datetime,
vol.Optional("search_end"): cv.datetime,
vol.Optional("search_start_time"): cv.time,
vol.Optional("search_start_day_offset", default=0): vol.All(vol.Coerce(int), vol.Range(min=-7, max=2)),
vol.Optional("search_end_time"): cv.time,
vol.Optional("search_end_day_offset", default=0): vol.All(vol.Coerce(int), vol.Range(min=-7, max=2)),
vol.Optional("search_start_offset_minutes"): vol.All(vol.Coerce(int), vol.Range(min=-10080, max=10080)),
vol.Optional("search_end_offset_minutes"): vol.All(vol.Coerce(int), vol.Range(min=-10080, max=10080)),
vol.Optional("search_scope"): vol.In(VALID_SEARCH_SCOPES),
vol.Optional("max_price_level"): vol.In([lvl.lower() for lvl in PRICE_LEVEL_ORDER]),
vol.Optional("min_price_level"): vol.In([lvl.lower() for lvl in PRICE_LEVEL_ORDER]),
@ -83,10 +107,14 @@ _COMMON_BLOCK_SCHEMA = {
vol.Optional("include_current_interval", default=True): cv.boolean,
vol.Optional("use_base_unit", default=False): cv.boolean,
vol.Optional("smooth_outliers", default=True): cv.boolean,
vol.Optional("min_distance_from_avg"): vol.All(vol.Coerce(float), vol.Range(min=0.1, max=50.0)),
vol.Optional("min_distance_from_avg"): or_entity_ref(
vol.All(vol.Coerce(float), vol.Range(min=0.1, max=50.0)),
),
vol.Optional("allow_relaxation", default=True): cv.boolean,
vol.Optional("duration_flexibility_minutes"): vol.All(vol.Coerce(int), vol.Range(min=0, max=120)),
vol.Optional("must_finish_by"): cv.datetime,
vol.Optional("duration_flexibility_minutes"): or_entity_ref(
vol.All(vol.Coerce(int), vol.Range(min=0, max=120)),
),
vol.Optional("must_finish_by"): or_entity_ref(cv.datetime),
}
FIND_CHEAPEST_BLOCK_SERVICE_SCHEMA = vol.Schema(_COMMON_BLOCK_SCHEMA)
@ -215,17 +243,21 @@ async def _handle_find_block(
"""
service_label = "find_most_expensive_block" if reverse else "find_cheapest_block"
hass: HomeAssistant = call.hass
entry_id: str = call.data.get("entry_id", "")
duration_td: timedelta = call.data["duration"]
use_base_unit: bool = call.data.get("use_base_unit", False)
max_price_level: str | None = call.data.get("max_price_level")
min_price_level: str | None = call.data.get("min_price_level")
include_comparison_details: bool = call.data.get("include_comparison_details", False)
power_profile: list[int] | None = call.data.get("power_profile")
smooth_outliers: bool = call.data.get("smooth_outliers", True)
min_distance_from_avg: float | None = call.data.get("min_distance_from_avg")
allow_relaxation: bool = call.data.get("allow_relaxation", True)
duration_flexibility_minutes: int | None = call.data.get("duration_flexibility_minutes")
# Resolve entity references (e.g., "input_number.wash_duration" → 90 minutes)
data, resolved_refs = resolve_entity_references(hass, call.data, COMMON_BLOCK_ENTITY_PARAMS)
entry_id: str = data.get("entry_id", "")
duration_td: timedelta = data["duration"]
use_base_unit: bool = data.get("use_base_unit", False)
max_price_level: str | None = data.get("max_price_level")
min_price_level: str | None = data.get("min_price_level")
include_comparison_details: bool = data.get("include_comparison_details", False)
power_profile: list[int] | None = data.get("power_profile")
smooth_outliers: bool = data.get("smooth_outliers", True)
min_distance_from_avg: float | None = data.get("min_distance_from_avg")
allow_relaxation: bool = data.get("allow_relaxation", True)
duration_flexibility_minutes: int | None = data.get("duration_flexibility_minutes")
duration_minutes_requested = int(duration_td.total_seconds() / 60)
# Round up to nearest quarter-hour interval
@ -249,8 +281,8 @@ async def _handle_find_block(
home_tz = ZoneInfo(home_timezone)
# Handle must_finish_by: convert deadline to search_end
validate_search_params(call.data)
effective_data, must_finish_by_dt = apply_must_finish_by(call.data, home_tz)
validate_search_params(data)
effective_data, must_finish_by_dt = apply_must_finish_by(data, home_tz)
# Resolve search range (priority: explicit datetime > time+offset > minutes offset > default)
now = dt_util.now().astimezone(home_tz)
@ -371,6 +403,8 @@ async def _handle_find_block(
}
if relaxation_applied:
response["relaxation_steps"] = relaxation_steps
if resolved_refs:
response["_resolved"] = resolved_refs
return response
# Effective duration may differ from original if relaxation reduced it
@ -435,6 +469,8 @@ async def _handle_find_block(
}
if relaxation_applied:
response["relaxation_steps"] = relaxation_steps
if resolved_refs:
response["_resolved"] = resolved_refs
_LOGGER.info(
"%s: found window at %s, mean=%.4f %s",

View file

@ -8,7 +8,7 @@ Intervals need not be contiguous — designed for flexible loads
from __future__ import annotations
from datetime import datetime, timedelta
from datetime import datetime, time as dt_time, timedelta
import logging
import math
from typing import TYPE_CHECKING, Any
@ -21,6 +21,7 @@ from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv
from homeassistant.util import dt as dt_util
from .entity_resolver import or_entity_ref, resolve_entity_references
from .helpers import (
INTERVAL_MINUTES,
PRICE_LEVEL_ORDER,
@ -55,23 +56,46 @@ _LOGGER = logging.getLogger(__name__)
FIND_CHEAPEST_HOURS_SERVICE_NAME = "find_cheapest_hours"
# Parameter types for entity reference resolution
_HOURS_ENTITY_PARAMS: dict[str, type] = {
"duration": timedelta,
"min_segment_duration": timedelta,
"search_start": datetime,
"search_end": datetime,
"search_start_time": dt_time,
"search_end_time": dt_time,
"search_start_day_offset": int,
"search_end_day_offset": int,
"search_start_offset_minutes": int,
"search_end_offset_minutes": int,
"min_distance_from_avg": float,
"duration_flexibility_minutes": int,
"must_finish_by": datetime,
}
_COMMON_HOURS_SCHEMA = {
vol.Optional("entry_id", default=""): cv.string,
vol.Required("duration"): vol.All(
cv.positive_time_period,
vol.Range(min=timedelta(minutes=1), max=timedelta(hours=24)),
vol.Required("duration"): or_entity_ref(
vol.All(cv.positive_time_period, vol.Range(min=timedelta(minutes=1), max=timedelta(hours=24))),
),
vol.Optional("search_start"): cv.datetime,
vol.Optional("search_end"): cv.datetime,
vol.Optional("search_start_time"): cv.time,
vol.Optional("search_start_day_offset", default=0): vol.All(vol.Coerce(int), vol.Range(min=-7, max=2)),
vol.Optional("search_end_time"): cv.time,
vol.Optional("search_end_day_offset", default=0): vol.All(vol.Coerce(int), vol.Range(min=-7, max=2)),
vol.Optional("search_start_offset_minutes"): vol.All(vol.Coerce(int), vol.Range(min=-10080, max=10080)),
vol.Optional("search_end_offset_minutes"): vol.All(vol.Coerce(int), vol.Range(min=-10080, max=10080)),
vol.Optional("min_segment_duration"): vol.All(
cv.positive_time_period,
vol.Range(min=timedelta(minutes=1), max=timedelta(hours=4)),
vol.Optional("search_start"): or_entity_ref(cv.datetime),
vol.Optional("search_end"): or_entity_ref(cv.datetime),
vol.Optional("search_start_time"): or_entity_ref(cv.time),
vol.Optional("search_start_day_offset", default=0): or_entity_ref(
vol.All(vol.Coerce(int), vol.Range(min=-7, max=2)),
),
vol.Optional("search_end_time"): or_entity_ref(cv.time),
vol.Optional("search_end_day_offset", default=0): or_entity_ref(
vol.All(vol.Coerce(int), vol.Range(min=-7, max=2)),
),
vol.Optional("search_start_offset_minutes"): or_entity_ref(
vol.All(vol.Coerce(int), vol.Range(min=-10080, max=10080)),
),
vol.Optional("search_end_offset_minutes"): or_entity_ref(
vol.All(vol.Coerce(int), vol.Range(min=-10080, max=10080)),
),
vol.Optional("min_segment_duration"): or_entity_ref(
vol.All(cv.positive_time_period, vol.Range(min=timedelta(minutes=1), max=timedelta(hours=4))),
),
vol.Optional("search_scope"): vol.In(VALID_SEARCH_SCOPES),
vol.Optional("max_price_level"): vol.In([lvl.lower() for lvl in PRICE_LEVEL_ORDER]),
@ -84,10 +108,14 @@ _COMMON_HOURS_SCHEMA = {
vol.Optional("include_current_interval", default=True): cv.boolean,
vol.Optional("use_base_unit", default=False): cv.boolean,
vol.Optional("smooth_outliers", default=True): cv.boolean,
vol.Optional("min_distance_from_avg"): vol.All(vol.Coerce(float), vol.Range(min=0.1, max=50.0)),
vol.Optional("min_distance_from_avg"): or_entity_ref(
vol.All(vol.Coerce(float), vol.Range(min=0.1, max=50.0)),
),
vol.Optional("allow_relaxation", default=True): cv.boolean,
vol.Optional("duration_flexibility_minutes"): vol.All(vol.Coerce(int), vol.Range(min=0, max=120)),
vol.Optional("must_finish_by"): cv.datetime,
vol.Optional("duration_flexibility_minutes"): or_entity_ref(
vol.All(vol.Coerce(int), vol.Range(min=0, max=120)),
),
vol.Optional("must_finish_by"): or_entity_ref(cv.datetime),
}
FIND_CHEAPEST_HOURS_SERVICE_SCHEMA = vol.Schema(_COMMON_HOURS_SCHEMA)
@ -275,18 +303,22 @@ async def _handle_find_hours(
"""
service_label = "find_most_expensive_hours" if reverse else "find_cheapest_hours"
hass: HomeAssistant = call.hass
entry_id: str = call.data.get("entry_id", "")
duration_td: timedelta = call.data["duration"]
min_segment_td: timedelta | None = call.data.get("min_segment_duration")
use_base_unit: bool = call.data.get("use_base_unit", False)
max_price_level: str | None = call.data.get("max_price_level")
min_price_level: str | None = call.data.get("min_price_level")
include_comparison_details: bool = call.data.get("include_comparison_details", False)
power_profile: list[int] | None = call.data.get("power_profile")
smooth_outliers: bool = call.data.get("smooth_outliers", True)
min_distance_from_avg: float | None = call.data.get("min_distance_from_avg")
allow_relaxation: bool = call.data.get("allow_relaxation", True)
duration_flexibility_minutes: int | None = call.data.get("duration_flexibility_minutes")
# Resolve entity references
data, resolved_refs = resolve_entity_references(hass, call.data, _HOURS_ENTITY_PARAMS)
entry_id: str = data.get("entry_id", "")
duration_td: timedelta = data["duration"]
min_segment_td: timedelta | None = data.get("min_segment_duration")
use_base_unit: bool = data.get("use_base_unit", False)
max_price_level: str | None = data.get("max_price_level")
min_price_level: str | None = data.get("min_price_level")
include_comparison_details: bool = data.get("include_comparison_details", False)
power_profile: list[int] | None = data.get("power_profile")
smooth_outliers: bool = data.get("smooth_outliers", True)
min_distance_from_avg: float | None = data.get("min_distance_from_avg")
allow_relaxation: bool = data.get("allow_relaxation", True)
duration_flexibility_minutes: int | None = data.get("duration_flexibility_minutes")
total_minutes_requested = int(duration_td.total_seconds() / 60)
min_segment_minutes_requested = int(min_segment_td.total_seconds() / 60) if min_segment_td else INTERVAL_MINUTES
@ -313,8 +345,8 @@ async def _handle_find_hours(
home_tz = ZoneInfo(home_timezone)
# Handle must_finish_by: convert deadline to search_end
validate_search_params(call.data)
effective_data, must_finish_by_dt = apply_must_finish_by(call.data, home_tz)
validate_search_params(data)
effective_data, must_finish_by_dt = apply_must_finish_by(data, home_tz)
# Resolve search range (priority: explicit datetime > time+offset > minutes offset > default)
now = dt_util.now().astimezone(home_tz)
@ -452,6 +484,8 @@ async def _handle_find_hours(
}
if relaxation_applied:
response["relaxation_steps"] = relaxation_steps
if resolved_refs:
response["_resolved"] = resolved_refs
return response
# Find opposite-direction selection for price comparison (from full unfiltered list)
@ -493,6 +527,9 @@ async def _handle_find_hours(
last_seg_end_dt = datetime.fromisoformat(last_seg_end)
schedule["seconds_until_end"] = max(0, int((last_seg_end_dt - now).total_seconds()))
if resolved_refs:
found_response["_resolved"] = resolved_refs
return found_response

View file

@ -8,7 +8,7 @@ each task claims the cheapest available contiguous window in the remaining pool.
from __future__ import annotations
from datetime import datetime, timedelta
from datetime import datetime, time as dt_time, timedelta
import logging
import math
from typing import TYPE_CHECKING, Any
@ -24,6 +24,7 @@ from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv
from homeassistant.util import dt as dt_util
from .entity_resolver import or_entity_ref, resolve_entity_references, resolve_task_entity_references
from .helpers import (
INTERVAL_MINUTES,
PRICE_LEVEL_ORDER,
@ -56,12 +57,26 @@ _LOGGER = logging.getLogger(__name__)
FIND_CHEAPEST_SCHEDULE_SERVICE_NAME = "find_cheapest_schedule"
# Parameter types for entity reference resolution
_SCHEDULE_ENTITY_PARAMS: dict[str, type] = {
"gap_minutes": int,
"search_start": datetime,
"search_end": datetime,
"search_start_time": dt_time,
"search_end_time": dt_time,
"search_start_day_offset": int,
"search_end_day_offset": int,
"search_start_offset_minutes": int,
"search_end_offset_minutes": int,
"must_finish_by": datetime,
"duration_flexibility_minutes": int,
}
_TASK_SCHEMA = vol.Schema(
{
vol.Required("name"): cv.string,
vol.Required("duration"): vol.All(
cv.positive_time_period,
vol.Range(min=timedelta(minutes=1), max=timedelta(hours=12)),
vol.Required("duration"): or_entity_ref(
vol.All(cv.positive_time_period, vol.Range(min=timedelta(minutes=1), max=timedelta(hours=12))),
),
vol.Optional("power_profile"): vol.All(
[vol.All(vol.Coerce(int), vol.Range(min=1, max=100000))],
@ -77,24 +92,37 @@ FIND_CHEAPEST_SCHEDULE_SERVICE_SCHEMA = vol.Schema(
[_TASK_SCHEMA],
vol.Length(min=1, max=4),
),
vol.Optional("gap_minutes", default=0): vol.All(vol.Coerce(int), vol.Range(min=0, max=120)),
vol.Optional("search_start"): cv.datetime,
vol.Optional("search_end"): cv.datetime,
vol.Optional("search_start_time"): cv.time,
vol.Optional("search_start_day_offset", default=0): vol.All(vol.Coerce(int), vol.Range(min=-7, max=2)),
vol.Optional("search_end_time"): cv.time,
vol.Optional("search_end_day_offset", default=0): vol.All(vol.Coerce(int), vol.Range(min=-7, max=2)),
vol.Optional("search_start_offset_minutes"): vol.All(vol.Coerce(int), vol.Range(min=-10080, max=10080)),
vol.Optional("search_end_offset_minutes"): vol.All(vol.Coerce(int), vol.Range(min=-10080, max=10080)),
vol.Optional("must_finish_by"): cv.datetime,
vol.Optional("gap_minutes", default=0): or_entity_ref(
vol.All(vol.Coerce(int), vol.Range(min=0, max=120)),
),
vol.Optional("search_start"): or_entity_ref(cv.datetime),
vol.Optional("search_end"): or_entity_ref(cv.datetime),
vol.Optional("search_start_time"): or_entity_ref(cv.time),
vol.Optional("search_start_day_offset", default=0): or_entity_ref(
vol.All(vol.Coerce(int), vol.Range(min=-7, max=2)),
),
vol.Optional("search_end_time"): or_entity_ref(cv.time),
vol.Optional("search_end_day_offset", default=0): or_entity_ref(
vol.All(vol.Coerce(int), vol.Range(min=-7, max=2)),
),
vol.Optional("search_start_offset_minutes"): or_entity_ref(
vol.All(vol.Coerce(int), vol.Range(min=-10080, max=10080)),
),
vol.Optional("search_end_offset_minutes"): or_entity_ref(
vol.All(vol.Coerce(int), vol.Range(min=-10080, max=10080)),
),
vol.Optional("must_finish_by"): or_entity_ref(cv.datetime),
vol.Optional("search_scope"): vol.In(VALID_SEARCH_SCOPES),
vol.Optional("max_price_level"): vol.In([lvl.lower() for lvl in PRICE_LEVEL_ORDER]),
vol.Optional("min_price_level"): vol.In([lvl.lower() for lvl in PRICE_LEVEL_ORDER]),
vol.Optional("include_comparison_details", default=False): cv.boolean,
vol.Optional("use_base_unit", default=False): cv.boolean,
vol.Optional("sequential", default=False): cv.boolean,
vol.Optional("smooth_outliers", default=True): cv.boolean,
vol.Optional("allow_relaxation", default=True): cv.boolean,
vol.Optional("duration_flexibility_minutes"): vol.All(vol.Coerce(int), vol.Range(min=0, max=120)),
vol.Optional("duration_flexibility_minutes"): or_entity_ref(
vol.All(vol.Coerce(int), vol.Range(min=0, max=120)),
),
}
)
@ -230,9 +258,14 @@ def _attempt_schedule(
tasks: list[dict[str, Any]],
gap_intervals: int,
smooth_outliers: bool,
sequential: bool = False,
) -> tuple[list[dict[str, Any]], list[str], list[dict[str, Any]]]:
"""Attempt to schedule tasks with specific filter parameters.
When sequential=True, tasks are placed in declaration order and each task's
search window begins after the previous task's end + gap. When False
(default), tasks are sorted longest-first for optimal greedy packing.
Returns:
(assignments, unscheduled_names, filtered_price_info)
@ -247,18 +280,38 @@ def _attempt_schedule(
if not search_data:
return [], [t["name"] for t in tasks], filtered
# Greedy assignment: longest task first
tasks_sorted = sorted(tasks, key=lambda t: t["duration_intervals"], reverse=True)
# Task ordering: declaration order when sequential, longest-first otherwise
tasks_ordered = list(tasks) if sequential else sorted(tasks, key=lambda t: t["duration_intervals"], reverse=True)
available = [True] * len(search_data)
assignments: list[dict[str, Any]] = []
unscheduled: list[str] = []
for task in tasks_sorted:
# In sequential mode, track the earliest allowed start index for the next task
sequential_min_idx = 0
sequential_chain_broken = False
for task in tasks_ordered:
dur_intervals = task["duration_intervals"]
# In sequential mode, if the chain is broken (previous task failed),
# all remaining tasks are also unscheduled
if sequential and sequential_chain_broken:
unscheduled.append(task["name"])
continue
# In sequential mode, restrict search to intervals at or after the
# minimum start index by marking earlier slots as unavailable
if sequential and sequential_min_idx > 0:
for k in range(min(sequential_min_idx, len(search_data))):
available[k] = False
window = _find_cheapest_window_in_pool(search_data, dur_intervals, available)
if window is None:
unscheduled.append(task["name"])
if sequential:
sequential_chain_broken = True
continue
start_idx, end_idx = window
@ -273,6 +326,10 @@ def _attempt_schedule(
for k in range(start_idx, gap_end):
available[k] = False
# In sequential mode, advance the minimum start for the next task
if sequential:
sequential_min_idx = gap_end
assignments.append(
{
"name": task["name"],
@ -284,20 +341,42 @@ def _attempt_schedule(
return assignments, unscheduled, filtered
def _resolve_schedule_entity_refs(
hass: HomeAssistant,
call_data: dict[str, Any] | Any,
) -> tuple[dict[str, Any], dict[str, dict[str, str | None]]]:
"""Resolve entity references in schedule service call data (top-level + tasks)."""
data_dict, resolved_refs = resolve_entity_references(hass, call_data, _SCHEDULE_ENTITY_PARAMS)
tasks_raw: list[dict[str, Any]] = data_dict["tasks"]
resolved_tasks, task_resolved_refs = resolve_task_entity_references(hass, tasks_raw)
if resolved_tasks:
data_dict["tasks"] = resolved_tasks
if task_resolved_refs:
resolved_refs.update(task_resolved_refs)
return data_dict, resolved_refs
async def handle_find_cheapest_schedule(call: ServiceCall) -> ServiceResponse:
"""Handle find_cheapest_schedule service call."""
service_label = "find_cheapest_schedule"
hass: HomeAssistant = call.hass
entry_id: str = call.data.get("entry_id", "")
tasks_raw: list[dict[str, Any]] = call.data["tasks"]
gap_minutes: int = call.data.get("gap_minutes", 0)
use_base_unit: bool = call.data.get("use_base_unit", False)
max_price_level: str | None = call.data.get("max_price_level")
min_price_level: str | None = call.data.get("min_price_level")
include_comparison_details: bool = call.data.get("include_comparison_details", False)
smooth_outliers: bool = call.data.get("smooth_outliers", True)
allow_relaxation: bool = call.data.get("allow_relaxation", True)
duration_flexibility_minutes: int | None = call.data.get("duration_flexibility_minutes")
# Resolve entity references (top-level params + task durations)
data_dict, resolved_refs = _resolve_schedule_entity_refs(hass, call.data)
tasks_raw: list[dict[str, Any]] = data_dict["tasks"]
entry_id: str = data_dict.get("entry_id", "")
gap_minutes: int = data_dict.get("gap_minutes", 0)
use_base_unit: bool = data_dict.get("use_base_unit", False)
max_price_level: str | None = data_dict.get("max_price_level")
min_price_level: str | None = data_dict.get("min_price_level")
include_comparison_details: bool = data_dict.get("include_comparison_details", False)
smooth_outliers: bool = data_dict.get("smooth_outliers", True)
allow_relaxation: bool = data_dict.get("allow_relaxation", True)
sequential: bool = data_dict.get("sequential", False)
duration_flexibility_minutes: int | None = data_dict.get("duration_flexibility_minutes")
# Validate task names are unique (before any expensive operations)
task_names = [t["name"] for t in tasks_raw]
@ -329,8 +408,8 @@ async def handle_find_cheapest_schedule(call: ServiceCall) -> ServiceResponse:
home_tz = ZoneInfo(home_timezone)
# Validate and handle must_finish_by
validate_search_params(call.data)
effective_data, must_finish_by_dt = apply_must_finish_by(call.data, home_tz)
validate_search_params(data_dict)
effective_data, must_finish_by_dt = apply_must_finish_by(data_dict, home_tz)
now = dt_util.now().astimezone(home_tz)
search_start, search_end = resolve_search_range(effective_data, now, home_tz)
@ -372,10 +451,11 @@ async def handle_find_cheapest_schedule(call: ServiceCall) -> ServiceResponse:
)
_LOGGER.info(
"%s called: %d tasks, gap=%dmin, range=%s to %s",
"%s called: %d tasks, gap=%dmin, sequential=%s, range=%s to %s",
service_label,
len(tasks),
gap_minutes,
sequential,
search_start,
search_end,
)
@ -411,6 +491,7 @@ async def handle_find_cheapest_schedule(call: ServiceCall) -> ServiceResponse:
tasks=tasks,
gap_intervals=gap_intervals,
smooth_outliers=smooth_outliers,
sequential=sequential,
)
all_scheduled = len(unscheduled) == 0
level_filter_active = min_price_level is not None or max_price_level is not None
@ -437,6 +518,7 @@ async def handle_find_cheapest_schedule(call: ServiceCall) -> ServiceResponse:
tasks=tasks,
gap_intervals=gap_intervals,
smooth_outliers=smooth_outliers,
sequential=sequential,
)
if len(a) > len(best_assignments):
best_assignments, best_unscheduled, best_filtered = a, u, f
@ -474,6 +556,7 @@ async def handle_find_cheapest_schedule(call: ServiceCall) -> ServiceResponse:
tasks=reduced,
gap_intervals=gap_intervals,
smooth_outliers=smooth_outliers,
sequential=sequential,
)
if len(a) > len(best_assignments):
best_assignments, best_unscheduled, best_filtered = a, u, f
@ -584,6 +667,7 @@ async def handle_find_cheapest_schedule(call: ServiceCall) -> ServiceResponse:
"must_finish_by": must_finish_by_dt.isoformat() if must_finish_by_dt else None,
"currency": currency,
"price_unit": price_unit,
"sequential": sequential,
"all_tasks_scheduled": all_scheduled,
"reason": reason,
"relaxation_applied": relaxation_applied,
@ -593,4 +677,6 @@ async def handle_find_cheapest_schedule(call: ServiceCall) -> ServiceResponse:
}
if relaxation_applied:
result["relaxation_steps"] = relaxation_steps
if resolved_refs:
result["_resolved"] = resolved_refs
return result

View file

@ -52,6 +52,7 @@ from custom_components.tibber_prices.const import (
from custom_components.tibber_prices.coordinator.helpers import get_intervals_for_day_offsets
from homeassistant.exceptions import ServiceValidationError
from .entity_resolver import or_entity_ref, resolve_entity_references
from .formatters import aggregate_to_hourly, get_period_data, normalize_level_filter, normalize_rating_level_filter
from .helpers import get_entry_and_data, has_tomorrow_data
@ -260,6 +261,11 @@ CHARTDATA_SERVICE_NAME: Final = "get_chartdata"
ATTR_DAY: Final = "day"
ATTR_ENTRY_ID: Final = "entry_id"
# Parameter types for entity reference resolution
_CHARTDATA_ENTITY_PARAMS: dict[str, type] = {
"round_decimals": int,
}
# Service schema
CHARTDATA_SERVICE_SCHEMA: Final = vol.Schema(
{
@ -269,7 +275,7 @@ CHARTDATA_SERVICE_SCHEMA: Final = vol.Schema(
vol.Optional("output_format", default="array_of_objects"): vol.In(["array_of_objects", "array_of_arrays"]),
vol.Optional("array_fields"): str,
vol.Optional("subunit_currency", default=False): bool,
vol.Optional("round_decimals"): vol.All(vol.Coerce(int), vol.Range(min=0, max=10)),
vol.Optional("round_decimals"): or_entity_ref(vol.All(vol.Coerce(int), vol.Range(min=0, max=10))),
vol.Optional("include_level", default=False): bool,
vol.Optional("include_rating_level", default=False): bool,
vol.Optional("include_average", default=False): bool,
@ -339,12 +345,16 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: C901
"""
hass = call.hass
entry_id: str = call.data.get(ATTR_ENTRY_ID, "")
# Resolve entity references
data, resolved_refs = resolve_entity_references(hass, call.data, _CHARTDATA_ENTITY_PARAMS)
entry_id: str = data.get(ATTR_ENTRY_ID, "")
# Get coordinator to check data availability
_, coordinator, _ = get_entry_and_data(hass, entry_id)
days_raw = call.data.get(ATTR_DAY)
days_raw = data.get(ATTR_DAY)
# If no day specified, use rolling 2-day window:
# - If tomorrow data available: today + tomorrow
# - If tomorrow data NOT available: yesterday + today
@ -356,32 +366,32 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: C901
else:
days = days_raw
start_time_field = call.data.get("start_time_field", "start_time")
end_time_field = call.data.get("end_time_field", "end_time")
price_field = call.data.get("price_field", "price_per_kwh")
level_field = call.data.get("level_field", "level")
rating_level_field = call.data.get("rating_level_field", "rating_level")
average_field = call.data.get("average_field", "average")
energy_field = call.data.get("energy_field", "energy_price")
tax_field = call.data.get("tax_field", "tax")
data_key = call.data.get("data_key", "data")
resolution = call.data.get("resolution", "interval")
output_format = call.data.get("output_format", "array_of_objects")
subunit_currency = call.data.get("subunit_currency", False)
metadata = call.data.get("metadata", "include")
round_decimals = call.data.get("round_decimals")
include_level = call.data.get("include_level", False)
include_rating_level = call.data.get("include_rating_level", False)
include_average = call.data.get("include_average", False)
include_energy = call.data.get("include_energy", False)
include_tax = call.data.get("include_tax", False)
insert_nulls = call.data.get("insert_nulls", "none")
connect_segments = call.data.get("connect_segments", False)
add_trailing_null = call.data.get("add_trailing_null", False)
period_filter = call.data.get("period_filter")
start_time_field = data.get("start_time_field", "start_time")
end_time_field = data.get("end_time_field", "end_time")
price_field = data.get("price_field", "price_per_kwh")
level_field = data.get("level_field", "level")
rating_level_field = data.get("rating_level_field", "rating_level")
average_field = data.get("average_field", "average")
energy_field = data.get("energy_field", "energy_price")
tax_field = data.get("tax_field", "tax")
data_key = data.get("data_key", "data")
resolution = data.get("resolution", "interval")
output_format = data.get("output_format", "array_of_objects")
subunit_currency = data.get("subunit_currency", False)
metadata = data.get("metadata", "include")
round_decimals = data.get("round_decimals")
include_level = data.get("include_level", False)
include_rating_level = data.get("include_rating_level", False)
include_average = data.get("include_average", False)
include_energy = data.get("include_energy", False)
include_tax = data.get("include_tax", False)
insert_nulls = data.get("insert_nulls", "none")
connect_segments = data.get("connect_segments", False)
add_trailing_null = data.get("add_trailing_null", False)
period_filter = data.get("period_filter")
# Filter values are already normalized to uppercase by schema validators
level_filter = call.data.get("level_filter")
rating_level_filter = call.data.get("rating_level_filter")
level_filter = data.get("level_filter")
rating_level_filter = data.get("rating_level_filter")
# --- Parameter dependency validation ---
@ -424,7 +434,7 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: C901
)
# array_fields is only meaningful with array_of_arrays format
if call.data.get("array_fields") and output_format != "array_of_arrays":
if data.get("array_fields") and output_format != "array_of_arrays":
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="array_fields_requires_array_format",
@ -464,12 +474,15 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: C901
subunit_currency=subunit_currency,
)
return {"metadata": metadata}
result_meta: dict[str, Any] = {"metadata": metadata}
if resolved_refs:
result_meta["_resolved"] = resolved_refs
return result_meta
# Filter values are already normalized to uppercase by schema validators
# If array_fields is specified, implicitly enable fields that are used
array_fields_template = call.data.get("array_fields")
array_fields_template = data.get("array_fields")
if array_fields_template and output_format == "array_of_arrays":
if level_field in array_fields_template:
include_level = True
@ -1024,7 +1037,7 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: C901
# Convert to array of arrays format if requested
if output_format == "array_of_arrays":
array_fields_template = call.data.get("array_fields")
array_fields_template = data.get("array_fields")
# Default: nur timestamp und price
if not array_fields_template:
@ -1070,6 +1083,8 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: C901
)
if metadata_obj:
result["metadata"] = metadata_obj # type: ignore[index]
if resolved_refs:
result["_resolved"] = resolved_refs # type: ignore[index]
return result
# Calculate metadata (before adding trailing null)
@ -1097,4 +1112,7 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: C901
null_point[field] = None
chart_data.append(null_point)
if resolved_refs:
result["_resolved"] = resolved_refs # type: ignore[index]
return result

View file

@ -12,6 +12,7 @@ Functions:
from __future__ import annotations
from datetime import datetime
import logging
from typing import TYPE_CHECKING
from zoneinfo import ZoneInfo
@ -23,22 +24,26 @@ from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv
from homeassistant.util import dt as dt_util
from .entity_resolver import or_entity_ref, resolve_entity_references
from .helpers import get_entry_and_data
if TYPE_CHECKING:
from datetime import datetime
from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse
_LOGGER = logging.getLogger(__name__)
GET_PRICE_SERVICE_NAME = "get_price"
_PRICE_ENTITY_PARAMS: dict[str, type] = {
"start_time": datetime,
"end_time": datetime,
}
GET_PRICE_SERVICE_SCHEMA = vol.Schema(
{
vol.Optional("entry_id", default=""): cv.string,
vol.Required("start_time"): cv.datetime,
vol.Required("end_time"): cv.datetime,
vol.Required("start_time"): or_entity_ref(cv.datetime),
vol.Required("end_time"): or_entity_ref(cv.datetime),
}
)
@ -70,9 +75,13 @@ async def handle_get_price(call: ServiceCall) -> ServiceResponse:
"""
hass: HomeAssistant = call.hass
entry_id: str = call.data.get("entry_id", "")
start_time: datetime = call.data["start_time"]
end_time: datetime = call.data["end_time"]
# Resolve entity references
data, resolved_refs = resolve_entity_references(hass, call.data, _PRICE_ENTITY_PARAMS)
entry_id: str = data.get("entry_id", "")
start_time: datetime = data["start_time"]
end_time: datetime = data["end_time"]
# Validate and get entry data
entry, coordinator, _data = get_entry_and_data(hass, entry_id)
@ -178,4 +187,7 @@ async def handle_get_price(call: ServiceCall) -> ServiceResponse:
len(price_info),
)
if resolved_refs:
response["_resolved"] = resolved_refs
return response

View file

@ -1292,6 +1292,21 @@
},
"must_finish_by_conflicts_with_end": {
"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": {
"message": "'{reference}' ist keine gültige Entity-Referenz. Verwende das Format 'domain.entity_id' oder 'domain.entity_id@attribut'."
},
"entity_not_found": {
"message": "Entity '{entity_id}' nicht gefunden. Überprüfe, ob die Entity existiert und verfügbar ist."
},
"entity_attribute_not_found": {
"message": "Entity '{entity_id}' hat kein Attribut '{attribute}'."
},
"entity_state_unavailable": {
"message": "Entity '{entity_id}' hat den Status '{state}'. Die Entity muss einen gültigen Statuswert haben."
},
"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."
}
},
"services": {
@ -2027,6 +2042,10 @@
"name": "Pause zwischen Aufgaben (Minuten)",
"description": "Mindestpause in Minuten zwischen aufeinanderfolgenden Aufgaben. Wird auf 15 Minuten aufgerundet. Standard: 0 (keine Pause)."
},
"sequential": {
"name": "Sequenzielle Reihenfolge",
"description": "Aufgaben in der Reihenfolge planen, in der sie in der Aufgabenliste stehen. Jede Aufgabe startet nach dem Ende der vorherigen (plus Pause). Nutze dies für abhängige Geräte wie Waschmaschine → Trockner. Standard: deaktiviert (Aufgaben werden nach Dauer sortiert für optimale Verteilung)."
},
"search_scope": {
"name": "Suchbereich (Shortcut)",
"description": "Kurzwahl für häufige Suchbereiche. Überschreibt alle anderen Zeitbereich-Optionen. today/tomorrow = ganzer Kalendertag, remaining_today = jetzt bis Mitternacht, next_24h/next_48h = rollierendes Fenster ab jetzt."

View file

@ -1292,6 +1292,21 @@
},
"must_finish_by_conflicts_with_end": {
"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": {
"message": "'{reference}' is not a valid entity reference. Use the format 'domain.entity_id' or 'domain.entity_id@attribute'."
},
"entity_not_found": {
"message": "Entity '{entity_id}' not found. Verify the entity exists and is available."
},
"entity_attribute_not_found": {
"message": "Entity '{entity_id}' does not have attribute '{attribute}'."
},
"entity_state_unavailable": {
"message": "Entity '{entity_id}' state is '{state}'. The entity must have a valid state value."
},
"entity_value_conversion_failed": {
"message": "Cannot convert value '{raw_value}' from '{entity_id}' ({attribute}) to {expected_type}. Verify the entity provides a compatible value."
}
},
"services": {
@ -2027,6 +2042,10 @@
"name": "Gap Between Tasks (minutes)",
"description": "Minimum gap in minutes between consecutive scheduled tasks. Rounded up to 15 minutes. Default: 0 (no gap)."
},
"sequential": {
"name": "Sequential Ordering",
"description": "Schedule tasks in the order they appear in the task list. Each task starts after the previous one ends (plus gap). Use this for dependent appliances like washing machine → dryer. Default: disabled (tasks are sorted by duration for optimal packing)."
},
"search_scope": {
"name": "Search Scope",
"description": "Shorthand for common search ranges. Overrides all other time range options. today / tomorrow = full calendar day, remaining_today = now until midnight, next_24h / next_48h = rolling window from now."

View file

@ -1292,6 +1292,21 @@
},
"must_finish_by_conflicts_with_end": {
"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": {
"message": "'{reference}' er ikke en gyldig entitetsreferanse. Bruk formatet 'domain.entity_id' eller 'domain.entity_id@attributt'."
},
"entity_not_found": {
"message": "Entitet '{entity_id}' ble ikke funnet. Kontroller at entiteten finnes og er tilgjengelig."
},
"entity_attribute_not_found": {
"message": "Entitet '{entity_id}' har ikke attributtet '{attribute}'."
},
"entity_state_unavailable": {
"message": "Entitet '{entity_id}' har tilstanden '{state}'. Entiteten må ha en gyldig tilstandsverdi."
},
"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."
}
},
"services": {
@ -2027,6 +2042,10 @@
"name": "Pause mellom oppgaver (minutter)",
"description": "Minimum pause i minutter mellom paafoeglende planlagte oppgaver. Avrundes opp til 15 minutter. Standard: 0 (ingen pause)."
},
"sequential": {
"name": "Sekvensiell rekkefølge",
"description": "Planlegg oppgaver i rekkefølgen de står i oppgavelisten. Hver oppgave starter etter at den forrige er ferdig (pluss pause). Bruk dette for avhengige apparater som vaskemaskin → tørketrommel. Standard: deaktivert (oppgaver sorteres etter varighet for optimal fordeling)."
},
"search_scope": {
"name": "Soekeomfang (snarvei)",
"description": "Snarvei for vanlige soekeomraader. Overstyrer alle andre tidsalternativer. today/tomorrow = hele kalenderdagen, remaining_today = naa til midnatt, next_24h/next_48h = rullende vindu fra naa."

View file

@ -1292,6 +1292,21 @@
},
"must_finish_by_conflicts_with_end": {
"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": {
"message": "'{reference}' is geen geldige entiteitsreferentie. Gebruik het formaat 'domain.entity_id' of 'domain.entity_id@attribuut'."
},
"entity_not_found": {
"message": "Entiteit '{entity_id}' niet gevonden. Controleer of de entiteit bestaat en beschikbaar is."
},
"entity_attribute_not_found": {
"message": "Entiteit '{entity_id}' heeft geen attribuut '{attribute}'."
},
"entity_state_unavailable": {
"message": "Entiteit '{entity_id}' heeft status '{state}'. De entiteit moet een geldige statuswaarde hebben."
},
"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."
}
},
"services": {
@ -2027,6 +2042,10 @@
"name": "Tussenpose tussen taken (minuten)",
"description": "Minimale tussenpose in minuten tussen opeenvolgende ingeplande taken. Afgerond omhoog tot 15 minuten. Standaard: 0 (geen tussenpose)."
},
"sequential": {
"name": "Sequentiële volgorde",
"description": "Plan taken in de volgorde waarin ze in de takenlijst staan. Elke taak start na het einde van de vorige (plus tussenpose). Gebruik dit voor afhankelijke apparaten zoals wasmachine → droger. Standaard: uitgeschakeld (taken worden gesorteerd op duur voor optimale verdeling)."
},
"search_scope": {
"name": "Zoekbereik (snelkoppeling)",
"description": "Snelkoppeling voor veelgebruikte zoekbereiken. Overschrijft alle andere tijdopties. today/tomorrow = volledige kalenderdag, remaining_today = nu tot middernacht, next_24h/next_48h = rolling venster vanaf nu."

View file

@ -1292,6 +1292,21 @@
},
"must_finish_by_conflicts_with_end": {
"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": {
"message": "'{reference}' är inte en giltig entitetsreferens. Använd formatet 'domain.entity_id' eller 'domain.entity_id@attribut'."
},
"entity_not_found": {
"message": "Entitet '{entity_id}' hittades inte. Kontrollera att entiteten finns och är tillgänglig."
},
"entity_attribute_not_found": {
"message": "Entitet '{entity_id}' har inte attributet '{attribute}'."
},
"entity_state_unavailable": {
"message": "Entitet '{entity_id}' har tillståndet '{state}'. Entiteten måste ha ett giltigt tillståndsvärde."
},
"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."
}
},
"services": {
@ -2027,6 +2042,10 @@
"name": "Paus mellan uppgifter (minuter)",
"description": "Minsta paus i minuter mellan paa varandra foeoljande schemalagda uppgifter. Avrundas uppaat till 15 minuter. Standard: 0 (ingen paus)."
},
"sequential": {
"name": "Sekventiell ordning",
"description": "Schemalägg uppgifter i den ordning de står i uppgiftslistan. Varje uppgift startar efter att den föregående är klar (plus paus). Använd detta för beroende apparater som tvättmaskin → torktumlare. Standard: inaktiverad (uppgifter sorteras efter varaktighet för optimal fördelning)."
},
"search_scope": {
"name": "Soekumfaang (genvaeg)",
"description": "Genvaeg foer vanliga soekomraaden. Aasidosaetter alla andra tidsalternativ. today/tomorrow = hela kalenderdagen, remaining_today = nu till midnatt, next_24h/next_48h = rullande foenster fraen nu."

View file

@ -85,16 +85,25 @@ sequenceDiagram
The classic use case. Every evening at 20:00, the automation plans when to start the dishwasher overnight.
:::info Blueprint available
These blueprints are **automatically installed** with the integration. Find them under **Settings → Automations → Blueprints**. You can also import them manually:
| Variant | Import |
|---------|--------|
| **Smart Plug** | [![Import](https://my.home-assistant.io/badges/blueprint_import.svg)](https://my.home-assistant.io/redirect/blueprint_import/?blueprint_url=https%3A%2F%2Fgithub.com%2Fjpawlowski%2Fhass.tibber_prices%2Fblob%2Fmain%2Fcustom_components%2Ftibber_prices%2Fblueprints%2Fautomation%2Ftibber_prices%2Fdishwasher.yaml) |
| **Home Connect** | [![Import](https://my.home-assistant.io/badges/blueprint_import.svg)](https://my.home-assistant.io/redirect/blueprint_import/?blueprint_url=https%3A%2F%2Fgithub.com%2Fjpawlowski%2Fhass.tibber_prices%2Fblob%2Fmain%2Fcustom_components%2Ftibber_prices%2Fblueprints%2Fautomation%2Ftibber_prices%2Fdishwasher_home_connect.yaml) |
| **Home Connect Alt** | [![Import](https://my.home-assistant.io/badges/blueprint_import.svg)](https://my.home-assistant.io/redirect/blueprint_import/?blueprint_url=https%3A%2F%2Fgithub.com%2Fjpawlowski%2Fhass.tibber_prices%2Fblob%2Fmain%2Fcustom_components%2Ftibber_prices%2Fblueprints%2Fautomation%2Ftibber_prices%2Fdishwasher_home_connect_alt.yaml) |
:::
**Prerequisite:** Create an `input_datetime` helper named `input_datetime.dishwasher_start` (type: Date and time).
<details>
<summary>Show YAML: Dishwasher — Plan + Execute (2 automations)</summary>
```yaml
# Automation 1: Plan the cheapest time (runs every evening)
automation:
# Automation 1: Plan the cheapest time (runs every evening)
- alias: "Dishwasher - Plan Cheapest Start Time"
description: "Every evening, find the cheapest 2h window overnight and store the start time"
trigger:
- platform: time
at: "20:00:00"
@ -121,14 +130,6 @@ automation:
| as_timestamp | timestamp_custom('%H:%M') }}.
Avg price: {{ result.window.price_mean | round(1) }}
{{ result.price_unit }}.
{% if result.relaxation_applied | default(false) %}
(Filters were relaxed to find a window.)
{% endif %}
else:
- service: notify.mobile_app
data:
title: "🍽️ Dishwasher"
message: "No cheap window found tonight. Consider running manually."
# Automation 2: Execute at the planned time
- alias: "Dishwasher - Start at Planned Time"
@ -136,33 +137,6 @@ automation:
- platform: time
at: input_datetime.dishwasher_start
action:
# ════════════════════════════════════════════
# Option A: Smart appliance (Home Connect)
# Calculate the delay and let the appliance handle timing.
# Prerequisite: "Remote Start" must be enabled on the appliance.
# ════════════════════════════════════════════
## --- Official Home Connect integration ---
# - service: home_connect.start_selected_program
# target:
# device_id: <your_dishwasher_device_id>
# data:
# b_s_h_common_option_start_in_relative: 0
## --- Home Connect Alt integration ---
# - service: home_connect_alt.start_program
# target:
# entity_id: switch.<your_dishwasher>
# data:
# program: Dishcare.Dishwasher.Program.Eco50
# options:
# - key: BSH.Common.Option.StartInRelative
# value: 0
# ════════════════════════════════════════════
# Option B: Simple smart plug
# For appliances without network connectivity.
# ════════════════════════════════════════════
- service: switch.turn_on
target:
entity_id: switch.dishwasher_smart_plug
@ -175,17 +149,47 @@ automation:
1. At 20:00, calls `find_cheapest_block` to find the cheapest contiguous 2h window between 22:00 and 06:00
2. Stores the start time in an `input_datetime` helper (survives HA restarts)
3. A second automation triggers at the stored time and starts the appliance
4. If relaxation was needed (tight filters), the notification mentions it
:::tip Home Connect: The modern way
If you have a **Bosch/Siemens appliance with Home Connect**, you don't need a smart plug at all. Enable "Remote Start" on the appliance, then use one of the Home Connect integrations to start the selected program directly:
- **[Official Home Connect](https://www.home-assistant.io/integrations/home_connect/)**: `home_connect.start_selected_program` with `b_s_h_common_option_start_in_relative: 0` (start immediately)
- **[Home Connect Alt](https://github.com/ekutner/home-connect-hass)** (HACS): `home_connect_alt.start_program` with `BSH.Common.Option.StartInRelative: 0`
You can also calculate a delayed start in seconds: `{{ ((result.window.start | as_datetime - now()).total_seconds()) | int }}` — but using `input_datetime` with immediate start at the planned time is simpler and more reliable.
:::tip Home Connect appliances
For **Bosch/Siemens** appliances with Home Connect, use the dedicated **Home Connect blueprints** above instead — they handle `StartInRelative` / `FinishInRelative` automatically. You can also import a blueprint and use **"Take Control"** in the automation editor to customize it.
:::
### Washing Machine: Cheapest Window Overnight
Same pattern as the dishwasher — change the `duration` to match your program (ECO 40-60 ~1:30h, Cotton 60°C ~2:00h) and swap the entity IDs.
:::info Blueprint available
These blueprints are **automatically installed** with the integration. Find them under **Settings → Automations → Blueprints**. You can also import them manually:
| Variant | Import |
|---------|--------|
| **Smart Plug** | [![Import](https://my.home-assistant.io/badges/blueprint_import.svg)](https://my.home-assistant.io/redirect/blueprint_import/?blueprint_url=https%3A%2F%2Fgithub.com%2Fjpawlowski%2Fhass.tibber_prices%2Fblob%2Fmain%2Fcustom_components%2Ftibber_prices%2Fblueprints%2Fautomation%2Ftibber_prices%2Fwashing_machine.yaml) |
| **Home Connect** | [![Import](https://my.home-assistant.io/badges/blueprint_import.svg)](https://my.home-assistant.io/redirect/blueprint_import/?blueprint_url=https%3A%2F%2Fgithub.com%2Fjpawlowski%2Fhass.tibber_prices%2Fblob%2Fmain%2Fcustom_components%2Ftibber_prices%2Fblueprints%2Fautomation%2Ftibber_prices%2Fwashing_machine_home_connect.yaml) |
| **Home Connect Alt** | [![Import](https://my.home-assistant.io/badges/blueprint_import.svg)](https://my.home-assistant.io/redirect/blueprint_import/?blueprint_url=https%3A%2F%2Fgithub.com%2Fjpawlowski%2Fhass.tibber_prices%2Fblob%2Fmain%2Fcustom_components%2Ftibber_prices%2Fblueprints%2Fautomation%2Ftibber_prices%2Fwashing_machine_home_connect_alt.yaml) |
:::
**Prerequisite:** Create an `input_datetime` helper named `input_datetime.washing_machine_start` (type: Date and time).
The YAML is identical to the [dishwasher example above](#dishwasher-find-cheapest-2-hour-window-tonight) — just adjust `duration`, helper name, and switch entity. Use the blueprints above for a one-click setup, including Home Connect support.
### Dryer: Cheapest Window Overnight
Same pattern again — dryer programs are typically shorter (45 min1:15h depending on load and program).
:::info Blueprint available
These blueprints are **automatically installed** with the integration. Find them under **Settings → Automations → Blueprints**. You can also import them manually:
| Variant | Import |
|---------|--------|
| **Smart Plug** | [![Import](https://my.home-assistant.io/badges/blueprint_import.svg)](https://my.home-assistant.io/redirect/blueprint_import/?blueprint_url=https%3A%2F%2Fgithub.com%2Fjpawlowski%2Fhass.tibber_prices%2Fblob%2Fmain%2Fcustom_components%2Ftibber_prices%2Fblueprints%2Fautomation%2Ftibber_prices%2Fdryer.yaml) |
| **Home Connect** | [![Import](https://my.home-assistant.io/badges/blueprint_import.svg)](https://my.home-assistant.io/redirect/blueprint_import/?blueprint_url=https%3A%2F%2Fgithub.com%2Fjpawlowski%2Fhass.tibber_prices%2Fblob%2Fmain%2Fcustom_components%2Ftibber_prices%2Fblueprints%2Fautomation%2Ftibber_prices%2Fdryer_home_connect.yaml) |
| **Home Connect Alt** | [![Import](https://my.home-assistant.io/badges/blueprint_import.svg)](https://my.home-assistant.io/redirect/blueprint_import/?blueprint_url=https%3A%2F%2Fgithub.com%2Fjpawlowski%2Fhass.tibber_prices%2Fblob%2Fmain%2Fcustom_components%2Ftibber_prices%2Fblueprints%2Fautomation%2Ftibber_prices%2Fdryer_home_connect_alt.yaml) |
:::
**Prerequisite:** Create an `input_datetime` helper named `input_datetime.dryer_start` (type: Date and time).
The YAML is identical to the [dishwasher example above](#dishwasher-find-cheapest-2-hour-window-tonight) — just change `duration` to `"01:00:00"`, helper name, and switch entity.
### Two Independent Appliances: No Overlap, Each at Its Cheapest
When running multiple **independent** appliances overnight (e.g., dishwasher + dryer that don't depend on each other), `find_cheapest_schedule` ensures they don't overlap and each gets its own cheapest slot.
@ -250,7 +254,7 @@ automation:
- platform: time
at: input_datetime.dishwasher_start
action:
# Use Home Connect or smart plug — see dishwasher example above
# For Home Connect, use the dedicated blueprints instead
- service: switch.turn_on
target:
entity_id: switch.dishwasher_smart_plug
@ -270,13 +274,13 @@ automation:
**Why `find_cheapest_schedule` instead of separate `find_cheapest_block` calls?**
If you call `find_cheapest_block` separately for each appliance, they might both pick the **same** cheap window. `find_cheapest_schedule` reserves each slot exclusively — the dryer gets the next-cheapest window after the dishwasher claims its slot, with a 15-minute gap between them.
:::caution `find_cheapest_schedule` does NOT guarantee order
The scheduler optimizes purely for price — the dryer might be scheduled **before** the dishwasher if that results in lower total cost. This is fine for independent appliances, but problematic for sequential workflows (e.g., washing machine → dryer). See the next example for that.
:::tip `sequential: true` for ordered workflows
By default, `find_cheapest_schedule` optimizes purely for price — the dryer might be scheduled **before** the dishwasher. This is fine for independent appliances. For sequential workflows (e.g., washing machine → dryer), add `sequential: true` — see the next example.
:::
### Washing Machine → Dryer: Sequential Scheduling
When the dryer **must** run after the washing machine, you need guaranteed order. Since `find_cheapest_schedule` doesn't guarantee task ordering, use **two sequential `find_cheapest_block` calls** instead:
When the dryer **must** run after the washing machine, use `sequential: true` to guarantee declaration-order scheduling. The scheduler places each task after the previous one finishes (plus gap).
**Prerequisite:** Create `input_datetime.washing_machine_start` and `input_datetime.dryer_start` helpers.
@ -291,59 +295,53 @@ automation:
- platform: time
at: "21:00:00"
action:
# Step 1: Find cheapest window for washing machine
- service: tibber_prices.find_cheapest_block
- service: tibber_prices.find_cheapest_schedule
data:
duration: "01:30:00"
sequential: true
gap_minutes: 15
search_start_time: "22:00:00"
search_end_time: "07:00:00"
search_end_time: "08:00:00"
search_end_day_offset: 1
response_variable: washer_result
tasks:
# Order matters! Washer runs first, dryer after.
- name: washing_machine
duration: "01:30:00"
- name: dryer
duration: "01:00:00"
response_variable: schedule
- if: "{{ washer_result.window_found }}"
- if: "{{ schedule.all_tasks_scheduled }}"
then:
- service: input_datetime.set_datetime
target:
entity_id: input_datetime.washing_machine_start
data:
datetime: "{{ washer_result.window.start }}"
# Step 2: Find cheapest window for dryer AFTER washer finishes
# Add 15 min gap for transferring laundry
- service: tibber_prices.find_cheapest_block
datetime: "{{ schedule.tasks[0].start }}"
- service: input_datetime.set_datetime
target:
entity_id: input_datetime.dryer_start
data:
duration: "01:00:00"
search_start: "{{ washer_result.window.end }}"
search_start_offset: "00:15:00"
search_end_time: "08:00:00"
search_end_day_offset: 1
response_variable: dryer_result
- if: "{{ dryer_result.window_found }}"
then:
- service: input_datetime.set_datetime
target:
entity_id: input_datetime.dryer_start
data:
datetime: "{{ dryer_result.window.start }}"
- service: notify.mobile_app
data:
title: "🧺 Laundry Planned"
message: >
Washing: {{ washer_result.window.start | as_datetime
| as_local | as_timestamp
| timestamp_custom('%H:%M') }}{{ washer_result.window.end
| as_datetime | as_local | as_timestamp
| timestamp_custom('%H:%M') }}
({{ washer_result.window.price_mean | round(1) }}
{{ washer_result.price_unit }})
Dryer: {{ dryer_result.window.start | as_datetime
| as_local | as_timestamp
| timestamp_custom('%H:%M') }}{{ dryer_result.window.end
| as_datetime | as_local | as_timestamp
| timestamp_custom('%H:%M') }}
({{ dryer_result.window.price_mean | round(1) }}
{{ dryer_result.price_unit }})
datetime: "{{ schedule.tasks[1].start }}"
- service: notify.mobile_app
data:
title: "🧺 Laundry Planned"
message: >
Washing: {{ schedule.tasks[0].start | as_datetime
| as_local | as_timestamp
| timestamp_custom('%H:%M') }}{{ schedule.tasks[0].end
| as_datetime | as_local | as_timestamp
| timestamp_custom('%H:%M') }}
({{ schedule.tasks[0].price_mean | round(1) }}
{{ schedule.price_unit }})
Dryer: {{ schedule.tasks[1].start | as_datetime
| as_local | as_timestamp
| timestamp_custom('%H:%M') }}{{ schedule.tasks[1].end
| as_datetime | as_local | as_timestamp
| timestamp_custom('%H:%M') }}
({{ schedule.tasks[1].price_mean | round(1) }}
{{ schedule.price_unit }})
Total: {{ schedule.total_estimated_cost | round(2) }}
{{ schedule.price_unit }}
# Execution automations
- alias: "Washing Machine - Start at Planned Time"
@ -351,7 +349,7 @@ automation:
- platform: time
at: input_datetime.washing_machine_start
action:
# Home Connect or smart plug (see dishwasher example above)
# For Home Connect, use the dedicated blueprints instead
- service: switch.turn_on
target:
entity_id: switch.washing_machine_smart_plug
@ -361,6 +359,7 @@ automation:
- platform: time
at: input_datetime.dryer_start
action:
# For Home Connect, use the dedicated blueprints instead
- service: switch.turn_on
target:
entity_id: switch.dryer_smart_plug
@ -370,18 +369,94 @@ automation:
**How it works:**
1. First `find_cheapest_block` finds the cheapest 1.5h window for the washing machine
2. Second `find_cheapest_block` uses the washer's **end time + 15 min gap** as its search start — guaranteeing the dryer runs after the washer
1. With `sequential: true`, the scheduler places the washing machine first at its cheapest window
2. The dryer's search window starts **after the washer ends + 15 min gap** — guaranteed order
3. The 15-minute gap gives you time to transfer laundry (adjust or remove as needed)
:::tip Alternative: Home Connect "Finish In Relative"
With Home Connect, you can use `b_s_h_common_option_finish_in_relative` (official) or `BSH.Common.Option.FinishInRelative` (Alt) to set a target finish time. This lets the appliance decide when to start within its own program duration — useful for washing machines that support "finish by" scheduling.
### Laundry Day Pipeline: Multiple Loads with Price Optimization
When you have a full laundry day (23 loads that each need washing and drying), managing the timing manually is tedious. The **Laundry Day Pipeline** blueprint automates the entire process:
```mermaid
sequenceDiagram
participant U as 👤 You
participant B as 🤖 Blueprint
participant T as ⚡ Tibber Prices
participant W as 👕 Washer
participant D as 🌀 Dryer
U->>B: Turn on "Laundry Day" toggle
B->>T: find_cheapest_block (wash 1)
T-->>B: Best window: 08:1509:45
B->>W: Start wash 1
Note over W: Washing... (90 min)
B-->>U: 📱 "Wash 1 done! Transfer to dryer"
U->>U: Transfer laundry
B->>T: find_cheapest_block (dry 1)
T-->>B: Best window: 10:1511:15
B->>D: Start dryer 1
Note over D,W: Pipeline: dryer runs while...
B->>T: find_cheapest_block (wash 2)
T-->>B: Best window: 10:1511:45
B->>W: Start wash 2
Note over W: Washing... (90 min)
B-->>U: 📱 "Wash 2 done! Transfer"
```
**What the blueprint handles:**
- **Price optimization**: Each cycle is scheduled at the cheapest available window
- **Pipeline mode** (optional): Next wash starts while the dryer runs, cutting total time significantly
- **Transfer reminders**: Notifications when each wash finishes
- **Automatic completion**: Toggle turns off when all loads are done
- **Cancellation**: Turn off the toggle at any time to stop the pipeline
**What you need:**
| Helper | Type | Settings |
|--------|------|----------|
| `input_boolean.laundry_day` | Toggle | Starts/stops laundry day |
| `input_number.laundry_loads` | Number | Min: 1, Max: 5, Step: 1 |
**Choose your variant and import:**
The blueprint comes in three variants depending on how you control your appliances. Pick the one that matches your setup:
| Variant | Control Method | Import |
|---------|---------------|--------|
| **Smart Plug** | Smart plug switches (any brand) | [![Import](https://my.home-assistant.io/badges/blueprint_import.svg)](https://my.home-assistant.io/redirect/blueprint_import/?blueprint_url=https%3A%2F%2Fgithub.com%2Fjpawlowski%2Fhass.tibber_prices%2Fblob%2Fmain%2Fcustom_components%2Ftibber_prices%2Fblueprints%2Fautomation%2Ftibber_prices%2Flaundry_day_pipeline.yaml) |
| **Home Connect** | HA Core Home Connect integration | [![Import](https://my.home-assistant.io/badges/blueprint_import.svg)](https://my.home-assistant.io/redirect/blueprint_import/?blueprint_url=https%3A%2F%2Fgithub.com%2Fjpawlowski%2Fhass.tibber_prices%2Fblob%2Fmain%2Fcustom_components%2Ftibber_prices%2Fblueprints%2Fautomation%2Ftibber_prices%2Flaundry_day_pipeline_home_connect.yaml) |
| **Home Connect Alt** | [HC Alt HACS integration](https://github.com/ekutner/home-connect-hass) | [![Import](https://my.home-assistant.io/badges/blueprint_import.svg)](https://my.home-assistant.io/redirect/blueprint_import/?blueprint_url=https%3A%2F%2Fgithub.com%2Fjpawlowski%2Fhass.tibber_prices%2Fblob%2Fmain%2Fcustom_components%2Ftibber_prices%2Fblueprints%2Fautomation%2Ftibber_prices%2Flaundry_day_pipeline_home_connect_alt.yaml) |
**After importing:**
1. Create the two helpers (`input_boolean` + `input_number`)
2. Create an automation from the blueprint
3. Configure your appliances, program durations, and deadline
4. On laundry day: set the load count, turn on the toggle, and let the blueprint handle the rest
:::info Pipeline mode vs. sequential
**Without pipeline** (default): Each wash + dry cycle completes fully before the next one starts. Safe for all setups.
**With pipeline**: The next wash starts immediately after the dryer begins. This overlaps washer and dryer operation, saving roughly one dryer cycle per load. Only enable this when your **wash duration ≥ dryer duration** — otherwise the dryer from the previous load might still be running when the next dryer needs to start.
**Example with 3 loads** (wash 90 min, dry 60 min, 15 min transfer):
- Sequential: ~8h 45min
- Pipeline: ~6h 15min — saves 2.5 hours!
:::
:::caution About program durations
The blueprint uses **estimated durations** (not real-time appliance feedback). Set your durations based on typical program times and add a small buffer (~5 min). If your actual program finishes earlier or later, the timing will drift slightly — this is acceptable for price optimization purposes.
:::
### EV Charging: Cheapest 4 Hours Overnight
For EV charging, you usually don't need one contiguous block — the charger can pause and resume. `find_cheapest_hours` picks the cheapest individual intervals.
:::info Blueprint available
This blueprint is **automatically installed** with the integration. You can also import it manually: [![Import](https://my.home-assistant.io/badges/blueprint_import.svg)](https://my.home-assistant.io/redirect/blueprint_import/?blueprint_url=https%3A%2F%2Fgithub.com%2Fjpawlowski%2Fhass.tibber_prices%2Fblob%2Fmain%2Fcustom_components%2Ftibber_prices%2Fblueprints%2Fautomation%2Ftibber_prices%2Fev_charging.yaml)
:::
**Prerequisite:** Create `input_datetime.ev_charge_start` helper. For multi-segment charging, see the note below.
<details>
@ -481,6 +556,10 @@ Use sensors for devices that run **continuously** and can **modulate** their pow
The simplest real-time approach: adjust the heat pump target temperature based on the current price rating.
:::info Blueprint available
This blueprint is **automatically installed** with the integration. You can also import it manually: [![Import](https://my.home-assistant.io/badges/blueprint_import.svg)](https://my.home-assistant.io/redirect/blueprint_import/?blueprint_url=https%3A%2F%2Fgithub.com%2Fjpawlowski%2Fhass.tibber_prices%2Fblob%2Fmain%2Fcustom_components%2Ftibber_prices%2Fblueprints%2Fautomation%2Ftibber_prices%2Fheat_pump_price_level.yaml)
:::
<details>
<summary>Show YAML: Heat Pump — Price-Based Temperature</summary>
@ -534,6 +613,10 @@ automation:
A more sophisticated approach: combine the best price period with trend sensors to **boost during the full cheap window**, not just the detected period.
:::info Blueprint available
This blueprint is **automatically installed** with the integration. You can also import it manually: [![Import](https://my.home-assistant.io/badges/blueprint_import.svg)](https://my.home-assistant.io/redirect/blueprint_import/?blueprint_url=https%3A%2F%2Fgithub.com%2Fjpawlowski%2Fhass.tibber_prices%2Fblob%2Fmain%2Fcustom_components%2Ftibber_prices%2Fblueprints%2Fautomation%2Ftibber_prices%2Fheat_pump_smart_boost.yaml)
:::
**Why?** On [V-shaped price days](concepts.md#v-shaped-and-u-shaped-price-days), the Best Price Period may cover only 12 hours, but prices remain favorable for 46 hours. By checking the price level and trend, you can extend the boost.
```mermaid
@ -629,6 +712,10 @@ A common misconception: **"rising" does NOT mean "too late"**. The Price Outlook
Use the best price period for charging and the peak price period for discharging:
:::info Blueprint available
This blueprint is **automatically installed** with the integration. You can also import it manually: [![Import](https://my.home-assistant.io/badges/blueprint_import.svg)](https://my.home-assistant.io/redirect/blueprint_import/?blueprint_url=https%3A%2F%2Fgithub.com%2Fjpawlowski%2Fhass.tibber_prices%2Fblob%2Fmain%2Fcustom_components%2Ftibber_prices%2Fblueprints%2Fautomation%2Ftibber_prices%2Fhome_battery.yaml)
:::
<details>
<summary>Show YAML: Home Battery — Charge/Discharge Cycle</summary>
@ -689,6 +776,10 @@ automation:
Heat your water tank during the cheap window so it's ready when prices rise:
:::info Blueprint available
This blueprint is **automatically installed** with the integration. You can also import it manually: [![Import](https://my.home-assistant.io/badges/blueprint_import.svg)](https://my.home-assistant.io/redirect/blueprint_import/?blueprint_url=https%3A%2F%2Fgithub.com%2Fjpawlowski%2Fhass.tibber_prices%2Fblob%2Fmain%2Fcustom_components%2Ftibber_prices%2Fblueprints%2Fautomation%2Ftibber_prices%2Fwater_heater.yaml)
:::
<details>
<summary>Show YAML: Water Heater — Pre-Heat During Cheap Prices</summary>
@ -1135,7 +1226,7 @@ automation:
| Scenario | Best approach | Why |
|----------|--------------|-----|
| Dishwasher tonight | `find_cheapest_block` | Fixed 2h runtime, needs exact start time |
| Washer → dryer (must be sequential) | 2× `find_cheapest_block` | Second call uses first result's end time as start |
| Washer → dryer (must be sequential) | `find_cheapest_schedule` | `sequential: true` + `gap_minutes` for guaranteed order |
| Dishwasher + dryer (independent) | `find_cheapest_schedule` | Multiple appliances, prevent overlap |
| EV charging by morning | `find_cheapest_hours` | Flexible, can split into segments |
| Heat pump all day | Sensors (rating_level) | Continuous, adjusts every 15 min |
@ -1164,7 +1255,7 @@ automation:
| One appliance, must run uninterrupted | `find_cheapest_block` | `duration` |
| One appliance, can pause/resume | `find_cheapest_hours` | `duration`, `min_segment_duration` |
| Multiple independent appliances, no overlap | `find_cheapest_schedule` | `tasks`, `gap_minutes` |
| Sequential chain (A must finish before B) | 2× `find_cheapest_block` | Use A's end as B's `search_start` |
| Sequential chain (A must finish before B) | `find_cheapest_schedule` | `sequential: true`, `gap_minutes` |
| Find the worst time (avoid it) | `find_most_expensive_block` | `duration` |
**→ [Scheduling Actions — Full Guide](scheduling-actions.md)** for all parameters, response formats, and advanced options (power profiles, relaxation, outlier smoothing).

View file

@ -33,7 +33,7 @@ flowchart TD
- **Dishwasher, washing machine, dryer**`find_cheapest_block` (must run X hours straight)
- **EV charging, battery, pool pump**`find_cheapest_hours` (total runtime matters, not continuity)
- **Multiple independent appliances**`find_cheapest_schedule` (prevents overlap + manages gaps)
- **Sequential chain (A must finish before B)**2× `find_cheapest_block` (use A's end as B's search start)
- **Sequential chain (A must finish before B)**`find_cheapest_schedule` with `sequential: true` (guaranteed order + gap)
- **"When should I NOT run this?"** → `find_most_expensive_block` or `find_most_expensive_hours`
---
@ -598,12 +598,21 @@ Schedules **multiple appliances** within the same search range, ensuring they do
### How It Works
**Default mode** (optimizes for price):
1. Tasks are sorted by duration (longest first — harder to place)
2. The longest task claims the cheapest contiguous block
3. Those intervals are marked as **unavailable**
4. The next task finds the cheapest block in the **remaining** intervals
5. Optional gap between tasks ensures a pause (e.g., for shared plumbing or circuit recovery)
**Sequential mode** (`sequential: true` — guarantees order):
1. Tasks are placed in **declaration order** (the order you list them)
2. Each task's search window starts after the previous task ends (+ gap)
3. Price optimization still applies **within** each task's available window
4. If a task can't be placed, all subsequent tasks are also unscheduled (the chain breaks)
### Basic Example
<details>
@ -730,8 +739,8 @@ response_variable: result
If you call `find_cheapest_block` separately for each appliance, they might all find the **same** cheap time window. `find_cheapest_schedule` solves this by tracking which intervals are already claimed — each appliance gets its own non-overlapping slot.
:::caution No ordering guarantee
`find_cheapest_schedule` optimizes purely for **price** — it does not guarantee task order. The dryer could be scheduled before the washing machine if that's cheaper. For sequential workflows (washing machine → dryer), use **two sequential `find_cheapest_block` calls** where the second call starts after the first result ends. See [Automation Examples — Sequential Scheduling](automation-examples.md#washing-machine--dryer-sequential-scheduling) for a complete example.
:::tip Sequential ordering
By default, `find_cheapest_schedule` optimizes purely for **price** — it does not guarantee task order. The dryer could be scheduled before the washing machine if that's cheaper. For sequential workflows (washing machine → dryer), add `sequential: true` to guarantee declaration-order scheduling. See [Automation Examples — Sequential Scheduling](automation-examples.md#washing-machine--dryer-sequential-scheduling) for a complete example.
:::
### Gap Minutes
@ -815,7 +824,7 @@ All examples below use `input_datetime` helpers to store planned start times. Th
Schedule dishwasher + washing machine to run overnight at cheapest prices, with a 15-minute gap between them. These appliances are **independent** — either can run first.
:::tip Sequential appliances (e.g., washer → dryer)?
If one appliance **must** finish before another starts, don't use `find_cheapest_schedule` — it doesn't guarantee order. Use **two sequential `find_cheapest_block` calls** instead. See [Automation Examples — Sequential Scheduling](automation-examples.md#washing-machine--dryer-sequential-scheduling).
If one appliance **must** finish before another starts, add `sequential: true` to your `find_cheapest_schedule` call — this guarantees tasks run in the order you list them. See [Automation Examples — Sequential Scheduling](automation-examples.md#washing-machine--dryer-sequential-scheduling).
:::
**Prerequisites:** Create `input_datetime.dishwasher_start` and `input_datetime.washing_machine_start` helpers.

View file

@ -127,8 +127,8 @@ explanations of each sensor's purpose, attributes, and automation examples.
| Entity ID suffix | 🇬🇧 English | 🇩🇪 Deutsch | 🇳🇴 Norsk | 🇳🇱 Nederlands | 🇸🇪 Svenska | Default |
|---|---|---|---|---|---|---|
| <span id="ref-current_price_trend" class="entity-anchor" data-refs="automation-examples#sensor-combination-quick-reference"></span>`current_price_trend` | Current Price Trend | Aktueller Preistrend | Nåværende pristrend | Huidige Prijstrend | Aktuell pristrend | ✅ |
| <span id="ref-next_price_trend_change" class="entity-anchor" data-refs="automation-examples#sensor-combination-quick-reference"></span>`next_price_trend_change` | Next Price Trend Change | Nächste Trendänderung | Neste trendendring | Volgende Prijstrend Wijziging | Nästa pristrendändring | ✅ |
| <span id="ref-current_price_trend" class="entity-anchor"></span>`current_price_trend` | Current Price Trend | Aktueller Preistrend | Nåværende pristrend | Huidige Prijstrend | Aktuell pristrend | ✅ |
| <span id="ref-next_price_trend_change" class="entity-anchor"></span>`next_price_trend_change` | Next Price Trend Change | Nächste Trendänderung | Neste trendendring | Volgende Prijstrend Wijziging | Nästa pristrendändring | ✅ |
| <span id="ref-next_price_trend_change_in" class="entity-anchor"></span>`next_price_trend_change_in` | Next Price Trend Change In | Nächste Trendänderung in | Neste trendendring om | Volgende Prijstrend Wijziging over | Nästa pristrendändring om | ✅ |
| <span id="ref-price_outlook_1h" class="entity-anchor"></span>`price_outlook_1h` | Price Outlook (1h) | Preisausblick (1h) | Prisutblikk (1t) | Prijsvooruitzicht (1u) | Prisöversikt (1h) | ✅ |
| <span id="ref-price_outlook_2h" class="entity-anchor"></span>`price_outlook_2h` | Price Outlook (2h) | Preisausblick (2h) | Prisutblikk (2t) | Prijsvooruitzicht (2u) | Prisöversikt (2h) | ✅ |
@ -151,7 +151,7 @@ explanations of each sensor's purpose, attributes, and automation examples.
| Entity ID suffix | 🇬🇧 English | 🇩🇪 Deutsch | 🇳🇴 Norsk | 🇳🇱 Nederlands | 🇸🇪 Svenska | Default |
|---|---|---|---|---|---|---|
| <span id="ref-today_volatility" class="entity-anchor" data-refs="automation-examples#sensor-combination-quick-reference,sensors-volatility#available-volatility-sensors"></span>`today_volatility` | Today's Price Volatility | Volatilität heute | Volatilitet i dag | Vandaag Prijsvolatiliteit | Dagens prisvolatilitet | ✅ |
| <span id="ref-today_volatility" class="entity-anchor" data-refs="sensors-volatility#available-volatility-sensors"></span>`today_volatility` | Today's Price Volatility | Volatilität heute | Volatilitet i dag | Vandaag Prijsvolatiliteit | Dagens prisvolatilitet | ✅ |
| <span id="ref-tomorrow_volatility" class="entity-anchor" data-refs="sensors-volatility#available-volatility-sensors"></span>`tomorrow_volatility` | Tomorrow's Price Volatility | Volatilität morgen | Volatilitet i morgen | Morgen Prijsvolatiliteit | Morgondagens prisvolatilitet | ❌ |
| <span id="ref-next_24h_volatility" class="entity-anchor"></span>`next_24h_volatility` | Next 24h Price Volatility | Volatilität der nächsten 24h | Volatilitet neste 24t | Komende 24u Prijsvolatiliteit | Nästa 24h prisvolatilitet | ❌ |
| <span id="ref-today_tomorrow_volatility" class="entity-anchor" data-refs="sensors-volatility#available-volatility-sensors"></span>`today_tomorrow_volatility` | Today+Tomorrow Price Volatility | Volatilität heute+morgen | Volatilitet i dag+i morgen | Vandaag+Morgen Prijsvolatiliteit | Idag+Imorgon prisvolatilitet | ❌ |

View file

@ -49,6 +49,8 @@ filterwarnings = [
"error",
# Ignore specific warnings from third-party libraries as needed
# "ignore:.*custom_components.* is using deprecated.*:DeprecationWarning",
# AsyncMock cleanup noise when mixing sync/async mocks
"ignore::pytest.PytestUnraisableExceptionWarning",
]
[tool.coverage.run]

View file

@ -0,0 +1,355 @@
"""Tests for sequential scheduling feature in find_cheapest_schedule."""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from typing import Any, cast
from custom_components.tibber_prices.services.find_cheapest_schedule import (
FIND_CHEAPEST_SCHEDULE_SERVICE_SCHEMA,
_attempt_schedule,
)
def _make_intervals(
prices: list[float],
start: datetime | None = None,
*,
level: str = "NORMAL",
) -> list[dict[str, Any]]:
"""Create contiguous quarter-hour intervals for tests."""
base = start or datetime(2026, 1, 1, 0, 0, tzinfo=UTC)
return [
{
"startsAt": (base + timedelta(minutes=15 * i)).isoformat(),
"total": price,
"level": level,
}
for i, price in enumerate(prices)
]
def _make_tasks(*specs: tuple[str, int]) -> list[dict[str, Any]]:
"""Create task dicts from (name, duration_intervals) tuples."""
return [
{
"name": name,
"duration_minutes_requested": dur * 15,
"duration_minutes": dur * 15,
"duration_intervals": dur,
"power_profile": None,
}
for name, dur in specs
]
class TestSequentialSchema:
"""Schema accepts sequential parameter."""
def test_schema_accepts_sequential_true(self) -> None:
"""Schema should accept sequential: true."""
result = cast(
"dict[str, Any]",
FIND_CHEAPEST_SCHEDULE_SERVICE_SCHEMA(
{
"tasks": [{"name": "dishwasher", "duration": timedelta(hours=1)}],
"sequential": True,
}
),
)
assert result["sequential"] is True
def test_schema_defaults_sequential_false(self) -> None:
"""Sequential should default to false when omitted."""
result = cast(
"dict[str, Any]",
FIND_CHEAPEST_SCHEDULE_SERVICE_SCHEMA(
{
"tasks": [{"name": "dishwasher", "duration": timedelta(hours=1)}],
}
),
)
assert result["sequential"] is False
class TestSequentialOrdering:
"""Sequential mode preserves declaration order and chains search windows."""
def test_non_sequential_sorts_by_duration(self) -> None:
"""Default (non-sequential) mode sorts tasks longest-first."""
# 16 intervals = 4 hours of data
# Prices: first 8 cheap, last 8 expensive
prices = [5.0] * 8 + [20.0] * 8
pool = _make_intervals(prices)
# Task A is short (2 intervals), Task B is long (4 intervals)
tasks = _make_tasks(("short_a", 2), ("long_b", 4))
assignments, unscheduled, _ = _attempt_schedule(
pool,
max_price_level=None,
min_price_level=None,
tasks=tasks,
gap_intervals=0,
smooth_outliers=False,
sequential=False,
)
assert not unscheduled
assert len(assignments) == 2
# Greedy longest-first: long_b gets placed first (cheapest window)
# Assignments are returned in placement order (longest first)
assert assignments[0]["name"] == "long_b"
assert assignments[1]["name"] == "short_a"
def test_sequential_preserves_declaration_order(self) -> None:
"""Sequential mode places tasks in the order they appear."""
# 16 intervals, all same price
prices = [10.0] * 16
pool = _make_intervals(prices)
# Declare short task first, long task second
tasks = _make_tasks(("short_a", 2), ("long_b", 4))
assignments, unscheduled, _ = _attempt_schedule(
pool,
max_price_level=None,
min_price_level=None,
tasks=tasks,
gap_intervals=0,
smooth_outliers=False,
sequential=True,
)
assert not unscheduled
assert len(assignments) == 2
# Sequential: short_a placed first, long_b after
assert assignments[0]["name"] == "short_a"
assert assignments[1]["name"] == "long_b"
def test_sequential_chains_search_windows(self) -> None:
"""Each sequential task starts after the previous task's end."""
# 12 intervals: first 4 are cheap, next 4 medium, last 4 expensive
prices = [5.0] * 4 + [10.0] * 4 + [20.0] * 4
pool = _make_intervals(prices)
# Two tasks of 3 intervals each
tasks = _make_tasks(("task_a", 3), ("task_b", 3))
assignments, unscheduled, _ = _attempt_schedule(
pool,
max_price_level=None,
min_price_level=None,
tasks=tasks,
gap_intervals=0,
smooth_outliers=False,
sequential=True,
)
assert not unscheduled
assert len(assignments) == 2
# Task A should get the cheapest window (intervals 0-2)
a_end_last = datetime.fromisoformat(assignments[0]["intervals"][-1]["startsAt"])
# Task B must start at or after task A's end
b_start = datetime.fromisoformat(assignments[1]["intervals"][0]["startsAt"])
assert b_start >= a_end_last + timedelta(minutes=15)
def test_sequential_respects_gap(self) -> None:
"""Sequential mode enforces gap between tasks."""
# 16 intervals of uniform price
prices = [10.0] * 16
pool = _make_intervals(prices)
# 2 tasks of 3 intervals each, with 2-interval (30 min) gap
tasks = _make_tasks(("washer", 3), ("dryer", 3))
assignments, unscheduled, _ = _attempt_schedule(
pool,
max_price_level=None,
min_price_level=None,
tasks=tasks,
gap_intervals=2,
smooth_outliers=False,
sequential=True,
)
assert not unscheduled
assert len(assignments) == 2
washer_end = datetime.fromisoformat(assignments[0]["intervals"][-1]["startsAt"]) + timedelta(minutes=15)
dryer_start = datetime.fromisoformat(assignments[1]["intervals"][0]["startsAt"])
# Gap should be at least 30 minutes (2 intervals × 15 min)
gap = dryer_start - washer_end
assert gap >= timedelta(minutes=30)
def test_sequential_chain_breaks_on_failure(self) -> None:
"""If a sequential task can't be placed, all later tasks are unscheduled."""
# Only 6 intervals — not enough for 3 tasks of 3 intervals each
prices = [10.0] * 6
pool = _make_intervals(prices)
tasks = _make_tasks(("task_a", 3), ("task_b", 3), ("task_c", 3))
assignments, unscheduled, _ = _attempt_schedule(
pool,
max_price_level=None,
min_price_level=None,
tasks=tasks,
gap_intervals=0,
smooth_outliers=False,
sequential=True,
)
# Task A and B fit (6 intervals total), task C doesn't
assert len(assignments) == 2
assert assignments[0]["name"] == "task_a"
assert assignments[1]["name"] == "task_b"
assert unscheduled == ["task_c"]
def test_sequential_all_fail_after_first_failure(self) -> None:
"""If the first task fails in sequential mode, all are unscheduled."""
# 2 intervals — not enough for any 3-interval task
prices = [10.0] * 2
pool = _make_intervals(prices)
tasks = _make_tasks(("task_a", 3), ("task_b", 2))
assignments, unscheduled, _ = _attempt_schedule(
pool,
max_price_level=None,
min_price_level=None,
tasks=tasks,
gap_intervals=0,
smooth_outliers=False,
sequential=True,
)
# Task A can't fit (needs 3, only 2 available)
# Task B should also be unscheduled because the chain is broken
assert len(assignments) == 0
assert unscheduled == ["task_a", "task_b"]
def test_sequential_optimizes_within_window(self) -> None:
"""Sequential still finds cheapest window within each task's available range."""
# 12 intervals: pattern cheap-expensive-cheap-expensive...
# First 6 for task A, second 6 for task B
# Within each half, there's a cheaper sub-window
prices = [20.0, 5.0, 5.0, 20.0, 20.0, 20.0, 20.0, 20.0, 5.0, 5.0, 20.0, 20.0]
pool = _make_intervals(prices)
tasks = _make_tasks(("task_a", 2), ("task_b", 2))
assignments, unscheduled, _ = _attempt_schedule(
pool,
max_price_level=None,
min_price_level=None,
tasks=tasks,
gap_intervals=0,
smooth_outliers=False,
sequential=True,
)
assert not unscheduled
assert len(assignments) == 2
# Task A should pick the cheapest 2-interval window: indices 1-2 (price 5.0 each)
a_prices = [iv["total"] for iv in assignments[0]["intervals"]]
assert a_prices == [5.0, 5.0]
# Task B searches from index 2 onward, cheapest is indices 8-9 (price 5.0 each)
b_prices = [iv["total"] for iv in assignments[1]["intervals"]]
assert b_prices == [5.0, 5.0]
def test_sequential_single_task_same_as_non_sequential(self) -> None:
"""With a single task, sequential and non-sequential produce the same result."""
prices = [20.0, 5.0, 5.0, 20.0, 10.0, 10.0]
pool = _make_intervals(prices)
tasks = _make_tasks(("only_task", 2))
a_seq, u_seq, _ = _attempt_schedule(
pool,
max_price_level=None,
min_price_level=None,
tasks=tasks,
gap_intervals=0,
smooth_outliers=False,
sequential=True,
)
a_non, u_non, _ = _attempt_schedule(
pool,
max_price_level=None,
min_price_level=None,
tasks=tasks,
gap_intervals=0,
smooth_outliers=False,
sequential=False,
)
assert not u_seq
assert not u_non
assert len(a_seq) == len(a_non) == 1
assert a_seq[0]["intervals"] == a_non[0]["intervals"]
class TestSequentialThreeTasks:
"""Sequential scheduling with three tasks (washer → dryer → fold reminder)."""
def test_three_tasks_chained(self) -> None:
"""Three sequential tasks are placed in order with no overlap."""
# 24 intervals (6 hours) with varying prices
prices = [15.0, 10.0, 5.0, 5.0, 10.0, 15.0, 20.0, 25.0] * 3
pool = _make_intervals(prices)
tasks = _make_tasks(("washer", 4), ("dryer", 3), ("fold", 1))
assignments, unscheduled, _ = _attempt_schedule(
pool,
max_price_level=None,
min_price_level=None,
tasks=tasks,
gap_intervals=0,
smooth_outliers=False,
sequential=True,
)
assert not unscheduled
assert len(assignments) == 3
assert assignments[0]["name"] == "washer"
assert assignments[1]["name"] == "dryer"
assert assignments[2]["name"] == "fold"
# Verify no overlap: each task starts after previous ends
for i in range(1, len(assignments)):
prev_end = datetime.fromisoformat(assignments[i - 1]["intervals"][-1]["startsAt"]) + timedelta(minutes=15)
curr_start = datetime.fromisoformat(assignments[i]["intervals"][0]["startsAt"])
assert curr_start >= prev_end
def test_three_tasks_with_gap(self) -> None:
"""Three sequential tasks respect gap between each pair."""
prices = [10.0] * 24
pool = _make_intervals(prices)
tasks = _make_tasks(("washer", 4), ("dryer", 3), ("fold", 1))
assignments, unscheduled, _ = _attempt_schedule(
pool,
max_price_level=None,
min_price_level=None,
tasks=tasks,
gap_intervals=1, # 15 min gap
smooth_outliers=False,
sequential=True,
)
assert not unscheduled
assert len(assignments) == 3
# Verify gaps between each pair
for i in range(1, len(assignments)):
prev_end = datetime.fromisoformat(assignments[i - 1]["intervals"][-1]["startsAt"]) + timedelta(minutes=15)
curr_start = datetime.fromisoformat(assignments[i]["intervals"][0]["startsAt"])
gap = curr_start - prev_end
assert gap >= timedelta(minutes=15)

View file

@ -295,6 +295,7 @@ class TestStorageCleanup:
# Create mocks
hass = AsyncMock()
hass.async_add_executor_job = AsyncMock()
hass.config_entries.async_entries = MagicMock(return_value=[])
config_entry = MagicMock()
config_entry.entry_id = "test_entry_123"