Fix multiple mode:restart state machine bugs in Motion Light
- Fix brightness threshold falsely resetting state to NONE during light transition ramp-up (brightness temporarily below threshold != light off) - Fix false manual override from Zigbee attribute-only updates (on→on) by requiring meaningful state change (on→off or off→on) - Fix disable guard to also accept ENABLING state (not just ENABLED) - Move state updates before service calls to survive mode:restart cancellation - Add ENABLING sub-case handler for motion-cleared-during-enable scenario - Add CASE 1 default handler for restart recovery disable path - Add comprehensive debug logging at automation entry and CASE 1 entry - Change default timeout_delay to 0 and grace_period to 2s - Remove unused is_debug/is_base_debug variables Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -141,7 +141,7 @@ blueprint:
|
||||
description: >
|
||||
Delay before turning off the light after all motion sensors
|
||||
clear. Set to 0 for immediate turn off.
|
||||
default: 120
|
||||
default: 0
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
@@ -393,7 +393,7 @@ blueprint:
|
||||
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: 10
|
||||
default: 2
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
@@ -527,12 +527,6 @@ condition: !input user_condition
|
||||
# =============================================================================
|
||||
variables:
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Debug Flags
|
||||
# ---------------------------------------------------------------------------
|
||||
is_debug: false # Detailed debug for specific actions
|
||||
is_base_debug: false # Basic debug info at start
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# State Machine Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -826,7 +820,7 @@ variables:
|
||||
# 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 }}"
|
||||
must_be_disabled_guard: "{{ state_is_enabled or state_is_enabling }}"
|
||||
must_be_disabled: >
|
||||
{{ must_be_disabled_preview and must_be_disabled_guard }}
|
||||
|
||||
@@ -836,22 +830,61 @@ variables:
|
||||
action:
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DEBUG: Log basic info (enable by setting is_base_debug: true)
|
||||
# DEBUG: Log entry state on every trigger (helps trace mode: restart issues)
|
||||
# ---------------------------------------------------------------------------
|
||||
- choose:
|
||||
- conditions:
|
||||
- condition: template
|
||||
value_template: "{{ is_base_debug }}"
|
||||
- conditions: "{{ enable_debug_notifications }}"
|
||||
sequence:
|
||||
- service: persistent_notification.create
|
||||
data:
|
||||
title: "Debug Info - Motion Light"
|
||||
title: "Motion Light Debug - ENTRY"
|
||||
message: >
|
||||
must_be_enabled_preview: {{ must_be_enabled_preview }},
|
||||
must_be_disabled_preview: {{ must_be_disabled_preview }},
|
||||
must_be_disabled: {{ must_be_disabled }},
|
||||
must_be_disabled_guard: {{ must_be_disabled_guard }},
|
||||
trigger_id: {{ trigger.id }}
|
||||
=== 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
|
||||
@@ -867,6 +900,26 @@ action:
|
||||
- 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 -----
|
||||
@@ -874,12 +927,13 @@ action:
|
||||
- conditions:
|
||||
- condition: template
|
||||
value_template: >
|
||||
{# BUG FIX: Changed from 'res = false' to 'res = true' for AND logic #}
|
||||
{# 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 brightness = state_attr(light_entity, 'brightness') | int(0) %}
|
||||
{% set light_off = is_state(light_entity, 'off') or brightness < brightness_threshold %}
|
||||
{% set res = res and light_off %}
|
||||
{% 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') %}
|
||||
@@ -898,34 +952,116 @@ action:
|
||||
{{ automation_state_global | combine({ automation_state_key: new_automation_state }) | tojson }}
|
||||
|
||||
# ----- Sub-case: Automation just turned on the light -----
|
||||
# Transition from ENABLING to ENABLED
|
||||
# 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:
|
||||
- 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 }}
|
||||
- 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 from ENABLED to MANUAL (user took 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.
|
||||
# Some devices (especially Zigbee) report delayed state updates.
|
||||
- conditions:
|
||||
- condition: template
|
||||
value_template: >
|
||||
{% set last_ts = automation_state.get(state_motion_light_last_action_timestamp, none) %}
|
||||
{% set grace = (transition_duration | float(0)) + (manual_override_grace_period | float(10)) %}
|
||||
{% if state_is_enabled and last_ts is not none %}
|
||||
{% set parsed = last_ts | as_datetime %}
|
||||
{{ parsed is none or (now() - parsed).total_seconds() > grace }}
|
||||
{% set meaningful_change = trigger.from_state.state != trigger.to_state.state %}
|
||||
{% if not meaningful_change %}
|
||||
{{ false }}
|
||||
{% else %}
|
||||
{{ state_is_enabled }}
|
||||
{% 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 %}
|
||||
{% set parsed = last_ts | as_datetime %}
|
||||
{{ parsed is none or (now() - parsed).total_seconds() > grace }}
|
||||
{% else %}
|
||||
{{ state_is_enabled }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
sequence:
|
||||
# BUG FIX: Fixed YAML structure - was 'data: >' instead of 'data:' with 'value: >'
|
||||
@@ -961,6 +1097,81 @@ action:
|
||||
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:
|
||||
# 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)
|
||||
# -----------------------------------------------------------------------
|
||||
@@ -988,6 +1199,21 @@ action:
|
||||
{{ state_attr(reference_light, 'brightness') | int(0) }}
|
||||
{% endif %}
|
||||
|
||||
# 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 }}
|
||||
|
||||
# Scene activation path
|
||||
- choose:
|
||||
- conditions:
|
||||
@@ -1028,19 +1254,6 @@ action:
|
||||
target:
|
||||
entity_id: "{{ resolved_all_switches }}"
|
||||
|
||||
# Update state to ENABLING (waiting for light state change confirmation)
|
||||
- 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 }}
|
||||
|
||||
# Execute enable callback action
|
||||
- choose:
|
||||
- conditions:
|
||||
@@ -1114,15 +1327,49 @@ action:
|
||||
- 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:
|
||||
- variables:
|
||||
last_brightness: "{{ automation_state.get(state_motion_light_last_brightness, 0) | int }}"
|
||||
|
||||
- choose:
|
||||
# Restore previous brightness if it was set
|
||||
- conditions:
|
||||
@@ -1153,33 +1400,3 @@ action:
|
||||
- service: switch.turn_off
|
||||
target:
|
||||
entity_id: "{{ resolved_all_switches }}"
|
||||
|
||||
# Update state to NONE (ready for next motion event)
|
||||
- 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
|
||||
- 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
|
||||
Time: {{ now().strftime('%H:%M:%S') }}
|
||||
Timeout: {{ timeout }}s
|
||||
Min On Duration: {{ min_on_duration }}s
|
||||
Dim Before Off: {{ enable_dim_before_off }}
|
||||
|
||||
Reference in New Issue
Block a user