Berry animation simplify sequence_manager (#24293)

This commit is contained in:
s-hadinger 2026-01-02 20:24:57 +01:00 committed by GitHub
parent 36424dd8e7
commit 3503cee120
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 1043 additions and 908 deletions

View File

@ -10,6 +10,7 @@ template animation shutter_bidir {
# since 'strip_length()' is a value provider, it must be assigned to a variable before being used # since 'strip_length()' is a value provider, it must be assigned to a variable before being used
set strip_len = strip_length() set strip_len = strip_length()
set strip_len_2 = (strip_len + 1) / 2 # half length rounded
# Define animated value for the size of the shutter, evolving linearly in time (sawtooth from 0% to 100%) # Define animated value for the size of the shutter, evolving linearly in time (sawtooth from 0% to 100%)
set shutter_size = sawtooth(min_value = 0, max_value = strip_len, duration = period) set shutter_size = sawtooth(min_value = 0, max_value = strip_len, duration = period)
@ -21,22 +22,18 @@ template animation shutter_bidir {
# Shutter moving in in-out # Shutter moving in in-out
animation shutter_inout_animation = beacon_animation( animation shutter_inout_animation = beacon_animation(
color = col2 color = col2 # Use two rotating colors
back_color = col1 back_color = col1 # Use two rotating colors
pos = 0 pos = strip_len_2 - (shutter_size + 1) / 2
beacon_size = shutter_size beacon_size = shutter_size
slew_size = 0
priority = 5
) )
# shutter moving in out-in # shutter moving in out-in
animation shutter_outin_animation = beacon_animation( animation shutter_outin_animation = beacon_animation(
color = col1 color = col1
back_color = col2 back_color = col2
pos = 0 pos = strip_len_2 - (strip_len - shutter_size + 1) / 2
beacon_size = strip_len - shutter_size beacon_size = strip_len - shutter_size
slew_size = 0
priority = 5
) )
# this is the overall sequence composed of two sub-sequences # this is the overall sequence composed of two sub-sequences

View File

@ -1186,62 +1186,65 @@ run main
### 8.3 Advanced Template with Conditional Flags ### 8.3 Advanced Template with Conditional Flags
<a href="https://tasmota.github.io/docs/Tasmota-Berry-emulator/index.html?example=chap_8_30_template_shutter" target="_blank"><img src="../../_media/berry_animation/chap_8_30.png" alt="Template Shutter"></a> <a href="https://tasmota.github.io/docs/Tasmota-Berry-emulator/index.html?example=chap_8_30_template_shutter_bidir_flags" target="_blank"><img src="../../_media/berry_animation/chap_8_30.png" alt="Template Shutter"></a>
Templates support `bool` parameters that can be used with `if` statements inside sequences. This allows users to enable or disable parts of the animation at instantiation time. Here we create a bidirectional shutter that can optionally run in-out, out-in, or both directions. Templates support `bool` parameters that can be used with `if` statements inside sequences. This allows users to enable or disable parts of the animation at instantiation time. Here we create a bidirectional shutter (based on chapter 6.4) that can optionally run in-out, out-in, or both directions.
**Dynamic parameter changes:** **Dynamic parameter changes:**
You can also modify template parameters at runtime using Berry code. Note that DSL variable names get an underscore suffix in Berry to avoid collisions with reserved words (e.g., `main` becomes `main_`). For example: `main_.inout = false` disables the in-out animation while it's running. You can also modify template parameters at runtime using Berry code. Note that DSL variable names get an underscore suffix in Berry to avoid collisions with reserved words (e.g., `main` becomes `main_`). For example: `main_.inout = false` disables the in-out animation while it's running.
```berry ```berry
# Template with conditional flags for bidirectional shutter # Template to illustrate the parameters and flags
# Define a template to package the shutter in-out-in from 6.40
# with flags to enable or disable in-out or out-in
template animation shutter_bidir { template animation shutter_bidir {
param colors type palette param colors type palette
param period default 2s param period default 2s
param inout type bool default true # Enable in-out animation param inout type bool default true # define to true to enable 'inout' part
param outin type bool default true # Enable out-in animation param outin type bool default true # define to true to enable 'outin' part
# since 'strip_length()' is a value provider, it must be assigned to a variable before being used
set strip_len = strip_length() set strip_len = strip_length()
set strip_len_2 = (strip_len + 1) / 2 # half length rounded
# Define animated value for the size of the shutter, evolving linearly in time (sawtooth from 0% to 100%)
set shutter_size = sawtooth(min_value = 0, max_value = strip_len, duration = period) set shutter_size = sawtooth(min_value = 0, max_value = strip_len, duration = period)
# Two rotating color providers # Define two rotating palettes, shifted by one color
color col1 = color_cycle(colors=colors, period=0) color col1 = color_cycle(colors=colors, period=0)
color col2 = color_cycle(colors=colors, period=0) color col2 = color_cycle(colors=colors, period=0)
col2.next = 1 col2.next = 1 # move 'col2' to the next color so it's shifte by one compared to 'col1'
# In-out shutter # Shutter moving in in-out
animation shutter_inout_animation = beacon_animation( animation shutter_inout_animation = beacon_animation(
color = col2 color = col2 # Use two rotating colors
back_color = col1 back_color = col1 # Use two rotating colors
pos = 0 pos = strip_len_2 - (shutter_size + 1) / 2
beacon_size = shutter_size beacon_size = shutter_size
slew_size = 0
priority = 5
) )
# Out-in shutter # shutter moving in out-in
animation shutter_outin_animation = beacon_animation( animation shutter_outin_animation = beacon_animation(
color = col1 color = col1
back_color = col2 back_color = col2
pos = 0 pos = strip_len_2 - (strip_len - shutter_size + 1) / 2
beacon_size = strip_len - shutter_size beacon_size = strip_len - shutter_size
slew_size = 0
priority = 5
) )
# Sequence with conditional blocks # this is the overall sequence composed of two sub-sequences
# the first in ascending mode, the second in descending
sequence shutter_seq repeat forever { sequence shutter_seq repeat forever {
if inout { # Only if inout is true if inout { # un only if 'ascending' is true
repeat col1.palette_size times { repeat col1.palette_size times { # run the shutter animation
restart shutter_size restart shutter_size # resync all times for this animation, to avoid temporal drift
play shutter_inout_animation for period play shutter_inout_animation for period # run the animation
col1.next = 1 col1.next = 1 # then move to next color for both palettes
col2.next = 1 col2.next = 1
} }
} }
if outin { # Only if outin is true if outin { # run only if 'descending' is true
repeat col1.palette_size times { repeat col1.palette_size times {
restart shutter_size restart shutter_size
play shutter_outin_animation for period play shutter_outin_animation for period
@ -1253,7 +1256,7 @@ template animation shutter_bidir {
run shutter_seq run shutter_seq
} }
# Define palette # define a palette of rainbow colors including white with constant brightness
palette rainbow_with_white = [ palette rainbow_with_white = [
0xFC0000 # Red 0xFC0000 # Red
0xFF8000 # Orange 0xFF8000 # Orange
@ -1265,7 +1268,6 @@ palette rainbow_with_white = [
0xCCCCCC # White 0xCCCCCC # White
] ]
# Use the template
animation main = shutter_bidir(colors = rainbow_with_white, period = 1.5s) animation main = shutter_bidir(colors = rainbow_with_white, period = 1.5s)
run main run main
``` ```

View File

@ -4,16 +4,29 @@
# #
# Extends ParameterizedObject to provide parameter management and playable interface, # Extends ParameterizedObject to provide parameter management and playable interface,
# allowing sequences to be treated uniformly with animations by the engine. # allowing sequences to be treated uniformly with animations by the engine.
#
# Memory-optimized: Uses two parallel arrays instead of array of maps
# - step_durations: encodes type and duration in a single integer
# - step_refs: stores references (animation, closure, sequence_manager, or nil)
#
# Duration encoding:
# >= 0: play (ref=animation) or wait (ref=nil)
# -2: closure (ref=closure function)
# -3: subsequence (ref=sequence_manager)
import "./core/param_encoder" as encode_constraints import "./core/param_encoder" as encode_constraints
class SequenceManager : animation.parameterized_object class SequenceManager : animation.parameterized_object
# static var DURATION_CLOSURE = -2
# static var DURATION_SUBSEQUENCE = -3
# Non-parameter instance variables # Non-parameter instance variables
var active_sequence # Currently running sequence var active_sequence # Currently running sequence
var sequence_state # Current sequence execution state var sequence_state # Current sequence execution state
var step_index # Current step in the sequence var step_index # Current step in the sequence
var step_start_time # When current step started var step_start_time # When current step started
var steps # List of sequence steps var step_durations # List of duration/type values (int)
var step_refs # List of references (animation, closure, sequence_manager, or nil)
# Repeat-specific properties # Repeat-specific properties
var repeat_count # Number of times to repeat this sequence (-1 for forever, 0 for no repeat) var repeat_count # Number of times to repeat this sequence (-1 for forever, 0 for no repeat)
@ -29,7 +42,8 @@ class SequenceManager : animation.parameterized_object
self.sequence_state = {} self.sequence_state = {}
self.step_index = 0 self.step_index = 0
self.step_start_time = 0 self.step_start_time = 0
self.steps = [] self.step_durations = []
self.step_refs = []
# Repeat logic # Repeat logic
self.repeat_count = repeat_count != nil ? repeat_count : 1 # Default: run once (can be function or number) self.repeat_count = repeat_count != nil ? repeat_count : 1 # Default: run once (can be function or number)
@ -37,51 +51,35 @@ class SequenceManager : animation.parameterized_object
self.is_repeat_sequence = repeat_count != nil && repeat_count != 1 self.is_repeat_sequence = repeat_count != nil && repeat_count != 1
end end
# Add a step to this sequence
def push_step(step)
self.steps.push(step)
return self
end
# Add a play step directly # Add a play step directly
def push_play_step(animation_ref, duration) def push_play_step(animation_ref, duration)
self.steps.push({ self.step_durations.push(duration != nil ? duration : 0)
"type": "play", self.step_refs.push(animation_ref)
"animation": animation_ref,
"duration": duration != nil ? duration : 0
})
return self return self
end end
# Add a wait step directly # Add a wait step directly
def push_wait_step(duration) def push_wait_step(duration)
self.steps.push({ self.step_durations.push(duration)
"type": "wait", self.step_refs.push(nil)
"duration": duration
})
return self return self
end end
# Add a closure step directly (used for both assign and log steps) # Add a closure step directly (used for both assign and log steps)
def push_closure_step(closure) def push_closure_step(closure)
self.steps.push({ self.step_durations.push(-2 #-self.DURATION_CLOSURE-#)
"type": "closure", self.step_refs.push(closure)
"closure": closure
})
return self return self
end end
# Add a repeat subsequence step directly # Add a repeat subsequence step directly
def push_repeat_subsequence(sequence_manager) def push_repeat_subsequence(sequence_manager)
self.steps.push({ self.step_durations.push(-3 #-self.DURATION_SUBSEQUENCE-#)
"type": "subsequence", self.step_refs.push(sequence_manager)
"sequence_manager": sequence_manager
})
return self return self
end end
# Start this sequence # Start this sequence
# FIXED: More conservative engine clearing to avoid black frames
def start(time_ms) def start(time_ms)
# Stop any current sequence # Stop any current sequence
if self.is_running if self.is_running
@ -113,23 +111,19 @@ class SequenceManager : animation.parameterized_object
end end
# Start executing if we have steps # Start executing if we have steps
if size(self.steps) > 0 var num_steps = size(self.step_durations)
if num_steps > 0
# Execute all consecutive closure steps at the beginning atomically # Execute all consecutive closure steps at the beginning atomically
while self.step_index < size(self.steps) while self.step_index < num_steps && self.step_durations[self.step_index] == -2 #-self.DURATION_CLOSURE-#
var step = self.steps[self.step_index] var closure_func = self.step_refs[self.step_index]
if step["type"] == "closure"
var closure_func = step["closure"]
if closure_func != nil if closure_func != nil
closure_func(self.engine) closure_func(self.engine)
end end
self.step_index += 1 self.step_index += 1
else
break
end
end end
# Now execute the next non-closure step (usually play) # Now execute the next non-closure step (usually play)
if self.step_index < size(self.steps) if self.step_index < num_steps
self.execute_current_step(time_ms) self.execute_current_step(time_ms)
end end
end end
@ -148,14 +142,15 @@ class SequenceManager : animation.parameterized_object
end end
# Stop any currently playing animations # Stop any currently playing animations
if self.step_index < size(self.steps) var num_steps = size(self.step_durations)
var current_step = self.steps[self.step_index] if self.step_index < num_steps
if current_step["type"] == "play" var dur = self.step_durations[self.step_index]
var anim = current_step["animation"] var ref = self.step_refs[self.step_index]
self.engine.remove(anim) if dur == -3 #-self.DURATION_SUBSEQUENCE-#
elif current_step["type"] == "subsequence" ref.stop()
var sub_seq = current_step["sequence_manager"] elif dur != -2 #-self.DURATION_CLOSURE-# && ref != nil
sub_seq.stop() # play step (dur is >= 0 or function, ref is animation)
self.engine.remove(ref)
end end
end end
@ -167,215 +162,198 @@ class SequenceManager : animation.parameterized_object
# Stop all sub-sequences in our steps # Stop all sub-sequences in our steps
def stop_all_subsequences() def stop_all_subsequences()
for step : self.steps var num_steps = size(self.step_durations)
if step["type"] == "subsequence" var i = 0
var sub_seq = step["sequence_manager"] while i < num_steps
sub_seq.stop() if self.step_durations[i] == -3 #-self.DURATION_SUBSEQUENCE-#
self.step_refs[i].stop()
end end
i += 1
end end
return self return self
end end
# Update sequence state - called from fast_loop # Update sequence state - called from fast_loop
def update(current_time) def update(current_time)
if !self.is_running || size(self.steps) == 0 var num_steps = size(self.step_durations)
if !self.is_running || num_steps == 0 || self.step_index >= num_steps
return return
end end
# Safety check: ensure step_index is valid var dur = self.step_durations[self.step_index]
if self.step_index >= size(self.steps)
return
end
var current_step = self.steps[self.step_index] # Only closure/subsequence have negative int markers, play/wait have >= 0 or function
if dur == -3 #-self.DURATION_SUBSEQUENCE-#
# Handle different step types # Handle sub-sequence
if current_step["type"] == "subsequence" var sub_seq = self.step_refs[self.step_index]
# Handle sub-sequence (including repeat sequences)
var sub_seq = current_step["sequence_manager"]
sub_seq.update(current_time) sub_seq.update(current_time)
if !sub_seq.is_running if !sub_seq.is_running
# Sub-sequence finished, advance to next step
self.advance_to_next_step(current_time) self.advance_to_next_step(current_time)
end end
elif current_step["type"] == "closure" elif dur == -2 #-self.DURATION_CLOSURE-#
# Closure steps are handled in batches by advance_to_next_step # Closure steps are handled in batches
# This should not happen in normal flow, but handle it just in case
self.execute_closure_steps_batch(current_time) self.execute_closure_steps_batch(current_time)
else else
# Handle regular steps with duration # Handle regular steps with duration (play or wait)
if current_step.contains("duration") && current_step["duration"] != nil # dur is either int >= 0, function, or bool
# Resolve duration - it can be a number or a closure var duration_value = dur
var duration_value = current_step["duration"] if type(dur) == "function"
if type(duration_value) == "function" duration_value = dur(self.engine)
# Duration is a closure - call it to get the actual value
duration_value = duration_value(self.engine)
end end
duration_value = int(duration_value) # Force int, works for bool
if duration_value > 0 if duration_value > 0
var elapsed = current_time - self.step_start_time if current_time - self.step_start_time >= duration_value
if elapsed >= duration_value
self.advance_to_next_step(current_time) self.advance_to_next_step(current_time)
end end
else else
# Duration is 0 or nil - complete immediately # Duration is 0 - complete immediately
self.advance_to_next_step(current_time)
end
else
# Steps without duration complete immediately
self.advance_to_next_step(current_time) self.advance_to_next_step(current_time)
end end
end end
end end
# Execute the current step # Execute the current step
def execute_current_step(current_time) # skip_budget limits how many consecutive immediate skips to prevent infinite loops
if self.step_index >= size(self.steps) def execute_current_step(current_time, skip_budget)
self.complete_iteration(current_time) if skip_budget == nil
skip_budget = size(self.step_durations) + 1 # Default: allow skipping all steps once
end
var num_steps = size(self.step_durations)
if self.step_index >= num_steps
self.complete_iteration(current_time, skip_budget)
return return
end end
var step = self.steps[self.step_index] var dur = self.step_durations[self.step_index]
var ref = self.step_refs[self.step_index]
if step["type"] == "play" if dur == -3 #-self.DURATION_SUBSEQUENCE-#
var anim = step["animation"] # Start sub-sequence
ref.start(current_time)
# Check if animation is nil (safety check) # If subsequence immediately completed (e.g., repeat_count resolved to 0),
if anim == nil # advance to next step immediately to avoid black frame (if we have budget)
if !ref.is_running && skip_budget > 0
self.advance_to_next_step(current_time, skip_budget - 1)
return return
end end
elif dur == -2 #-self.DURATION_CLOSURE-#
# Check if animation is already in the engine (avoid duplicate adds) # Closure steps should be handled in batches
if ref != nil
ref(self.engine)
end
elif ref != nil
# Play step (dur >= 0 and ref is animation)
# Check if animation is already in the engine
var animations = self.engine.get_animations() var animations = self.engine.get_animations()
var already_added = false var already_added = false
for existing_anim : animations for existing_anim : animations
if existing_anim == anim if existing_anim == ref
already_added = true already_added = true
break break
end end
end end
if !already_added if !already_added
self.engine.add(anim) self.engine.add(ref)
end end
# Always restart the animation to ensure proper timing # Always restart the animation to ensure proper timing
anim.start(current_time) ref.start(current_time)
elif step["type"] == "wait"
# Wait steps are handled by the update loop checking duration
# No animation needed for wait
elif step["type"] == "stop"
var anim = step["animation"]
self.engine.remove(anim)
elif step["type"] == "closure"
# Closure steps should be handled in batches by execute_closure_steps_batch
# This should not happen in normal flow, but handle it for safety
var closure_func = step["closure"]
if closure_func != nil
closure_func(self.engine)
end
elif step["type"] == "subsequence"
# Start sub-sequence (including repeat sequences)
var sub_seq = step["sequence_manager"]
sub_seq.start(current_time)
end end
# else: wait step (dur >= 0 and ref is nil) - nothing to do
self.step_start_time = current_time self.step_start_time = current_time
end end
# Advance to the next step in the sequence # Advance to the next step in the sequence
# FIXED: Atomic transition to eliminate black frames def advance_to_next_step(current_time, skip_budget)
def advance_to_next_step(current_time) var num_steps = size(self.step_durations)
# Get current step info BEFORE advancing
var current_step = self.steps[self.step_index]
var current_anim = nil
# Store reference to current animation but DON'T remove it yet if skip_budget == nil
if current_step["type"] == "play" && current_step.contains("duration") skip_budget = num_steps + 1
current_anim = current_step["animation"] end
# Get current animation ref BEFORE advancing (for atomic transition)
# If dur is not a special marker and ref is not nil, it's a play step
var dur = self.step_durations[self.step_index]
var ref = self.step_refs[self.step_index]
var current_anim = nil
if dur != -2 #-self.DURATION_CLOSURE-# && dur != -3 #-self.DURATION_SUBSEQUENCE-# && ref != nil
current_anim = ref
end end
self.step_index += 1 self.step_index += 1
if self.step_index >= size(self.steps) if self.step_index >= num_steps
# Only remove animation when completing iteration
if current_anim != nil if current_anim != nil
self.engine.remove(current_anim) self.engine.remove(current_anim)
end end
self.complete_iteration(current_time) self.complete_iteration(current_time, skip_budget)
else else
# Execute closures and start next animation BEFORE removing current one self.execute_closure_steps_batch_atomic(current_time, current_anim, skip_budget)
self.execute_closure_steps_batch_atomic(current_time, current_anim)
end end
end end
# Execute all consecutive closure steps in a batch to avoid black frames # Execute all consecutive closure steps in a batch to avoid black frames
def execute_closure_steps_batch(current_time) def execute_closure_steps_batch(current_time)
var num_steps = size(self.step_durations)
# Execute all consecutive closure steps # Execute all consecutive closure steps
while self.step_index < size(self.steps) while self.step_index < num_steps && self.step_durations[self.step_index] == -2 #-self.DURATION_CLOSURE-#
var step = self.steps[self.step_index] var closure_func = self.step_refs[self.step_index]
if step["type"] == "closure"
# Execute closure function
var closure_func = step["closure"]
if closure_func != nil if closure_func != nil
closure_func(self.engine) closure_func(self.engine)
end end
self.step_index += 1 self.step_index += 1
else
break
end
end end
# Now execute the next non-closure step # Now execute the next non-closure step
if self.step_index < size(self.steps) if self.step_index < num_steps
self.execute_current_step(current_time) self.execute_current_step(current_time)
else else
self.complete_iteration(current_time) self.complete_iteration(current_time)
end end
end end
# ADDED: Atomic batch execution to eliminate black frames # Atomic batch execution to eliminate black frames
def execute_closure_steps_batch_atomic(current_time, previous_anim) def execute_closure_steps_batch_atomic(current_time, previous_anim, skip_budget)
if skip_budget == nil
skip_budget = size(self.step_durations) + 1
end
var num_steps = size(self.step_durations)
# Execute all consecutive closure steps # Execute all consecutive closure steps
while self.step_index < size(self.steps) while self.step_index < num_steps && self.step_durations[self.step_index] == -2 #-self.DURATION_CLOSURE-#
var step = self.steps[self.step_index] var closure_func = self.step_refs[self.step_index]
if step["type"] == "closure"
var closure_func = step["closure"]
if closure_func != nil if closure_func != nil
closure_func(self.engine) closure_func(self.engine)
end end
self.step_index += 1 self.step_index += 1
else
break
end
end end
# CRITICAL FIX: Handle the case where the next step is the SAME animation # Check if the next step is the SAME animation
# This prevents removing and re-adding the same animation, which causes black frames
var next_step = nil
var is_same_animation = false var is_same_animation = false
if self.step_index < num_steps && previous_anim != nil
if self.step_index < size(self.steps) var next_dur = self.step_durations[self.step_index]
next_step = self.steps[self.step_index] var next_ref = self.step_refs[self.step_index]
if next_step["type"] == "play" && previous_anim != nil # If not a special marker and same animation ref
is_same_animation = (next_step["animation"] == previous_anim) if next_dur != -2 #-self.DURATION_CLOSURE-# && next_dur != -3 #-self.DURATION_SUBSEQUENCE-# && next_ref == previous_anim
is_same_animation = true
end end
end end
if is_same_animation if is_same_animation
# Same animation continuing - don't remove/re-add, but DO restart for timing sync # Same animation continuing - don't remove/re-add, but DO restart for timing sync
self.step_start_time = current_time self.step_start_time = current_time
# CRITICAL: Still need to restart the animation to sync with sequence timing
previous_anim.start(current_time) previous_anim.start(current_time)
else else
# Different animation or no next animation # Different animation or no next animation
# Start the next animation BEFORE removing the previous one # Start the next animation BEFORE removing the previous one
if self.step_index < size(self.steps) if self.step_index < num_steps
self.execute_current_step(current_time) self.execute_current_step(current_time, skip_budget)
end end
# NOW it's safe to remove the previous animation (no gap) # NOW it's safe to remove the previous animation (no gap)
@ -385,14 +363,17 @@ class SequenceManager : animation.parameterized_object
end end
# Handle completion # Handle completion
if self.step_index >= size(self.steps) if self.step_index >= num_steps
self.complete_iteration(current_time) self.complete_iteration(current_time, skip_budget)
end end
end end
# Complete current iteration and check if we should repeat # Complete current iteration and check if we should repeat
# FIXED: Ensure atomic transitions during repeat iterations def complete_iteration(current_time, skip_budget)
def complete_iteration(current_time) if skip_budget == nil
skip_budget = size(self.step_durations) + 1
end
self.current_iteration += 1 self.current_iteration += 1
# Update iteration context in engine stack if this is a repeat sequence # Update iteration context in engine stack if this is a repeat sequence
@ -405,26 +386,29 @@ class SequenceManager : animation.parameterized_object
# Check if we should continue repeating # Check if we should continue repeating
if resolved_repeat_count == -1 || self.current_iteration < resolved_repeat_count if resolved_repeat_count == -1 || self.current_iteration < resolved_repeat_count
# If we've exhausted skip budget, stop here and let next update() continue
# This prevents infinite loops when all steps are skipped
if skip_budget <= 0
self.step_index = 0
return
end
# Start next iteration - execute all initial closures atomically # Start next iteration - execute all initial closures atomically
self.step_index = 0 self.step_index = 0
var num_steps = size(self.step_durations)
# Execute all consecutive closure steps at the beginning atomically # Execute all consecutive closure steps at the beginning atomically
while self.step_index < size(self.steps) while self.step_index < num_steps && self.step_durations[self.step_index] == -2 #-self.DURATION_CLOSURE-#
var step = self.steps[self.step_index] var closure_func = self.step_refs[self.step_index]
if step["type"] == "closure"
var closure_func = step["closure"]
if closure_func != nil if closure_func != nil
closure_func(self.engine) closure_func(self.engine)
end end
self.step_index += 1 self.step_index += 1
else
break
end
end end
# Now execute the next non-closure step (usually play) # Now execute the next non-closure step (usually play)
if self.step_index < size(self.steps) if self.step_index < num_steps
self.execute_current_step(current_time) self.execute_current_step(current_time, skip_budget - 1)
end end
else else
# All iterations complete # All iterations complete
@ -455,23 +439,6 @@ class SequenceManager : animation.parameterized_object
def is_sequence_running() def is_sequence_running()
return self.is_running return self.is_running
end end
# # Get current step info for debugging
# def get_current_step_info()
# if !self.is_running || self.step_index >= size(self.steps)
# return nil
# end
# return {
# "step_index": self.step_index,
# "total_steps": size(self.steps),
# "current_step": self.steps[self.step_index],
# "elapsed_ms": self.engine.time_ms - self.step_start_time,
# "repeat_count": self.repeat_count,
# "current_iteration": self.current_iteration,
# "is_repeat_sequence": self.is_repeat_sequence
# }
# end
end end
return {'sequence_manager': SequenceManager } return {'sequence_manager': SequenceManager }

View File

@ -21,8 +21,10 @@ def test_sequence_manager_basic()
# Test initialization # Test initialization
var seq_manager = animation.sequence_manager(engine) var seq_manager = animation.sequence_manager(engine)
assert(seq_manager.engine == engine, "Engine should be set correctly") assert(seq_manager.engine == engine, "Engine should be set correctly")
assert(seq_manager.steps != nil, "Steps list should be initialized") assert(seq_manager.step_durations != nil, "Step durations list should be initialized")
assert(seq_manager.steps.size() == 0, "Steps list should be empty initially") assert(seq_manager.step_refs != nil, "Step refs list should be initialized")
assert(size(seq_manager.step_durations) == 0, "Step durations list should be empty initially")
assert(size(seq_manager.step_refs) == 0, "Step refs list should be empty initially")
assert(seq_manager.step_index == 0, "Step index should be 0 initially") assert(seq_manager.step_index == 0, "Step index should be 0 initially")
assert(seq_manager.is_running == false, "Sequence should not be running initially") assert(seq_manager.is_running == false, "Sequence should not be running initially")
@ -48,26 +50,22 @@ def test_sequence_manager_step_creation()
# Test push_play_step # Test push_play_step
seq_manager.push_play_step(test_anim, 5000) seq_manager.push_play_step(test_anim, 5000)
assert(seq_manager.steps.size() == 1, "Should have one step after push_play_step") assert(size(seq_manager.step_durations) == 1, "Should have one step after push_play_step")
var play_step = seq_manager.steps[0] assert(seq_manager.step_durations[0] == 5000, "Play step should have correct duration")
assert(play_step["type"] == "play", "Play step should have correct type") assert(seq_manager.step_refs[0] == test_anim, "Play step should have correct animation")
assert(play_step["animation"] == test_anim, "Play step should have correct animation")
assert(play_step["duration"] == 5000, "Play step should have correct duration")
# Test push_wait_step # Test push_wait_step
seq_manager.push_wait_step(2000) seq_manager.push_wait_step(2000)
assert(seq_manager.steps.size() == 2, "Should have two steps after push_wait_step") assert(size(seq_manager.step_durations) == 2, "Should have two steps after push_wait_step")
var wait_step = seq_manager.steps[1] assert(seq_manager.step_durations[1] == 2000, "Wait step should have correct duration")
assert(wait_step["type"] == "wait", "Wait step should have correct type") assert(seq_manager.step_refs[1] == nil, "Wait step should have nil ref")
assert(wait_step["duration"] == 2000, "Wait step should have correct duration")
# Test push_closure_step # Test push_closure_step
var test_closure = def (engine) test_anim.opacity = 128 end var test_closure = def (engine) test_anim.opacity = 128 end
seq_manager.push_closure_step(test_closure) seq_manager.push_closure_step(test_closure)
assert(seq_manager.steps.size() == 3, "Should have three steps after push_closure_step") assert(size(seq_manager.step_durations) == 3, "Should have three steps after push_closure_step")
var assign_step = seq_manager.steps[2] assert(seq_manager.step_durations[2] == -2, "Closure step should have correct duration marker (-2)")
assert(assign_step["type"] == "closure", "Assign step should have correct type") assert(seq_manager.step_refs[2] == test_closure, "Closure step should have correct closure")
assert(assign_step["closure"] == test_closure, "Assign step should have correct closure")
print("✓ Step creation tests passed") print("✓ Step creation tests passed")
end end
@ -109,7 +107,7 @@ def test_sequence_manager_execution()
seq_manager.start() seq_manager.start()
assert(seq_manager.is_running == true, "Sequence should be running after start") assert(seq_manager.is_running == true, "Sequence should be running after start")
assert(seq_manager.steps.size() == 3, "Sequence should have 3 steps") assert(size(seq_manager.step_durations) == 3, "Sequence should have 3 steps")
assert(seq_manager.step_index == 0, "Should start at step 0") assert(seq_manager.step_index == 0, "Should start at step 0")
# Check that first animation was started # Check that first animation was started
@ -921,6 +919,189 @@ def test_sequence_manager_boolean_repeat_counts()
print("✓ Boolean repeat count tests passed") print("✓ Boolean repeat count tests passed")
end end
def test_sequence_manager_false_conditional_immediate_skip()
print("=== SequenceManager False Conditional Immediate Skip Tests ===")
# This test verifies that when a conditional subsequence (if block) has a false condition,
# the parent sequence immediately advances to the next step without waiting for a tick.
# This prevents the "black frame" issue where no animation runs for one tick.
# Create strip and engine
var strip = global.Leds(30)
var engine = animation.create_engine(strip)
# Create test animations
var color_provider1 = animation.static_color(engine)
color_provider1.color = 0xFFFF0000 # Red
var anim1 = animation.solid(engine)
anim1.color = color_provider1
anim1.priority = 0
anim1.duration = 0
anim1.loop = true
var color_provider2 = animation.static_color(engine)
color_provider2.color = 0xFF00FF00 # Green
var anim2 = animation.solid(engine)
anim2.color = color_provider2
anim2.priority = 0
anim2.duration = 0
anim2.loop = true
# Create a parent sequence with:
# 1. A conditional subsequence that is FALSE (should skip immediately)
# 2. A play step for anim2
# Create the false conditional subsequence (simulating "if false { ... }")
var false_condition = def (engine) return false end
var false_subseq = animation.sequence_manager(engine, false_condition)
false_subseq.push_play_step(anim1, 1000) # This should never execute
# Create the parent sequence
var parent_seq = animation.sequence_manager(engine, 1) # Run once
parent_seq.push_repeat_subsequence(false_subseq) # This should skip immediately
parent_seq.push_play_step(anim2, 500) # This should start immediately after the skip
# Start the parent sequence
tasmota.set_millis(200000)
engine.add(parent_seq)
engine.run()
engine.on_tick(200000)
# At this point, the false subsequence should have been skipped immediately,
# and anim2 should already be playing (step_index should be 1)
assert(parent_seq.is_running == true, "Parent sequence should be running")
assert(parent_seq.step_index == 1, f"Parent should have advanced past false conditional to step 1, got {parent_seq.step_index}")
# Verify anim2 is in the engine (not anim1)
var animations = engine.get_animations()
var anim2_found = false
var anim1_found = false
for anim : animations
if anim == anim2
anim2_found = true
end
if anim == anim1
anim1_found = true
end
end
assert(anim2_found == true, "anim2 should be playing after false conditional skip")
assert(anim1_found == false, "anim1 should NOT be playing (false conditional was skipped)")
# Test 2: Multiple consecutive false conditionals should all skip immediately
# Create fresh engine and animations
var strip2 = global.Leds(30)
var engine2 = animation.create_engine(strip2)
var color_provider3 = animation.static_color(engine2)
color_provider3.color = 0xFFFF0000 # Red
var anim3 = animation.solid(engine2)
anim3.color = color_provider3
anim3.priority = 0
anim3.duration = 0
anim3.loop = true
var color_provider4 = animation.static_color(engine2)
color_provider4.color = 0xFF00FF00 # Green
var anim4 = animation.solid(engine2)
anim4.color = color_provider4
anim4.priority = 0
anim4.duration = 0
anim4.loop = true
var false_condition2 = def (engine) return false end
var false_subseq2 = animation.sequence_manager(engine2, false_condition2)
false_subseq2.push_play_step(anim3, 1000)
var false_subseq3 = animation.sequence_manager(engine2, false_condition2)
false_subseq3.push_play_step(anim3, 1000)
var parent_seq2 = animation.sequence_manager(engine2, 1)
parent_seq2.push_repeat_subsequence(false_subseq2) # Skip
parent_seq2.push_repeat_subsequence(false_subseq3) # Skip
parent_seq2.push_play_step(anim4, 500) # Should start immediately
engine2.add(parent_seq2)
tasmota.set_millis(201000)
engine2.run()
engine2.on_tick(201000)
# Should have skipped both false conditionals and be at step 2
assert(parent_seq2.is_running == true, "Parent sequence 2 should be running")
assert(parent_seq2.step_index == 2, f"Parent should have advanced past both false conditionals to step 2, got {parent_seq2.step_index}")
print("✓ False conditional immediate skip tests passed")
end
def test_sequence_manager_all_false_conditionals_no_infinite_loop()
print("=== SequenceManager All False Conditionals No Infinite Loop Tests ===")
# This test verifies that when ALL conditional subsequences in a repeat-forever
# sequence are false, the sequence doesn't go into an infinite loop.
# Instead, it should yield control back after one pass through all steps.
# Create strip and engine
var strip = global.Leds(30)
var engine = animation.create_engine(strip)
# Create test animation (should never be used)
var color_provider = animation.static_color(engine)
color_provider.color = 0xFFFF0000
var test_anim = animation.solid(engine)
test_anim.color = color_provider
test_anim.priority = 0
test_anim.duration = 0
test_anim.loop = true
# Track how many times closures are executed
var closure_count = 0
# Create two false conditional subsequences (simulating "if inout" and "if outin" both false)
var false_condition = def (engine) return false end
var false_subseq1 = animation.sequence_manager(engine, false_condition)
false_subseq1.push_play_step(test_anim, 1000)
var false_subseq2 = animation.sequence_manager(engine, false_condition)
false_subseq2.push_play_step(test_anim, 1000)
# Create parent sequence that repeats forever with both false conditionals
var parent_seq = animation.sequence_manager(engine, -1) # Repeat forever
parent_seq.push_closure_step(def (engine) closure_count += 1 end) # Track iterations
parent_seq.push_repeat_subsequence(false_subseq1) # if inout (false)
parent_seq.push_repeat_subsequence(false_subseq2) # if outin (false)
# Start the sequence
tasmota.set_millis(300000)
engine.add(parent_seq)
engine.run()
engine.on_tick(300000)
# The sequence should be running but not in an infinite loop
assert(parent_seq.is_running == true, "Parent sequence should still be running")
# The closure should have been executed a limited number of times (not infinite)
# With skip_budget, it should execute at most a few times before yielding
assert(closure_count < 100, f"Closure should not execute infinitely, got {closure_count}")
# Simulate a few update cycles - should not hang
var update_count = 0
var max_updates = 10
while update_count < max_updates
tasmota.set_millis(300000 + update_count * 100)
engine.on_tick(300000 + update_count * 100)
parent_seq.update(300000 + update_count * 100)
update_count += 1
end
# Should still be running (repeat forever) but not have executed infinitely
assert(parent_seq.is_running == true, "Parent sequence should still be running after updates")
assert(closure_count < 1000, f"Closure count should be bounded, got {closure_count}")
print("✓ All false conditionals no infinite loop tests passed")
end
# Run all tests # Run all tests
def run_all_sequence_manager_tests() def run_all_sequence_manager_tests()
print("Starting SequenceManager Unit Tests...") print("Starting SequenceManager Unit Tests...")
@ -943,6 +1124,8 @@ def run_all_sequence_manager_tests()
test_sequence_manager_zero_iterations() test_sequence_manager_zero_iterations()
test_sequence_manager_zero_palette_size() test_sequence_manager_zero_palette_size()
test_sequence_manager_boolean_repeat_counts() test_sequence_manager_boolean_repeat_counts()
test_sequence_manager_false_conditional_immediate_skip()
test_sequence_manager_all_false_conditionals_no_infinite_loop()
print("\n🎉 All SequenceManager tests passed!") print("\n🎉 All SequenceManager tests passed!")
return true return true
@ -970,5 +1153,7 @@ return {
"test_sequence_manager_complex_parametric_scenario": test_sequence_manager_complex_parametric_scenario, "test_sequence_manager_complex_parametric_scenario": test_sequence_manager_complex_parametric_scenario,
"test_sequence_manager_zero_iterations": test_sequence_manager_zero_iterations, "test_sequence_manager_zero_iterations": test_sequence_manager_zero_iterations,
"test_sequence_manager_zero_palette_size": test_sequence_manager_zero_palette_size, "test_sequence_manager_zero_palette_size": test_sequence_manager_zero_palette_size,
"test_sequence_manager_boolean_repeat_counts": test_sequence_manager_boolean_repeat_counts "test_sequence_manager_boolean_repeat_counts": test_sequence_manager_boolean_repeat_counts,
"test_sequence_manager_false_conditional_immediate_skip": test_sequence_manager_false_conditional_immediate_skip,
"test_sequence_manager_all_false_conditionals_no_infinite_loop": test_sequence_manager_all_false_conditionals_no_infinite_loop
} }