Addressable RGB pixel effect for Xmas Trees

In a quest to find a great RGB pixel effect for Christmas Tree pixel lights, I ended up writing my own algorithm as an Esphome addressable effect. Rather than a more basic colourful sparkle, I wanted something which could blend multiple elements. which cluster around nearby pixels, and where the overall tone of the string doesn’t just average out into white. It has a lot of customisation to suit a broad variety of moods, including desaturation, so that you can tone down the extreme vividness which LED is known for—if you want.

I have exposed many of the parameters as dimmable lights, which, when paired with slider-entity-row, gives you a one page interface for experimenting with the variables.

The presets I’ve included might not look great on your own strings because differences in gamma curves can really mess up subtle effects. But hopefully this is fun and/or interesting for someone.

festoon.yaml

substitutions:
  pin_pixels: GPIO2
  pin_pixel_power: GPIO13
  pixel_type: RGB
  num_leds: "100" # Don't go more than 255, as that's the current limit of the algorithm.
  # Increasing the internal canvas beyond 255 is buggy, likely a misuse of 8 bit int somewhere.
  update_interval: 10ms
  neopixel_method: ESP32_I2S
  
esphome:
  on_shutdown:
    then:
      - light.turn_off: # Turn it off to make OTA flashing more reliable
          id: the_pixels

globals:
  - id: global_ambient_r
    type: int
    restore_value: no
    initial_value: "0"
  - id: global_ambient_g
    type: int
    restore_value: no
    initial_value: "0"
  - id: global_ambient_b
    type: int
    restore_value: no
    initial_value: "0"
  - id: global_colour_volume
    type: int
    restore_value: no
    initial_value: "0"
  - id: global_saturation
    type: int
    restore_value: no
    initial_value: "0"
  - id: global_saturationf
    type: float
    restore_value: no
    initial_value: "0"
  - id: global_warmth
    type: int
    restore_value: no
    initial_value: "0"
  - id: global_sparkle_power
    type: int
    restore_value: no
    initial_value: "0"
  - id: global_sparkle_rate
    type: int
    restore_value: no
    initial_value: "0"
  - id: global_shimmer
    type: int
    restore_value: no
    initial_value: "0"

output:
  - platform: template
    id: ambient_r_out
    type: float
    write_action:
      then:
      - lambda: |-
          id(global_ambient_r) = state*255;
          ESP_LOGI("Ambient R", "%f > %d", state, id(global_ambient_r));
  - platform: template
    id: ambient_g_out
    type: float
    write_action:
      then:
      - lambda: |-
          id(global_ambient_g) = state*255;
          ESP_LOGI("Ambient G", "%f > %d", state, id(global_ambient_g));
  - platform: template
    id: ambient_b_out
    type: float
    write_action:
      then:
      - lambda: |-
          id(global_ambient_b) = state*255;
          ESP_LOGI("Ambient B", "%f > %d", state, id(global_ambient_b));
  - platform: template
    id: colour_volume_out
    type: float
    write_action:
      then:
      - lambda: |-
          id(global_colour_volume) = state*45;
  - platform: template
    id: saturation_out
    type: float
    write_action:
      then:
      - lambda: |-
          id(global_saturation) = state*255;
          id(global_saturationf) = state;
  - platform: template
    id: warmth_out
    type: float
    write_action:
      then:
      - lambda: |-
          id(global_warmth) = state*50;
  - platform: template
    id: sparkle_power_out
    type: float
    write_action:
      then:
      - lambda: |-
          id(global_sparkle_power) = state*255;
  - platform: template
    id: sparkle_rate_out
    type: float
    write_action:
      then:
      - lambda: |-
          id(global_sparkle_rate) = state*22;
  - platform: template
    id: shimmer_out
    type: float
    write_action:
      then:
      - lambda: |-
          id(global_shimmer) = state*20;

power_supply:
  - id: pixel_relay
    pin:
      number: ${pin_pixel_power}
      inverted: "true"
    keep_on_time: 100ms

