diff --git a/custom_components/tibber_prices/__init__.py b/custom_components/tibber_prices/__init__.py index 1854399..0d0e003 100644 --- a/custom_components/tibber_prices/__init__.py +++ b/custom_components/tibber_prices/__init__.py @@ -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, diff --git a/custom_components/tibber_prices/blueprints/automation/tibber_prices/dishwasher.yaml b/custom_components/tibber_prices/blueprints/automation/tibber_prices/dishwasher.yaml new file mode 100644 index 0000000..992e62d --- /dev/null +++ b/custom_components/tibber_prices/blueprints/automation/tibber_prices/dishwasher.yaml @@ -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. diff --git a/custom_components/tibber_prices/blueprints/automation/tibber_prices/dishwasher_home_connect.yaml b/custom_components/tibber_prices/blueprints/automation/tibber_prices/dishwasher_home_connect.yaml new file mode 100644 index 0000000..4ce81c3 --- /dev/null +++ b/custom_components/tibber_prices/blueprints/automation/tibber_prices/dishwasher_home_connect.yaml @@ -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 }}" diff --git a/custom_components/tibber_prices/blueprints/automation/tibber_prices/dishwasher_home_connect_alt.yaml b/custom_components/tibber_prices/blueprints/automation/tibber_prices/dishwasher_home_connect_alt.yaml new file mode 100644 index 0000000..019cf35 --- /dev/null +++ b/custom_components/tibber_prices/blueprints/automation/tibber_prices/dishwasher_home_connect_alt.yaml @@ -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 }}" diff --git a/custom_components/tibber_prices/blueprints/automation/tibber_prices/dryer.yaml b/custom_components/tibber_prices/blueprints/automation/tibber_prices/dryer.yaml new file mode 100644 index 0000000..1b2c8aa --- /dev/null +++ b/custom_components/tibber_prices/blueprints/automation/tibber_prices/dryer.yaml @@ -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. diff --git a/custom_components/tibber_prices/blueprints/automation/tibber_prices/dryer_home_connect.yaml b/custom_components/tibber_prices/blueprints/automation/tibber_prices/dryer_home_connect.yaml new file mode 100644 index 0000000..9f5712a --- /dev/null +++ b/custom_components/tibber_prices/blueprints/automation/tibber_prices/dryer_home_connect.yaml @@ -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 }}" diff --git a/custom_components/tibber_prices/blueprints/automation/tibber_prices/dryer_home_connect_alt.yaml b/custom_components/tibber_prices/blueprints/automation/tibber_prices/dryer_home_connect_alt.yaml new file mode 100644 index 0000000..29e77df --- /dev/null +++ b/custom_components/tibber_prices/blueprints/automation/tibber_prices/dryer_home_connect_alt.yaml @@ -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 }}" diff --git a/custom_components/tibber_prices/blueprints/automation/tibber_prices/ev_charging.yaml b/custom_components/tibber_prices/blueprints/automation/tibber_prices/ev_charging.yaml new file mode 100644 index 0000000..42c1bfc --- /dev/null +++ b/custom_components/tibber_prices/blueprints/automation/tibber_prices/ev_charging.yaml @@ -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. diff --git a/custom_components/tibber_prices/blueprints/automation/tibber_prices/heat_pump_price_level.yaml b/custom_components/tibber_prices/blueprints/automation/tibber_prices/heat_pump_price_level.yaml new file mode 100644 index 0000000..b190fd1 --- /dev/null +++ b/custom_components/tibber_prices/blueprints/automation/tibber_prices/heat_pump_price_level.yaml @@ -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._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. diff --git a/custom_components/tibber_prices/blueprints/automation/tibber_prices/heat_pump_smart_boost.yaml b/custom_components/tibber_prices/blueprints/automation/tibber_prices/heat_pump_smart_boost.yaml new file mode 100644 index 0000000..add1f44 --- /dev/null +++ b/custom_components/tibber_prices/blueprints/automation/tibber_prices/heat_pump_smart_boost.yaml @@ -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._best_price_period` from + Tibber Prices. + selector: + entity: + filter: + domain: binary_sensor + integration: tibber_prices + price_sensor: + name: Current Price Sensor + description: > + The `sensor._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._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. diff --git a/custom_components/tibber_prices/blueprints/automation/tibber_prices/home_battery.yaml b/custom_components/tibber_prices/blueprints/automation/tibber_prices/home_battery.yaml new file mode 100644 index 0000000..8f525ac --- /dev/null +++ b/custom_components/tibber_prices/blueprints/automation/tibber_prices/home_battery.yaml @@ -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._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._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. diff --git a/custom_components/tibber_prices/blueprints/automation/tibber_prices/laundry_day_pipeline.yaml b/custom_components/tibber_prices/blueprints/automation/tibber_prices/laundry_day_pipeline.yaml new file mode 100644 index 0000000..9904357 --- /dev/null +++ b/custom_components/tibber_prices/blueprints/automation/tibber_prices/laundry_day_pipeline.yaml @@ -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 }}" diff --git a/custom_components/tibber_prices/blueprints/automation/tibber_prices/laundry_day_pipeline_home_connect.yaml b/custom_components/tibber_prices/blueprints/automation/tibber_prices/laundry_day_pipeline_home_connect.yaml new file mode 100644 index 0000000..361f163 --- /dev/null +++ b/custom_components/tibber_prices/blueprints/automation/tibber_prices/laundry_day_pipeline_home_connect.yaml @@ -0,0 +1,1124 @@ +blueprint: + name: "Tibber Prices: Laundry Day Pipeline (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** laundry pipeline — schedule multiple wash + dry + cycles at the cheapest electricity prices using the **Home Connect** + integration (HA Core). + + **How it works:** + + 1. Turn on the Laundry Day toggle + + 2. You get a notification to prepare the washer + + 3. Select your program, close the door, enable Remote Start + + 4. The blueprint reads the estimated duration from the device + + 5. Finds the cheapest window and tells the machine via + `FinishInRelative` — the appliance manages the countdown + + 6. Waits for the washer to report "Finished" (live monitoring) + + 7. Notifies you to transfer laundry to the dryer + + 8. Repeats for each load until all cycles are complete + + **Key improvements over v1:** + + - Duration is read **from the device**, not configured manually + + - Completion detection via **operation state**, not fixed timers + + - Transfer detection via **door + Remote Start** sensors + + - Survives HA restarts (appliance manages countdown) + + **Pipeline mode** (optional): The next wash starts while the dryer + is still running, cutting total time significantly. + + **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 your appliances + + - 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 + + **Other variants:** + [Smart Plug](https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/laundry_day_pipeline.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.11.0" + source_url: https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/laundry_day_pipeline_home_connect.yaml + input: + washer: + name: Washing Machine + icon: mdi:washing-machine + description: > + Select your Home Connect washing machine device and entities. + input: + washer_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 + washer_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 + washer_remote_start_sensor: + name: Remote Control Sensor + description: > + The "Remote Control Active" binary sensor. + selector: + entity: + filter: + integration: home_connect + domain: binary_sensor + washer_estimated_duration_entity: + name: Estimated Program Duration + description: > + The "Estimated Total Program Time" sensor. + If unavailable, the fallback duration is used. + selector: + entity: + filter: + integration: home_connect + domain: sensor + washer_operation_state_entity: + name: Operation State + description: > + The "Operation State" sensor. + Used to detect when the wash cycle finishes. + selector: + entity: + filter: + integration: home_connect + domain: sensor + + dryer: + name: Dryer + icon: mdi:tumble-dryer + description: > + Select your dryer device and entities. Disable "Include Dryer" + if you hang laundry to dry. + input: + 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_device: + name: Dryer Device + description: > + Your dryer from the Home Connect integration. + Only used when "Include Dryer" is enabled. + default: "" + selector: + device: + filter: + integration: home_connect + dryer_door_sensor: + name: Door Sensor + description: > + The door sensor of your dryer. + Only used when "Include Dryer" is enabled. + default: "" + selector: + entity: + filter: + integration: home_connect + domain: binary_sensor + device_class: door + dryer_remote_start_sensor: + name: Remote Control Sensor + description: > + The "Remote Control Active" binary sensor of the dryer. + Only used when "Include Dryer" is enabled. + default: "" + selector: + entity: + filter: + integration: home_connect + domain: binary_sensor + dryer_estimated_duration_entity: + name: Estimated Program Duration + description: > + The "Estimated Total Program Time" sensor of the dryer. + Only used when "Include Dryer" is enabled. + default: "" + selector: + entity: + filter: + integration: home_connect + domain: sensor + dryer_operation_state_entity: + name: Operation State + description: > + The "Operation State" sensor of the dryer. + Only used when "Include Dryer" is enabled. + default: "" + selector: + entity: + filter: + integration: home_connect + domain: sensor + + 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 + must_finish_by: + name: Must Finish By + description: > + Each cycle must be finished by this time. + If this time has already passed today, the deadline + automatically moves to tomorrow (overnight mode). + default: "22:00:00" + selector: + time: + duration_fallback_washer: + name: Washer Fallback Duration (minutes) + description: > + Used **only** if the washing machine doesn't report + estimated duration. Normally read from the device. + default: 95 + selector: + number: + min: 15 + max: 240 + step: 5 + unit_of_measurement: min + mode: slider + duration_fallback_dryer: + name: Dryer Fallback Duration (minutes) + description: > + Used **only** if the dryer doesn't report estimated + duration. Normally read from the device. + default: 65 + selector: + number: + min: 15 + max: 180 + step: 5 + unit_of_measurement: min + mode: slider + + 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. + + **Only safe when wash duration ≥ dryer duration.** + default: false + selector: + boolean: + + notifications: + name: Notifications + icon: mdi:bell-outline + collapsed: true + description: > + Optional notifications for transfer reminders and progress. + Use **simple mode** (service) or an **advanced script** for + multi-target, presence-aware 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 + +mode: single +max_exceeded: warning + +triggers: + - trigger: state + entity_id: !input trigger_entity + to: "on" + +variables: + _blueprint_variant: "home_connect" + washer_device: !input washer_device + washer_door_sensor: !input washer_door_sensor + washer_remote_start_sensor: !input washer_remote_start_sensor + washer_estimated_duration_entity: !input washer_estimated_duration_entity + washer_operation_state_entity: !input washer_operation_state_entity + include_dryer: !input include_dryer + dryer_device: !input dryer_device + dryer_door_sensor: !input dryer_door_sensor + dryer_remote_start_sensor: !input dryer_remote_start_sensor + dryer_estimated_duration_entity: !input dryer_estimated_duration_entity + dryer_operation_state_entity: !input dryer_operation_state_entity + trigger_entity: !input trigger_entity + loads_entity: !input loads_entity + must_finish_by_time: !input must_finish_by + duration_fallback_washer: !input duration_fallback_washer + duration_fallback_dryer: !input duration_fallback_dryer + pipeline_mode: !input pipeline_mode + notify_service: !input notify_service + notification_script: !input notification_script + total_loads: "{{ states(loads_entity) | int(1) }}" + +actions: + # ════════════════════════════════════════════════════════ + # PREFLIGHT CHECKS + # ════════════════════════════════════════════════════════ + - variables: + _tp_entities: "{{ integration_entities('tibber_prices') | list }}" + - if: + - condition: template + value_template: "{{ _tp_entities | length == 0 }}" + then: + - variables: + _n_title: "🧺 Laundry Day — 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: laundry_pipeline + 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 }}" + - action: input_boolean.turn_off + target: + entity_id: "{{ trigger_entity }}" + - stop: "Tibber Prices integration not found" + + - if: + - condition: template + value_template: "{{ total_loads < 1 or total_loads > 5 }}" + then: + - variables: + _n_title: "🧺 Laundry Day — Invalid Config" + _n_message: > + Loads: {{ total_loads }}. + Set {{ loads_entity }} to 1–5. + - choose: + - conditions: + - condition: template + value_template: "{{ notification_script | length > 0 }}" + sequence: + - action: script.turn_on + target: + entity_id: "{{ notification_script }}" + data: + variables: + event_type: notification + appliance: laundry_pipeline + 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 }}" + - action: input_boolean.turn_off + target: + entity_id: "{{ trigger_entity }}" + - stop: "Invalid load count" + + # ════════════════════════════════════════════════════════ + # START NOTIFICATION + # ════════════════════════════════════════════════════════ + - variables: + _n_title: "🧺 Laundry Day Started" + _n_message: > + {{ total_loads }} + load{{ 's' if total_loads | int > 1 else '' }} + {{ '(wash + dry)' if include_dryer else '(wash only)' }} + · Deadline + {{ must_finish_by_time[:5] if must_finish_by_time | length >= 5 + else must_finish_by_time }}. + 👕 Prepare load 1: program → close door → 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: started + appliance: laundry_pipeline + title: "{{ _n_title }}" + message: "{{ _n_message }}" + load_index: "{{ repeat.index }}" + total_loads: "{{ total_loads }}" + - 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 }}" + + # ════════════════════════════════════════════════════════ + # MAIN PIPELINE LOOP + # ════════════════════════════════════════════════════════ + - repeat: + count: "{{ total_loads }}" + sequence: + # ── CHECK CANCELLATION ────────────────────────── + - if: + - condition: template + value_template: "{{ is_state(trigger_entity, 'off') }}" + then: + - variables: + _n_title: "🧺 Laundry Day Cancelled" + _n_message: > + Stopped after {{ repeat.index - 1 }} + of {{ total_loads }} loads. + - choose: + - conditions: + - condition: template + value_template: "{{ notification_script | length > 0 }}" + sequence: + - action: script.turn_on + target: + entity_id: "{{ notification_script }}" + data: + variables: + event_type: cancelled + appliance: laundry_pipeline + title: "{{ _n_title }}" + message: "{{ _n_message }}" + load_index: "{{ repeat.index }}" + total_loads: "{{ total_loads }}" + - 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: "Cancelled by user" + + # ── WAIT FOR WASHER READINESS ────────────────── + - if: + - condition: template + value_template: > + {{ not (is_state(washer_door_sensor, 'off') + and is_state(washer_remote_start_sensor, 'on')) }} + then: + - if: + - condition: template + value_template: "{{ repeat.index > 1 }}" + then: + - variables: + _n_title: > + 👕 Washer — Load {{ repeat.index }}/{{ total_loads }} + _n_message: > + Program → close door → 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: prepare_washer + appliance: laundry_pipeline + title: "{{ _n_title }}" + message: "{{ _n_message }}" + load_index: "{{ repeat.index }}" + total_loads: "{{ total_loads }}" + - 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 }}" + - wait_for_trigger: + - trigger: template + value_template: > + {{ is_state(washer_door_sensor, 'off') + and is_state(washer_remote_start_sensor, 'on') }} + timeout: + hours: 4 + continue_on_timeout: true + - if: + - condition: template + value_template: "{{ wait.trigger is none }}" + then: + - variables: + _n_title: "⚠️ Load {{ repeat.index }} — Washer Timeout" + _n_message: > + Not ready after 4 h. Pipeline stopped. + - choose: + - conditions: + - condition: template + value_template: "{{ notification_script | length > 0 }}" + sequence: + - action: script.turn_on + target: + entity_id: "{{ notification_script }}" + data: + variables: + event_type: timeout + appliance: laundry_pipeline + title: "{{ _n_title }}" + message: "{{ _n_message }}" + load_index: "{{ repeat.index }}" + total_loads: "{{ total_loads }}" + - 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 }}" + - action: input_boolean.turn_off + target: + entity_id: "{{ trigger_entity }}" + - stop: "Washer readiness timeout" + + # ── READ WASHER DATA ─────────────────────────── + - variables: + _raw_washer_duration: "{{ states(washer_estimated_duration_entity) }}" + washer_duration: > + {% set raw = states(washer_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_washer }} + {% endif %} + deadline: > + {% set dl = today_at(must_finish_by_time) %} + {% if dl <= now() %} + {{ (dl + timedelta(days=1)).isoformat() }} + {% else %} + {{ dl.isoformat() }} + {% endif %} + + # ── FIND CHEAPEST WASH WINDOW ────────────────── + - action: tibber_prices.find_cheapest_block + data: + duration: > + {{ '%02d:%02d:00' | format( + (washer_duration | int) // 60, + (washer_duration | int) % 60) }} + must_finish_by: "{{ deadline }}" + response_variable: wash_result + + - if: + - condition: template + value_template: "{{ not wash_result.window_found }}" + then: + - variables: + _n_title: "👕 Wash {{ repeat.index }}/{{ total_loads }} — No Cheap Slot" + _n_message: > + No cheap slot before + {{ deadline | as_datetime | as_local + | as_timestamp | timestamp_custom('%H:%M') }} + for {{ washer_duration }} min. + - 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: laundry_pipeline + title: "{{ _n_title }}" + message: "{{ _n_message }}" + deadline: "{{ deadline }}" + duration_minutes: "{{ washer_duration | int }}" + load_index: "{{ repeat.index }}" + total_loads: "{{ total_loads }}" + - 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 }}" + - action: input_boolean.turn_off + target: + entity_id: "{{ trigger_entity }}" + - stop: "No wash window found" + + # ── START WASHER ─────────────────────────────── + - variables: + _wash_window_start: "{{ wash_result.window.start | as_datetime }}" + wash_finish_in_relative: > + {% set window_end = _wash_window_start + timedelta(minutes=washer_duration | int) %} + {% set seconds_until_end = ((window_end - now()).total_seconds()) | int %} + {{ [washer_duration | int * 60, seconds_until_end] | max }} + + # Washing machines use FinishInRelative + - action: home_connect.set_program_and_options + target: + device_id: "{{ washer_device }}" + data: + affects_to: active_program + b_s_h_common_option_finish_in_relative: "{{ wash_finish_in_relative }}" + + - variables: + _n_title: > + 👕 Wash {{ repeat.index }}/{{ total_loads }} — Planned! + _n_message: > + {% set delay = wash_finish_in_relative | int - (washer_duration | int * 60) %} + {% if delay > 60 %} + ⏰ ~{{ _wash_window_start | as_local + | as_timestamp | timestamp_custom('%H:%M') }} + (in {{ (delay / 3600) | round(1) }} h) + {% else %} + ▶️ Starting now! + {% endif %} + · ~{{ washer_duration }} min + · {{ wash_result.window.price_mean | round(1) }} {{ wash_result.price_unit }}/kWh + - choose: + - conditions: + - condition: template + value_template: "{{ notification_script | length > 0 }}" + sequence: + - action: script.turn_on + target: + entity_id: "{{ notification_script }}" + data: + variables: + event_type: wash_planned + appliance: laundry_pipeline + title: "{{ _n_title }}" + message: "{{ _n_message }}" + start_time: "{{ _wash_window_start | as_local | as_timestamp | timestamp_custom('%Y-%m-%dT%H:%M:%S') }}" + duration_minutes: "{{ washer_duration | int }}" + price_mean: "{{ wash_result.window.price_mean | round(1) }}" + price_unit: "{{ wash_result.price_unit }}" + load_index: "{{ repeat.index }}" + total_loads: "{{ total_loads }}" + - 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 }}" + + # ── WAIT FOR WASHER TO FINISH ────────────────── + - wait_for_trigger: + - trigger: template + value_template: > + {% set op = states(washer_operation_state_entity) %} + {{ 'Finished' in op + or is_state(trigger_entity, 'off') }} + timeout: + seconds: "{{ wash_finish_in_relative | int + 1800 }}" + continue_on_timeout: true + + # Check: cancelled? + - if: + - condition: template + value_template: "{{ is_state(trigger_entity, 'off') }}" + then: + - variables: + _n_title: "🧺 Laundry Day Cancelled" + _n_message: > + Stopped during wash {{ repeat.index }} + of {{ total_loads }}. + - choose: + - conditions: + - condition: template + value_template: "{{ notification_script | length > 0 }}" + sequence: + - action: script.turn_on + target: + entity_id: "{{ notification_script }}" + data: + variables: + event_type: cancelled + appliance: laundry_pipeline + title: "{{ _n_title }}" + message: "{{ _n_message }}" + load_index: "{{ repeat.index }}" + total_loads: "{{ total_loads }}" + - 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: "Cancelled by user" + + # Check: timeout? + - if: + - condition: template + value_template: "{{ wait.trigger is none }}" + then: + - variables: + _n_title: "⚠️ Wash {{ repeat.index }} — Timeout" + _n_message: > + Didn't finish in time. Check appliance. + Pipeline continues. + - choose: + - conditions: + - condition: template + value_template: "{{ notification_script | length > 0 }}" + sequence: + - action: script.turn_on + target: + entity_id: "{{ notification_script }}" + data: + variables: + event_type: timeout + appliance: laundry_pipeline + title: "{{ _n_title }}" + message: "{{ _n_message }}" + load_index: "{{ repeat.index }}" + total_loads: "{{ total_loads }}" + - 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 }}" + + # ── WASH DONE ────────────────────────────────── + - variables: + _n_title: > + ✅ Wash {{ repeat.index }}/{{ total_loads }} Done! + _n_message: > + {% if include_dryer %} + 🌀 Transfer to dryer: program → close door + → Remote Start. + {% endif %} + {% if repeat.index | int < total_loads | int and not include_dryer %} + 👕 Prepare load {{ repeat.index + 1 }}: program → + close door → Remote Start. + {% 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: wash_done + appliance: laundry_pipeline + title: "{{ _n_title }}" + message: "{{ _n_message }}" + load_index: "{{ repeat.index }}" + total_loads: "{{ total_loads }}" + - 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 }}" + + # ── DRYER (if enabled) ───────────────────────── + - if: + - condition: template + value_template: "{{ include_dryer }}" + then: + # Wait for dryer readiness + - if: + - condition: template + value_template: > + {{ not (is_state(dryer_door_sensor, 'off') + and is_state(dryer_remote_start_sensor, 'on')) }} + then: + - wait_for_trigger: + - trigger: template + value_template: > + {{ is_state(dryer_door_sensor, 'off') + and is_state(dryer_remote_start_sensor, 'on') }} + timeout: + hours: 2 + continue_on_timeout: true + - if: + - condition: template + value_template: "{{ wait.trigger is none }}" + then: + - variables: + _n_title: "⚠️ Dryer {{ repeat.index }} — Skipped" + _n_message: > + Not ready after 2 h. Dry manually. + - choose: + - conditions: + - condition: template + value_template: "{{ notification_script | length > 0 }}" + sequence: + - action: script.turn_on + target: + entity_id: "{{ notification_script }}" + data: + variables: + event_type: dryer_skipped + appliance: laundry_pipeline + title: "{{ _n_title }}" + message: "{{ _n_message }}" + load_index: "{{ repeat.index }}" + total_loads: "{{ total_loads }}" + - 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 }}" + + - if: + - condition: template + value_template: > + {{ is_state(dryer_door_sensor, 'off') + and is_state(dryer_remote_start_sensor, 'on') }} + then: + # Read dryer data + - variables: + _raw_dryer_duration: "{{ states(dryer_estimated_duration_entity) }}" + dryer_duration: > + {% set raw = states(dryer_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_dryer }} + {% endif %} + + # Find cheapest dryer window + - action: tibber_prices.find_cheapest_block + data: + duration: > + {{ '%02d:%02d:00' | format( + (dryer_duration | int) // 60, + (dryer_duration | int) % 60) }} + must_finish_by: "{{ deadline }}" + response_variable: dry_result + + - if: + - condition: template + value_template: "{{ not dry_result.window_found }}" + then: + - variables: + _n_title: "🌀 Dryer {{ repeat.index }} — No Cheap Slot" + _n_message: > + No cheap slot found. Run dryer manually. + - 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: laundry_pipeline + title: "{{ _n_title }}" + message: "{{ _n_message }}" + deadline: "{{ deadline }}" + duration_minutes: "{{ dryer_duration | int }}" + load_index: "{{ repeat.index }}" + total_loads: "{{ total_loads }}" + - 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 }}" + + - if: + - condition: template + value_template: > + {{ dry_result.window_found | default(false) }} + then: + # Start dryer + - variables: + _dry_window_start: "{{ dry_result.window.start | as_datetime }}" + dry_finish_in_relative: > + {% set window_end = _dry_window_start + timedelta(minutes=dryer_duration | int) %} + {% set seconds_until_end = ((window_end - now()).total_seconds()) | int %} + {{ [dryer_duration | int * 60, seconds_until_end] | max }} + + # Dryers use FinishInRelative + - action: home_connect.set_program_and_options + target: + device_id: "{{ dryer_device }}" + data: + affects_to: active_program + b_s_h_common_option_finish_in_relative: "{{ dry_finish_in_relative }}" + + - variables: + _n_title: > + 🌀 Dryer {{ repeat.index }}/{{ total_loads }} — Planned! + _n_message: > + {% set delay = dry_finish_in_relative | int - (dryer_duration | int * 60) %} + {% if delay > 60 %} + ⏰ ~{{ _dry_window_start | as_local + | as_timestamp | timestamp_custom('%H:%M') }} + (in {{ (delay / 3600) | round(1) }} h) + {% else %} + ▶️ Starting now! + {% endif %} + · ~{{ dryer_duration }} min + {% if pipeline_mode + and repeat.index | int < total_loads | int %} + · Next wash planned now — dryer runs in parallel + {% 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: dryer_planned + appliance: laundry_pipeline + title: "{{ _n_title }}" + message: "{{ _n_message }}" + start_time: "{{ _dry_window_start | as_local | as_timestamp | timestamp_custom('%Y-%m-%dT%H:%M:%S') }}" + duration_minutes: "{{ dryer_duration | int }}" + price_mean: "{{ dry_result.window.price_mean | round(1) }}" + price_unit: "{{ dry_result.price_unit }}" + load_index: "{{ repeat.index }}" + total_loads: "{{ total_loads }}" + - 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 }}" + + # 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: + - wait_for_trigger: + - trigger: template + value_template: > + {% set op = states(dryer_operation_state_entity) %} + {{ 'Finished' in op + or is_state(trigger_entity, 'off') }} + timeout: + seconds: "{{ dry_finish_in_relative | int + 1800 }}" + continue_on_timeout: true + - if: + - condition: template + value_template: "{{ is_state(trigger_entity, 'off') }}" + then: + - stop: "Cancelled by user" + + # ════════════════════════════════════════════════════════ + # ALL DONE + # ════════════════════════════════════════════════════════ + - variables: + _n_title: "🎉 Laundry Day Complete!" + _n_message: > + All {{ total_loads }} + load{{ 's' if total_loads | int > 1 else '' }} + washed{{ ' and dried' if include_dryer else '' }}. + Time to fold! 🧺 + - choose: + - conditions: + - condition: template + value_template: "{{ notification_script | length > 0 }}" + sequence: + - action: script.turn_on + target: + entity_id: "{{ notification_script }}" + data: + variables: + event_type: complete + appliance: laundry_pipeline + title: "{{ _n_title }}" + message: "{{ _n_message }}" + load_index: "{{ repeat.index }}" + total_loads: "{{ total_loads }}" + - 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 }}" + + - action: input_boolean.turn_off + target: + entity_id: "{{ trigger_entity }}" diff --git a/custom_components/tibber_prices/blueprints/automation/tibber_prices/laundry_day_pipeline_home_connect_alt.yaml b/custom_components/tibber_prices/blueprints/automation/tibber_prices/laundry_day_pipeline_home_connect_alt.yaml new file mode 100644 index 0000000..6136c37 --- /dev/null +++ b/custom_components/tibber_prices/blueprints/automation/tibber_prices/laundry_day_pipeline_home_connect_alt.yaml @@ -0,0 +1,1179 @@ +blueprint: + name: "Tibber Prices: Laundry Day Pipeline (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** laundry pipeline — schedule multiple wash + dry + cycles at the cheapest electricity prices 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. Turn on the Laundry Day toggle + + 2. You get a notification to prepare the washer + + 3. Select your program, close the door, enable Remote Start + + 4. The blueprint reads program + duration from the device + + 5. Finds the cheapest window and tells the machine via + `FinishInRelative` — the appliance manages the countdown + + 6. Waits for the washer to report "Finished" (live monitoring) + + 7. Notifies you to transfer laundry to the dryer + + 8. Repeats for each load until all cycles are complete + + **Key improvements over v1:** + + - Programs are selected **on the device**, not hardcoded + + - Duration is read **from the device**, not configured manually + + - Completion detection via **operation state**, not fixed timers + + - Transfer detection via **door + Remote Start** sensors + + - Survives HA restarts (appliance manages countdown) + + **Pipeline mode** (optional): The next wash starts while the dryer + is still running, cutting total time significantly. + + **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 your appliances + + - 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 + + **Other variants:** + [Smart Plug](https://github.com/jpawlowski/hass.tibber_prices/blob/main/custom_components/tibber_prices/blueprints/automation/tibber_prices/laundry_day_pipeline.yaml) + · + [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) + 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/laundry_day_pipeline_home_connect_alt.yaml + input: + washer: + name: Washing Machine + icon: mdi:washing-machine + description: > + Select your washing machine entities from Home Connect Alt. + input: + washer_program_entity: + name: Selected Program Entity + description: > + The program selector entity of your washing machine + (e.g., `select.bosch_washer_programs`). + The currently selected program is read from here. + selector: + entity: + filter: + integration: home_connect_alt + domain: select + washer_door_sensor: + name: Door Sensor + description: > + The door sensor of your washing machine. + selector: + entity: + filter: + integration: home_connect_alt + domain: binary_sensor + device_class: door + washer_remote_start_sensor: + name: Remote Control Sensor + description: > + The "Remote Control Active" binary sensor. + selector: + entity: + filter: + integration: home_connect_alt + domain: binary_sensor + washer_estimated_duration_entity: + name: Estimated Program Duration + description: > + The "Estimated Total Program Time" sensor. + If unavailable, the fallback duration is used. + selector: + entity: + filter: + integration: home_connect_alt + domain: sensor + washer_operation_state_entity: + name: Operation State + description: > + The "Operation State" sensor. + Used to detect when the wash cycle finishes. + selector: + entity: + filter: + integration: home_connect_alt + domain: sensor + + dryer: + name: Dryer + icon: mdi:tumble-dryer + description: > + Select your dryer entities. Disable "Include Dryer" if you + hang laundry to dry. + input: + 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_program_entity: + name: Selected Program Entity + description: > + The program selector entity of your dryer + (e.g., `select.bosch_dryer_programs`). + Only used when "Include Dryer" is enabled. + default: "" + selector: + entity: + filter: + integration: home_connect_alt + domain: select + dryer_door_sensor: + name: Door Sensor + description: > + The door sensor of your dryer. + Only used when "Include Dryer" is enabled. + default: "" + selector: + entity: + filter: + integration: home_connect_alt + domain: binary_sensor + device_class: door + dryer_remote_start_sensor: + name: Remote Control Sensor + description: > + The "Remote Control Active" binary sensor of the dryer. + Only used when "Include Dryer" is enabled. + default: "" + selector: + entity: + filter: + integration: home_connect_alt + domain: binary_sensor + dryer_estimated_duration_entity: + name: Estimated Program Duration + description: > + The "Estimated Total Program Time" sensor of the dryer. + Only used when "Include Dryer" is enabled. + default: "" + selector: + entity: + filter: + integration: home_connect_alt + domain: sensor + dryer_operation_state_entity: + name: Operation State + description: > + The "Operation State" sensor of the dryer. + Only used when "Include Dryer" is enabled. + default: "" + selector: + entity: + filter: + integration: home_connect_alt + domain: sensor + + 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 + must_finish_by: + name: Must Finish By + description: > + Each cycle must be finished by this time. + If this time has already passed today, the deadline + automatically moves to tomorrow (overnight mode). + default: "22:00:00" + selector: + time: + duration_fallback_washer: + name: Washer Fallback Duration (minutes) + description: > + Used **only** if the washing machine doesn't report + estimated duration. Normally read from the device. + default: 95 + selector: + number: + min: 15 + max: 240 + step: 5 + unit_of_measurement: min + mode: slider + duration_fallback_dryer: + name: Dryer Fallback Duration (minutes) + description: > + Used **only** if the dryer doesn't report estimated + duration. Normally read from the device. + default: 65 + selector: + number: + min: 15 + max: 180 + step: 5 + unit_of_measurement: min + mode: slider + + 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. + + **Only safe when wash duration ≥ dryer duration.** + default: false + selector: + boolean: + + notifications: + name: Notifications + icon: mdi:bell-outline + collapsed: true + description: > + Optional notifications for transfer reminders and progress. + Use **simple mode** (service) or an **advanced script** for + multi-target, presence-aware 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 + +mode: single +max_exceeded: warning + +triggers: + - trigger: state + entity_id: !input trigger_entity + to: "on" + +variables: + _blueprint_variant: "home_connect_alt" + washer_program_entity: !input washer_program_entity + washer_door_sensor: !input washer_door_sensor + washer_remote_start_sensor: !input washer_remote_start_sensor + washer_estimated_duration_entity: !input washer_estimated_duration_entity + washer_operation_state_entity: !input washer_operation_state_entity + include_dryer: !input include_dryer + dryer_program_entity: !input dryer_program_entity + dryer_door_sensor: !input dryer_door_sensor + dryer_remote_start_sensor: !input dryer_remote_start_sensor + dryer_estimated_duration_entity: !input dryer_estimated_duration_entity + dryer_operation_state_entity: !input dryer_operation_state_entity + trigger_entity: !input trigger_entity + loads_entity: !input loads_entity + must_finish_by_time: !input must_finish_by + duration_fallback_washer: !input duration_fallback_washer + duration_fallback_dryer: !input duration_fallback_dryer + pipeline_mode: !input pipeline_mode + notify_service: !input notify_service + notification_script: !input notification_script + total_loads: "{{ states(loads_entity) | int(1) }}" + +actions: + # ════════════════════════════════════════════════════════ + # PREFLIGHT CHECKS + # ════════════════════════════════════════════════════════ + - variables: + _tp_entities: "{{ integration_entities('tibber_prices') | list }}" + - if: + - condition: template + value_template: "{{ _tp_entities | length == 0 }}" + then: + - variables: + _n_title: "🧺 Laundry Day — 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: laundry_pipeline + 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 }}" + - action: input_boolean.turn_off + target: + entity_id: "{{ trigger_entity }}" + - stop: "Tibber Prices integration not found" + + - if: + - condition: template + value_template: "{{ total_loads < 1 or total_loads > 5 }}" + then: + - variables: + _n_title: "🧺 Laundry Day — Invalid Config" + _n_message: > + Loads: {{ total_loads }}. + Set {{ loads_entity }} to 1–5. + - choose: + - conditions: + - condition: template + value_template: "{{ notification_script | length > 0 }}" + sequence: + - action: script.turn_on + target: + entity_id: "{{ notification_script }}" + data: + variables: + event_type: notification + appliance: laundry_pipeline + 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 }}" + - action: input_boolean.turn_off + target: + entity_id: "{{ trigger_entity }}" + - stop: "Invalid load count" + + # ════════════════════════════════════════════════════════ + # START NOTIFICATION + # ════════════════════════════════════════════════════════ + - variables: + _n_title: "🧺 Laundry Day Started" + _n_message: > + {{ total_loads }} + load{{ 's' if total_loads | int > 1 else '' }} + {{ '(wash + dry)' if include_dryer else '(wash only)' }} + · Deadline + {{ must_finish_by_time[:5] if must_finish_by_time | length >= 5 + else must_finish_by_time }}. + 👕 Prepare load 1: program → close door → 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: started + appliance: laundry_pipeline + title: "{{ _n_title }}" + message: "{{ _n_message }}" + load_index: "{{ repeat.index }}" + total_loads: "{{ total_loads }}" + - 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 }}" + + # ════════════════════════════════════════════════════════ + # MAIN PIPELINE LOOP + # ════════════════════════════════════════════════════════ + - repeat: + count: "{{ total_loads }}" + sequence: + # ── CHECK CANCELLATION ────────────────────────── + - if: + - condition: template + value_template: "{{ is_state(trigger_entity, 'off') }}" + then: + - variables: + _n_title: "🧺 Laundry Day Cancelled" + _n_message: > + Stopped after {{ repeat.index - 1 }} + of {{ total_loads }} loads. + - choose: + - conditions: + - condition: template + value_template: "{{ notification_script | length > 0 }}" + sequence: + - action: script.turn_on + target: + entity_id: "{{ notification_script }}" + data: + variables: + event_type: cancelled + appliance: laundry_pipeline + title: "{{ _n_title }}" + message: "{{ _n_message }}" + load_index: "{{ repeat.index }}" + total_loads: "{{ total_loads }}" + - 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: "Cancelled by user" + + # ── WAIT FOR WASHER READINESS ────────────────── + - if: + - condition: template + value_template: > + {{ not (is_state(washer_door_sensor, 'off') + and is_state(washer_remote_start_sensor, 'on')) }} + then: + - if: + - condition: template + value_template: "{{ repeat.index > 1 }}" + then: + - variables: + _n_title: > + 👕 Washer — Load {{ repeat.index }}/{{ total_loads }} + _n_message: > + Program → close door → 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: prepare_washer + appliance: laundry_pipeline + title: "{{ _n_title }}" + message: "{{ _n_message }}" + load_index: "{{ repeat.index }}" + total_loads: "{{ total_loads }}" + - 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 }}" + - wait_for_trigger: + - trigger: template + value_template: > + {{ is_state(washer_door_sensor, 'off') + and is_state(washer_remote_start_sensor, 'on') }} + timeout: + hours: 4 + continue_on_timeout: true + - if: + - condition: template + value_template: "{{ wait.trigger is none }}" + then: + - variables: + _n_title: "⚠️ Load {{ repeat.index }} — Washer Timeout" + _n_message: > + Not ready after 4 h. Pipeline stopped. + - choose: + - conditions: + - condition: template + value_template: "{{ notification_script | length > 0 }}" + sequence: + - action: script.turn_on + target: + entity_id: "{{ notification_script }}" + data: + variables: + event_type: timeout + appliance: laundry_pipeline + title: "{{ _n_title }}" + message: "{{ _n_message }}" + load_index: "{{ repeat.index }}" + total_loads: "{{ total_loads }}" + - 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 }}" + - action: input_boolean.turn_off + target: + entity_id: "{{ trigger_entity }}" + - stop: "Washer readiness timeout" + + # ── READ WASHER DATA ─────────────────────────── + - variables: + washer_program: "{{ states(washer_program_entity) }}" + _raw_washer_duration: "{{ states(washer_estimated_duration_entity) }}" + washer_duration: > + {% set raw = states(washer_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_washer }} + {% endif %} + deadline: > + {% set dl = today_at(must_finish_by_time) %} + {% if dl <= now() %} + {{ (dl + timedelta(days=1)).isoformat() }} + {% else %} + {{ dl.isoformat() }} + {% endif %} + + # Validate program + - if: + - condition: template + value_template: > + {{ washer_program in ['unknown', 'unavailable', 'None', ''] }} + then: + - variables: + _n_title: "👕 Load {{ repeat.index }} — 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: laundry_pipeline + title: "{{ _n_title }}" + message: "{{ _n_message }}" + load_index: "{{ repeat.index }}" + total_loads: "{{ total_loads }}" + - 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 }}" + - action: input_boolean.turn_off + target: + entity_id: "{{ trigger_entity }}" + - stop: "No washer program selected" + + # ── FIND CHEAPEST WASH WINDOW ────────────────── + - action: tibber_prices.find_cheapest_block + data: + duration: > + {{ '%02d:%02d:00' | format( + (washer_duration | int) // 60, + (washer_duration | int) % 60) }} + must_finish_by: "{{ deadline }}" + response_variable: wash_result + + - if: + - condition: template + value_template: "{{ not wash_result.window_found }}" + then: + - variables: + _n_title: "👕 Wash {{ repeat.index }}/{{ total_loads }} — No Cheap Slot" + _n_message: > + No cheap slot before + {{ deadline | as_datetime | as_local + | as_timestamp | timestamp_custom('%H:%M') }} + for {{ washer_duration }} min. + - 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: laundry_pipeline + title: "{{ _n_title }}" + message: "{{ _n_message }}" + deadline: "{{ deadline }}" + duration_minutes: "{{ washer_duration | int }}" + load_index: "{{ repeat.index }}" + total_loads: "{{ total_loads }}" + - 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 }}" + - action: input_boolean.turn_off + target: + entity_id: "{{ trigger_entity }}" + - stop: "No wash window found" + + # ── START WASHER ─────────────────────────────── + - variables: + _wash_window_start: "{{ wash_result.window.start | as_datetime }}" + wash_finish_in_relative: > + {% set window_end = _wash_window_start + timedelta(minutes=washer_duration | int) %} + {% set seconds_until_end = ((window_end - now()).total_seconds()) | int %} + {{ [washer_duration | int * 60, seconds_until_end] | max }} + + - action: home_connect_alt.start_program + target: + entity_id: "{{ washer_program_entity }}" + data: + program: "{{ washer_program }}" + options: + - key: BSH.Common.Option.FinishInRelative + value: "{{ wash_finish_in_relative }}" + + - variables: + _n_title: > + 👕 Wash {{ repeat.index }}/{{ total_loads }} — Planned! + _n_message: > + {{ washer_program }} + {% set delay = wash_finish_in_relative | int - (washer_duration | int * 60) %} + {% if delay > 60 %} + · ⏰ ~{{ _wash_window_start | as_local + | as_timestamp | timestamp_custom('%H:%M') }} + (in {{ (delay / 3600) | round(1) }} h) + {% else %} + · ▶️ Starting now! + {% endif %} + · ~{{ washer_duration }} min + · {{ wash_result.window.price_mean | round(1) }} {{ wash_result.price_unit }}/kWh + - choose: + - conditions: + - condition: template + value_template: "{{ notification_script | length > 0 }}" + sequence: + - action: script.turn_on + target: + entity_id: "{{ notification_script }}" + data: + variables: + event_type: wash_planned + appliance: laundry_pipeline + title: "{{ _n_title }}" + message: "{{ _n_message }}" + start_time: "{{ _wash_window_start | as_local | as_timestamp | timestamp_custom('%Y-%m-%dT%H:%M:%S') }}" + duration_minutes: "{{ washer_duration | int }}" + price_mean: "{{ wash_result.window.price_mean | round(1) }}" + price_unit: "{{ wash_result.price_unit }}" + load_index: "{{ repeat.index }}" + total_loads: "{{ total_loads }}" + - 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 }}" + + # ── WAIT FOR WASHER TO FINISH ────────────────── + - wait_for_trigger: + - trigger: template + value_template: > + {% set op = states(washer_operation_state_entity) %} + {{ 'Finished' in op + or is_state(trigger_entity, 'off') }} + timeout: + seconds: "{{ wash_finish_in_relative | int + 1800 }}" + continue_on_timeout: true + + # Check: cancelled? + - if: + - condition: template + value_template: "{{ is_state(trigger_entity, 'off') }}" + then: + - variables: + _n_title: "🧺 Laundry Day Cancelled" + _n_message: > + Stopped during wash {{ repeat.index }} + of {{ total_loads }}. + - choose: + - conditions: + - condition: template + value_template: "{{ notification_script | length > 0 }}" + sequence: + - action: script.turn_on + target: + entity_id: "{{ notification_script }}" + data: + variables: + event_type: cancelled + appliance: laundry_pipeline + title: "{{ _n_title }}" + message: "{{ _n_message }}" + load_index: "{{ repeat.index }}" + total_loads: "{{ total_loads }}" + - 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: "Cancelled by user" + + # Check: timeout? + - if: + - condition: template + value_template: "{{ wait.trigger is none }}" + then: + - variables: + _n_title: "⚠️ Wash {{ repeat.index }} — Timeout" + _n_message: > + Didn't finish in time. Check appliance. + Pipeline continues. + - choose: + - conditions: + - condition: template + value_template: "{{ notification_script | length > 0 }}" + sequence: + - action: script.turn_on + target: + entity_id: "{{ notification_script }}" + data: + variables: + event_type: timeout + appliance: laundry_pipeline + title: "{{ _n_title }}" + message: "{{ _n_message }}" + load_index: "{{ repeat.index }}" + total_loads: "{{ total_loads }}" + - 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 }}" + + # ── WASH DONE ────────────────────────────────── + - variables: + _n_title: > + ✅ Wash {{ repeat.index }}/{{ total_loads }} Done! + _n_message: > + {% if include_dryer %} + 🌀 Transfer to dryer: program → close door + → Remote Start. + {% endif %} + {% if repeat.index | int < total_loads | int and not include_dryer %} + 👕 Prepare load {{ repeat.index + 1 }}: program → + close door → Remote Start. + {% 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: wash_done + appliance: laundry_pipeline + title: "{{ _n_title }}" + message: "{{ _n_message }}" + load_index: "{{ repeat.index }}" + total_loads: "{{ total_loads }}" + - 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 }}" + + # ── DRYER (if enabled) ───────────────────────── + - if: + - condition: template + value_template: "{{ include_dryer }}" + then: + # Wait for dryer readiness + - if: + - condition: template + value_template: > + {{ not (is_state(dryer_door_sensor, 'off') + and is_state(dryer_remote_start_sensor, 'on')) }} + then: + - wait_for_trigger: + - trigger: template + value_template: > + {{ is_state(dryer_door_sensor, 'off') + and is_state(dryer_remote_start_sensor, 'on') }} + timeout: + hours: 2 + continue_on_timeout: true + - if: + - condition: template + value_template: "{{ wait.trigger is none }}" + then: + - variables: + _n_title: "⚠️ Dryer {{ repeat.index }} — Skipped" + _n_message: > + Not ready after 2 h. Dry manually. + - choose: + - conditions: + - condition: template + value_template: "{{ notification_script | length > 0 }}" + sequence: + - action: script.turn_on + target: + entity_id: "{{ notification_script }}" + data: + variables: + event_type: dryer_skipped + appliance: laundry_pipeline + title: "{{ _n_title }}" + message: "{{ _n_message }}" + load_index: "{{ repeat.index }}" + total_loads: "{{ total_loads }}" + - 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 }}" + + - if: + - condition: template + value_template: > + {{ is_state(dryer_door_sensor, 'off') + and is_state(dryer_remote_start_sensor, 'on') }} + then: + # Read dryer data + - variables: + dryer_program: "{{ states(dryer_program_entity) }}" + _raw_dryer_duration: "{{ states(dryer_estimated_duration_entity) }}" + dryer_duration: > + {% set raw = states(dryer_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_dryer }} + {% endif %} + + # Find cheapest dryer window + - action: tibber_prices.find_cheapest_block + data: + duration: > + {{ '%02d:%02d:00' | format( + (dryer_duration | int) // 60, + (dryer_duration | int) % 60) }} + must_finish_by: "{{ deadline }}" + response_variable: dry_result + + - if: + - condition: template + value_template: "{{ not dry_result.window_found }}" + then: + - variables: + _n_title: "🌀 Dryer {{ repeat.index }} — No Cheap Slot" + _n_message: > + No cheap slot found. Run dryer manually. + - 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: laundry_pipeline + title: "{{ _n_title }}" + message: "{{ _n_message }}" + deadline: "{{ deadline }}" + duration_minutes: "{{ dryer_duration | int }}" + load_index: "{{ repeat.index }}" + total_loads: "{{ total_loads }}" + - 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 }}" + + - if: + - condition: template + value_template: > + {{ dry_result.window_found | default(false) }} + then: + # Start dryer + - variables: + _dry_window_start: "{{ dry_result.window.start | as_datetime }}" + dry_finish_in_relative: > + {% set window_end = _dry_window_start + timedelta(minutes=dryer_duration | int) %} + {% set seconds_until_end = ((window_end - now()).total_seconds()) | int %} + {{ [dryer_duration | int * 60, seconds_until_end] | max }} + + - action: home_connect_alt.start_program + target: + entity_id: "{{ dryer_program_entity }}" + data: + program: "{{ dryer_program }}" + options: + - key: BSH.Common.Option.FinishInRelative + value: "{{ dry_finish_in_relative }}" + + - variables: + _n_title: > + 🌀 Dryer {{ repeat.index }}/{{ total_loads }} — Planned! + _n_message: > + {{ dryer_program }} + {% set delay = dry_finish_in_relative | int - (dryer_duration | int * 60) %} + {% if delay > 60 %} + · ⏰ ~{{ _dry_window_start | as_local + | as_timestamp | timestamp_custom('%H:%M') }} + (in {{ (delay / 3600) | round(1) }} h) + {% else %} + · ▶️ Starting now! + {% endif %} + · ~{{ dryer_duration }} min + {% if pipeline_mode + and repeat.index | int < total_loads | int %} + · Next wash planned now — dryer runs in parallel + {% 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: dryer_planned + appliance: laundry_pipeline + title: "{{ _n_title }}" + message: "{{ _n_message }}" + start_time: "{{ _dry_window_start | as_local | as_timestamp | timestamp_custom('%Y-%m-%dT%H:%M:%S') }}" + duration_minutes: "{{ dryer_duration | int }}" + price_mean: "{{ dry_result.window.price_mean | round(1) }}" + price_unit: "{{ dry_result.price_unit }}" + load_index: "{{ repeat.index }}" + total_loads: "{{ total_loads }}" + - 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 }}" + + # 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: + - wait_for_trigger: + - trigger: template + value_template: > + {% set op = states(dryer_operation_state_entity) %} + {{ 'Finished' in op + or is_state(trigger_entity, 'off') }} + timeout: + seconds: "{{ dry_finish_in_relative | int + 1800 }}" + continue_on_timeout: true + - if: + - condition: template + value_template: "{{ is_state(trigger_entity, 'off') }}" + then: + - stop: "Cancelled by user" + + # ════════════════════════════════════════════════════════ + # ALL DONE + # ════════════════════════════════════════════════════════ + - variables: + _n_title: "🎉 Laundry Day Complete!" + _n_message: > + All {{ total_loads }} + load{{ 's' if total_loads | int > 1 else '' }} + washed{{ ' and dried' if include_dryer else '' }}. + Time to fold! 🧺 + - choose: + - conditions: + - condition: template + value_template: "{{ notification_script | length > 0 }}" + sequence: + - action: script.turn_on + target: + entity_id: "{{ notification_script }}" + data: + variables: + event_type: complete + appliance: laundry_pipeline + title: "{{ _n_title }}" + message: "{{ _n_message }}" + load_index: "{{ repeat.index }}" + total_loads: "{{ total_loads }}" + - 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 }}" + + - action: input_boolean.turn_off + target: + entity_id: "{{ trigger_entity }}" diff --git a/custom_components/tibber_prices/blueprints/automation/tibber_prices/washing_machine.yaml b/custom_components/tibber_prices/blueprints/automation/tibber_prices/washing_machine.yaml new file mode 100644 index 0000000..53effbe --- /dev/null +++ b/custom_components/tibber_prices/blueprints/automation/tibber_prices/washing_machine.yaml @@ -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. diff --git a/custom_components/tibber_prices/blueprints/automation/tibber_prices/washing_machine_home_connect.yaml b/custom_components/tibber_prices/blueprints/automation/tibber_prices/washing_machine_home_connect.yaml new file mode 100644 index 0000000..67a90d7 --- /dev/null +++ b/custom_components/tibber_prices/blueprints/automation/tibber_prices/washing_machine_home_connect.yaml @@ -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 }}" diff --git a/custom_components/tibber_prices/blueprints/automation/tibber_prices/washing_machine_home_connect_alt.yaml b/custom_components/tibber_prices/blueprints/automation/tibber_prices/washing_machine_home_connect_alt.yaml new file mode 100644 index 0000000..f9217eb --- /dev/null +++ b/custom_components/tibber_prices/blueprints/automation/tibber_prices/washing_machine_home_connect_alt.yaml @@ -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 }}" diff --git a/custom_components/tibber_prices/blueprints/automation/tibber_prices/water_heater.yaml b/custom_components/tibber_prices/blueprints/automation/tibber_prices/water_heater.yaml new file mode 100644 index 0000000..8e9f189 --- /dev/null +++ b/custom_components/tibber_prices/blueprints/automation/tibber_prices/water_heater.yaml @@ -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._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. diff --git a/custom_components/tibber_prices/blueprints/script/tibber_prices/notify_residents.yaml b/custom_components/tibber_prices/blueprints/script/tibber_prices/notify_residents.yaml new file mode 100644 index 0000000..fa1c9cb --- /dev/null +++ b/custom_components/tibber_prices/blueprints/script/tibber_prices/notify_residents.yaml @@ -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 }}"