Tasmota/lib/libesp32/berry_animation/docs/ANIMATION_DEVELOPMENT.md

16 KiB

Animation Development Guide

Guide for developers creating custom animation classes in the Berry Animation Framework.

Overview

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.

Animation Class Structure

Basic Class Template

#@ 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
  
  def render(frame, time_ms)
    if !self.is_running || frame == nil
      return false
    end
    
    # Use engine time if not provided
    if time_ms == nil
      time_ms = self.engine.time_ms
    end
    
    # Use virtual parameter access - automatically resolves ValueProviders
    var param1 = self.my_param1
    var param2 = self.my_param2
    
    # 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

PARAMS System

Static Parameter Definition

The PARAMS static variable defines all parameters specific to your animation class. This system provides:

  • Parameter validation with min/max constraints and type checking
  • Default value handling for initialization
  • Virtual parameter access through getmember/setmember
  • Automatic ValueProvider resolution

Parameter Definition Format

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)
  }
}

Supported Types

  • "int" - Integer values (default if not specified)
  • "string" - String values
  • "bool" - Boolean values (true/false)
  • "instance" - Object instances
  • "any" - Any type (no type validation)

Important Rules

  • Do NOT include inherited parameters - Animation base class parameters are handled automatically
  • Only define class-specific parameters in your PARAMS
  • No constructor parameter mapping - the new system uses engine-only constructors
  • Parameters are accessed via virtual members: obj.param_name

Constructor Implementation

Engine-Only Constructor Pattern

def 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

Parameter Change Handling

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

Key Changes from Old System

  • Engine-only constructor: Constructor takes ONLY the engine parameter
  • No parameter initialization: Parameters are set by caller using virtual member assignment
  • No instance variables for parameters: Parameters are handled by the virtual system
  • Automatic validation: Parameter validation happens automatically based on PARAMS constraints

Value Provider Integration

Automatic ValueProvider Resolution

The virtual parameter system automatically resolves ValueProviders when you access parameters:

def render(frame, time_ms)
  # Use engine time if not provided
  if time_ms == nil
    time_ms = self.engine.time_ms
  end
  
  # Virtual parameter access automatically resolves ValueProviders
  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 resolved values in rendering logic
  for i: position..(position + size - 1)
    if i >= 0 && i < frame.width
      frame.set_pixel_color(i, color)
    end
  end
  
  return true
end

Setting Dynamic Parameters

Users can set both static values and ValueProviders 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)

Performance Optimization

For performance-critical code, cache parameter values:

def render(frame, time_ms)
  # 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 < frame.width
      frame.set_pixel_color(i, current_color)
    end
  end
  
  return true
end

Parameter Access

Direct Virtual Member Assignment

The 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

Parameter Validation

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

Accessing Raw Parameters

# Get current parameter value (resolved if ValueProvider)
var current_color = anim.color

# Get raw parameter (returns ValueProvider if set)
var raw_color = anim.get_param("color")

# Check if parameter is a ValueProvider
if animation.is_value_provider(raw_color)
  print("Color is dynamic")
else
  print("Color is static")
end

Rendering Implementation

Frame Buffer Operations

def render(frame, time_ms)
  if !self.is_running || frame == nil
    return false
  end
  
  # Get frame dimensions
  var width = frame.width
  var height = frame.height  # Usually 1 for LED strips
  
  # 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
  for i: 0..(width-1)
    var pixel_color = calculate_pixel_color(i, time_ms)
    frame.set_pixel_color(i, pixel_color)
  end
  
  # Apply opacity if not full
  if opacity < 255
    frame.apply_opacity(opacity)
  end
  
  return true  # Frame was modified
end

Common Rendering Patterns

Fill Pattern

# Fill entire frame with color
frame.fill_pixels(color)

Position-Based Effects

# 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

Gradient Effects

# 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

Complete Example: BeaconAnimation

Here's a complete example showing all concepts:

#@ solidify:BeaconAnimation,weak
class BeaconAnimation : 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)
    if frame == nil
      return false
    end
    
    # Use engine time if not provided
    if time_ms == nil
      time_ms = self.engine.time_ms
    end
    
    var pixel_size = frame.width
    # Use virtual parameter access - automatically resolves ValueProviders
    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"BeaconAnimation(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_animation': BeaconAnimation}

Testing Your Animation

Unit Tests

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.animation_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)
  assert(result == true, "Should render successfully")
  
  print("✓ All tests passed")
end

test_my_animation()

Integration Testing

Test with the animation engine:

var strip = global.Leds(30)  # Use built-in LED strip
var engine = animation.animation_engine(strip)
var anim = animation.my_animation(engine)

# Set parameters
anim.color = 0xFFFF0000
anim.pos = 5
anim.beacon_size = 3

engine.add_animation(anim)
engine.start()

# Let it run for a few seconds
tasmota.delay(3000)

engine.stop()
print("Integration test completed")

Best Practices

Performance

  • Minimize calculations in render() method
  • Cache resolved values when possible
  • Use integer math instead of floating point
  • Avoid memory allocation in render loops

Memory Management

  • Reuse objects when possible
  • Clear references to large objects when done
  • Use static variables for constants

Code Organization

  • Group related parameters together
  • Use descriptive variable names
  • Comment complex algorithms
  • Follow Berry naming conventions

Error Handling

  • Validate parameters in constructor
  • Handle edge cases gracefully
  • Return false from render() on errors
  • Use meaningful error messages

Publishing Your Animation Class

Once you've created a new animation class:

  1. Add it to the animation module by importing it in animation.be
  2. Create a factory function following the engine-first pattern
  3. Add DSL support by ensuring the transpiler recognizes your factory function
  4. Document parameters in the class hierarchy documentation
  5. Test with DSL to ensure users can access your animation declaratively

Remember: 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.