light:
  - platform: rgb
    name: "${entity_prefix} Ambient"
    id: ambient_light
    red: ambient_r_out
    green: ambient_g_out
    blue: ambient_b_out
    gamma_correct: 1
    default_transition_length: 0s
    restore_mode: RESTORE_DEFAULT_OFF
  - platform: monochromatic
    name: "${entity_prefix} Colour Volume"
    id: colour_volume_light
    output: colour_volume_out
    gamma_correct: 1
    default_transition_length: 0s
    restore_mode: RESTORE_DEFAULT_OFF
  - platform: monochromatic
    name: "${entity_prefix} Saturation"
    id: saturation_light
    output: saturation_out
    gamma_correct: 1
    default_transition_length: 0s
    restore_mode: RESTORE_DEFAULT_OFF
  - platform: monochromatic
    name: "${entity_prefix} Warmth"
    id: warmth_light
    output: warmth_out
    gamma_correct: 1
    default_transition_length: 0s
    restore_mode: RESTORE_DEFAULT_OFF
  - platform: monochromatic
    name: "${entity_prefix} Sparkle Brightness"
    id: sparkle_power_light
    output: sparkle_power_out
    gamma_correct: 1
    default_transition_length: 0s
    restore_mode: RESTORE_DEFAULT_OFF
  - platform: monochromatic
    name: "${entity_prefix} Sparkle Quantity"
    id: sparkle_rate_light
    output: sparkle_rate_out
    gamma_correct: 1
    default_transition_length: 0s
    restore_mode: RESTORE_DEFAULT_OFF
  - platform: monochromatic
    name: "${entity_prefix} Shimmer"
    id: shimmer_light
    output: shimmer_out
    gamma_correct: 1
    default_transition_length: 0s
    restore_mode: RESTORE_DEFAULT_OFF
    
  - platform: neopixelbus
    id: the_pixels
    name: "${entity_prefix}"
    type: ${pixel_type}
    variant: 800KBPS
    method: ${neopixel_method}
    power_supply: pixel_relay
    pin: ${pin_pixels}
    num_leds: ${num_leds}
    restore_mode: RESTORE_DEFAULT_OFF
    effects:
    - addressable_lambda:
        name: "Festoon"
        update_interval: ${update_interval}
        lambda: |-
          
          // Static constants
          static const int stroke_length = 15; // Longer strokes increases accumulation per iteration
          static const int canvas_size = 255; // Bigger canvas disperses accumulation per iteration
          static const int canvas_offset = (canvas_size - it.size()) / 2;
          static const uint8_t red_paint_hue = 15;    // 0 is pure red
          static const uint8_t amber_paint_hue = 30;  // 32 is orange
          static const uint8_t yellow_paint_hue = 60; // 64 is pure yellow
          static const uint8_t teal_paint_hue = 150;  // 128 is pure aqua
          static const uint8_t ambient_hue = 200;     // 160 is pure blue; was set to 180 for a while
          
          // Static variables
          static int red_brush[canvas_size];
          static int red_heat[canvas_size];
          static int amber_brush[canvas_size];
          static int amber_heat[canvas_size];
          static int yellow_brush[canvas_size];
          static int yellow_heat[canvas_size];
          static int teal_brush[canvas_size];
          static int teal_heat[canvas_size];
          
          // Iteration counter
          static uint32_t effect_i;
          effect_i++;
          if (effect_i >= 6000) effect_i = 0;
          //ESP_LOGI("Iteration", "%i", effect_i);
          
          // Esphome global values
          esphome::Color ambient_colour = esphome::Color(id(global_ambient_r), id(global_ambient_g), id(global_ambient_b));
          int ambient_level = max(id(global_ambient_r), max(id(global_ambient_g), id(global_ambient_b)));
          
          int colour_volume = id(global_colour_volume);
          int saturation = id(global_saturation);
          float sat_float = id(global_saturationf);
          float sat_mult = (sat_float/2) + 0.5;
          int warmth = id(global_warmth);
          int sparkle_power = id(global_sparkle_power);
          int sparkle_rate = id(global_sparkle_rate);
          int shimmer = id(global_shimmer);
          
          if (initial_run) {
            effect_i = 0;
            memset(red_brush,   0, canvas_size);
            memset(red_heat,    0, canvas_size);
            memset(amber_brush, 0, canvas_size);
            memset(amber_heat,  0, canvas_size);
            memset(yellow_brush,0, canvas_size);
            memset(yellow_heat, 0, canvas_size);
            memset(teal_brush,  0, canvas_size);
            memset(teal_heat,   0, canvas_size);
          }
          
          // Inject a new brush in a random location every so often
          if (effect_i %  7 == 0) red_brush[random_uint32() % canvas_size] = 1;
          if (effect_i % 11 == 0) amber_brush[random_uint32() % canvas_size] = 1;
          if (effect_i % 10 == 0) yellow_brush[random_uint32() % canvas_size] = 1;
          if (effect_i %  8 == 0) teal_brush[random_uint32() % canvas_size] = 1;
          
          // Every second iteration, shift brushes along
          if (effect_i % 2 == 0) {
            
            // Swipe red and yellow brushes forwards by iterating backwards
            for ( int i = canvas_size; i >= 0; i--) {
              if (red_brush[i] > 0) {
                if (red_brush[i] >= stroke_length) red_brush[i] = 0; // When the brush has iterated enough times, delete it
                else if ((i+1) < canvas_size) red_brush[i+1] = red_brush[i]+1; // Increment and move to next position in array
                red_brush[i] = 0; // Zero this position
              }
              if (yellow_brush[i] > 0) {
                if (yellow_brush[i] >= stroke_length) yellow_brush[i] = 0;
                else if ((i+1) < canvas_size) yellow_brush[i+1] = yellow_brush[i]+1;
                yellow_brush[i] = 0;
              }
            }
            
            // Swipe amber and teal brushes backwards by iterating forwards
            for ( int i = 0; i < canvas_size; i++) {
              if (amber_brush[i] > 0) {
                if (amber_brush[i] >= stroke_length) amber_brush[i] = 0;
                else if (i > 0) amber_brush[i-1] = amber_brush[i]+1;
                amber_brush[i] = 0;
              }
              if (teal_brush[i] > 0) {
                if (teal_brush[i] >= stroke_length) teal_brush[i] = 0;
                else if (i > 0) teal_brush[i-1] = teal_brush[i]+1;
                teal_brush[i] = 0;
              }
            }
            
          }
          
          // Set up a pixel canvas
          Color pcanvas[canvas_size];
          
          // For each position on the canvas 
          for ( int i = 0; i < canvas_size; i++) {
            
            // Continuously cool down
            if (red_heat[i] > 0) red_heat[i]--;
            if (amber_heat[i] > 0) amber_heat[i]--;
            if (yellow_heat[i] > 0) yellow_heat[i]--;
            if (teal_heat[i] > 0) teal_heat[i]--;
            
            // Cool more aggressively when extra hot
            if (red_heat[i] > 350) red_heat[i]--;
            if (amber_heat[i] > 350) amber_heat[i]--;
            if (yellow_heat[i] > 350) yellow_heat[i]--;
            if (teal_heat[i] > 350) teal_heat[i]--;
            
            // Apply brush to heatmap
            if (red_brush[i]    > 0) red_heat[i]    += ((stroke_length - red_brush[i])*2)    + colour_volume;
            if (amber_brush[i]  > 0) amber_heat[i]  += ((stroke_length - amber_brush[i])*2)  + colour_volume;
            if (yellow_brush[i] > 0) yellow_heat[i] += ((stroke_length - yellow_brush[i])*2) + colour_volume;
            if (teal_brush[i]   > 0) teal_heat[i]   += ((stroke_length - teal_brush[i])*2)   + colour_volume;
            
            // If any values exceed 255, accumulate the overflow(s) and add it back as (warm) white later
            // This means an overly bright red (for example) won't clip at pure red, but rather keep getting brigher towards pink and ultimately pure white
            int overflow_heat = std::min(255, 
                  ( red_heat[i]>255 ? red_heat[i]-255 : 0 )
                + ( amber_heat[i]>255 ? amber_heat[i]-255 : 0 )
                + ( yellow_heat[i]>255 ? yellow_heat[i]-255 : 0 )
                + ( teal_heat[i]>255 ? teal_heat[i]-255 : 0 )
            );
            
            // Calculate colour as HSV, convert to RGB, additively mix to pixel
            pcanvas[i] = Color::BLACK
                        + ESPHSVColor(red_paint_hue,    saturation,   std::min(255, (int)( red_heat[i]    * sat_mult ))).to_rgb()
                        + ESPHSVColor(amber_paint_hue,  saturation,   std::min(255, (int)( amber_heat[i]  * sat_mult ))).to_rgb()
                        + ESPHSVColor(yellow_paint_hue, saturation,   std::min(255, (int)( yellow_heat[i] * sat_mult ))).to_rgb()
                        + ESPHSVColor(teal_paint_hue,   saturation/2, std::min(255, (int)( teal_heat[i]   * sat_mult ))).to_rgb()
                        + Color(overflow_heat, overflow_heat/2, overflow_heat/2)
                        - Color(0, warmth/2, warmth);
            
            // Add ambience (i.e. a solid base colour)
            // But only when the pixel was going to be dark
            int intensity = (pcanvas[i].r + pcanvas[i].g + pcanvas[i].b)/2;
            if (intensity < ambient_level) {
              pcanvas[i] += ambient_colour.darken(intensity);
            }
          
            // Add shimmer (i.e. random noise)
            if (shimmer > 0) {
              if (random_uint32() % 2 == 0) {
                pcanvas[i] += Color(
                  (random_uint32() % shimmer),
                  (random_uint32() % shimmer),
                  (random_uint32() % shimmer)
                );
              } else {
                pcanvas[i] -= Color(
                  (random_uint32() % shimmer),
                  (random_uint32() % shimmer),
                  (random_uint32() % shimmer)
                );
              }
            }
          
          }
          
          // Sparkle (i.e. random bright pixels)
          if (sparkle_rate > 0 && sparkle_power > 0) {
            
            // If sparkle power is low, randomly sparkle between 0 and 1 pixels per iteration
            if (sparkle_rate < 15) {
              int rollTheDice = random_uint32() % 15;
              if (sparkle_rate > rollTheDice) {
                size_t pickAPixel = random_uint32() % canvas_size;
                pcanvas[pickAPixel] += ESPHSVColor(0, 0, sparkle_power).to_rgb();
              }
      
            // If sparkle power is high, randomly sparkle multiple pixels per iteration
            } else if (sparkle_rate >= 15) {
      
              for (int i = 15; i <= sparkle_rate; i++) {
                size_t pickAPixel = random_uint32() % canvas_size;
                pcanvas[pickAPixel] += ESPHSVColor(0, 0, sparkle_power).to_rgb();
              }
      
            }
          }
          
          // Copy values from canvas (pcanvas) to Esphome pixels (supplied by lambda as "it")
          for ( int i = 0; i < it.size(); i++) {
            it[i] = pcanvas[i+canvas_offset]; // Offset means it's copying values from the centre of the canvas
          }

xmas-tree.yaml (your device)

substitutions:
  device_name: xmas-tree
  entity_prefix: Christmas Tree
  pin_pixels: GPIO2
  pin_pixel_power: GPIO13
  num_leds: "251" # Don't go more than 255, as that's the current limit of the algorithm.
  # Increasing the internal canvas beyond 255 is buggy, likely a misuse of 8 bit int somewhere.
  pixel_type: RGB
  neopixel_method: ESP32_I2S
  
packages:
  core: !include your_own_core_entities.yaml
  festoon: !include festoon.yaml

esphome:
  on_boot:
    priority: -100.0
    then:
      if:
        condition:
          light.is_off: the_pixels
        then:
          - light.turn_on:
              id: ambient_light
              red: "0.75"
              green: "0.78"
              blue: "0.9"
          - light.turn_on:
              id: colour_volume_light
              brightness: "0.3"
          - light.turn_on:
              id: saturation_light
              brightness: "1.0"
          - light.turn_on:
              id: warmth_light
              brightness: "0.0"
          - light.turn_on:
              id: sparkle_power_light
              brightness: "0.3"
          - light.turn_on:
              id: sparkle_rate_light
              brightness: "0.3"
          - light.turn_on:
              id: shimmer_light
              brightness: "0.0"
          - light.turn_on:
              id: the_pixels
              effect: "Festoon"

