From 9d19dfa8d381af718cb1f055fb2540467ad5aad3 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Fri, 20 Feb 2026 01:57:45 +0300 Subject: [PATCH] Fix multiple mode:restart state machine bugs in `Motion Light` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- Common/Motion Light/blueprint.yaml | 385 ++++++++++++++++++++++------- manifest.json | 2 +- 2 files changed, 302 insertions(+), 85 deletions(-) diff --git a/Common/Motion Light/blueprint.yaml b/Common/Motion Light/blueprint.yaml index 0538945..082f801 100644 --- a/Common/Motion Light/blueprint.yaml +++ b/Common/Motion Light/blueprint.yaml @@ -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 }} diff --git a/manifest.json b/manifest.json index 6cb5a26..1465fc0 100644 --- a/manifest.json +++ b/manifest.json @@ -1,3 +1,3 @@ { - "version": "2.2.3" + "version": "2.2.9" }