Files
haos-blueprints/Common/Motion Light/blueprint.yaml
T
alexei.dolgolyov 34cf5b1f7a feat: harden Motion Light manual-override and turn-off recovery
- Add motion-off debounce to filter PIR drop-outs
- Treat pre-emptive manual turn-on (from idle) as MANUAL mode
- Recover from external turn-off while motion still active
- Run enable/disable callbacks before turn-on/off for mode:restart safety
- Document brightness-threshold interaction and input_text max_length
2026-05-27 13:09:50 +03:00

1610 lines
70 KiB
YAML

# Motion Light Automation Blueprint
# Smart motion-activated light control with manual override detection.
# See README.md for detailed documentation.
blueprint:
name: "Custom: Motion Light"
description: >
Smart motion sensor automation blueprint.
Note: By default will not run if light was already ON. If light was turned
ON during automation running, the automation will enter manual state and
will not trigger until the light is turned off.
Note: Not tested when motion sensors and state sensors are used at the same time.
domain: automation
# ===========================================================================
# INPUT CONFIGURATION
# ===========================================================================
input:
# -------------------------------------------------------------------------
# Motion & Condition Sensors
# -------------------------------------------------------------------------
controls:
name: "Controls"
collapsed: false
input:
motion_sensors:
name: Motion sensors
description: >
Select one or more motion sensors. Light turns ON if ANY sensor
detects motion (OR logic).
default: []
selector:
entity:
domain:
- binary_sensor
- switch
- group
- light
- input_boolean
multiple: true
condition_switches:
name: Condition switches
description: >
Automation will not trigger if ANY of these switches is OFF.
All must be ON for the automation to work (AND logic).
default: []
selector:
entity:
domain:
- input_boolean
- switch
- group
- light
- binary_sensor
multiple: true
# -------------------------------------------------------------------------
# Target Devices
# -------------------------------------------------------------------------
devices_group:
name: "Devices"
collapsed: false
input:
target_lights:
name: Target Lights (optional)
description: One or more lights to control
default: []
selector:
entity:
domain: light
multiple: true
target_switches:
name: Target Switches (optional)
description: One or more switches to control
default: []
selector:
entity:
domain: switch
multiple: true
target_light_group:
name: Target Light Group (optional)
description: A light group entity to control as a single unit
default: null
selector:
entity:
domain: light
target_area:
name: Target Area (optional)
description: >
Pick an area. All lights and switches in the area will be
discovered and controlled.
default:
selector:
area: {}
target_light_data:
name: Light Data Dictionary (optional)
default: {}
description: >
Provide a YAML dictionary of light.turn_on parameters.
If not specified, the light's last settings are preserved.
Example:
brightness: 200
color_temp_kelvin: 4000
rgb_color: [255, 0, 0]
selector:
object: {}
brightness_threshold:
name: Brightness Threshold
description: >
Automation only triggers if the light's current brightness is
below this value. Set to 0 to disable this check.
default: 0
selector:
number:
min: 0
max: 255
step: 1
transition_duration:
name: Transition Duration
description: >
Duration in seconds for smooth light transitions.
Set to 0 for instant changes.
default: 1
selector:
number:
min: 0
max: 10
step: 0.5
unit_of_measurement: "s"
timeout_delay:
name: Timeout Delay (seconds)
description: >
Delay before turning off the light after all motion sensors
clear. Set to 0 for immediate turn off.
default: 0
selector:
number:
min: 0
max: 3600
step: 5
unit_of_measurement: seconds
min_on_duration:
name: Minimum On Duration (seconds)
description: >
Light must stay on for at least this duration even if motion
clears. Prevents rapid on/off cycling.
default: 10
selector:
number:
min: 0
max: 300
step: 5
unit_of_measurement: "seconds"
# -------------------------------------------------------------------------
# Persistent State Configuration
# -------------------------------------------------------------------------
persistent_state:
name: "Persistent State"
collapsed: true
input:
automation_state_entity:
name: Automation state entity
description: >
`input_text` entity that stores the automation state in JSON format.
Required for manual override detection and state tracking.
Doesn't require specific initial state - values can be empty.
Each automation instance must have its own entity.
selector:
entity:
domain: input_text
automation_state_placeholder_key:
name: Automation state placeholder key
description: >
Overrides key for persistent storage if not empty.
By default uses the target light/switch entity ID.
Don't override if you don't understand the meaning.
default: ''
selector:
text:
# -------------------------------------------------------------------------
# Luminance Control
# -------------------------------------------------------------------------
luminance:
name: "Luminance"
collapsed: true
input:
luminance_sensor:
name: Luminance sensor (optional)
description: Sensor reporting ambient light level (lux)
default: null
selector:
entity:
domain: sensor
luminance_threshold:
name: Luminance threshold (optional)
description: >
Light will only turn on if luminance sensor value is below
this threshold (darker than this level).
default: 50
selector:
number:
min: 0
max: 10000
step: 5
unit_of_measurement: "lux"
luminance_enable_switch:
name: Luminance control enable switch (optional)
description: >
Switch or input_boolean to enable/disable luminance-based control.
When OFF, luminance check is skipped.
default: null
selector:
entity:
domain:
- switch
- input_boolean
# -------------------------------------------------------------------------
# Time-Based Control
# -------------------------------------------------------------------------
time_based_control:
name: "Time-Based Control"
collapsed: true
input:
enable_time_condition:
name: Enable Time Condition
description: Only allow automation during specified time window
default: false
selector:
boolean:
time_after:
name: Active After
description: Automation only active after this time
default: "00:00:00"
selector:
time:
time_before:
name: Active Before
description: Automation only active before this time
default: "23:59:59"
selector:
time:
# -------------------------------------------------------------------------
# Day/Night Settings
# -------------------------------------------------------------------------
day_night_settings:
name: "Day/Night Settings"
collapsed: true
input:
enable_day_night_mode:
name: Enable Day/Night Mode
description: Use different light settings based on time of day
default: false
selector:
boolean:
night_mode_after:
name: Night Mode After
description: Switch to night settings after this time
default: "22:00:00"
selector:
time:
night_mode_before:
name: Night Mode Before
description: Switch to day settings after this time
default: "06:00:00"
selector:
time:
day_light_data:
name: Day Light Settings
description: Light parameters during day mode (YAML dictionary)
default: {}
selector:
object: {}
night_light_data:
name: Night Light Settings
description: Light parameters during night mode (YAML dictionary)
default: {}
selector:
object: {}
# -------------------------------------------------------------------------
# Scene Support
# -------------------------------------------------------------------------
scene_support:
name: "Scene Support"
collapsed: true
input:
use_scene_instead:
name: Use Scene Instead of Light Data
description: Activate a scene instead of setting light parameters
default: false
selector:
boolean:
scene_entity:
name: Scene Entity
description: Scene to activate when motion detected
default: null
selector:
entity:
domain: scene
night_scene_entity:
name: Night Scene Entity (optional)
description: Scene to activate during night mode (if day/night enabled)
default: null
selector:
entity:
domain: scene
# -------------------------------------------------------------------------
# Dim Before Off
# -------------------------------------------------------------------------
dim_before_off:
name: "Dim Before Off"
collapsed: true
input:
enable_dim_before_off:
name: Enable Dim Before Off
description: Gradually dim light before turning off completely
default: false
selector:
boolean:
dim_brightness:
name: Dim Brightness Level
description: Brightness to dim to before turning off (1-255)
default: 25
selector:
number:
min: 1
max: 255
step: 5
dim_duration:
name: Dim Duration (seconds)
description: How long to stay dimmed before turning off
default: 5
selector:
number:
min: 1
max: 60
step: 1
unit_of_measurement: "seconds"
# -------------------------------------------------------------------------
# Motion Sensor Settings
# -------------------------------------------------------------------------
motion_settings:
name: "Motion Sensor Settings"
collapsed: true
input:
motion_debounce:
name: Motion Debounce (seconds)
description: >
Motion must be sustained for this duration before triggering.
Helps filter out brief false triggers. Set to 0 to disable.
default: 0
selector:
number:
min: 0
max: 30
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: >
After the automation turns on a light, ignore state changes for
this many seconds to avoid false manual override detection.
Some devices (especially Zigbee) report delayed state updates
that can be mistaken for manual control. Increase this value
if you see false manual overrides in the debug log.
default: 2
selector:
number:
min: 0
max: 30
step: 1
unit_of_measurement: "seconds"
# -------------------------------------------------------------------------
# Debug
# -------------------------------------------------------------------------
debug:
name: "Debug"
collapsed: true
input:
enable_debug_notifications:
name: Enable Debug Notifications
description: Send persistent notifications for debugging automation behavior
default: false
selector:
boolean:
# -------------------------------------------------------------------------
# Callback Actions
# -------------------------------------------------------------------------
actions:
name: "Actions"
collapsed: true
input:
user_condition:
name: Condition block
description: Optional condition(s) that must pass for actions to run
default: []
selector:
condition: {}
enable_action:
name: Enable callback action (optional)
description: Runs when light is turned ON by this automation
default: []
selector:
action: {}
disable_action:
name: Disable callback action (optional)
description: Runs when light is turned OFF by this automation
default: []
selector:
action: {}
manual_action_runs_disable_action:
name: Manual also runs disable action
description: >
If checked, entering manual mode will also execute the
disable callback action.
default: false
selector:
boolean: {}
manual_action:
name: Manual callback action (optional)
description: >
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:
action: {}
# =============================================================================
# AUTOMATION MODE
# =============================================================================
# Restart mode ensures rapid motion events don't queue up
mode: restart
# =============================================================================
# TRIGGERS
# =============================================================================
trigger:
# Motion sensors ON (with debounce)
- platform: state
entity_id: !input motion_sensors
to: "on"
for:
seconds: !input motion_debounce
id: "motion_sensor_on"
# 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
- platform: state
entity_id: !input condition_switches
# Light state changed (for manual override detection)
- platform: state
entity_id: !input target_lights
id: "light_state_changed"
# Switch state changed (for manual override detection)
- platform: state
entity_id: !input target_switches
id: "switch_state_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) | float(99999) < (luminance_threshold | float(50)) }}
{% else %}
false
{% endif %}
# Luminance enable switch turned ON (re-evaluate)
- platform: template
value_template: >
{% if luminance_enable_switch %}
{{ is_state(luminance_enable_switch, 'on') }}
{% else %}
false
{% endif %}
# =============================================================================
# CONDITIONS
# =============================================================================
condition: !input user_condition
# =============================================================================
# VARIABLES
# =============================================================================
variables:
# ---------------------------------------------------------------------------
# State Machine Constants
# ---------------------------------------------------------------------------
# These define the possible automation states stored in persistent storage
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
automation_state_manual: '3' # User took control, automation paused
# Persistent state JSON keys
state_motion_light_state: 'mls' # Current state machine state
state_motion_light_last_action_timestamp: 'mllat' # Last action timestamp
state_motion_light_last_brightness: 'mllb' # Brightness before automation
# ---------------------------------------------------------------------------
# Trigger Context
# ---------------------------------------------------------------------------
date_time_now: "{{ now() }}"
trigger_id: "{{ trigger.id }}"
# ---------------------------------------------------------------------------
# Input Variables
# ---------------------------------------------------------------------------
sensors: !input motion_sensors
condition_switches: !input condition_switches
timeout: !input timeout_delay
min_on_duration: !input min_on_duration
brightness_threshold: !input brightness_threshold
transition_duration: !input transition_duration
manual_override_grace_period: !input manual_override_grace_period
# ---------------------------------------------------------------------------
# Target Device Resolution
# ---------------------------------------------------------------------------
target_lights: !input target_lights
target_switches: !input target_switches
target_light_group: !input target_light_group
target_area: !input target_area
# Resolve all lights from direct selection, groups, and areas
resolved_all_lights: >-
{% set result = [] %}
{% if target_lights | length > 0 %}
{% set result = result + target_lights %}
{% endif %}
{% if target_light_group is not none %}
{% set result = result + [target_light_group] %}
{% endif %}
{% 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 %}
{% set seen = namespace(items=[]) %}
{% for item in result %}
{% if item not in seen.items %}
{% set seen.items = seen.items + [item] %}
{% endif %}
{% endfor %}
{{ seen.items }}
# Resolve all switches from direct selection and areas
resolved_all_switches: >-
{% set result = [] %}
{% if target_switches | length > 0 %}
{% set result = result + target_switches %}
{% endif %}
{% 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 %}
{{ result | unique | list }}
# Reference light for state checks (first available)
reference_light: "{{ resolved_all_lights[0] if resolved_all_lights | length > 0 else none }}"
# Check if any device is on (respects brightness_threshold)
any_device_on: >
{% set ns = namespace(lights_on=false) %}
{% for light in resolved_all_lights %}
{% if is_state(light, 'on') %}
{% if brightness_threshold | int(0) > 0 %}
{% if state_attr(light, 'brightness') | int(0) >= brightness_threshold | int(0) %}
{% set ns.lights_on = true %}
{% endif %}
{% else %}
{% set ns.lights_on = true %}
{% endif %}
{% endif %}
{% endfor %}
{% set switches_on = resolved_all_switches | select('is_state', 'on') | list | length > 0 %}
{{ ns.lights_on or switches_on }}
all_devices_off: "{{ not any_device_on }}"
# Legacy compatibility aliases
light_entity: "{{ reference_light }}"
switch_entity: "{{ resolved_all_switches[0] if resolved_all_switches | length > 0 else none }}"
# ---------------------------------------------------------------------------
# Persistent State Management
# ---------------------------------------------------------------------------
automation_state_entity: !input automation_state_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 %}
{% set parsed = text | from_json(default=dict()) %}
{{ parsed if parsed is mapping else dict() }}
{% endif %}
automation_state_placeholder_key: !input automation_state_placeholder_key
# Determine the key for this automation's state
automation_state_key: >
{% if automation_state_placeholder_key != '' %}
{{ automation_state_placeholder_key }}
{% elif switch_entity is not none %}
{{ switch_entity }}
{% elif light_entity is not none %}
{{ light_entity }}
{% else %}
{{ 'default_motion_light_placeholder' }}
{% endif %}
# Get this automation's state from global state
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) }}"
# 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 }}"
state_is_enabling: "{{ (motion_light_state | string) == automation_state_enabling }}"
state_is_manual: "{{ (motion_light_state | string) == automation_state_manual }}"
# ---------------------------------------------------------------------------
# Callback Actions
# ---------------------------------------------------------------------------
manual_action: !input manual_action
disable_action: !input disable_action
enable_action: !input enable_action
manual_action_runs_disable_action: !input manual_action_runs_disable_action
light_data: !input target_light_data
# ---------------------------------------------------------------------------
# Luminance Configuration
# ---------------------------------------------------------------------------
luminance_sensor: !input luminance_sensor
luminance_threshold: !input luminance_threshold
luminance_enable_switch: !input luminance_enable_switch
# Check if luminance conditions allow triggering
luminance_ok: >
{% if luminance_sensor is not none and luminance_threshold is not none %}
{% set val = states(luminance_sensor) | float(0) %}
{% set enabled = true %}
{% if luminance_enable_switch %}
{% set enabled = is_state(luminance_enable_switch, 'on') %}
{% endif %}
{{ enabled and val < luminance_threshold }}
{% else %}
{{ true }}
{% endif %}
# ---------------------------------------------------------------------------
# Time-Based Control
# ---------------------------------------------------------------------------
enable_time_condition: !input enable_time_condition
time_after: !input time_after
time_before: !input time_before
time_condition_ok: >
{% if not enable_time_condition %}
{{ true }}
{% else %}
{% set now_time = now().strftime('%H:%M:%S') %}
{% set after = time_after | string %}
{% set before = time_before | string %}
{% if after <= before %}
{{ after <= now_time <= before }}
{% else %}
{# Spans midnight (e.g., 22:00 to 06:00) #}
{{ now_time >= after or now_time <= before }}
{% endif %}
{% endif %}
# ---------------------------------------------------------------------------
# Day/Night Mode
# ---------------------------------------------------------------------------
enable_day_night_mode: !input enable_day_night_mode
night_mode_after: !input night_mode_after
night_mode_before: !input night_mode_before
day_light_data: !input day_light_data
night_light_data: !input night_light_data
is_night_mode: >
{% if not enable_day_night_mode %}
{{ false }}
{% else %}
{% set now_time = now().strftime('%H:%M:%S') %}
{% set after = night_mode_after | string %}
{% set before = night_mode_before | string %}
{% if after <= before %}
{{ after <= now_time <= before }}
{% else %}
{{ now_time >= after or now_time <= before }}
{% endif %}
{% endif %}
effective_light_data: >
{% if enable_day_night_mode %}
{{ night_light_data if is_night_mode else day_light_data }}
{% else %}
{{ light_data }}
{% endif %}
# ---------------------------------------------------------------------------
# Scene Support
# ---------------------------------------------------------------------------
use_scene_instead: !input use_scene_instead
scene_entity: !input scene_entity
night_scene_entity: !input night_scene_entity
effective_scene: >
{% if use_scene_instead %}
{% if enable_day_night_mode and is_night_mode and night_scene_entity is not none %}
{{ night_scene_entity }}
{% else %}
{{ scene_entity }}
{% endif %}
{% else %}
{{ none }}
{% endif %}
# ---------------------------------------------------------------------------
# Dim Before Off
# ---------------------------------------------------------------------------
enable_dim_before_off: !input enable_dim_before_off
dim_brightness: !input dim_brightness
dim_duration: !input dim_duration
# ---------------------------------------------------------------------------
# Debug
# ---------------------------------------------------------------------------
enable_debug_notifications: !input enable_debug_notifications
# ---------------------------------------------------------------------------
# Trigger Evaluation
# ---------------------------------------------------------------------------
# Check if ALL condition switches are ON (AND logic)
all_of_condition_switches_on: >
{% set e = condition_switches if condition_switches is iterable else [condition_switches] %}
{% if e | length == 0 %}
{{ true }}
{% else %}
{{ (e | select('is_state', 'on') | list | length) == (e | length) }}
{% endif %}
# Count how many motion sensors are currently detecting motion
count_of_enabled_sensor: >
{% set e = sensors if sensors is iterable else [sensors] %}
{{ e | select('is_state', 'on') | list | length }}
# Motion state checks
motion_on: "{{ count_of_enabled_sensor > 0 }}"
motion_all_off: "{{ count_of_enabled_sensor == 0 }}"
# ---------------------------------------------------------------------------
# Enable/Disable Decision Logic
# ---------------------------------------------------------------------------
# Should we enable the light? (All conditions must be met)
must_be_enabled_preview: >
{{ (all_of_condition_switches_on and luminance_ok and motion_on and time_condition_ok) | bool }}
must_be_enabled_guard: "{{ state_is_none }}"
must_be_enabled: >
{{ must_be_enabled_preview and must_be_enabled_guard }}
# Should we disable the light? (Motion cleared OR condition switch turned off)
must_be_disabled_preview: >
{{ ((not all_of_condition_switches_on) or motion_all_off) | bool }}
must_be_disabled_guard: "{{ state_is_enabled or state_is_enabling }}"
must_be_disabled: >
{{ must_be_disabled_preview and must_be_disabled_guard }}
# =============================================================================
# ACTIONS
# =============================================================================
action:
# ---------------------------------------------------------------------------
# DEBUG: Log entry state on every trigger (helps trace mode: restart issues)
# ---------------------------------------------------------------------------
- choose:
- conditions: "{{ enable_debug_notifications }}"
sequence:
- service: persistent_notification.create
data:
title: "Motion Light Debug - ENTRY"
message: >
=== Automation Triggered ===
Time: {{ now().strftime('%H:%M:%S.%f')[:12] }}
Trigger ID: {{ trigger_id }}
Trigger Entity: {{ trigger.entity_id | default('N/A') }}
Trigger From: {{ trigger.from_state.state | default('N/A') }}
Trigger To: {{ trigger.to_state.state | default('N/A') }}
=== State Machine ===
State: {{ motion_light_state }} (NONE=0, ENABLED=1, ENABLING=2, MANUAL=3)
state_is_none: {{ state_is_none }}
state_is_enabling: {{ state_is_enabling }}
state_is_enabled: {{ state_is_enabled }}
state_is_manual: {{ state_is_manual }}
=== Decision Variables ===
motion_on: {{ motion_on }} ({{ count_of_enabled_sensor }} sensors)
all_of_condition_switches_on: {{ all_of_condition_switches_on }}
luminance_ok: {{ luminance_ok }}
time_condition_ok: {{ time_condition_ok }}
any_device_on: {{ any_device_on }}
=== Enable/Disable Logic ===
must_be_enabled_preview: {{ must_be_enabled_preview }}
must_be_enabled_guard: {{ must_be_enabled_guard }}
must_be_enabled: {{ must_be_enabled }}
must_be_disabled_preview: {{ must_be_disabled_preview }}
must_be_disabled_guard: {{ must_be_disabled_guard }}
must_be_disabled: {{ must_be_disabled }}
=== Which CASE will match ===
CASE 1 (state changed): {{ trigger_id == 'light_state_changed' or trigger_id == 'switch_state_changed' }}
CASE 2 (enable): {{ must_be_enabled }}
CASE 3 (disable): {{ must_be_disabled }}
=== Grace Period ===
{%- 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)) -%}
last_action_ts: {{ last_ts | default('not set') }}
grace_period: {{ grace }}s
{%- if last_ts is not none -%}
{%- set parsed = last_ts | as_datetime -%}
{%- if parsed is not none %}
time_since_action: {{ (now() - parsed).total_seconds() | round(2) }}s
grace_expired: {{ (now() - parsed).total_seconds() > grace }}
{%- endif -%}
{%- endif %}
# ===========================================================================
# MAIN STATE MACHINE
# ===========================================================================
- choose:
# -----------------------------------------------------------------------
# CASE 1: Light/Switch State Changed (Manual Override Detection)
# -----------------------------------------------------------------------
# Handles state changes from the light/switch itself to detect
# when automation turned it on vs when user manually changed it
- conditions:
- condition: template
value_template: "{{ trigger_id == 'light_state_changed' or trigger_id == 'switch_state_changed' }}"
sequence:
# Debug: log which CASE 1 sub-case will fire
- choose:
- conditions: "{{ enable_debug_notifications }}"
sequence:
- service: persistent_notification.create
data:
title: "Motion Light Debug - CASE 1"
message: >
CASE 1: light/switch state changed
Time: {{ now().strftime('%H:%M:%S.%f')[:12] }}
Trigger: {{ trigger_id }}
Entity: {{ trigger.entity_id | default('N/A') }}
From: {{ trigger.from_state.state | default('N/A') }} → To: {{ trigger.to_state.state | default('N/A') }}
Meaningful change: {{ trigger.from_state.state != trigger.to_state.state }}
Light brightness: {{ state_attr(reference_light, 'brightness') | default('N/A') }}
State: {{ motion_light_state }}
state_is_enabling: {{ state_is_enabling }}
state_is_enabled: {{ state_is_enabled }}
must_be_disabled_preview: {{ must_be_disabled_preview }}
- choose:
# ----- Sub-case: Light/Switch turned OFF -----
# Reset to NONE state so automation can work again
- conditions:
- condition: template
value_template: >
{# 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 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,
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
# the automation was actively controlling it, wait briefly and
# re-enable if conditions are still met.
# Safety: state_is_enabled/state_is_enabling reflect the state at
# run start (before reset). When CASE 3 turns off the light, it
# sets state to NONE first, so on restart state_is_none=true and
# this block is skipped — preventing unwanted re-enable.
- choose:
- conditions:
- condition: template
value_template: "{{ state_is_enabled or state_is_enabling }}"
sequence:
# Wait for the off-transition to finish
- delay:
seconds: "{{ transition_duration }}"
# Fresh condition check (evaluates current entity states)
- condition: template
value_template: >
{% set e = sensors if sensors is iterable else [sensors] %}
{% set motion_active = e | select('is_state', 'on') | list | length > 0 %}
{% set cond_ok = true %}
{% set cs = condition_switches if condition_switches is iterable else [condition_switches] %}
{% if cs | length > 0 %}
{% set cond_ok = (cs | select('is_state', 'on') | list | length) == (cs | length) %}
{% endif %}
{{ motion_active and cond_ok }}
# Re-read state from input_text (may have changed during delay)
- variables:
re_eval_state_global: >
{% set text = states(automation_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 %}
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) }}"
# Only proceed if state is still NONE (no other run claimed it)
- condition: template
value_template: "{{ (re_eval_motion_light_state | string) == automation_state_none }}"
# --- Re-enable path (mirrors CASE 2) ---
# 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 }}"
data:
value: >
{% set new_automation_state = (re_eval_state | combine({
state_motion_light_state: automation_state_enabling,
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 }}
# 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
value_template: "{{ use_scene_instead and effective_scene is not none }}"
sequence:
- service: scene.turn_on
target:
entity_id: "{{ effective_scene }}"
data:
transition: "{{ transition_duration }}"
default:
# Turn ON lights
- choose:
- conditions:
- condition: template
value_template: "{{ resolved_all_lights | length > 0 }}"
sequence:
- service: light.turn_on
target:
entity_id: "{{ resolved_all_lights }}"
data: >
{% set d = effective_light_data if effective_light_data else {} %}
{% if transition_duration > 0 %}
{% set d = d | combine({'transition': transition_duration}) %}
{% endif %}
{{ d }}
# Turn ON switches
- choose:
- conditions:
- condition: template
value_template: "{{ resolved_all_switches | length > 0 }}"
sequence:
- service: switch.turn_on
target:
entity_id: "{{ resolved_all_switches }}"
# ----- Sub-case: Automation just turned on the light -----
# Transition from ENABLING to ENABLED, or disable immediately
# if motion already cleared during the ENABLING phase
- conditions:
- condition: template
value_template: "{{ state_is_enabling }}"
sequence:
- choose:
# If disable conditions are already met (motion cleared
# while light was still in ENABLING state), skip ENABLED
# and go straight to disable
- conditions:
- condition: template
value_template: "{{ must_be_disabled_preview }}"
sequence:
# Reset state to NONE
- 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 })) %}
{{ automation_state_global | combine({ automation_state_key: new_automation_state }) | tojson }}
# Turn OFF or restore lights
- choose:
- conditions:
- condition: template
value_template: "{{ resolved_all_lights | length > 0 }}"
sequence:
- variables:
last_brightness: "{{ automation_state.get(state_motion_light_last_brightness, 0) | int }}"
- choose:
- conditions:
- condition: template
value_template: "{{ last_brightness > 0 }}"
sequence:
- service: light.turn_on
target:
entity_id: "{{ resolved_all_lights }}"
data:
brightness: "{{ last_brightness }}"
transition: "{{ transition_duration }}"
default:
- service: light.turn_off
target:
entity_id: "{{ resolved_all_lights }}"
data:
transition: "{{ transition_duration }}"
# Turn OFF switches
- choose:
- conditions:
- condition: template
value_template: "{{ resolved_all_switches | length > 0 }}"
sequence:
- service: switch.turn_off
target:
entity_id: "{{ resolved_all_switches }}"
# Execute disable callback
- choose:
- conditions:
- condition: template
value_template: "{{ disable_action != [] }}"
sequence: !input disable_action
# Debug notification
- choose:
- conditions: "{{ enable_debug_notifications }}"
sequence:
- service: persistent_notification.create
data:
title: "Motion Light Debug"
message: >
Action: DISABLE (enabling interrupted)
Time: {{ now().strftime('%H:%M:%S') }}
Trigger: {{ trigger_id }}
# Normal case: motion still active, transition to ENABLED
default:
- 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_enabled })) %}
{{ automation_state_global | combine({ automation_state_key: new_automation_state }) | tojson }}
# ----- Sub-case: User manually changed the light -----
# 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.
# 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 }}
{% 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 last_ts is none %}
{{ true }}
{% else %}
{% set parsed = last_ts | as_datetime %}
{{ parsed is none or (now() - parsed).total_seconds() > grace }}
{% endif %}
{% else %}
{{ false }}
{% endif %}
sequence:
# BUG FIX: Fixed YAML structure - was 'data: >' instead of 'data:' with 'value: >'
- 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_manual })) %}
{{ automation_state_global | combine({ automation_state_key: new_automation_state }) | tojson }}
# 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: "{{ state_is_enabled and manual_action_runs_disable_action and disable_action != [] }}"
sequence: !input disable_action
# Call manual action callback
- choose:
- conditions: "{{ manual_action != [] }}"
sequence: !input manual_action
# Debug notification
- choose:
- conditions: "{{ enable_debug_notifications }}"
sequence:
- service: persistent_notification.create
data:
title: "Motion Light Debug"
message: >
Action: MANUAL OVERRIDE
Time: {{ now().strftime('%H:%M:%S') }}
Previous State: {{ 'ENABLED' if state_is_enabled else 'NONE (pre-emptive)' }}
New State: MANUAL
Trigger: {{ trigger_id }}
# ----- Default: No sub-case matched -----
# This handles the case where a light_state_changed trigger fires
# during the grace period (e.g., Zigbee delayed state reports) while
# the disable path was already in progress but got cancelled by
# mode: restart. If disable conditions are met, turn off directly.
default:
- choose:
- conditions:
- 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:
entity_id: "{{ automation_state_entity }}"
data:
value: >
{% set new_automation_state = (automation_state | combine({ state_motion_light_state: automation_state_none })) %}
{{ automation_state_global | combine({ automation_state_key: new_automation_state }) | tojson }}
# Turn OFF or restore lights
- choose:
- conditions:
- condition: template
value_template: "{{ resolved_all_lights | length > 0 }}"
sequence:
- variables:
last_brightness: "{{ automation_state.get(state_motion_light_last_brightness, 0) | int }}"
- choose:
- conditions:
- condition: template
value_template: "{{ last_brightness > 0 }}"
sequence:
- service: light.turn_on
target:
entity_id: "{{ resolved_all_lights }}"
data:
brightness: "{{ last_brightness }}"
transition: "{{ transition_duration }}"
default:
- service: light.turn_off
target:
entity_id: "{{ resolved_all_lights }}"
data:
transition: "{{ transition_duration }}"
# Turn OFF switches
- choose:
- conditions:
- condition: template
value_template: "{{ resolved_all_switches | length > 0 }}"
sequence:
- service: switch.turn_off
target:
entity_id: "{{ resolved_all_switches }}"
# Execute disable callback
- choose:
- conditions:
- condition: template
value_template: "{{ disable_action != [] }}"
sequence: !input disable_action
# Debug notification
- choose:
- conditions: "{{ enable_debug_notifications }}"
sequence:
- service: persistent_notification.create
data:
title: "Motion Light Debug"
message: >
Action: DISABLE (restart recovery)
Time: {{ now().strftime('%H:%M:%S') }}
Trigger: {{ trigger_id }}
# -----------------------------------------------------------------------
# CASE 2: Enable Path (Motion Detected, Should Turn On)
# -----------------------------------------------------------------------
- conditions:
- condition: template
value_template: "{{ must_be_enabled }}"
sequence:
- choose:
# Guard: Stop if any device is already ON
# (Don't hijack user-controlled lights)
- conditions:
- condition: template
value_template: "{{ any_device_on }}"
sequence:
- stop: "Light is already ON when sensors were triggered"
# Enable the light/switch
default:
# 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 %}
{% 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.
- 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_enabling,
state_motion_light_last_action_timestamp: date_time_now,
state_motion_light_last_brightness: last_brightness
})) %}
{{ 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:
- condition: template
value_template: "{{ use_scene_instead and effective_scene is not none }}"
sequence:
- service: scene.turn_on
target:
entity_id: "{{ effective_scene }}"
data:
transition: "{{ transition_duration }}"
# Default: Turn ON lights/switches
default:
# Turn ON the lights
- choose:
- conditions:
- condition: template
value_template: "{{ resolved_all_lights | length > 0 }}"
sequence:
- service: light.turn_on
target:
entity_id: "{{ resolved_all_lights }}"
data: >
{% set d = effective_light_data if effective_light_data else {} %}
{% if transition_duration > 0 %}
{% set d = d | combine({'transition': transition_duration}) %}
{% endif %}
{{ d }}
# Turn ON the switches
- choose:
- conditions:
- condition: template
value_template: "{{ resolved_all_switches | length > 0 }}"
sequence:
- service: switch.turn_on
target:
entity_id: "{{ resolved_all_switches }}"
# -----------------------------------------------------------------------
# CASE 3: Disable Path (Motion Cleared, Should Turn Off)
# -----------------------------------------------------------------------
- conditions:
- condition: template
value_template: "{{ must_be_disabled }}"
sequence:
# Calculate minimum on duration remaining
- 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 }}"
# Wait remaining minimum on duration if needed
- choose:
- conditions:
- condition: template
value_template: "{{ remaining_min_on > 0 }}"
sequence:
- delay:
seconds: "{{ remaining_min_on }}"
# Wait for timeout before turning off
- delay:
seconds: "{{ timeout }}"
# Dim before off (if enabled)
- choose:
- conditions:
- condition: template
value_template: "{{ enable_dim_before_off and resolved_all_lights | length > 0 }}"
sequence:
- service: light.turn_on
target:
entity_id: "{{ resolved_all_lights }}"
data:
brightness: "{{ dim_brightness }}"
transition: "{{ transition_duration }}"
- delay:
seconds: "{{ dim_duration }}"
# Read last_brightness before resetting state
- variables:
last_brightness: "{{ automation_state.get(state_motion_light_last_brightness, 0) | int }}"
# Update state to NONE BEFORE turning off the light.
# This must happen first because mode: restart may cancel
# subsequent steps if the light state change fires during
# turn_off transition, which could falsely trigger manual override.
- 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 })) %}
{{ automation_state_global | combine({ automation_state_key: new_automation_state }) | tojson }}
# Execute disable callback action (before turn-off to avoid restart cancellation)
- choose:
- conditions:
- condition: template
value_template: "{{ disable_action != [] }}"
sequence: !input disable_action
# Debug notification (before turn-off to avoid restart cancellation)
- choose:
- conditions: "{{ enable_debug_notifications }}"
sequence:
- service: persistent_notification.create
data:
title: "Motion Light Debug"
message: >
Action: DISABLE
Time: {{ now().strftime('%H:%M:%S') }}
Timeout: {{ timeout }}s
Min On Duration: {{ min_on_duration }}s
Dim Before Off: {{ enable_dim_before_off }}
# Turn OFF or restore the lights
- choose:
- conditions:
- condition: template
value_template: "{{ resolved_all_lights | length > 0 }}"
sequence:
- choose:
# Restore previous brightness if it was set
- conditions:
- condition: template
value_template: "{{ last_brightness > 0 }}"
sequence:
- service: light.turn_on
target:
entity_id: "{{ resolved_all_lights }}"
data:
brightness: "{{ last_brightness }}"
transition: "{{ transition_duration }}"
# Otherwise turn off completely
default:
- service: light.turn_off
target:
entity_id: "{{ resolved_all_lights }}"
data:
transition: "{{ transition_duration }}"
# Turn OFF the switches
- choose:
- conditions:
- condition: template
value_template: "{{ resolved_all_switches | length > 0 }}"
sequence:
- service: switch.turn_off
target:
entity_id: "{{ resolved_all_switches }}"