esp32:
  board: nodemcu-32s

button:
  - platform: template
    name: "Evening Preset"
    on_press:
          - light.turn_on:
              id: ambient_light
              red: "0.28"
              green: "0.21"
              blue: "0.35"
          - light.turn_on:
              id: colour_volume_light
              brightness: "0.4"
          - light.turn_on:
              id: saturation_light
              brightness: "0.4"
          - light.turn_on:
              id: warmth_light
              brightness: "0.2"
          - light.turn_on:
              id: sparkle_power_light
              brightness: "0.4"
          - light.turn_on:
              id: sparkle_rate_light
              brightness: "0.05"
          - light.turn_on:
              id: shimmer_light
              brightness: "0.3"
          - light.turn_on:
              id: the_pixels
              effect: "Festoon"
  - platform: template
    name: "Daytime Preset"
    on_press:
          - light.turn_on:
              id: ambient_light
              red: "0.45"
              green: "0.55"
              blue: "0.35"
          - light.turn_on:
              id: colour_volume_light
              brightness: "0.8"
          - light.turn_on:
              id: saturation_light
              brightness: "0.8"
          - light.turn_on:
              id: warmth_light
              brightness: "0.0"
          - light.turn_on:
              id: sparkle_power_light
              brightness: "0.6"
          - light.turn_on:
              id: sparkle_rate_light
              brightness: "0.6"
          - light.turn_on:
              id: shimmer_light
              brightness: "0.5"
          - light.turn_on:
              id: the_pixels
              effect: "Festoon"
  - platform: template
    name: "Magenta Preset"
    on_press:
          - light.turn_on:
              id: ambient_light
              red: "1.0"
              green: "0.0"
              blue: "0.75"
          - light.turn_on:
              id: colour_volume_light
              brightness: "0.5"
          - light.turn_on:
              id: saturation_light
              brightness: "1.0"
          - light.turn_on:
              id: warmth_light
              brightness: "1.0"
          - light.turn_on:
              id: sparkle_power_light
              brightness: "1.0"
          - light.turn_on:
              id: sparkle_rate_light
              brightness: "1.0"
          - light.turn_on:
              id: shimmer_light
              brightness: "0.0"
          - light.turn_on:
              id: the_pixels
              effect: "Festoon"

Example HA entities card

(Uses light-entity-card and big-slider-card from HACS.)

type: entities
entities:
  - type: custom:light-entity-card
    color_picker: false
    shorten_cards: true
    entity: light.entrance_pixel
    header: Master brightness
  - type: custom:light-entity-card
    shorten_cards: true
    entity: light.entrance_pixel_ambient
    header: Ambience (RGB)
  - type: custom:big-slider-card
    height: 40
    entity: light.entrance_pixel_colour_volume
    name: Effect intensity
  - type: custom:big-slider-card
    height: 40
    entity: light.entrance_pixel_saturation
    name: Effect saturation
  - type: custom:big-slider-card
    height: 40
    entity: light.entrance_pixel_sparkle_quantity
    name: Sparkles
  - type: custom:big-slider-card
    height: 40
    entity: light.entrance_pixel_sparkle_brightness
    name: Sparkle brightness
  - type: custom:big-slider-card
    height: 40
    entity: light.entrance_pixel_shimmer
    name: Shimmer
  - type: custom:big-slider-card
    height: 40
    entity: light.entrance_pixel_warmth
    name: Warmth
  - entity: button.daytime_preset
  - entity: button.magenta_preset
  - entity: button.evening_preset
title: Christmas Tree
show_header_toggle: false