mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-05-28 18:43:40 +00:00
Compare commits
6 commits
0162394263
...
093e904329
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
093e904329 | ||
|
|
e75e0ed1dc | ||
|
|
2d2873f75f | ||
|
|
e01cc5d447 | ||
|
|
a8d1519a26 | ||
|
|
31fca73ccd |
40 changed files with 9900 additions and 237 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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 }}"
|
||||
|
|
@ -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 }}"
|
||||
|
|
@ -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.
|
||||
|
|
@ -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 }}"
|
||||
|
|
@ -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 }}"
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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 1–2 hours, but prices remain favorable for 4–6 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.
|
||||
|
|
@ -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 (0–100%). 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.
|
||||
|
|
@ -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 1–5 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 (1–5) 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 }}"
|
||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -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.
|
||||
|
|
@ -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 }}"
|
||||
|
|
@ -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 }}"
|
||||
|
|
@ -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.
|
||||
|
|
@ -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 }}"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
285
custom_components/tibber_prices/services/entity_resolver.py
Normal file
285
custom_components/tibber_prices/services/entity_resolver.py
Normal 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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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** | [](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** | [](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** | [](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** | [](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** | [](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** | [](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 min–1: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** | [](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** | [](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** | [](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 (2–3 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:15–09: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:15–11: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:15–11: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) | [](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 | [](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) | [](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: [](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: [](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: [](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 1–2 hours, but prices remain favorable for 4–6 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: [](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: [](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).
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 | ❌ |
|
||||
|
|
|
|||
0
examples/scripts/tibber_notify_residents.yaml
Normal file
0
examples/scripts/tibber_notify_residents.yaml
Normal 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]
|
||||
|
|
|
|||
355
tests/services/test_sequential_scheduling.py
Normal file
355
tests/services/test_sequential_scheduling.py
Normal 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)
|
||||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue