From 34cf5b1f7aad799fb16eda56c2cd0bc2a8037481 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Wed, 27 May 2026 13:09:50 +0300 Subject: [PATCH] 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 --- Common/Motion Light/README.md | 34 ++-- Common/Motion Light/blueprint.yaml | 290 +++++++++++++++++++---------- 2 files changed, 211 insertions(+), 113 deletions(-) diff --git a/Common/Motion Light/README.md b/Common/Motion Light/README.md index da1ec70..396e88b 100644 --- a/Common/Motion Light/README.md +++ b/Common/Motion Light/README.md @@ -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 diff --git a/Common/Motion Light/blueprint.yaml b/Common/Motion Light/blueprint.yaml index be7705a..7b4f896 100644 --- a/Common/Motion Light/blueprint.yaml +++ b/Common/Motion Light/blueprint.yaml @@ -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) # -----------------------------------------------------------------------