Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fafcf116be | |||
| 3d15f481a0 | |||
| ad6f30ce3c | |||
| 34cf5b1f7a |
@@ -7,10 +7,10 @@ This blueprint creates a smart motion-activated light control system. It handles
|
||||
- Multiple motion sensor support (triggers on ANY sensor)
|
||||
- Condition switches (ALL must be ON for automation to work)
|
||||
- Multiple lights and/or switches control
|
||||
- Light groups and area-based targeting
|
||||
- Light groups and area-based targeting (native area picker)
|
||||
- Configurable timeout delay before turning off
|
||||
- Minimum on duration (prevents rapid on/off cycling)
|
||||
- Motion sensor debounce (filter false triggers)
|
||||
- Motion sensor on/off debounce (filter false triggers and PIR drop-outs)
|
||||
- Smooth light transitions with configurable duration
|
||||
- Luminance sensor support (only trigger in dark conditions)
|
||||
- Time-based conditions (only active during specified hours)
|
||||
@@ -36,19 +36,29 @@ The automation tracks these states via persistent storage:
|
||||
|
||||
## Behavior Notes
|
||||
|
||||
- Will NOT turn on light if it's already ON (prevents hijacking user control)
|
||||
- If user changes light while automation is active, enters MANUAL mode (after grace period)
|
||||
- Grace period prevents false manual overrides from delayed device state reports (e.g., Zigbee)
|
||||
- MANUAL mode exits when light is turned OFF (by any means)
|
||||
- Timeout delay only applies when turning OFF (motion cleared)
|
||||
- Time conditions support overnight windows (e.g., 22:00 to 06:00)
|
||||
- Day/Night mode uses separate time window from time conditions
|
||||
- Will NOT turn on light if it's already ON (prevents hijacking user control).
|
||||
- If user **changes** the light while automation is active (e.g., dim, color, scene), enters MANUAL mode (after grace period).
|
||||
- If the user **turns the light ON** while the automation is idle (pre-emptive manual control), it enters MANUAL mode and fires the `manual_action` callback. The `disable_action` callback is **not** run in this case (nothing was enabled to disable).
|
||||
- If the light is **turned off** by any external source while motion is still active, the automation re-enables it (external turn-off recovery). To intentionally exit the automation, also turn off the corresponding **condition switch** — this routes through the disable path and respects the configured timeouts.
|
||||
- Grace period prevents false manual overrides from delayed device state reports (e.g., Zigbee) — including stray reports that can follow a turn-off.
|
||||
- MANUAL mode exits when light is turned OFF (by any means).
|
||||
- Timeout delay only applies when turning OFF (motion cleared).
|
||||
- Time conditions support overnight windows (e.g., 22:00 to 06:00).
|
||||
- Day/Night mode uses separate time window from time conditions.
|
||||
|
||||
### Brightness threshold interaction
|
||||
|
||||
`brightness_threshold` is checked only when the automation decides whether to **enable**. A light that is on but dimmer than the threshold is treated as "available to take over" — so the automation will run with that light. When the disable path runs, it will turn the light **off** (it does not restore a below-threshold dim state).
|
||||
|
||||
### Callback ordering and `mode: restart`
|
||||
|
||||
Light state changes feed back into the automation, which uses `mode: restart` to handle rapid bursts. To make sure callbacks aren't silently dropped when the automation restarts mid-action, both `enable_action` and `disable_action` run **before** the corresponding turn-on/turn-off command.
|
||||
|
||||
## Requirements
|
||||
|
||||
- At least one motion sensor
|
||||
- `input_text` entity for persistent state storage
|
||||
- Target light(s), switch(es), group, or area to control
|
||||
- At least one motion sensor.
|
||||
- `input_text` entity for persistent state storage. Each automation instance must have its own entity. Increase the entity's `max_length` (default 100) to **at least 255** to leave room for the JSON state — short values can cause silent truncation.
|
||||
- Target light(s), switch(es), group, or area to control.
|
||||
|
||||
## Author
|
||||
|
||||
|
||||
+189
-101
@@ -92,15 +92,15 @@ blueprint:
|
||||
target_area:
|
||||
name: Target Area (optional)
|
||||
description: >
|
||||
Area ID (e.g., "living_room"). All lights and switches in the
|
||||
area will be discovered and controlled.
|
||||
default: ""
|
||||
Pick an area. All lights and switches in the area will be
|
||||
discovered and controlled.
|
||||
default:
|
||||
selector:
|
||||
text:
|
||||
area: {}
|
||||
|
||||
target_light_data:
|
||||
name: Light Data Dictionary (optional)
|
||||
default: ""
|
||||
default: {}
|
||||
description: >
|
||||
Provide a YAML dictionary of light.turn_on parameters.
|
||||
If not specified, the light's last settings are preserved.
|
||||
@@ -289,14 +289,14 @@ blueprint:
|
||||
day_light_data:
|
||||
name: Day Light Settings
|
||||
description: Light parameters during day mode (YAML dictionary)
|
||||
default: ""
|
||||
default: {}
|
||||
selector:
|
||||
object: {}
|
||||
|
||||
night_light_data:
|
||||
name: Night Light Settings
|
||||
description: Light parameters during night mode (YAML dictionary)
|
||||
default: ""
|
||||
default: {}
|
||||
selector:
|
||||
object: {}
|
||||
|
||||
@@ -385,6 +385,20 @@ blueprint:
|
||||
step: 1
|
||||
unit_of_measurement: "seconds"
|
||||
|
||||
motion_off_debounce:
|
||||
name: Motion Off Debounce (seconds)
|
||||
description: >
|
||||
Motion sensor must report 'off' for this duration before the
|
||||
disable path starts evaluating. Filters out brief sensor
|
||||
drop-outs (common with PIR sensors). Set to 0 to disable.
|
||||
default: 0
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
max: 30
|
||||
step: 1
|
||||
unit_of_measurement: "seconds"
|
||||
|
||||
manual_override_grace_period:
|
||||
name: Manual Override Grace Period (seconds)
|
||||
description: >
|
||||
@@ -455,7 +469,9 @@ blueprint:
|
||||
manual_action:
|
||||
name: Manual callback action (optional)
|
||||
description: >
|
||||
Runs when user manually changes the light while automation is active.
|
||||
Runs when the user takes manual control of the light: either by
|
||||
changing it while the automation is active, or by turning it ON
|
||||
while the automation is idle (pre-emptive manual control).
|
||||
Requires 'Automation state entity' to be configured.
|
||||
default: []
|
||||
selector:
|
||||
@@ -479,10 +495,12 @@ trigger:
|
||||
seconds: !input motion_debounce
|
||||
id: "motion_sensor_on"
|
||||
|
||||
# Motion sensors OFF
|
||||
# Motion sensors OFF (with debounce to filter PIR drop-outs)
|
||||
- platform: state
|
||||
entity_id: !input motion_sensors
|
||||
to: "off"
|
||||
for:
|
||||
seconds: !input motion_off_debounce
|
||||
id: "motion_sensor_off"
|
||||
|
||||
# Condition switches ON/OFF
|
||||
@@ -499,20 +517,22 @@ trigger:
|
||||
entity_id: !input target_switches
|
||||
id: "switch_state_changed"
|
||||
|
||||
# Luminance sensor value changed
|
||||
# Luminance dropped below threshold (re-evaluate, may now enable)
|
||||
# Template triggers fire on false→true transition, so this only fires when
|
||||
# the sensor *crosses* the threshold downward — exactly when we care.
|
||||
- platform: template
|
||||
value_template: >
|
||||
{% if luminance_sensor %}
|
||||
{{ states(luminance_sensor) not in ['unknown','unavailable'] }}
|
||||
{{ states(luminance_sensor) | float(99999) < (luminance_threshold | float(50)) }}
|
||||
{% else %}
|
||||
false
|
||||
{% endif %}
|
||||
|
||||
# Luminance enable switch changed
|
||||
# Luminance enable switch turned ON (re-evaluate)
|
||||
- platform: template
|
||||
value_template: >
|
||||
{% if luminance_enable_switch %}
|
||||
{{ states(luminance_enable_switch) not in ['unknown','unavailable'] }}
|
||||
{{ is_state(luminance_enable_switch, 'on') }}
|
||||
{% else %}
|
||||
false
|
||||
{% endif %}
|
||||
@@ -531,7 +551,6 @@ variables:
|
||||
# State Machine Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
# These define the possible automation states stored in persistent storage
|
||||
automation_state_invalid: '-1' # Error state
|
||||
automation_state_none: '0' # Idle, waiting for motion
|
||||
automation_state_enabled: '1' # Light is ON and controlled by automation
|
||||
automation_state_enabling: '2' # Turn-on command sent, awaiting confirmation
|
||||
@@ -576,7 +595,7 @@ variables:
|
||||
{% if target_light_group is not none %}
|
||||
{% set result = result + [target_light_group] %}
|
||||
{% endif %}
|
||||
{% if target_area != '' %}
|
||||
{% if target_area is not none and target_area != '' %}
|
||||
{% set area_lights = area_entities(target_area) | select('match', '^light\\.') | list %}
|
||||
{% set result = result + area_lights %}
|
||||
{% endif %}
|
||||
@@ -594,7 +613,7 @@ variables:
|
||||
{% if target_switches | length > 0 %}
|
||||
{% set result = result + target_switches %}
|
||||
{% endif %}
|
||||
{% if target_area != '' %}
|
||||
{% if target_area is not none and target_area != '' %}
|
||||
{% set area_switches = area_entities(target_area) | select('match', '^switch\\.') | list %}
|
||||
{% set result = result + area_switches %}
|
||||
{% endif %}
|
||||
@@ -631,13 +650,16 @@ variables:
|
||||
# ---------------------------------------------------------------------------
|
||||
automation_state_entity: !input automation_state_entity
|
||||
|
||||
# Parse global state JSON from input_text entity
|
||||
# Parse global state JSON from input_text entity.
|
||||
# Tolerant of empty/unknown/unavailable values and corrupt JSON
|
||||
# (truncation can occur if input_text max_length is too short).
|
||||
automation_state_global: >
|
||||
{% set text = states(automation_state_entity) | string %}
|
||||
{% if text in ['unknown','unavailable','none',''] %}
|
||||
{{ dict() }}
|
||||
{% else %}
|
||||
{{ text | from_json }}
|
||||
{% set parsed = text | from_json(default=dict()) %}
|
||||
{{ parsed if parsed is mapping else dict() }}
|
||||
{% endif %}
|
||||
|
||||
automation_state_placeholder_key: !input automation_state_placeholder_key
|
||||
@@ -655,20 +677,11 @@ variables:
|
||||
{% endif %}
|
||||
|
||||
# Get this automation's state from global state
|
||||
# BUG FIX: Changed from 'light_entity != ""' to proper none check
|
||||
automation_state: "{{ automation_state_global.get(automation_state_key, dict()) if (light_entity is not none or switch_entity is not none) else dict() }}"
|
||||
|
||||
# Current state machine state
|
||||
motion_light_state: "{{ automation_state.get(state_motion_light_state, automation_state_none) }}"
|
||||
|
||||
# Track last action timestamp
|
||||
motion_light_last_action_timestamp: >
|
||||
{% if trigger_id == 'state_motion' %}
|
||||
{{ date_time_now }}
|
||||
{% else %}
|
||||
{{ automation_state.get(state_motion_light_last_action_timestamp, none) }}
|
||||
{% endif %}
|
||||
|
||||
# State machine state checks (for readability)
|
||||
state_is_none: "{{ (motion_light_state | string) == automation_state_none }}"
|
||||
state_is_enabled: "{{ (motion_light_state | string) == automation_state_enabled }}"
|
||||
@@ -927,28 +940,31 @@ action:
|
||||
- conditions:
|
||||
- condition: template
|
||||
value_template: >
|
||||
{# Check actual on/off state only — do NOT use brightness_threshold here.
|
||||
Brightness threshold is for the enable guard (any_device_on), not for
|
||||
detecting whether the light was actually turned off. During transitions,
|
||||
brightness may temporarily be below threshold while the light is still on. #}
|
||||
{% set res = true %}
|
||||
{% if light_entity is not none %}
|
||||
{% set res = res and is_state(light_entity, 'off') %}
|
||||
{% endif %}
|
||||
{% if switch_entity is not none %}
|
||||
{% set res = res and is_state(switch_entity, 'off') %}
|
||||
{% endif %}
|
||||
{# Only true if we have at least one device and all are off #}
|
||||
{{ res and (light_entity is not none or switch_entity is not none) }}
|
||||
{# Check actual on/off state across ALL resolved devices — do NOT use
|
||||
brightness_threshold here. Threshold is for the enable guard
|
||||
(any_device_on), not for detecting actual off. During transitions,
|
||||
brightness may temporarily dip below threshold while still on. #}
|
||||
{% set has_devices = (resolved_all_lights | length > 0) or (resolved_all_switches | length > 0) %}
|
||||
{% set lights_on = resolved_all_lights | select('is_state', 'on') | list | length > 0 %}
|
||||
{% set switches_on = resolved_all_switches | select('is_state', 'on') | list | length > 0 %}
|
||||
{{ has_devices and not lights_on and not switches_on }}
|
||||
|
||||
sequence:
|
||||
# Reset state to NONE
|
||||
# Reset state to NONE and stamp the action timestamp.
|
||||
# Every turn-off (automation OR user) passes through here, so
|
||||
# stamping now starts the grace window that protects the
|
||||
# pre-emptive manual-ON detection (NONE -> MANUAL) below from
|
||||
# stray off->on device reports that can follow a turn-off
|
||||
# (common with Zigbee bulbs + transitions).
|
||||
- service: input_text.set_value
|
||||
target:
|
||||
entity_id: "{{ automation_state_entity }}"
|
||||
data:
|
||||
value: >
|
||||
{% set new_automation_state = (automation_state | combine({ state_motion_light_state: automation_state_none })) %}
|
||||
{% set new_automation_state = (automation_state | combine({
|
||||
state_motion_light_state: automation_state_none,
|
||||
state_motion_light_last_action_timestamp: date_time_now
|
||||
})) %}
|
||||
{{ automation_state_global | combine({ automation_state_key: new_automation_state }) | tojson }}
|
||||
|
||||
# Re-evaluate: if an external source turned off the light while
|
||||
@@ -986,7 +1002,8 @@ action:
|
||||
{% if text in ['unknown','unavailable','none',''] %}
|
||||
{{ dict() }}
|
||||
{% else %}
|
||||
{{ text | from_json }}
|
||||
{% set parsed = text | from_json(default=dict()) %}
|
||||
{{ parsed if parsed is mapping else dict() }}
|
||||
{% endif %}
|
||||
re_eval_state: "{{ re_eval_state_global.get(automation_state_key, dict()) }}"
|
||||
re_eval_motion_light_state: "{{ re_eval_state.get(state_motion_light_state, automation_state_none) }}"
|
||||
@@ -997,7 +1014,18 @@ action:
|
||||
|
||||
# --- Re-enable path (mirrors CASE 2) ---
|
||||
|
||||
# Set state to ENABLING before turning on
|
||||
# Guard: scene mode without resolved scene
|
||||
- choose:
|
||||
- conditions:
|
||||
- condition: template
|
||||
value_template: "{{ use_scene_instead and effective_scene is none }}"
|
||||
sequence:
|
||||
- stop: "Scene mode enabled but no scene configured for current mode"
|
||||
|
||||
# Set state to ENABLING before turning on.
|
||||
# last_brightness=0 is correct here: this path runs after the
|
||||
# light was turned off externally, so there's no meaningful
|
||||
# brightness to capture.
|
||||
- service: input_text.set_value
|
||||
target:
|
||||
entity_id: "{{ automation_state_entity }}"
|
||||
@@ -1005,12 +1033,33 @@ action:
|
||||
value: >
|
||||
{% set new_automation_state = (re_eval_state | combine({
|
||||
state_motion_light_state: automation_state_enabling,
|
||||
state_motion_light_last_action_timestamp: now(),
|
||||
state_motion_light_last_action_timestamp: date_time_now,
|
||||
state_motion_light_last_brightness: 0
|
||||
})) %}
|
||||
{{ re_eval_state_global | combine({ automation_state_key: new_automation_state }) | tojson }}
|
||||
|
||||
# Scene or light activation
|
||||
# Run enable callback BEFORE turning on (mode:restart safety)
|
||||
- choose:
|
||||
- conditions:
|
||||
- condition: template
|
||||
value_template: "{{ enable_action != [] }}"
|
||||
sequence: !input enable_action
|
||||
|
||||
# Debug notification (also before turn-on for restart safety)
|
||||
- choose:
|
||||
- conditions: "{{ enable_debug_notifications }}"
|
||||
sequence:
|
||||
- service: persistent_notification.create
|
||||
data:
|
||||
title: "Motion Light Debug"
|
||||
message: >
|
||||
Action: RE-ENABLE (external turn-off recovery)
|
||||
Time: {{ now().strftime('%H:%M:%S') }}
|
||||
Lights: {{ resolved_all_lights }}
|
||||
Switches: {{ resolved_all_switches }}
|
||||
Scene: {{ effective_scene if use_scene_instead else 'N/A' }}
|
||||
|
||||
# Scene or light activation (after callback/debug for restart safety)
|
||||
- choose:
|
||||
- conditions:
|
||||
- condition: template
|
||||
@@ -1049,27 +1098,6 @@ action:
|
||||
target:
|
||||
entity_id: "{{ resolved_all_switches }}"
|
||||
|
||||
# Execute enable callback
|
||||
- choose:
|
||||
- conditions:
|
||||
- condition: template
|
||||
value_template: "{{ enable_action != [] }}"
|
||||
sequence: !input enable_action
|
||||
|
||||
# Debug notification
|
||||
- choose:
|
||||
- conditions: "{{ enable_debug_notifications }}"
|
||||
sequence:
|
||||
- service: persistent_notification.create
|
||||
data:
|
||||
title: "Motion Light Debug"
|
||||
message: >
|
||||
Action: RE-ENABLE (external turn-off recovery)
|
||||
Time: {{ now().strftime('%H:%M:%S') }}
|
||||
Lights: {{ resolved_all_lights }}
|
||||
Switches: {{ resolved_all_switches }}
|
||||
Scene: {{ effective_scene if use_scene_instead else 'N/A' }}
|
||||
|
||||
# ----- Sub-case: Automation just turned on the light -----
|
||||
# Transition from ENABLING to ENABLED, or disable immediately
|
||||
# if motion already cleared during the ENABLING phase
|
||||
@@ -1160,27 +1188,36 @@ action:
|
||||
{{ automation_state_global | combine({ automation_state_key: new_automation_state }) | tojson }}
|
||||
|
||||
# ----- Sub-case: User manually changed the light -----
|
||||
# Transition from ENABLED to MANUAL (user took control)
|
||||
# Transition to MANUAL (user took control). Fires when EITHER:
|
||||
# * state is ENABLED and the user changes the light, OR
|
||||
# * state is NONE and the user turns the light ON while the
|
||||
# automation is idle (pre-emptive manual control).
|
||||
# Only triggers on meaningful state changes (on→off or off→on),
|
||||
# NOT on attribute-only updates (on→on) which Zigbee devices
|
||||
# commonly send as the light settles after a transition.
|
||||
# Grace period: ignore state changes shortly after the automation
|
||||
# turns on the light to avoid false manual override detection.
|
||||
# In NONE state, only a turn-ON counts as a takeover (a turn-OFF
|
||||
# is handled by the "Light turned OFF" sub-case above).
|
||||
# Grace period: ignore state changes shortly after the automation's
|
||||
# last action to avoid false manual override detection.
|
||||
- conditions:
|
||||
- condition: template
|
||||
value_template: >
|
||||
{% set meaningful_change = trigger.from_state.state != trigger.to_state.state %}
|
||||
{% if not meaningful_change %}
|
||||
{{ false }}
|
||||
{% else %}
|
||||
{% elif state_is_none and trigger.to_state.state != 'on' %}
|
||||
{{ false }}
|
||||
{% elif state_is_enabled or state_is_none %}
|
||||
{% set last_ts = automation_state.get(state_motion_light_last_action_timestamp, none) %}
|
||||
{% set grace = (transition_duration | float(0)) + (manual_override_grace_period | float(2)) %}
|
||||
{% if state_is_enabled and last_ts is not none %}
|
||||
{% if last_ts is none %}
|
||||
{{ true }}
|
||||
{% else %}
|
||||
{% set parsed = last_ts | as_datetime %}
|
||||
{{ parsed is none or (now() - parsed).total_seconds() > grace }}
|
||||
{% else %}
|
||||
{{ state_is_enabled }}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{{ false }}
|
||||
{% endif %}
|
||||
sequence:
|
||||
# BUG FIX: Fixed YAML structure - was 'data: >' instead of 'data:' with 'value: >'
|
||||
@@ -1192,9 +1229,12 @@ action:
|
||||
{% set new_automation_state = (automation_state | combine({ state_motion_light_state: automation_state_manual })) %}
|
||||
{{ automation_state_global | combine({ automation_state_key: new_automation_state }) | tojson }}
|
||||
|
||||
# Call disable action if configured
|
||||
# Call disable action if configured.
|
||||
# Only when we were actively ENABLED — a pre-emptive manual ON
|
||||
# (from NONE) never enabled anything, so there is nothing to
|
||||
# "disable" and running it could touch unrelated devices.
|
||||
- choose:
|
||||
- conditions: "{{ manual_action_runs_disable_action and disable_action != [] }}"
|
||||
- conditions: "{{ state_is_enabled and manual_action_runs_disable_action and disable_action != [] }}"
|
||||
sequence: !input disable_action
|
||||
|
||||
# Call manual action callback
|
||||
@@ -1212,7 +1252,7 @@ action:
|
||||
message: >
|
||||
Action: MANUAL OVERRIDE
|
||||
Time: {{ now().strftime('%H:%M:%S') }}
|
||||
Previous State: ENABLED
|
||||
Previous State: {{ 'ENABLED' if state_is_enabled else 'NONE (pre-emptive)' }}
|
||||
New State: MANUAL
|
||||
Trigger: {{ trigger_id }}
|
||||
|
||||
@@ -1227,6 +1267,33 @@ action:
|
||||
- condition: template
|
||||
value_template: "{{ (state_is_enabled or state_is_enabling) and must_be_disabled_preview }}"
|
||||
sequence:
|
||||
# Honor min_on_duration even on the recovery path so a
|
||||
# mistimed Zigbee report can't chop the on-time short.
|
||||
# We skip timeout_delay here intentionally — this path
|
||||
# is the recovery for a cancelled CASE 3, and the user
|
||||
# already paid that delay before the restart.
|
||||
- variables:
|
||||
time_since_enabled: >
|
||||
{% set last_ts = automation_state.get(state_motion_light_last_action_timestamp, none) %}
|
||||
{% if last_ts is none %}
|
||||
{{ 9999 }}
|
||||
{% else %}
|
||||
{% set parsed = last_ts | as_datetime %}
|
||||
{% if parsed is none %}
|
||||
{{ 9999 }}
|
||||
{% else %}
|
||||
{{ (now() - parsed).total_seconds() | int }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
remaining_min_on: "{{ [0, min_on_duration - time_since_enabled] | max }}"
|
||||
- choose:
|
||||
- conditions:
|
||||
- condition: template
|
||||
value_template: "{{ remaining_min_on > 0 }}"
|
||||
sequence:
|
||||
- delay:
|
||||
seconds: "{{ remaining_min_on }}"
|
||||
|
||||
# Reset state to NONE first (before turn-off triggers another restart)
|
||||
- service: input_text.set_value
|
||||
target:
|
||||
@@ -1309,15 +1376,33 @@ action:
|
||||
|
||||
# Enable the light/switch
|
||||
default:
|
||||
# Store current brightness (to restore later if configured)
|
||||
# Store current brightness (to restore later if configured).
|
||||
# If light is on but below brightness_threshold, treat as "off"
|
||||
# for restore purposes — otherwise the disable path would
|
||||
# restore that dim state instead of actually turning off.
|
||||
- variables:
|
||||
last_brightness: >
|
||||
{% if reference_light is none or is_state(reference_light, 'off') %}
|
||||
{{ 0 }}
|
||||
{% else %}
|
||||
{{ state_attr(reference_light, 'brightness') | int(0) }}
|
||||
{% set br = state_attr(reference_light, 'brightness') | int(0) %}
|
||||
{% set thr = brightness_threshold | int(0) %}
|
||||
{% if thr > 0 and br < thr %}
|
||||
{{ 0 }}
|
||||
{% else %}
|
||||
{{ br }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
# Guard: scene mode is on but no scene resolved — stop loudly
|
||||
# rather than silently falling back to lights with default data.
|
||||
- choose:
|
||||
- conditions:
|
||||
- condition: template
|
||||
value_template: "{{ use_scene_instead and effective_scene is none }}"
|
||||
sequence:
|
||||
- stop: "Scene mode enabled but no scene configured for current mode"
|
||||
|
||||
# Update state to ENABLING BEFORE turning on the light.
|
||||
# This must happen first because mode: restart may cancel
|
||||
# subsequent steps if the light state change fires immediately.
|
||||
@@ -1333,6 +1418,31 @@ action:
|
||||
})) %}
|
||||
{{ automation_state_global | combine({ automation_state_key: new_automation_state }) | tojson }}
|
||||
|
||||
# Run enable callback BEFORE turning on the light.
|
||||
# Light state change fires mode:restart which cancels remaining
|
||||
# steps — running the callback first ensures it isn't silently
|
||||
# dropped (mirrors the CASE 3 disable_action ordering).
|
||||
- choose:
|
||||
- conditions:
|
||||
- condition: template
|
||||
value_template: "{{ enable_action != [] }}"
|
||||
sequence: !input enable_action
|
||||
|
||||
# Debug notification (also before turn-on for restart safety)
|
||||
- choose:
|
||||
- conditions: "{{ enable_debug_notifications }}"
|
||||
sequence:
|
||||
- service: persistent_notification.create
|
||||
data:
|
||||
title: "Motion Light Debug"
|
||||
message: >
|
||||
Action: ENABLE
|
||||
Time: {{ now().strftime('%H:%M:%S') }}
|
||||
Lights: {{ resolved_all_lights }}
|
||||
Switches: {{ resolved_all_switches }}
|
||||
Scene: {{ effective_scene if use_scene_instead else 'N/A' }}
|
||||
Night Mode: {{ is_night_mode }}
|
||||
|
||||
# Scene activation path
|
||||
- choose:
|
||||
- conditions:
|
||||
@@ -1373,28 +1483,6 @@ action:
|
||||
target:
|
||||
entity_id: "{{ resolved_all_switches }}"
|
||||
|
||||
# Execute enable callback action
|
||||
- choose:
|
||||
- conditions:
|
||||
- condition: template
|
||||
value_template: "{{ enable_action != [] }}"
|
||||
sequence: !input enable_action
|
||||
|
||||
# Debug notification
|
||||
- choose:
|
||||
- conditions: "{{ enable_debug_notifications }}"
|
||||
sequence:
|
||||
- service: persistent_notification.create
|
||||
data:
|
||||
title: "Motion Light Debug"
|
||||
message: >
|
||||
Action: ENABLE
|
||||
Time: {{ now().strftime('%H:%M:%S') }}
|
||||
Lights: {{ resolved_all_lights }}
|
||||
Switches: {{ resolved_all_switches }}
|
||||
Scene: {{ effective_scene if use_scene_instead else 'N/A' }}
|
||||
Night Mode: {{ is_night_mode }}
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# CASE 3: Disable Path (Motion Cleared, Should Turn Off)
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
# Presence Scene Controller Blueprint
|
||||
|
||||
A per-room, presence-aware time-of-day scene controller. Maps scenes to time-of-day options by index — like the [Day Scene Controller](../Day%20Scene%20Controller/README.md) — but adds presence gating, vacant/sleep scenes, and explicit coexistence with the [Motion Light](../Motion%20Light/README.md) blueprint.
|
||||
|
||||
Designed to take over the **per-room** responsibilities that have outgrown a single house-wide scene per time slot. Day Scene Controller can be kept alongside this blueprint for genuinely house-wide scenes (Away, Goodnight, All-Off).
|
||||
|
||||
## Why this exists
|
||||
|
||||
`Day Scene Controller` triggers one global scene per time-of-day slot. As a home grows, those scenes balloon to dozens of entities and start fighting the user — empty bedrooms light up at sunset, scenes overwrite whatever is happening in the room you're actually in, and a single light requires editing four monolithic scenes.
|
||||
|
||||
This blueprint splits the problem per room. Each room gets its own small scenes (3–6 entities), its own presence sensors, its own vacancy behavior, and a clean handoff with motion-driven automations.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Time of Day Selector (input_select, 1 instance)
|
||||
│
|
||||
├──► Day Scene Controller (house-wide, kept for Away/Goodnight)
|
||||
└──► Presence Scene Controller × N (one instance per room)
|
||||
```
|
||||
|
||||
Per room the user provides:
|
||||
- A small set of scenes (Morning/Day/Evening/Night, plus a Vacant scene).
|
||||
- Optional presence sensors.
|
||||
- Optional sleep-mode switch + sleep scene.
|
||||
- Optional Motion Light state entity for coexistence.
|
||||
|
||||
## Migration path
|
||||
|
||||
The blueprint is built so you can adopt it gradually:
|
||||
|
||||
1. **Day 1.** Instantiate the blueprint per room with **no presence sensors** and small per-room scenes. With no sensors the room is treated as always-occupied and behaves as a per-room TOD mapper — you immediately gain smaller scenes and per-room control without buying any new hardware.
|
||||
2. **Day N.** Add presence sensors to one room (start with the bedroom — biggest UX win). Append the sensor entity to the input list. Vacancy gating, the sleep-person fix, and TOD-defer all activate for that room. No other config changes.
|
||||
3. Repeat per room as you wire more sensors. Day Scene Controller can stay around for the house-wide scenes that still make sense, or be retired completely.
|
||||
|
||||
## How it works
|
||||
|
||||
### Triggers
|
||||
|
||||
The automation re-evaluates on any of:
|
||||
|
||||
- Time-of-day state change.
|
||||
- Any presence sensor reporting `on`.
|
||||
- All presence sensors reporting `off` for the configured timeout.
|
||||
- Room enable switch toggled.
|
||||
- Sleep mode switch toggled.
|
||||
- Motion Light state entity changed (used as an abort signal).
|
||||
|
||||
### Decision flow
|
||||
|
||||
On every run, in order:
|
||||
|
||||
1. **Yield to Motion Light.** If the configured Motion Light state entity reports the same room is in `ENABLING`/`ENABLED`/`MANUAL`, stop immediately.
|
||||
2. **Motion-Light-state-change abort.** If this run was triggered by a Motion Light state change and Motion Light is now released, stop without re-applying anything (avoids fighting a freshly-disabled Motion Light).
|
||||
3. **Validate TOD.** Stop if the time-of-day state is not in the options list, or if the scenes list is shorter than the options.
|
||||
4. **Apply TOD-while-occupied policy** (only when the trigger was a TOD flip and the room is currently occupied):
|
||||
- `defer` — stop, wait for the next vacancy cycle.
|
||||
- `apply_if_lights_off` — stop unless every light/switch in the target scene is currently off.
|
||||
- `apply` — continue.
|
||||
5. **Skip if same scene.** If a `last_applied_state_entity` is configured and the target scene matches what was last applied, stop.
|
||||
6. **Apply the target scene.**
|
||||
7. **Persist last-applied scene** (if a state entity is configured).
|
||||
8. **Post-apply re-check.** After 300 ms re-read Motion Light state; if Motion Light has since claimed the room, stop without running callbacks.
|
||||
9. **Run callback** — `vacant_scene_applied_callback` or `scene_applied_callback` depending on which scene was applied.
|
||||
|
||||
### Target-scene resolution
|
||||
|
||||
Computed at action time (not trigger time), so a TOD flip that fires while a vacancy timeout is still counting down resolves to the new TOD's scene at apply time:
|
||||
|
||||
| Condition | Target scene |
|
||||
|---|---|
|
||||
| Sleep mode switch ON (and sleep scene configured) | sleep_scene |
|
||||
| Room enable switch OFF | vacant_scene |
|
||||
| Occupied and TOD index valid | scenes[tod_index] |
|
||||
| Vacant | vacant_scene |
|
||||
|
||||
### "TOD change while occupied" — the headline rule
|
||||
|
||||
Three modes for the toggle, default `apply_if_lights_off`:
|
||||
|
||||
| Mode | What it does | Best for |
|
||||
|---|---|---|
|
||||
| `apply_if_lights_off` | Re-apply only if every light/switch in the target scene is currently off | **Default** — handles "sleeping at 06:00 in a dark room" and "kitchen is dark, refresh it" with a single rule. Doesn't fight a user who is already reading with the lamp on. |
|
||||
| `defer` | Never re-apply on TOD flip; wait for the next vacancy cycle | Bedrooms with mmWave that always reports occupancy. |
|
||||
| `apply` | Always re-apply on TOD flip | Kitchen, hallway, transit areas. |
|
||||
|
||||
The deferred TOD scene is delivered automatically the next time the room transitions vacant→occupied — when you walk back in, you see the new TOD's scene.
|
||||
|
||||
### Vacancy
|
||||
|
||||
When all presence sensors have been off for `presence_off_timeout`, the `vacant_scene` is applied. Typical choices:
|
||||
|
||||
- An "All Off" scene for hallways and bathrooms.
|
||||
- A dim night-light scene for the bedroom.
|
||||
- An empty-room scene that turns off only the discretionary lights.
|
||||
|
||||
If no presence sensors are configured, vacancy is never reached and `vacant_scene` is effectively unused (still required as input, but ignored).
|
||||
|
||||
### Sleep mode
|
||||
|
||||
When `sleep_mode_switch` is ON (and `sleep_scene` is configured), every run resolves to `sleep_scene` regardless of TOD or presence. Use it for:
|
||||
|
||||
- Shift workers sleeping during the day.
|
||||
- Naps where TOD says "Afternoon" but you want the room dark.
|
||||
- A partner entering the bedroom without lighting it up.
|
||||
|
||||
The switch can be wired to a dashboard button, an NFC tag, a voice command, an alarm-clock automation that flips it off in the morning, or a sleep tracker.
|
||||
|
||||
### Motion Light coexistence
|
||||
|
||||
If both Motion Light and Presence Scene Controller target the same lights, point Presence Scene Controller at Motion Light's state `input_text` plus the matching key (Motion Light's `automation_state_placeholder_key`, or — by default — the room enable switch entity ID).
|
||||
|
||||
Coexistence is enforced two ways:
|
||||
|
||||
- **Yield gate.** Every evaluation reads Motion Light's state JSON. If it reports `ENABLING/ENABLED/MANUAL` for this room, we stop.
|
||||
- **mode: restart abort.** A change on the Motion Light state entity is one of the triggers, so a transition into ENABLING during a scene application cancels the in-flight run via Home Assistant's restart mode.
|
||||
- **Post-apply re-check.** A 300 ms delay after `scene.turn_on` is followed by a fresh read of Motion Light state. If Motion Light claimed the room during the apply, we yield without calling the success callback.
|
||||
|
||||
This is advisory rather than a hard lock — Home Assistant doesn't expose CAS-style locking — but it eliminates the common race in practice.
|
||||
|
||||
### Presence semantics
|
||||
|
||||
- ANY-on logic: the room is occupied if any presence sensor reports on.
|
||||
- Sensors reporting `unavailable` or `unknown` are treated as **occupied** — safer than the alternative (briefly plunging a real-occupied room into the vacant scene during a Zigbee hiccup).
|
||||
- `presence_off_timeout` debounces motion drop-outs and short absences.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Group | Input | Notes |
|
||||
|-------|-------|-------|
|
||||
| **States** | Time of Day State Selector | Required. The shared `input_select`. |
|
||||
| | Room Enable Switch | Optional kill switch. OFF → apply vacant scene. |
|
||||
| **Presence** | Presence Sensors | Optional list. Empty = always occupied. |
|
||||
| | Presence Off Timeout | Seconds (default 120). |
|
||||
| **Scenes** | Time-of-Day Scenes | Required ordered list, one per TOD option. |
|
||||
| | Vacant Scene | Required. Applied on vacancy / room disabled. |
|
||||
| | Sleep Scene | Optional. Required for sleep mode to do anything. |
|
||||
| **Behavior** | TOD Change Behavior While Occupied | `apply_if_lights_off` (default) / `defer` / `apply`. |
|
||||
| | Sleep Mode Switch | Optional. |
|
||||
| **Motion Light Coexistence** | Motion Light State Entity | Optional `input_text` from a Motion Light instance. |
|
||||
| | Motion Light State Key | Optional override of the JSON key. |
|
||||
| **Persistence** | Last Applied Scene Entity | Optional `input_text` for skip-if-same-scene. |
|
||||
| **Actions** | Scene Applied Callback | Runs after a TOD/sleep scene is applied. |
|
||||
| | Vacant Scene Applied Callback | Runs after the vacant scene is applied. |
|
||||
| **Debug** | Enable Debug Notifications | Posts a persistent notification per evaluation. |
|
||||
|
||||
## Behavior notes & known limitations
|
||||
|
||||
- **HA restart while occupied.** After a restart, the first vacancy cycle applies the *current* TOD's scene to the room. If TOD already advanced past several boundaries during the restart, intermediate scenes are not retroactively applied.
|
||||
- **Identical vacant scene and TOD scene.** If you accidentally set them to the same entity, the skip-if-same-scene guard suppresses redundant work — but only if you've configured a `last_applied_state_entity`.
|
||||
- **Motion Light coexistence is advisory.** A worst-case race between a scene apply and Motion Light's enable path can still produce a brief flicker before the post-apply re-check yields. If you need stricter mutual exclusion, wire a per-room "scene controller idle" `input_boolean` into Motion Light's `condition_switches`.
|
||||
- **Settled occupancy isn't modeled.** A user who rolls out of bed but stays in the room never triggers a vacancy cycle, so the deferred TOD scene won't apply until they leave and re-enter. Acceptable in practice; the first bathroom trip resets it.
|
||||
- **One blueprint instance per room.** If you need separate behavior for two distinct light sets in the same room (e.g., bedroom main lights vs. bedroom fan + reading lamp), instantiate the blueprint twice with non-overlapping scenes.
|
||||
- **Optional state-trigger entities.** Triggers reference `room_enable_switch`, `sleep_mode_switch`, and `motion_light_state_entity`. Leaving them empty is supported by modern Home Assistant; on older versions you may see a non-fatal warning in the log. The automation continues to work either way.
|
||||
|
||||
## Author
|
||||
|
||||
Alexei Dolgolyov (dolgolyov.alexei@gmail.com)
|
||||
@@ -0,0 +1,626 @@
|
||||
# Presence Scene Controller Blueprint
|
||||
# Per-room presence-aware time-of-day scene controller.
|
||||
# See README.md for detailed documentation.
|
||||
#
|
||||
# Author: Alexei Dolgolyov (dolgolyov.alexei@gmail.com)
|
||||
|
||||
blueprint:
|
||||
name: "Custom: Presence Scene Controller"
|
||||
description: >
|
||||
Per-room presence-aware time-of-day scene controller. Maps scenes to
|
||||
time-of-day options by index (like Day Scene Controller), but adds
|
||||
presence gating, vacant/sleep scenes, and Motion Light coexistence.
|
||||
Designed to take over the per-room responsibilities of Day Scene
|
||||
Controller — keep Day Scene Controller for genuinely house-wide
|
||||
scenes (Away, Goodnight, All-Off).
|
||||
domain: automation
|
||||
|
||||
input:
|
||||
# -------------------------------------------------------------------------
|
||||
# State Sources
|
||||
# -------------------------------------------------------------------------
|
||||
states_group:
|
||||
name: States
|
||||
collapsed: false
|
||||
input:
|
||||
time_of_day_state:
|
||||
name: Time of Day State Selector
|
||||
description: >
|
||||
input_select entity holding the current time-of-day state.
|
||||
Typically managed by Time of Day Selector or Time of Day
|
||||
Controller.
|
||||
selector:
|
||||
entity:
|
||||
domain: input_select
|
||||
|
||||
room_enable_switch:
|
||||
name: Room Enable Switch (optional)
|
||||
description: >
|
||||
When OFF, the vacant scene is applied and TOD/presence are
|
||||
ignored. Useful as a per-room kill switch (movie night, guest
|
||||
bedroom). Leave empty to skip this gate.
|
||||
default: ""
|
||||
selector:
|
||||
entity:
|
||||
domain:
|
||||
- binary_sensor
|
||||
- input_boolean
|
||||
- switch
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Presence
|
||||
# -------------------------------------------------------------------------
|
||||
presence_group:
|
||||
name: Presence
|
||||
collapsed: false
|
||||
input:
|
||||
presence_sensors:
|
||||
name: Presence Sensors (optional)
|
||||
description: >
|
||||
Any presence-related entities — motion sensors, mmWave,
|
||||
occupancy groups, input_booleans. ANY-on logic: the room is
|
||||
occupied if any sensor reports on. Sensors reporting
|
||||
unavailable/unknown are treated as on (safe default).
|
||||
Leave empty to disable presence gating; the room is then
|
||||
always treated as occupied and the vacant scene is never
|
||||
applied.
|
||||
default: []
|
||||
selector:
|
||||
entity:
|
||||
domain:
|
||||
- binary_sensor
|
||||
- input_boolean
|
||||
- group
|
||||
multiple: true
|
||||
|
||||
presence_off_timeout:
|
||||
name: Presence Off Timeout (seconds)
|
||||
description: >
|
||||
How long all presence sensors must report off before the
|
||||
room is considered vacant. Filters PIR drop-outs and short
|
||||
absences.
|
||||
default: 120
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
max: 3600
|
||||
step: 5
|
||||
unit_of_measurement: seconds
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Scenes
|
||||
# -------------------------------------------------------------------------
|
||||
scenes_group:
|
||||
name: Scenes
|
||||
collapsed: false
|
||||
input:
|
||||
scenes:
|
||||
name: Time-of-Day Scenes
|
||||
description: >
|
||||
Ordered list of scenes matching time-of-day options by index.
|
||||
Length must equal the number of time-of-day options.
|
||||
selector:
|
||||
entity:
|
||||
domain: scene
|
||||
multiple: true
|
||||
|
||||
vacant_scene:
|
||||
name: Vacant Scene
|
||||
description: >
|
||||
Scene applied when the room becomes vacant (after the
|
||||
presence-off timeout) or when the room enable switch is OFF.
|
||||
Typically a dim or all-off scene.
|
||||
selector:
|
||||
entity:
|
||||
domain: scene
|
||||
|
||||
sleep_scene:
|
||||
name: Sleep Scene (optional)
|
||||
description: >
|
||||
Scene applied when the sleep mode switch is ON. Bypasses TOD
|
||||
and presence entirely. Typically a very dim "do not disturb"
|
||||
scene or all-off. Leave empty if sleep mode is not used.
|
||||
default: ""
|
||||
selector:
|
||||
entity:
|
||||
domain: scene
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Behavior
|
||||
# -------------------------------------------------------------------------
|
||||
behavior_group:
|
||||
name: Behavior
|
||||
collapsed: false
|
||||
input:
|
||||
tod_behavior_while_occupied:
|
||||
name: TOD Change Behavior While Occupied
|
||||
description: >
|
||||
What to do when time-of-day flips while the room is occupied:
|
||||
|
||||
- apply_if_lights_off (default): re-apply only if every
|
||||
targeted light/switch in the new scene is currently off.
|
||||
Doesn't disturb a sleeping/relaxing user, refreshes a
|
||||
dark-but-occupied room.
|
||||
|
||||
- defer: never re-apply on TOD flip; wait for the next
|
||||
vacancy cycle. Best for bedrooms with mmWave that always
|
||||
reports occupancy.
|
||||
|
||||
- apply: always re-apply on TOD flip. Best for kitchens,
|
||||
hallways, transit areas.
|
||||
default: apply_if_lights_off
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- apply_if_lights_off
|
||||
- defer
|
||||
- apply
|
||||
|
||||
sleep_mode_switch:
|
||||
name: Sleep Mode Switch (optional)
|
||||
description: >
|
||||
input_boolean / binary_sensor that, when ON, forces the
|
||||
sleep scene and ignores TOD/presence. Useful for shift
|
||||
workers, daytime naps, or letting a partner enter without
|
||||
lighting up the room. Requires sleep_scene to be configured
|
||||
(otherwise the switch is silently ignored).
|
||||
default: ""
|
||||
selector:
|
||||
entity:
|
||||
domain:
|
||||
- binary_sensor
|
||||
- input_boolean
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Motion Light Coexistence (advanced)
|
||||
# -------------------------------------------------------------------------
|
||||
motion_light_group:
|
||||
name: Motion Light Coexistence
|
||||
collapsed: true
|
||||
input:
|
||||
motion_light_state_entity:
|
||||
name: Motion Light State Entity (optional)
|
||||
description: >
|
||||
input_text used by a Motion Light blueprint instance that
|
||||
targets the same lights as this room. While Motion Light is
|
||||
actively controlling the lights (state ENABLING/ENABLED/
|
||||
MANUAL), this blueprint stays out of its way. State changes
|
||||
on this entity also abort any in-flight scene application
|
||||
via mode: restart.
|
||||
default: ""
|
||||
selector:
|
||||
entity:
|
||||
domain: input_text
|
||||
|
||||
motion_light_state_key:
|
||||
name: Motion Light State Key (optional)
|
||||
description: >
|
||||
JSON key used inside Motion Light's state entity. Leave
|
||||
empty to fall back to the room enable switch entity ID; set
|
||||
explicitly to match Motion Light's
|
||||
automation_state_placeholder_key.
|
||||
default: ""
|
||||
selector:
|
||||
text:
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Persistence
|
||||
# -------------------------------------------------------------------------
|
||||
persistence_group:
|
||||
name: Persistence
|
||||
collapsed: true
|
||||
input:
|
||||
last_applied_state_entity:
|
||||
name: Last Applied Scene Entity (optional)
|
||||
description: >
|
||||
input_text used to remember the last scene this automation
|
||||
applied, so identical re-applications can be skipped. Each
|
||||
instance must have its own entity. Default max_length 100
|
||||
is usually enough; ensure it fits the longest scene entity
|
||||
ID. Leave empty to disable the skip-if-same-scene guard.
|
||||
default: ""
|
||||
selector:
|
||||
entity:
|
||||
domain: input_text
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Callbacks
|
||||
# -------------------------------------------------------------------------
|
||||
actions_group:
|
||||
name: Actions
|
||||
collapsed: true
|
||||
input:
|
||||
scene_applied_callback:
|
||||
name: Scene Applied Callback
|
||||
description: >
|
||||
Runs after a TOD or sleep scene is applied. Useful for
|
||||
notifications, dependent automations.
|
||||
default: []
|
||||
selector:
|
||||
action: {}
|
||||
|
||||
vacant_scene_applied_callback:
|
||||
name: Vacant Scene Applied Callback
|
||||
description: Runs after the vacant scene is applied.
|
||||
default: []
|
||||
selector:
|
||||
action: {}
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Debug
|
||||
# -------------------------------------------------------------------------
|
||||
debug_group:
|
||||
name: Debug
|
||||
collapsed: true
|
||||
input:
|
||||
enable_debug_notifications:
|
||||
name: Enable Debug Notifications
|
||||
description: >
|
||||
Send a persistent notification on every evaluation with the
|
||||
decision inputs and the resolved target scene.
|
||||
default: false
|
||||
selector:
|
||||
boolean:
|
||||
|
||||
# Restart mode: rapid TOD/presence changes always use latest values, and
|
||||
# Motion Light state changes can abort an in-flight scene application.
|
||||
mode: restart
|
||||
|
||||
# =============================================================================
|
||||
# Triggers
|
||||
# =============================================================================
|
||||
trigger:
|
||||
# Time-of-day state changed
|
||||
- platform: state
|
||||
entity_id: !input time_of_day_state
|
||||
id: tod_changed
|
||||
|
||||
# Any presence sensor reports on (immediate)
|
||||
- platform: state
|
||||
entity_id: !input presence_sensors
|
||||
to: "on"
|
||||
id: presence_on
|
||||
|
||||
# All presence sensors have been off for the configured timeout
|
||||
- platform: state
|
||||
entity_id: !input presence_sensors
|
||||
to: "off"
|
||||
for:
|
||||
seconds: !input presence_off_timeout
|
||||
id: presence_off
|
||||
|
||||
# Room enable switch toggled
|
||||
- platform: state
|
||||
entity_id: !input room_enable_switch
|
||||
id: room_enable_changed
|
||||
|
||||
# Sleep mode switch toggled
|
||||
- platform: state
|
||||
entity_id: !input sleep_mode_switch
|
||||
id: sleep_mode_changed
|
||||
|
||||
# Motion Light state entity changed (abort signal via mode: restart)
|
||||
- platform: state
|
||||
entity_id: !input motion_light_state_entity
|
||||
id: motion_light_state_changed
|
||||
|
||||
# =============================================================================
|
||||
# Variables
|
||||
# =============================================================================
|
||||
variables:
|
||||
# ---------------------------------------------------------------------------
|
||||
# Inputs
|
||||
# ---------------------------------------------------------------------------
|
||||
time_of_day_state: !input time_of_day_state
|
||||
presence_sensors: !input presence_sensors
|
||||
room_enable_switch: !input room_enable_switch
|
||||
scenes_list: !input scenes
|
||||
vacant_scene: !input vacant_scene
|
||||
sleep_scene: !input sleep_scene
|
||||
sleep_mode_switch: !input sleep_mode_switch
|
||||
tod_behavior_while_occupied: !input tod_behavior_while_occupied
|
||||
motion_light_state_entity: !input motion_light_state_entity
|
||||
motion_light_state_key_input: !input motion_light_state_key
|
||||
last_applied_state_entity: !input last_applied_state_entity
|
||||
enable_debug_notifications: !input enable_debug_notifications
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Trigger context
|
||||
# ---------------------------------------------------------------------------
|
||||
trigger_id: "{{ trigger.id | default('') }}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TOD resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
tod_state: "{{ states(time_of_day_state) }}"
|
||||
tod_options: "{{ state_attr(time_of_day_state, 'options') or [] }}"
|
||||
tod_index: "{{ tod_options.index(tod_state) if tod_state in tod_options else -1 }}"
|
||||
tod_valid: "{{ tod_index >= 0 and tod_index < (scenes_list | length) }}"
|
||||
tod_scene: "{{ scenes_list[tod_index] if tod_valid else '' }}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Presence resolution (ANY-on logic)
|
||||
# Sensors reporting unavailable/unknown are treated as occupied — safer than
|
||||
# the alternative of plunging an actually-occupied room into vacant scene.
|
||||
# ---------------------------------------------------------------------------
|
||||
has_presence_sensors: "{{ (presence_sensors | length) > 0 }}"
|
||||
any_presence_on: >
|
||||
{% set sensors = presence_sensors if presence_sensors is iterable else [presence_sensors] %}
|
||||
{% set ns = namespace(on=false) %}
|
||||
{% for s in sensors %}
|
||||
{% set v = states(s) %}
|
||||
{% if v in ['on', 'home', 'true', '1', 'unavailable', 'unknown'] %}
|
||||
{% set ns.on = true %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{{ ns.on }}
|
||||
is_occupied: "{{ (not has_presence_sensors) or any_presence_on }}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Room enable / sleep mode
|
||||
# ---------------------------------------------------------------------------
|
||||
room_enabled: >
|
||||
{% if room_enable_switch in [none, ''] %}
|
||||
true
|
||||
{% else %}
|
||||
{{ states(room_enable_switch) in ['on', 'home', 'true', '1'] }}
|
||||
{% endif %}
|
||||
|
||||
sleep_active: >
|
||||
{% if sleep_mode_switch in [none, ''] or sleep_scene in [none, ''] %}
|
||||
false
|
||||
{% else %}
|
||||
{{ states(sleep_mode_switch) in ['on', 'home', 'true', '1'] }}
|
||||
{% endif %}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Motion Light coexistence
|
||||
# State JSON shape (from Motion Light):
|
||||
# { "<key>": { "mls": "0|1|2|3", "mllat": "...", "mllb": ... } }
|
||||
# mls: 0=NONE, 1=ENABLED, 2=ENABLING, 3=MANUAL.
|
||||
# ---------------------------------------------------------------------------
|
||||
motion_light_state_global: >
|
||||
{% if motion_light_state_entity in [none, ''] %}
|
||||
{{ dict() }}
|
||||
{% else %}
|
||||
{% set text = states(motion_light_state_entity) | string %}
|
||||
{% if text in ['unknown', 'unavailable', 'none', ''] %}
|
||||
{{ dict() }}
|
||||
{% else %}
|
||||
{% set parsed = text | from_json(default=dict()) %}
|
||||
{{ parsed if parsed is mapping else dict() }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
motion_light_resolved_key: >
|
||||
{% if motion_light_state_key_input not in [none, ''] %}
|
||||
{{ motion_light_state_key_input }}
|
||||
{% else %}
|
||||
{{ room_enable_switch | default('', true) }}
|
||||
{% endif %}
|
||||
|
||||
motion_light_room_state: >
|
||||
{% set inner = motion_light_state_global.get(motion_light_resolved_key, dict()) %}
|
||||
{{ (inner.get('mls', '0') | string) if inner is mapping else '0' }}
|
||||
|
||||
motion_light_claimed: "{{ motion_light_room_state in ['1', '2', '3'] }}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Target scene (recomputed at apply time, never cached at trigger time)
|
||||
# ---------------------------------------------------------------------------
|
||||
target_scene: >
|
||||
{% if sleep_active %}
|
||||
{{ sleep_scene }}
|
||||
{% elif not room_enabled %}
|
||||
{{ vacant_scene }}
|
||||
{% elif is_occupied and tod_valid %}
|
||||
{{ tod_scene }}
|
||||
{% else %}
|
||||
{{ vacant_scene }}
|
||||
{% endif %}
|
||||
|
||||
is_vacant_target: "{{ target_scene == vacant_scene }}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Skip-if-same-scene guard
|
||||
# ---------------------------------------------------------------------------
|
||||
last_applied_scene: >
|
||||
{% if last_applied_state_entity in [none, ''] %}
|
||||
''
|
||||
{% else %}
|
||||
{{ states(last_applied_state_entity) | string }}
|
||||
{% endif %}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# `apply_if_lights_off` evaluation: are all entities in the target scene
|
||||
# currently off? Looks at the scene's entity_id attribute.
|
||||
# ---------------------------------------------------------------------------
|
||||
scene_lights_off: >
|
||||
{% set ns = namespace(any_on=false) %}
|
||||
{% if target_scene not in [none, ''] %}
|
||||
{% set entities = state_attr(target_scene, 'entity_id') or [] %}
|
||||
{% for e in entities %}
|
||||
{% if (e.startswith('light.') or e.startswith('switch.')) and is_state(e, 'on') %}
|
||||
{% set ns.any_on = true %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{{ not ns.any_on }}
|
||||
|
||||
# =============================================================================
|
||||
# Actions
|
||||
# =============================================================================
|
||||
action:
|
||||
# ---------------------------------------------------------------------------
|
||||
# Debug: log entry state
|
||||
# ---------------------------------------------------------------------------
|
||||
- choose:
|
||||
- conditions: "{{ enable_debug_notifications }}"
|
||||
sequence:
|
||||
- service: persistent_notification.create
|
||||
data:
|
||||
title: "Presence Scene Controller — entry"
|
||||
message: >
|
||||
Trigger: {{ trigger_id }}
|
||||
TOD: {{ tod_state }} (idx {{ tod_index }})
|
||||
Occupied: {{ is_occupied }} | Enabled: {{ room_enabled }} | Sleep: {{ sleep_active }}
|
||||
ML claimed: {{ motion_light_claimed }} (mls={{ motion_light_room_state }}, key={{ motion_light_resolved_key }})
|
||||
Target: {{ target_scene }}
|
||||
Last applied: {{ last_applied_scene }}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Yield to Motion Light if it's currently controlling the room
|
||||
# ---------------------------------------------------------------------------
|
||||
- choose:
|
||||
- conditions:
|
||||
- condition: template
|
||||
value_template: "{{ motion_light_claimed }}"
|
||||
sequence:
|
||||
- stop: "Motion Light is controlling this room — yielding."
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Motion Light state-change is purely an abort signal. If we reach here and
|
||||
# ML is NOT claimed, do nothing — ML may have just released the room and
|
||||
# turned the lights off intentionally; we should not immediately re-apply.
|
||||
# ---------------------------------------------------------------------------
|
||||
- choose:
|
||||
- conditions:
|
||||
- condition: template
|
||||
value_template: "{{ trigger_id == 'motion_light_state_changed' }}"
|
||||
sequence:
|
||||
- stop: "Motion Light state changed — abort signal only."
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Validate TOD configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
- choose:
|
||||
- conditions:
|
||||
- condition: template
|
||||
value_template: "{{ tod_index == -1 }}"
|
||||
sequence:
|
||||
- stop: "Invalid time-of-day state — current state not in options list."
|
||||
|
||||
- conditions:
|
||||
- condition: template
|
||||
value_template: "{{ is_occupied and room_enabled and not sleep_active and not tod_valid }}"
|
||||
sequence:
|
||||
- stop: "Scene index out of range — scenes list shorter than TOD options."
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TOD-while-occupied policy. Only kicks in when this run was triggered by a
|
||||
# TOD flip AND the room is occupied AND no override mode is in effect.
|
||||
# ---------------------------------------------------------------------------
|
||||
- choose:
|
||||
- conditions:
|
||||
- condition: template
|
||||
value_template: >
|
||||
{{ trigger_id == 'tod_changed'
|
||||
and is_occupied
|
||||
and room_enabled
|
||||
and not sleep_active }}
|
||||
sequence:
|
||||
- choose:
|
||||
- conditions:
|
||||
- condition: template
|
||||
value_template: "{{ tod_behavior_while_occupied == 'defer' }}"
|
||||
sequence:
|
||||
- stop: "TOD changed while occupied — deferring per policy."
|
||||
|
||||
- conditions:
|
||||
- condition: template
|
||||
value_template: >
|
||||
{{ tod_behavior_while_occupied == 'apply_if_lights_off' and not scene_lights_off }}
|
||||
sequence:
|
||||
- stop: "TOD changed while occupied with lights on — deferring."
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Skip if the target scene is already what we last applied
|
||||
# ---------------------------------------------------------------------------
|
||||
- choose:
|
||||
- conditions:
|
||||
- condition: template
|
||||
value_template: >
|
||||
{{ last_applied_scene != ''
|
||||
and target_scene not in [none, '']
|
||||
and last_applied_scene == target_scene }}
|
||||
sequence:
|
||||
- stop: "Scene already applied — no-op."
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Apply the target scene
|
||||
# ---------------------------------------------------------------------------
|
||||
- choose:
|
||||
- conditions:
|
||||
- condition: template
|
||||
value_template: "{{ target_scene not in [none, ''] }}"
|
||||
sequence:
|
||||
- service: scene.turn_on
|
||||
target:
|
||||
entity_id: "{{ target_scene }}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Persist last-applied scene
|
||||
# ---------------------------------------------------------------------------
|
||||
- choose:
|
||||
- conditions:
|
||||
- condition: template
|
||||
value_template: >
|
||||
{{ last_applied_state_entity not in [none, '']
|
||||
and target_scene not in [none, ''] }}
|
||||
sequence:
|
||||
- service: input_text.set_value
|
||||
target:
|
||||
entity_id: "{{ last_applied_state_entity }}"
|
||||
data:
|
||||
value: "{{ target_scene }}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Post-apply Motion Light re-check. Brief delay then re-read ML state — if
|
||||
# Motion Light claimed the room while we were applying, do not contest.
|
||||
# ---------------------------------------------------------------------------
|
||||
- delay:
|
||||
milliseconds: 300
|
||||
|
||||
- variables:
|
||||
ml_state_after: >
|
||||
{% if motion_light_state_entity in [none, ''] %}
|
||||
0
|
||||
{% else %}
|
||||
{% set text = states(motion_light_state_entity) | string %}
|
||||
{% if text in ['unknown', 'unavailable', 'none', ''] %}
|
||||
0
|
||||
{% else %}
|
||||
{% set parsed = text | from_json(default=dict()) %}
|
||||
{% if parsed is mapping %}
|
||||
{% set inner = parsed.get(motion_light_resolved_key, dict()) %}
|
||||
{{ (inner.get('mls', '0') | string) if inner is mapping else '0' }}
|
||||
{% else %}
|
||||
0
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
- choose:
|
||||
- conditions:
|
||||
- condition: template
|
||||
value_template: "{{ (ml_state_after | string) in ['1', '2', '3'] }}"
|
||||
sequence:
|
||||
- choose:
|
||||
- conditions: "{{ enable_debug_notifications }}"
|
||||
sequence:
|
||||
- service: persistent_notification.create
|
||||
data:
|
||||
title: "Presence Scene Controller"
|
||||
message: "Motion Light claimed room post-apply — yielding without callback."
|
||||
- stop: "Motion Light took over after scene apply."
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Run the appropriate callback
|
||||
# ---------------------------------------------------------------------------
|
||||
- choose:
|
||||
- conditions:
|
||||
- condition: template
|
||||
value_template: "{{ is_vacant_target }}"
|
||||
sequence: !input vacant_scene_applied_callback
|
||||
|
||||
default: !input scene_applied_callback
|
||||
@@ -0,0 +1,51 @@
|
||||
# Time of Day Selector Blueprint
|
||||
|
||||
Event-driven blueprint that updates an `input_select` based on a list of time triggers. Each trigger entity is mapped to a state name; when the trigger fires, the matching state becomes active.
|
||||
|
||||
This is a sibling to the [Time Of Day Controller](../Time%20Of%20Day%20Controller/README.md) blueprint, which uses minute-by-minute polling and threshold comparison. Use this one when you want exact event-based firing and the ability to attach multiple trigger sources (e.g. a sun event **or** a manual forced time) to the same state.
|
||||
|
||||
## How It Works
|
||||
|
||||
- Define a list of **time triggers** — `input_datetime` helpers and/or timestamp sensors such as `sensor.sun_next_rising` / `sensor.sun_next_dusk` / `sensor.sun_next_midnight`.
|
||||
- Define a parallel list of **state names** — one per trigger, in the same order.
|
||||
- When any trigger fires, the blueprint looks up the firing entity in the trigger list and selects the matching state name on the target `input_select`.
|
||||
- Duplicate state names produce **OR-override** behavior: whichever associated trigger fires first wins.
|
||||
|
||||
## Index Mapping
|
||||
|
||||
The two lists are matched by position:
|
||||
|
||||
| Index | Trigger entity | State name |
|
||||
|-------|---------------|------------|
|
||||
| 0 | `sensor.sun_next_rising` | `Утро` |
|
||||
| 1 | `input_datetime.dnevnoe_vremia` | `День` |
|
||||
| 2 | `sensor.sun_next_dusk` | `Вечер` |
|
||||
| 3 | `input_datetime.vechernee_vremia_prinuditelno` | `Вечер` |
|
||||
| 4 | `sensor.sun_next_midnight` | `Ночь` |
|
||||
| 5 | `input_datetime.polnochnoe_vremia_prinuditelno` | `Ночь` |
|
||||
|
||||
In this example, `Вечер` is set when either the natural dusk time or the forced datetime fires — whichever comes first.
|
||||
|
||||
## Supported Time Sources
|
||||
|
||||
- `input_datetime` entities (time-only: `HH:MM:SS`).
|
||||
- Timestamp sensors that report an ISO datetime (e.g. `sensor.sun_next_rising`).
|
||||
|
||||
## Configuration
|
||||
|
||||
| Input | Description |
|
||||
|-------|-------------|
|
||||
| **Time of Day Selector** | The `input_select` entity to update. Its options must include every state name in the list below. |
|
||||
| **Time of Day Triggers** | Ordered list of trigger entities. |
|
||||
| **Time of Day States** | Parallel list of state names, same length as triggers. |
|
||||
| **Debug mode** | When enabled, posts a persistent notification on each fire with the firing entity, resolved state, and previous state. |
|
||||
|
||||
## Behavioral Notes
|
||||
|
||||
- `input_select.select_option` is idempotent — repeated triggers for the same state cause no extra state changes.
|
||||
- A guard condition skips the action if the resolved state already matches the current `input_select` value.
|
||||
- On Home Assistant restart, the state is **not** retroactively corrected — it updates on the next trigger. If you need self-correcting state on restart, use the [Time Of Day Controller](../Time%20Of%20Day%20Controller/README.md) (polling) blueprint instead.
|
||||
|
||||
## Author
|
||||
|
||||
Alexei Dolgolyov (dolgolyov.alexei@gmail.com)
|
||||
@@ -0,0 +1,147 @@
|
||||
# Time of Day Selector Blueprint
|
||||
# Event-driven: sets an input_select when any configured time entity fires.
|
||||
# See README.md for detailed documentation.
|
||||
#
|
||||
# Author: Alexei Dolgolyov (dolgolyov.alexei@gmail.com)
|
||||
|
||||
blueprint:
|
||||
name: "Custom: Time of Day Selector"
|
||||
description: >
|
||||
Event-driven time-of-day state machine.
|
||||
Maps a list of time entities to a parallel list of state names.
|
||||
When any time entity fires, the corresponding state is selected on the
|
||||
target input_select. Multiple entities mapping to the same state name
|
||||
produce OR-override behavior (e.g. sun_next_dusk OR a forced
|
||||
input_datetime — whichever fires first wins).
|
||||
domain: automation
|
||||
|
||||
input:
|
||||
# -------------------------------------------------------------------------
|
||||
# Output Configuration
|
||||
# -------------------------------------------------------------------------
|
||||
tod_select:
|
||||
name: Time of Day Selector
|
||||
description: >
|
||||
The input_select entity whose option will be updated.
|
||||
Its options must include every state name listed below.
|
||||
selector:
|
||||
entity:
|
||||
domain: input_select
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Trigger / State Mapping
|
||||
# -------------------------------------------------------------------------
|
||||
tod_times:
|
||||
name: Time of Day Triggers
|
||||
description: >
|
||||
Ordered list of time entities. Each fires when its configured time
|
||||
is reached. Accepts input_datetime (time-only) and timestamp sensors
|
||||
(e.g. sensor.sun_next_rising, sensor.sun_next_dusk).
|
||||
selector:
|
||||
entity:
|
||||
domain:
|
||||
- input_datetime
|
||||
- sensor
|
||||
multiple: true
|
||||
|
||||
tod_states:
|
||||
name: Time of Day States
|
||||
description: >
|
||||
Parallel list of state names — one entry for each trigger above, in
|
||||
the same order. Each name must match an option of the target
|
||||
input_select exactly. Repeat a name to give that state multiple
|
||||
trigger sources (override / "whichever fires first" pattern).
|
||||
selector:
|
||||
text:
|
||||
multiple: true
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Debug
|
||||
# -------------------------------------------------------------------------
|
||||
is_debug:
|
||||
name: Debug mode
|
||||
description: >
|
||||
When enabled, posts a persistent notification on each fire showing
|
||||
the firing entity, resolved state, and previous state.
|
||||
default: false
|
||||
selector:
|
||||
boolean:
|
||||
|
||||
# Single mode is sufficient: select_option is idempotent and trigger collisions
|
||||
# are extremely rare (different time entities reaching the same instant).
|
||||
mode: single
|
||||
|
||||
# =============================================================================
|
||||
# Triggers
|
||||
# =============================================================================
|
||||
trigger:
|
||||
- platform: time
|
||||
at: !input tod_times
|
||||
|
||||
# =============================================================================
|
||||
# Variables
|
||||
# =============================================================================
|
||||
variables:
|
||||
tod_select: !input tod_select
|
||||
tod_times: !input tod_times
|
||||
tod_states: !input tod_states
|
||||
is_debug: !input is_debug
|
||||
|
||||
# Index of the entity that fired, within tod_times. -1 if not found.
|
||||
fired_index: >
|
||||
{% set ns = namespace(idx=-1) %}
|
||||
{% for e in tod_times %}
|
||||
{% if e == trigger.entity_id %}
|
||||
{% set ns.idx = loop.index0 %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{{ ns.idx }}
|
||||
|
||||
# State name resolved from the firing entity. Empty if lookup failed
|
||||
# (e.g. mismatched list lengths or unknown trigger entity).
|
||||
target_state: >
|
||||
{%- if fired_index >= 0 and fired_index < tod_states | length -%}
|
||||
{{ tod_states[fired_index] }}
|
||||
{%- endif -%}
|
||||
|
||||
previous_state: "{{ states(tod_select) }}"
|
||||
|
||||
# =============================================================================
|
||||
# Condition - Only proceed if a valid state was resolved and it differs
|
||||
# =============================================================================
|
||||
condition:
|
||||
- condition: template
|
||||
value_template: "{{ target_state | length > 0 and target_state != previous_state }}"
|
||||
|
||||
# =============================================================================
|
||||
# Actions
|
||||
# =============================================================================
|
||||
action:
|
||||
# ---------------------------------------------------------------------------
|
||||
# Debug Logging (optional)
|
||||
# ---------------------------------------------------------------------------
|
||||
- choose:
|
||||
- conditions:
|
||||
- condition: template
|
||||
value_template: "{{ is_debug }}"
|
||||
sequence:
|
||||
- service: persistent_notification.create
|
||||
data:
|
||||
title: "Time of Day Selector"
|
||||
message: >
|
||||
Fired entity: {{ trigger.entity_id }}
|
||||
|
||||
Index in triggers list: {{ fired_index }}
|
||||
|
||||
Previous state: {{ previous_state }}
|
||||
|
||||
Target state: {{ target_state }}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Update Time of Day State
|
||||
# ---------------------------------------------------------------------------
|
||||
- service: input_select.select_option
|
||||
target:
|
||||
entity_id: "{{ tod_select }}"
|
||||
data:
|
||||
option: "{{ target_state }}"
|
||||
@@ -7,6 +7,7 @@ This blueprint monitors washing machine or dryer appliances and sends notificati
|
||||
- Start notification with cycle duration, estimated end time, and mode details
|
||||
- Completion notification (reminder to unload clothes) with energy report
|
||||
- "Almost done" notification (configurable minutes before end)
|
||||
- Unload reminder (configurable delay, optional repeats) — auto-cancelled when a new cycle starts or a configured door/lid sensor opens
|
||||
- Pause/Resume notifications (detect when cycle is paused or resumed)
|
||||
- Error message notifications
|
||||
- Preparation mode notification (e.g., for dryer prep)
|
||||
@@ -39,6 +40,8 @@ The automation tracks the appliance through these states:
|
||||
| `cst` | Cycle Start Time (ISO timestamp) |
|
||||
| `esmp` | Energy Samples accumulator (Wh) |
|
||||
| `lst` | Last Sample Time (ISO timestamp) |
|
||||
| `cct` | Cycle Completion Time (ISO timestamp, drives unload reminder) |
|
||||
| `urc` | Unload Reminder Count (number of reminders already sent) |
|
||||
|
||||
## Message Template Variables
|
||||
|
||||
@@ -49,7 +52,7 @@ All message templates support these placeholder variables (use single braces):
|
||||
| `{appliance_name}` | Device name (e.g., "Washing Machine") |
|
||||
| `{remaining}` | Remaining time as string (e.g., "01:30:00") |
|
||||
| `{estimated_end}` | Estimated completion time (e.g., "14:30") |
|
||||
| `{minutes}` | Remaining minutes as number (e.g., 90) |
|
||||
| `{minutes}` | Remaining minutes (almost-done) or elapsed minutes since completion (unload reminder) |
|
||||
| `{error}` | Error message text (only in error notification) |
|
||||
| `{tub_count}` | Tub clean counter value (only in tub clean notification) |
|
||||
| `{tub_threshold}` | Tub clean threshold (only in tub clean notification) |
|
||||
@@ -64,6 +67,18 @@ All message templates support these placeholder variables (use single braces):
|
||||
- Notification service entity
|
||||
- (Optional) Power sensor for energy tracking
|
||||
|
||||
## Unload Reminder
|
||||
|
||||
If `Unload Reminder Delay` is greater than 0, the automation:
|
||||
|
||||
1. Records the completion timestamp (`cct`) when the cycle finishes — but **only if** every door sensor in `Unload Reminder Door Sensors` is currently closed (or the list is empty).
|
||||
2. Sends a reminder N minutes later (where N = delay), then optionally repeats every `Unload Reminder Repeat Interval` minutes up to `Unload Reminder Repeat Count` total.
|
||||
3. Cancels all pending reminders when:
|
||||
- A new cycle starts (start handler clears `cct` and `urc`), **or**
|
||||
- Any of the configured door / lid sensors transitions to `on` (treated as "user has unloaded").
|
||||
|
||||
The check runs once per minute via a `time_pattern` trigger. The door sensor list is optional — leave it empty to keep reminders purely time-based.
|
||||
|
||||
## Note
|
||||
|
||||
Default messages are in Russian for LG ThinQ integration. Customize messages in the "Messages" section for your language.
|
||||
|
||||
@@ -65,6 +65,67 @@ blueprint:
|
||||
selector:
|
||||
boolean:
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Unload Reminder
|
||||
# -------------------------------------------------------------------------
|
||||
# Optional reminder to unload the appliance after the cycle completes.
|
||||
# Purely time-based: no door/state tracking required.
|
||||
unload_reminder_group:
|
||||
name: "Unload Reminder"
|
||||
collapsed: true
|
||||
input:
|
||||
unload_reminder_delay:
|
||||
name: Unload Reminder Delay (minutes)
|
||||
description: >
|
||||
Send a reminder this many minutes after the cycle completes
|
||||
if the appliance has not been emptied yet.
|
||||
Set to 0 to disable unload reminders entirely.
|
||||
default: 0
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
max: 180
|
||||
unit_of_measurement: minutes
|
||||
mode: slider
|
||||
|
||||
unload_reminder_repeat_count:
|
||||
name: Unload Reminder Repeat Count
|
||||
description: >
|
||||
Total number of unload reminders to send (including the first).
|
||||
Set to 1 for a single reminder.
|
||||
default: 1
|
||||
selector:
|
||||
number:
|
||||
min: 1
|
||||
max: 10
|
||||
mode: slider
|
||||
|
||||
unload_reminder_repeat_interval:
|
||||
name: Unload Reminder Repeat Interval (minutes)
|
||||
description: >
|
||||
Time between repeated unload reminders.
|
||||
Only used when Repeat Count > 1.
|
||||
default: 15
|
||||
selector:
|
||||
number:
|
||||
min: 5
|
||||
max: 120
|
||||
unit_of_measurement: minutes
|
||||
mode: slider
|
||||
|
||||
unload_reminder_door_sensors:
|
||||
name: Unload Reminder Door Sensors (optional)
|
||||
description: >
|
||||
Optional list of door / lid binary sensors. When any of them
|
||||
opens after the cycle completes, pending unload reminders are
|
||||
cancelled (treated as "user has unloaded").
|
||||
Leave empty to keep reminders purely time-based.
|
||||
default: []
|
||||
selector:
|
||||
entity:
|
||||
domain: binary_sensor
|
||||
multiple: true
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Persistent State Configuration
|
||||
# -------------------------------------------------------------------------
|
||||
@@ -143,6 +204,17 @@ blueprint:
|
||||
text:
|
||||
multiline: true
|
||||
|
||||
message_unload_reminder:
|
||||
name: "Unload Reminder Message"
|
||||
description: >
|
||||
Reminder sent after the cycle completes if the appliance has not
|
||||
been unloaded within the configured delay.
|
||||
Variables: `{appliance_name}`, `{minutes}` (elapsed since completion)
|
||||
default: "🧺 {appliance_name}: прошло {minutes} мин. — пора достать вещи!"
|
||||
selector:
|
||||
text:
|
||||
multiline: true
|
||||
|
||||
message_almost_done:
|
||||
name: "Almost Done Message"
|
||||
description: >
|
||||
@@ -389,6 +461,18 @@ trigger:
|
||||
entity_id: !input power_sensor
|
||||
id: "power_update"
|
||||
|
||||
# Periodic tick for time-based checks (e.g., unload reminder)
|
||||
- platform: time_pattern
|
||||
minutes: "/1"
|
||||
id: "reminder_tick"
|
||||
|
||||
# Door / lid opened after completion (cancels unload reminder)
|
||||
# Note: Uses multiple selector, so empty list means trigger is skipped
|
||||
- platform: state
|
||||
entity_id: !input unload_reminder_door_sensors
|
||||
to: 'on'
|
||||
id: "door_opened"
|
||||
|
||||
# =============================================================================
|
||||
# CONDITIONS
|
||||
# =============================================================================
|
||||
@@ -412,6 +496,8 @@ variables:
|
||||
state_cycle_start_time: 'cst' # Cycle start timestamp
|
||||
state_energy_samples: 'esmp' # Energy sample accumulator (Wh)
|
||||
state_last_sample_time: 'lst' # Last power sample timestamp
|
||||
state_cycle_completion_time: 'cct' # Cycle completion timestamp (for unload reminder)
|
||||
state_unload_reminder_count: 'urc' # Number of unload reminders already sent
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Input Variables
|
||||
@@ -431,6 +517,10 @@ variables:
|
||||
show_estimated_end_time: !input show_estimated_end_time
|
||||
power_sensor: !input power_sensor
|
||||
energy_cost_per_kwh: !input energy_cost_per_kwh
|
||||
unload_reminder_delay: !input unload_reminder_delay
|
||||
unload_reminder_repeat_count: !input unload_reminder_repeat_count
|
||||
unload_reminder_repeat_interval: !input unload_reminder_repeat_interval
|
||||
unload_reminder_door_sensors: !input unload_reminder_door_sensors
|
||||
enable_debug_notifications: !input enable_debug_notifications
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -438,6 +528,7 @@ variables:
|
||||
# ---------------------------------------------------------------------------
|
||||
message_start_template: !input message_start
|
||||
message_completed_template: !input message_completed
|
||||
message_unload_reminder_template: !input message_unload_reminder
|
||||
message_almost_done_template: !input message_almost_done
|
||||
message_preparation_template: !input message_preparation
|
||||
message_error_template: !input message_error
|
||||
@@ -619,7 +710,9 @@ action:
|
||||
state_notification_about_start_sent: true,
|
||||
state_cycle_start_time: now().isoformat(),
|
||||
state_energy_samples: 0,
|
||||
state_last_sample_time: now().isoformat()
|
||||
state_last_sample_time: now().isoformat(),
|
||||
state_cycle_completion_time: '',
|
||||
state_unload_reminder_count: 0
|
||||
})) %}
|
||||
{{ automation_state_global | combine({ automation_state_key: new_automation_state }) | tojson }}
|
||||
|
||||
@@ -686,13 +779,17 @@ action:
|
||||
entity_id: "{{ automation_state_entity }}"
|
||||
data:
|
||||
value: >
|
||||
{% set any_door_open = (unload_reminder_door_sensors | select('is_state', 'on') | list | length) > 0 %}
|
||||
{% set arm_reminder = (unload_reminder_delay | int(0)) > 0 and not any_door_open %}
|
||||
{% set new_automation_state = (automation_state | combine({
|
||||
state_notification_about_remaining_time_sent: false,
|
||||
state_notification_about_start_sent: false,
|
||||
state_notification_about_preparation_sent: false,
|
||||
state_notification_about_pause_sent: false,
|
||||
state_was_paused: false,
|
||||
state_energy_samples: 0
|
||||
state_energy_samples: 0,
|
||||
state_cycle_completion_time: (now().isoformat() if arm_reminder else ''),
|
||||
state_unload_reminder_count: 0
|
||||
})) %}
|
||||
{{ automation_state_global | combine({ automation_state_key: new_automation_state }) | tojson }}
|
||||
|
||||
@@ -973,3 +1070,100 @@ action:
|
||||
state_last_sample_time: now().isoformat()
|
||||
})) %}
|
||||
{{ automation_state_global | combine({ automation_state_key: new_automation_state }) | tojson }}
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# CASE 10: Unload Reminder
|
||||
# -----------------------------------------------------------------------
|
||||
# Triggered when: Cycle completion timestamp is recorded and the
|
||||
# configured delay (plus any repeat intervals) has elapsed without a
|
||||
# new cycle starting. CASE 1 clears the timestamp on a new cycle.
|
||||
# Action: Remind the user to unload the appliance.
|
||||
- conditions:
|
||||
- condition: template
|
||||
value_template: >
|
||||
{%- set cct_val = automation_state.get(state_cycle_completion_time, '') -%}
|
||||
{%- set count_val = automation_state.get(state_unload_reminder_count, 0) | int(0) -%}
|
||||
{{ (unload_reminder_delay | int(0)) > 0
|
||||
and cct_val not in ['', 'unknown', 'unavailable', 'none']
|
||||
and count_val < (unload_reminder_repeat_count | int(1))
|
||||
and ((now() - (cct_val | as_datetime)).total_seconds() / 60)
|
||||
>= ((unload_reminder_delay | int(0)) + count_val * (unload_reminder_repeat_interval | int(0))) }}
|
||||
sequence:
|
||||
- variables:
|
||||
cct_val: "{{ automation_state.get(state_cycle_completion_time, '') }}"
|
||||
count_val: "{{ automation_state.get(state_unload_reminder_count, 0) | int(0) }}"
|
||||
new_reminder_count: "{{ (count_val | int(0)) + 1 }}"
|
||||
elapsed_minutes: >
|
||||
{{ ((now() - (cct_val | as_datetime)).total_seconds() / 60) | round(0) | int }}
|
||||
# Render the message template with available variables
|
||||
message: >
|
||||
{% set tpl = message_unload_reminder_template %}
|
||||
{{ tpl | replace('{appliance_name}', appliance_name)
|
||||
| replace('{minutes}', elapsed_minutes | string) }}
|
||||
|
||||
# Send unload reminder notification
|
||||
- service: notify.send_message
|
||||
target:
|
||||
entity_id: !input notify_target
|
||||
data:
|
||||
message: "{{ message }}"
|
||||
|
||||
# Increment reminder count; clear completion time when last reminder is sent
|
||||
- service: input_text.set_value
|
||||
target:
|
||||
entity_id: "{{ automation_state_entity }}"
|
||||
data:
|
||||
value: >
|
||||
{% set new_automation_state = (automation_state | combine({
|
||||
state_unload_reminder_count: new_reminder_count | int(0),
|
||||
state_cycle_completion_time: ('' if (new_reminder_count | int(0)) >= (unload_reminder_repeat_count | int(1)) else cct_val)
|
||||
})) %}
|
||||
{{ automation_state_global | combine({ automation_state_key: new_automation_state }) | tojson }}
|
||||
|
||||
# Debug notification for unload reminder
|
||||
- choose:
|
||||
- conditions: "{{ enable_debug_notifications }}"
|
||||
sequence:
|
||||
- service: persistent_notification.create
|
||||
data:
|
||||
title: "{appliance_name} - UNLOAD REMINDER"
|
||||
message: >
|
||||
Action: UNLOAD REMINDER
|
||||
Time: {{ now().strftime('%H:%M:%S') }}
|
||||
Reminder #: {{ new_reminder_count }} of {{ unload_reminder_repeat_count }}
|
||||
Elapsed: {{ elapsed_minutes }} min
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# CASE 11: Door Opened After Completion (cancel unload reminder)
|
||||
# -----------------------------------------------------------------------
|
||||
# Triggered when: A configured door / lid sensor opens while a cycle
|
||||
# completion timestamp is pending. Treated as "user has unloaded".
|
||||
# Action: Clear cct and urc so no further reminders fire.
|
||||
- conditions:
|
||||
- condition: template
|
||||
value_template: >
|
||||
{{ trigger.id == 'door_opened'
|
||||
and automation_state.get(state_cycle_completion_time, '') not in ['', 'unknown', 'unavailable', 'none'] }}
|
||||
sequence:
|
||||
- service: input_text.set_value
|
||||
target:
|
||||
entity_id: "{{ automation_state_entity }}"
|
||||
data:
|
||||
value: >
|
||||
{% set new_automation_state = (automation_state | combine({
|
||||
state_cycle_completion_time: '',
|
||||
state_unload_reminder_count: 0
|
||||
})) %}
|
||||
{{ automation_state_global | combine({ automation_state_key: new_automation_state }) | tojson }}
|
||||
|
||||
# Debug notification for door-cancelled reminder
|
||||
- choose:
|
||||
- conditions: "{{ enable_debug_notifications }}"
|
||||
sequence:
|
||||
- service: persistent_notification.create
|
||||
data:
|
||||
title: "{appliance_name} - UNLOAD DETECTED"
|
||||
message: >
|
||||
Action: UNLOAD REMINDER CANCELLED
|
||||
Time: {{ now().strftime('%H:%M:%S') }}
|
||||
Door: {{ trigger.entity_id }}
|
||||
|
||||
@@ -14,11 +14,13 @@ A collection of automation blueprints for Home Assistant.
|
||||
| Home Presence | Determines home presence from multiple signals |
|
||||
| Immich Album Watcher | Sends notifications when photos are added to Immich albums |
|
||||
| Motion Light | Smart motion sensor light control |
|
||||
| Presence Scene Controller | Per-room presence-aware time-of-day scene controller |
|
||||
| Refrigerator | Monitors refrigerator temperature and triggers express mode |
|
||||
| Telegram Commands | Responds to Telegram bot commands with callback actions |
|
||||
| Telegram Question | Sends Telegram messages with inline keyboard buttons |
|
||||
| Thermostat Controller | Controls thermostat based on schedules, presence, and window sensors |
|
||||
| Time Of Day Controller | Sets input_select based on time-of-day thresholds |
|
||||
| Time Of Day Selector | Sets an input_select state when a configured time entity fires |
|
||||
| Track Abnormal Plug Activity | Monitors power sensors for sustained overconsumption |
|
||||
| Washing Machine | Sends notifications for washing machine events |
|
||||
|
||||
|
||||
@@ -4,19 +4,42 @@ Controls lights and switches using Zigbee2MQTT button devices with multiple acti
|
||||
|
||||
## Features
|
||||
|
||||
- Map multiple action IDs to different lights/switches
|
||||
- Supports light, switch, and input_boolean entities
|
||||
- Visual feedback via blink indication when pressing already-active light
|
||||
- Tracks last interacted entity in an input_text helper
|
||||
- Supports multiple MQTT topics (multiple buttons)
|
||||
- Map multiple action IDs to different lights/switches by index position
|
||||
- Supports `light`, `switch`, and `input_boolean` entities
|
||||
- Visual feedback via blink indication when pressing an already-active light after an idle timeout
|
||||
- Optionally tracks last interacted entity in an `input_text` helper
|
||||
- Supports two MQTT topics for two physical buttons
|
||||
- Optional debug notifications for troubleshooting
|
||||
|
||||
## How It Works
|
||||
|
||||
1. Receives MQTT messages with action IDs from Zigbee buttons
|
||||
2. Maps action ID to corresponding entity by index position
|
||||
3. Toggles the matched entity (light/switch/input_boolean)
|
||||
4. Optionally blinks light if it's already on (idle timeout feature)
|
||||
1. Receives MQTT messages from one or two Zigbee buttons.
|
||||
2. Reads `action` from the payload and looks it up in the configured **Action IDs** list.
|
||||
3. The matched index is used to pick the corresponding entity from the **Switches** list.
|
||||
4. Toggles the matched entity (`light` / `switch` / `input_boolean`).
|
||||
5. For lights, if the light is already on and has been on for longer than the configured idle timeout, the light is briefly blinked instead of toggled, to indicate it is already active.
|
||||
6. If an `input_text` helper is configured, the matched entity ID is written to it.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Input | Description |
|
||||
| --- | --- |
|
||||
| **MQTT Topic** | Primary topic (e.g. `zigbee2mqtt/my_button1`). |
|
||||
| **MQTT Topic 2 (Optional)** | Second topic. Leave the placeholder to disable the second trigger. |
|
||||
| **Action IDs** | Ordered list of action strings emitted by the button (e.g. `single`, `double`, `hold`). |
|
||||
| **Switches** | Entities matched to **Action IDs** by index — index `0` maps to action `0`, and so on. |
|
||||
| **Last Interacted Entity Text Helper (optional)** | `input_text` that receives the last toggled entity ID. Leave empty to skip. |
|
||||
| **Timeout for indicator blink** | Idle seconds after which a press on an already-on light blinks instead of toggling. `0` disables blinking. |
|
||||
| **Count of blinks** | Number of blink cycles. `0` also disables blinking (every press just toggles). |
|
||||
| **Interval between blinks** | Delay between turn off / turn on in milliseconds. |
|
||||
| **Enable Debug Notifications** | When on, posts a persistent notification per trigger and per light-branch decision. |
|
||||
|
||||
The number of **Action IDs** should match the number of **Switches**. Out-of-range or unknown actions are ignored safely.
|
||||
|
||||
## Debug Mode
|
||||
|
||||
Enable **Enable Debug Notifications** to surface the resolved action, target index, target entity, and (for lights) the elapsed-time / blink decision in Home Assistant's persistent notifications panel. Disable when not troubleshooting to avoid notification noise.
|
||||
|
||||
## Author
|
||||
|
||||
Alexei Dolgolyov (dolgolyov.alexei@gmail.com)
|
||||
Alexei Dolgolyov (<dolgolyov.alexei@gmail.com>)
|
||||
|
||||
@@ -6,10 +6,13 @@
|
||||
|
||||
blueprint:
|
||||
name: "Custom: MQTT Button Control"
|
||||
description: Control a Zigbee2MQTT device with multiple actions, that allows to toggle lights and switches and store entity identifier of last interacted switch/light.
|
||||
description: >
|
||||
Control a Zigbee2MQTT device with multiple actions: toggle lights,
|
||||
switches and input_booleans, and optionally remember the entity that
|
||||
was last interacted with in an input_text helper.
|
||||
domain: automation
|
||||
input:
|
||||
|
||||
|
||||
mqtt_group:
|
||||
name: "MQTT"
|
||||
collapsed: false
|
||||
@@ -19,7 +22,7 @@ blueprint:
|
||||
description: The MQTT topic for your Zigbee button (e.g., zigbee2mqtt/my_button1).
|
||||
selector:
|
||||
text:
|
||||
|
||||
|
||||
mqtt_topic2:
|
||||
name: MQTT Topic 2 (Optional)
|
||||
description: >
|
||||
@@ -28,7 +31,7 @@ blueprint:
|
||||
default: "blueprint/disabled/mqtt_button_control"
|
||||
selector:
|
||||
text:
|
||||
|
||||
|
||||
devices:
|
||||
name: "Primary"
|
||||
collapsed: false
|
||||
@@ -40,19 +43,19 @@ blueprint:
|
||||
selector:
|
||||
text:
|
||||
multiple: true
|
||||
|
||||
|
||||
switches:
|
||||
name: Switches
|
||||
description: "The list of switches to control. Next types are supported: `light`, `switch`, `input_boolean`"
|
||||
default: []
|
||||
selector:
|
||||
entity:
|
||||
domain:
|
||||
domain:
|
||||
- light
|
||||
- switch
|
||||
- input_boolean
|
||||
multiple: true
|
||||
|
||||
multiple: true
|
||||
|
||||
outputs:
|
||||
name: "Outputs"
|
||||
collapsed: false
|
||||
@@ -64,7 +67,7 @@ blueprint:
|
||||
selector:
|
||||
entity:
|
||||
domain: input_text
|
||||
|
||||
|
||||
common:
|
||||
name: "Common"
|
||||
collapsed: false
|
||||
@@ -78,8 +81,8 @@ blueprint:
|
||||
min: 0
|
||||
max: 100
|
||||
step: 1
|
||||
unit_of_measurement: "s"
|
||||
|
||||
unit_of_measurement: "s"
|
||||
|
||||
blink_count:
|
||||
name: Count of blinks
|
||||
description: "Count of blinks to indicate active light"
|
||||
@@ -88,8 +91,8 @@ blueprint:
|
||||
number:
|
||||
min: 0
|
||||
max: 5
|
||||
step: 1
|
||||
|
||||
step: 1
|
||||
|
||||
blink_interval:
|
||||
name: Interval between blinks
|
||||
description: "Interval between indicator blinks (in ms)"
|
||||
@@ -98,18 +101,36 @@ blueprint:
|
||||
number:
|
||||
min: 0
|
||||
max: 1000
|
||||
step: 50
|
||||
unit_of_measurement: "ms"
|
||||
step: 50
|
||||
unit_of_measurement: "ms"
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Debug
|
||||
# -------------------------------------------------------------------------
|
||||
debug_group:
|
||||
name: "Debug"
|
||||
collapsed: true
|
||||
input:
|
||||
enable_debug_notifications:
|
||||
name: Enable Debug Notifications
|
||||
description: >
|
||||
Send persistent notifications for debugging automation behavior.
|
||||
Shows received action, resolved target entity and blink decision.
|
||||
default: false
|
||||
selector:
|
||||
boolean:
|
||||
|
||||
trigger:
|
||||
- platform: mqtt
|
||||
topic: !input mqtt_topic
|
||||
id: button_1
|
||||
- platform: mqtt
|
||||
topic: !input mqtt_topic2
|
||||
enabled: "{{ mqtt_topic2 != '' }}"
|
||||
|
||||
id: button_2
|
||||
enabled: "{{ mqtt_topic2 not in ['', 'blueprint/disabled/mqtt_button_control'] }}"
|
||||
|
||||
mode: restart
|
||||
|
||||
|
||||
condition:
|
||||
- condition: template
|
||||
value_template: "{{ 'action' in trigger.payload_json }}"
|
||||
@@ -117,21 +138,59 @@ condition:
|
||||
action:
|
||||
- variables:
|
||||
action_id: "{{ trigger.payload_json.action }}"
|
||||
|
||||
|
||||
switches: !input switches
|
||||
action_ids: !input action_ids
|
||||
|
||||
target_index: >
|
||||
{% if action_id in action_ids %}
|
||||
{{ action_ids.index(action_id) }}
|
||||
{% else %}
|
||||
-1
|
||||
{% endif %}
|
||||
target_entity: "{{ switches[target_index] if target_index != -1 else none }}"
|
||||
last_interacted_text: !input last_interacted_text
|
||||
|
||||
is_debug: false
|
||||
|
||||
is_debug: !input enable_debug_notifications
|
||||
|
||||
# Resolve action_id → switch index. Whitespace-controlled and coerced
|
||||
# to int so subscripting `switches[target_index]` is safe.
|
||||
target_index: >-
|
||||
{%- if action_id in action_ids -%}
|
||||
{{ action_ids.index(action_id) }}
|
||||
{%- else -%}
|
||||
-1
|
||||
{%- endif -%}
|
||||
# Bounds-check the index: action_ids may have more entries than switches.
|
||||
target_entity: >-
|
||||
{%- set idx = target_index | int(-1) -%}
|
||||
{%- if idx >= 0 and idx < (switches | length) -%}
|
||||
{{ switches[idx] }}
|
||||
{%- else -%}
|
||||
{{ none }}
|
||||
{%- endif -%}
|
||||
has_last_interacted_text: "{{ last_interacted_text is string and last_interacted_text | trim != '' }}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Debug: trigger received
|
||||
# ---------------------------------------------------------------------------
|
||||
- choose:
|
||||
- conditions:
|
||||
- condition: template
|
||||
value_template: "{{ is_debug }}"
|
||||
sequence:
|
||||
- service: persistent_notification.create
|
||||
data:
|
||||
notification_id: "mqtt_button_control_debug"
|
||||
title: "MQTT Button Control Debug"
|
||||
message: >
|
||||
Trigger: {{ trigger.id }}
|
||||
|
||||
Topic: {{ trigger.topic }}
|
||||
|
||||
action_id: {{ action_id }}
|
||||
|
||||
action_ids: {{ action_ids }}
|
||||
|
||||
switches: {{ switches }}
|
||||
|
||||
target_index: {{ target_index | int(-1) }}
|
||||
|
||||
target_entity: {{ target_entity if target_entity is not none else 'none' }}
|
||||
|
||||
has_last_interacted_text: {{ has_last_interacted_text }}
|
||||
|
||||
- choose:
|
||||
- conditions:
|
||||
- condition: template
|
||||
@@ -139,12 +198,18 @@ action:
|
||||
sequence:
|
||||
- variables:
|
||||
entity_type: "{{ target_entity.split('.')[0] }}"
|
||||
|
||||
- service: input_text.set_value
|
||||
data:
|
||||
entity_id: "{{ last_interacted_text }}"
|
||||
value: "{{ target_entity }}"
|
||||
|
||||
|
||||
# Persist last interacted entity only when a helper is configured.
|
||||
- choose:
|
||||
- conditions:
|
||||
- condition: template
|
||||
value_template: "{{ has_last_interacted_text }}"
|
||||
sequence:
|
||||
- service: input_text.set_value
|
||||
data:
|
||||
entity_id: "{{ last_interacted_text }}"
|
||||
value: "{{ target_entity }}"
|
||||
|
||||
- choose:
|
||||
# Light
|
||||
- conditions:
|
||||
@@ -153,14 +218,29 @@ action:
|
||||
sequence:
|
||||
- variables:
|
||||
timeout_for_indication_blink: !input timeout_for_indication_blink
|
||||
seconds_elapsed: >
|
||||
{{ (as_timestamp(now()) - as_timestamp(states[target_entity].last_changed)) | int }}
|
||||
should_blink: "{{ timeout_for_indication_blink != 0 and seconds_elapsed > timeout_for_indication_blink }}"
|
||||
blink_count: !input blink_count
|
||||
blink_timeout: !input blink_interval
|
||||
is_light_on: "{{ is_state(target_entity, 'on') }}"
|
||||
|
||||
# Debug
|
||||
# Inline state access — HA stringifies State objects when
|
||||
# stored in `variables:`, so `.last_changed` must be read
|
||||
# within a single template render.
|
||||
seconds_elapsed: >-
|
||||
{%- set s = states[target_entity] -%}
|
||||
{%- if s is not none -%}
|
||||
{{ (as_timestamp(now()) - as_timestamp(s.last_changed)) | int(0) }}
|
||||
{%- else -%}
|
||||
0
|
||||
{%- endif -%}
|
||||
# Blink only when both the idle timeout AND a positive
|
||||
# blink count are configured; otherwise fall through to a
|
||||
# plain toggle (a 0 count would otherwise run an empty
|
||||
# repeat and leave the light untouched).
|
||||
should_blink: >-
|
||||
{{ timeout_for_indication_blink != 0
|
||||
and (blink_count | int(0)) > 0
|
||||
and (seconds_elapsed | int(0)) > timeout_for_indication_blink }}
|
||||
|
||||
# Debug: light branch state
|
||||
- choose:
|
||||
- conditions:
|
||||
- condition: template
|
||||
@@ -168,13 +248,25 @@ action:
|
||||
sequence:
|
||||
- service: persistent_notification.create
|
||||
data:
|
||||
title: "Debug Info"
|
||||
notification_id: "mqtt_button_control_debug_light"
|
||||
title: "MQTT Button Control Debug (light)"
|
||||
message: >
|
||||
seconds_elapsed = {{ seconds_elapsed }},
|
||||
should_blink = {{ should_blink }}
|
||||
|
||||
target_entity: {{ target_entity }}
|
||||
|
||||
is_light_on: {{ is_light_on }}
|
||||
|
||||
seconds_elapsed: {{ seconds_elapsed }}
|
||||
|
||||
timeout_for_indication_blink: {{ timeout_for_indication_blink }}
|
||||
|
||||
should_blink: {{ should_blink }}
|
||||
|
||||
blink_count: {{ blink_count }}
|
||||
|
||||
blink_timeout (ms): {{ blink_timeout }}
|
||||
|
||||
- choose:
|
||||
# Blink
|
||||
# Blink (indicate light is already on after idle timeout)
|
||||
- conditions:
|
||||
- condition: template
|
||||
value_template: "{{ should_blink and is_light_on }}"
|
||||
@@ -182,7 +274,6 @@ action:
|
||||
- repeat:
|
||||
count: "{{ blink_count }}"
|
||||
sequence:
|
||||
|
||||
- service: light.turn_off
|
||||
target:
|
||||
entity_id: "{{ target_entity }}"
|
||||
@@ -190,7 +281,7 @@ action:
|
||||
transition: 0
|
||||
- delay:
|
||||
milliseconds: "{{ blink_timeout }}"
|
||||
|
||||
|
||||
- service: light.turn_on
|
||||
target:
|
||||
entity_id: "{{ target_entity }}"
|
||||
@@ -198,13 +289,13 @@ action:
|
||||
transition: 0
|
||||
- delay:
|
||||
milliseconds: "{{ blink_timeout }}"
|
||||
|
||||
# Actually toggle
|
||||
|
||||
# Actually toggle
|
||||
default:
|
||||
- service: light.toggle
|
||||
target:
|
||||
entity_id: "{{ target_entity }}"
|
||||
|
||||
|
||||
# Switch
|
||||
- conditions:
|
||||
- condition: template
|
||||
@@ -213,7 +304,7 @@ action:
|
||||
- service: switch.toggle
|
||||
target:
|
||||
entity_id: "{{ target_entity }}"
|
||||
|
||||
|
||||
# Input Boolean
|
||||
- conditions:
|
||||
- condition: template
|
||||
@@ -221,4 +312,4 @@ action:
|
||||
sequence:
|
||||
- service: input_boolean.toggle
|
||||
target:
|
||||
entity_id: "{{ target_entity }}"
|
||||
entity_id: "{{ target_entity }}"
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"version": "2.9.1"
|
||||
"version": "2.13.0"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user