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
This commit is contained in:
2026-05-27 13:09:50 +03:00
parent a52cffa062
commit 34cf5b1f7a
2 changed files with 211 additions and 113 deletions
+22 -12
View File
@@ -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
View File
@@ -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)
# -----------------------------------------------------------------------