lib/libesp32/berry_animation/animation_docs/Animation_Development.md
Guide for developers creating custom animation classes in the Berry Animation Framework.
Note: This guide is for developers who want to extend the framework by creating new animation classes. For using existing animations, see the DSL Reference which provides a declarative way to create animations without programming.
The Berry Animation Framework uses a unified architecture where all visual elements inherit from the base Animation class. This guide explains how to create custom animation classes that integrate seamlessly with the framework's parameter system, value providers, and rendering pipeline.
#@ solidify:MyAnimation,weak
class MyAnimation : animation.animation
# NO instance variables for parameters - they are handled by the virtual parameter system
# Parameter definitions following the new specification
static var PARAMS = {
"my_param1": {"default": "default_value", "type": "string"},
"my_param2": {"min": 0, "max": 255, "default": 100, "type": "int"}
# Do NOT include inherited Animation parameters here
}
def init(engine)
# Engine parameter is MANDATORY and cannot be nil
super(self).init(engine)
# Only initialize non-parameter instance variables (none in this example)
# Parameters are handled by the virtual parameter system
end
# Handle parameter changes (optional)
def on_param_changed(name, value)
# Add custom logic for parameter changes if needed
# Parameter validation is handled automatically by the framework
end
# Update animation state (no return value needed)
def update(time_ms)
super(self).update(time_ms)
# Your update logic here
end
def render(frame, time_ms, strip_length)
if !self.is_running || frame == nil
return false
end
# Use virtual parameter access - automatically resolves value_providers
var param1 = self.my_param1
var param2 = self.my_param2
# Use strip_length parameter instead of self.engine.strip_length for performance
# Your rendering logic here
# ...
return true
end
# NO setter methods needed - use direct virtual parameter assignment:
# obj.my_param1 = value
# obj.my_param2 = value
def tostring()
return f"MyAnimation(param1={self.my_param1}, param2={self.my_param2}, running={self.is_running})"
end
end
The PARAMS static variable defines all parameters specific to your animation class. This system provides:
static var PARAMS = {
"parameter_name": {
"default": default_value, # Default value (optional)
"min": minimum_value, # Minimum value for integers (optional)
"max": maximum_value, # Maximum value for integers (optional)
"enum": [val1, val2, val3], # Valid enum values (optional)
"type": "parameter_type", # Expected type (optional)
"nillable": true # Whether nil values are allowed (optional)
}
}
"int" - Integer values (default if not specified)"string" - String values"bool" - Boolean values (true/false)"bytes" - Bytes objects (validated using isinstance())"instance" - Object instances"any" - Any type (no type validation)obj.param_namedef init(engine)
# 1. ALWAYS call super with engine (engine is the ONLY parameter)
super(self).init(engine)
# 2. Initialize non-parameter instance variables only
self.internal_state = initial_value
self.buffer = nil
# Do NOT initialize parameters here - they are handled by the virtual system
end
def on_param_changed(name, value)
# Optional method to handle parameter changes
if name == "scale"
# Recalculate internal state when scale changes
self._update_internal_buffers()
elif name == "color"
# Handle color changes
self._invalidate_color_cache()
end
end
The virtual parameter system automatically resolves value_providers when you access parameters:
def render(frame, time_ms, strip_length)
# Virtual parameter access automatically resolves value_providers
var color = self.color # Returns current color value, not the provider
var position = self.pos # Returns current position value
var size = self.size # Returns current size value
# Use strip_length parameter (computed once by engine_proxy) instead of self.engine.strip_length
# Use resolved values in rendering logic
for i: position..(position + size - 1)
if i >= 0 && i < strip_length
frame.set_pixel_color(i, color)
end
end
return true
end
Users can set both static values and value_providers using the same syntax:
# Create animation
var anim = animation.my_animation(engine)
# Static values
anim.color = 0xFFFF0000
anim.pos = 5
anim.size = 3
# Dynamic values
anim.color = animation.smooth(0xFF000000, 0xFFFFFFFF, 2000)
anim.pos = animation.triangle(0, 29, 3000)
For performance-critical code, cache parameter values:
def render(frame, time_ms, strip_length)
# Cache parameter values to avoid multiple virtual member access
var current_color = self.color
var current_pos = self.pos
var current_size = self.size
# Use cached values in loops
for i: current_pos..(current_pos + current_size - 1)
if i >= 0 && i < strip_length
frame.set_pixel_color(i, current_color)
end
end
return true
end
For color providers that perform expensive color calculations (like palette interpolation), the base color_provider class provides a Lookup Table (LUT) mechanism for caching pre-computed colors:
#@ solidify:MyColorProvider,weak
class MyColorProvider : animation.color_provider
# Instance variables (all should start with underscore)
var _cached_data # Your custom cached data
def init(engine)
super(self).init(engine) # Initializes _color_lut and _lut_dirty
self._cached_data = nil
end
# Mark LUT as dirty when parameters change
def on_param_changed(name, value)
super(self).on_param_changed(name, value)
if name == "colors" || name == "transition_type"
self._lut_dirty = true # Inherited from color_provider
end
end
# Rebuild LUT when needed
def _rebuild_color_lut()
# Allocate LUT (e.g., 129 entries * 4 bytes = 516 bytes)
if self._color_lut == nil
self._color_lut = bytes()
self._color_lut.resize(129 * 4)
end
# Pre-compute colors for values 0, 2, 4, ..., 254, 255
var i = 0
while i < 128
var value = i * 2
var color = self._compute_color_expensive(value)
self._color_lut.set(i * 4, color, 4)
i += 1
end
# Add final entry for value 255
var color_255 = self._compute_color_expensive(255)
self._color_lut.set(128 * 4, color_255, 4)
self._lut_dirty = false
end
# Update method checks if LUT needs rebuilding
def update(time_ms)
if self._lut_dirty || self._color_lut == nil
self._rebuild_color_lut()
end
return self.is_running
end
# Fast color lookup using LUT
def get_color_for_value(value, time_ms)
# Build LUT if needed (lazy initialization)
if self._lut_dirty || self._color_lut == nil
self._rebuild_color_lut()
end
# Map value to LUT index (divide by 2, special case for 255)
var lut_index = value >> 1
if value >= 255
lut_index = 128
end
# Retrieve pre-computed color from LUT
var color = self._color_lut.get(lut_index * 4, 4)
# Apply brightness scaling using static method (only if not 255)
var brightness = self.brightness
if brightness != 255
return animation.color_provider.apply_brightness(color, brightness)
end
return color
end
# Access LUT from outside (returns bytes() or nil)
# Inherited from color_provider: get_lut()
end
LUT Benefits:
When to use LUT:
LUT Guidelines:
Brightness Handling:
The color_provider base class includes a brightness parameter (0-255, default 255) and a static method for applying brightness scaling:
# Static method for brightness scaling (only scales if brightness != 255)
animation.color_provider.apply_brightness(color, brightness)
Best Practices:
brightness != 255 to avoid unnecessary overheadThe new system uses direct parameter assignment instead of setter methods:
# Create animation
var anim = animation.my_animation(engine)
# Direct parameter assignment (recommended)
anim.color = 0xFF00FF00
anim.pos = 10
anim.size = 5
# Method chaining is not needed - just set parameters directly
The parameter system handles validation automatically based on PARAMS constraints:
# This will raise an exception due to min: 0 constraint
anim.size = -1 # Raises value_error
# This will be accepted
anim.size = 5 # Parameter updated successfully
# Method-based setting returns true/false for validation
var success = anim.set_param("size", -1) # Returns false, no exception
# Get current parameter value (resolved if value_provider)
var current_color = anim.color
# Get raw parameter (returns value_provider if set)
var raw_color = anim.get_param("color")
# Check if parameter is a value_provider
if animation.is_value_provider(raw_color)
print("Color is dynamic")
else
print("Color is static")
end
def render(frame, time_ms, strip_length)
if !self.is_running || frame == nil
return false
end
# Resolve dynamic parameters
var color = self.resolve_value(self.color, "color", time_ms)
var opacity = self.resolve_value(self.opacity, "opacity", time_ms)
# Render your effect using strip_length parameter
for i: 0..(strip_length-1)
var pixel_color = calculate_pixel_color(i, time_ms)
frame.set_pixel_color(i, pixel_color)
end
# Apply opacity if not full (supports numbers, animations)
if opacity < 255
frame.apply_opacity(opacity)
end
return true # Frame was modified
end
# Fill entire frame with color
frame.fill_pixels(color)
# Render at specific positions
var start_pos = self.resolve_value(self.pos, "pos", time_ms)
var size = self.resolve_value(self.size, "size", time_ms)
for i: 0..(size-1)
var pixel_pos = start_pos + i
if pixel_pos >= 0 && pixel_pos < frame.width
frame.set_pixel_color(pixel_pos, color)
end
end
# Create gradient across frame
for i: 0..(frame.width-1)
var progress = i / (frame.width - 1.0) # 0.0 to 1.0
var interpolated_color = interpolate_color(start_color, end_color, progress)
frame.set_pixel_color(i, interpolated_color)
end
Here's a complete example showing all concepts:
#@ solidify:beacon,weak
class beacon : animation.animation
# NO instance variables for parameters - they are handled by the virtual parameter system
# Parameter definitions following the new specification
static var PARAMS = {
"color": {"default": 0xFFFFFFFF},
"back_color": {"default": 0xFF000000},
"pos": {"default": 0},
"beacon_size": {"min": 0, "default": 1},
"slew_size": {"min": 0, "default": 0}
}
# Initialize a new Pulse Position animation
# Engine parameter is MANDATORY and cannot be nil
def init(engine)
# Call parent constructor with engine (engine is the ONLY parameter)
super(self).init(engine)
# Only initialize non-parameter instance variables (none in this case)
# Parameters are handled by the virtual parameter system
end
# Handle parameter changes (optional - can be removed if no special handling needed)
def on_param_changed(name, value)
# No special handling needed for this animation
# Parameter validation is handled automatically by the framework
end
# Render the pulse to the provided frame buffer
def render(frame, time_ms, strip_length)
if frame == nil
return false
end
var pixel_size = strip_length
# Use virtual parameter access - automatically resolves value_providers
var back_color = self.back_color
var pos = self.pos
var slew_size = self.slew_size
var beacon_size = self.beacon_size
var color = self.color
# Fill background if not transparent
if back_color != 0xFF000000
frame.fill_pixels(back_color)
end
# Calculate pulse boundaries
var pulse_min = pos
var pulse_max = pos + beacon_size
# Clamp to frame boundaries
if pulse_min < 0
pulse_min = 0
end
if pulse_max >= pixel_size
pulse_max = pixel_size
end
# Draw the main pulse
var i = pulse_min
while i < pulse_max
frame.set_pixel_color(i, color)
i += 1
end
# Draw slew regions if slew_size > 0
if slew_size > 0
# Left slew (fade from background to pulse color)
var left_slew_min = pos - slew_size
var left_slew_max = pos
if left_slew_min < 0
left_slew_min = 0
end
if left_slew_max >= pixel_size
left_slew_max = pixel_size
end
i = left_slew_min
while i < left_slew_max
# Calculate blend factor
var blend_factor = tasmota.scale_uint(i, pos - slew_size, pos - 1, 255, 0)
var alpha = 255 - blend_factor
var blend_color = (alpha << 24) | (color & 0x00FFFFFF)
var blended_color = frame.blend(back_color, blend_color)
frame.set_pixel_color(i, blended_color)
i += 1
end
# Right slew (fade from pulse color to background)
var right_slew_min = pos + beacon_size
var right_slew_max = pos + beacon_size + slew_size
if right_slew_min < 0
right_slew_min = 0
end
if right_slew_max >= pixel_size
right_slew_max = pixel_size
end
i = right_slew_min
while i < right_slew_max
# Calculate blend factor
var blend_factor = tasmota.scale_uint(i, pos + beacon_size, pos + beacon_size + slew_size - 1, 0, 255)
var alpha = 255 - blend_factor
var blend_color = (alpha << 24) | (color & 0x00FFFFFF)
var blended_color = frame.blend(back_color, blend_color)
frame.set_pixel_color(i, blended_color)
i += 1
end
end
return true
end
# NO setter methods - use direct virtual parameter assignment instead:
# obj.color = value
# obj.pos = value
# obj.beacon_size = value
# obj.slew_size = value
# String representation of the animation
def tostring()
return f"beacon(color=0x{self.color :08x}, pos={self.pos}, beacon_size={self.beacon_size}, slew_size={self.slew_size})"
end
end
# Export class directly - no redundant factory function needed
return {'beacon': beacon}
Create comprehensive tests for your animation:
import animation
def test_my_animation()
# Create LED strip and engine for testing
var strip = global.Leds(10) # Use built-in LED strip for testing
var engine = animation.create_engine(strip)
# Test basic construction
var anim = animation.my_animation(engine)
assert(anim != nil, "Animation should be created")
# Test parameter setting
anim.color = 0xFFFF0000
assert(anim.color == 0xFFFF0000, "Color should be set")
# Test parameter updates
anim.color = 0xFF00FF00
assert(anim.color == 0xFF00FF00, "Color should be updated")
# Test value providers
var dynamic_color = animation.smooth(engine)
dynamic_color.min_value = 0xFF000000
dynamic_color.max_value = 0xFFFFFFFF
dynamic_color.duration = 2000
anim.color = dynamic_color
var raw_color = anim.get_param("color")
assert(animation.is_value_provider(raw_color), "Should accept value provider")
# Test rendering
var frame = animation.frame_buffer(10)
anim.start()
var result = anim.render(frame, 1000, engine.strip_length)
assert(result == true, "Should render successfully")
print("✓ All tests passed")
end
test_my_animation()
Test with the animation engine:
var strip = global.Leds(30) # Use built-in LED strip
var engine = animation.create_engine(strip)
var anim = animation.my_animation(engine)
# Set parameters
anim.color = 0xFFFF0000
anim.pos = 5
anim.beacon_size = 3
engine.add(anim) # Unified method for animations and sequence managers
engine.run()
# Let it run for a few seconds
tasmota.delay(3000)
engine.stop()
print("Integration test completed")
Once you've created a new animation class:
animation.beRemember: Users should primarily interact with animations through the DSL. The programmatic API is mainly for framework development and advanced integrations.
This guide provides everything needed to create professional-quality animation classes that integrate seamlessly with the Berry Animation Framework's parameter system and rendering pipeline.