Controlling WS2812 LED with Rotary Encoder

Trying to setup a rotary encoder along with a WS2812 LED ring. The encoder both controls the volume on the device, and the LED creates a visual representation of the volume level (1-12). Which is to say, when the volume on the encoder is 6, 6 LEDs are turned on, etc. Similar functionality to what you see here Would like to keep the logic to the ESP32 board as HA automation would likely have latency, and I may build more devices like this in the future.

I’ve tried a few methods but nothing compiles except for this brute force method below; which does everything correctly at the log level, but does not actually seem to control the turning on and off of lights.

id(circular_led).addressable_set(i, Color(255,255,255)); → doesn’t exist.
id(circular_led).set_pixel(i, 255, 255, 255); → no such method.
id(circular_led).get()[i] = esphome::Color(255,255,255); → doesn’t exist

I have a feeling I’ve been fighting this too long and can’t see the forest through the trees. Any help is appreciated.

# -------------------- ROTARY ENCODER (VOLUME CONTROL) --------------------
sensor:
  - platform: rotary_encoder
    name: "Volume Level"
    id: volume_level
    pin_a: GPIO17
    pin_b: GPIO16
    min_value: 0
    max_value: 12
    resolution: 1
    filters:
      - debounce: 5ms
    on_value:
      then:
        - lambda: |-
            ESP_LOGD("encoder", "Volume Level Changed: %d", (int) id(volume_level).state);

            int volume = (int) id(volume_level).state;
            ESP_LOGD("encoder", "Turning on LEDs up to index: %d", volume);

            id(led_1).turn_off();
            id(led_2).turn_off();
            id(led_3).turn_off();
            id(led_4).turn_off();
            id(led_5).turn_off();
            id(led_6).turn_off();
            id(led_7).turn_off();
            id(led_8).turn_off();
            id(led_9).turn_off();
            id(led_10).turn_off();
            id(led_11).turn_off();
            id(led_12).turn_off();

            for (int i = 0; i < volume; i++) {
                ESP_LOGD("encoder", "Turning on LED %d", i);
                switch (i) {
                  case 0: id(led_1).turn_on(); break;
                  case 1: id(led_2).turn_on(); break;
                  case 2: id(led_3).turn_on(); break;
                  case 3: id(led_4).turn_on(); break;
                  case 4: id(led_5).turn_on(); break;
                  case 5: id(led_6).turn_on(); break;
                  case 6: id(led_7).turn_on(); break;
                  case 7: id(led_8).turn_on(); break;
                  case 8: id(led_9).turn_on(); break;
                  case 9: id(led_10).turn_on(); break;
                  case 10: id(led_11).turn_on(); break;
                  case 11: id(led_12).turn_on(); break;
                }
            }

# -------------------- LED FEEDBACK (VOLUME INDICATOR) --------------------
light:
  - platform: neopixelbus
    type: GRB
    variant: WS2812
    pin: GPIO14
    num_leds: 12
    name: "Full LED Ring"
    id: full_led_ring
    internal: true  # Hide the full ring from Home Assistant to prevent conflicts
    
# -------------------- PARTITIONED LIGHT CONTROL --------------------
  - platform: partition
    name: "LED 1"
    id: led_1
    segments:
      - id: full_led_ring
        from: 0
        to: 0

  - platform: partition
    name: "LED 2"
    id: led_2
    segments:
      - id: full_led_ring
        from: 1
        to: 1

  - platform: partition
    name: "LED 3"
    id: led_3
    segments:
      - id: full_led_ring
        from: 2
        to: 2

  - platform: partition
    name: "LED 4"
    id: led_4
    segments:
      - id: full_led_ring
        from: 3
        to: 3
      
  - platform: partition
    name: "LED 5"
    id: led_5
    segments:
      - id: full_led_ring
        from: 4
        to: 4 

  - platform: partition
    name: "LED 6"
    id: led_6
    segments:
      - id: full_led_ring
        from: 5
        to: 5 

  - platform: partition
    name: "LED 7"
    id: led_7
    segments:
      - id: full_led_ring
        from: 6
        to: 6 

  - platform: partition
    name: "LED 8"
    id: led_8
    segments:
      - id: full_led_ring
        from: 7
        to: 7 

  - platform: partition
    name: "LED 9"
    id: led_9
    segments:
      - id: full_led_ring
        from: 8
        to: 8 

  - platform: partition
    name: "LED 10"
    id: led_10
    segments:
      - id: full_led_ring
        from: 9
        to: 9 

  - platform: partition
    name: "LED 11"
    id: led_11
    segments:
      - id: full_led_ring
        from: 10
        to: 10 

  - platform: partition
    name: "LED 12"
    id: led_12
    segments:
      - id: full_led_ring
        from: 11
        to: 11

I’m not sure what you are looking for…

  - light.addressable_set:
      id: my_light
      range_from: 0
      range_to: 50
      red: 100%
      green: 0%
      blue: 0%

I’m trying to use a rotary encoder to control a WS2812 LED ring (12 LEDs), where the number of LEDs lit should correspond to the encoder position. Essentially, if the encoder is at step 6, the first 6 LEDs should be on, and the rest should be off.

I’ve attempted to define each LED as a seperate partitioned light segment. This allowed me to control each LED manually, but the rotary encoder failed to update them dynamically.

I used a lambda function inside on_value to loop through the LEDs and turn on the correct number based on the encoder position:


on_value:
  then:
    - lambda: |-
        int volume = id(volume_level).state;
        id(circular_led).clear_effects();
        id(circular_led).addressable_set_range(0, volume, Color(255, 255, 255));  // Turn on LEDs up to volume level
        id(circular_led).addressable_set_range(volume, 12, Color(0, 0, 0));  // Turn off LEDs beyond volume level
        id(circular_led).update();

The log shows the encoder registering changes but the LEDs do not change.

I found this forum post where they use homeassistant.service calls within ESPHome to update the lights. However, its unclear if this is the best way, as it may introduce latency compared to direction ESPHome control.

Should I be using addressable_light instead of light? I feel like my issue may be that I haven’t defined the LED ring properly in ESPHome. Has anyone successfully used addressable_set_range() for this kind of setup? My implementation seem to have no effect.

So why don’t you try with the code I posted.
All variables are templatable

range_to: !lambda |-
return id(some_sensor).state;

Ah. I get it now! I knew I was making it overly complicated.

Thank you @Karosm

# -------------------- LED FEEDBACK (VOLUME INDICATOR) --------------------
light:
  - platform: neopixelbus
    type: GRB
    variant: WS2812
    pin: GPIO14
    num_leds: 12
    name: "Circular LED Ring"
    id: circular_led

# -------------------- ROTARY ENCODER (VOLUME CONTROL) --------------------
sensor:
  - platform: rotary_encoder
    name: "Volume Level"
    id: volume_level
    pin_a: GPIO17
    pin_b: GPIO16
    min_value: 0
    max_value: 24
    resolution: 1
    filters:
      - debounce: 5ms
    on_value:
      then:
        - light.addressable_set:
            id: circular_led
            range_from: 0
            range_to: !lambda |-
              return id(volume_level).state - 1;
            red: 100%
            green: 100%
            blue: 100%

        - script.execute: turn_off_leds_delayed 

# -------------------- LED TURN-OFF TIMER --------------------
script:
  - id: turn_off_leds_delayed
    mode: restart
    then:
      - delay: 10s
      - light.turn_off:
          id: circular_led

I still have an issue here where the encoder turns the lights on, but turning the dial in the opposite direction doesn’t turn them off. Will keep tinkiering, but if anyone already has a solution, it would be much appreciated.

Is it even counting on that direction?
Log is your best friend, what you get there when rotating “opposite” direction?

This is actually working almost perfectly. There is an issue where at volume step 24 all the LEDs shut off. Looking at the logs everything is working correctly, so maybe this is some kind of rounding error problem. Dont know. Added an exception to band-aid it together.

# -------------------- LED FEEDBACK (VOLUME INDICATOR) --------------------
light:
  - platform: neopixelbus
    type: GRB
    variant: WS2812
    pin: GPIO14
    num_leds: 12
    name: "Circular LED Ring"
    id: circular_led

# -------------------- ROTARY ENCODER (VOLUME CONTROL) --------------------
sensor:
  - platform: rotary_encoder
    name: "Volume Level"
    id: volume_level
    pin_a: GPIO17
    pin_b: GPIO16
    min_value: 0
    max_value: 24
    resolution: 1
    filters:
      - debounce: 5ms
    on_value:
      then:
        - light.addressable_set:
            id: circular_led
            range_from: 0
            range_to: !lambda |-
              int mapped_value = id(volume_level).state / 2;
              return (id(volume_level).state >= 24) ? 11 : mapped_value - 1;
            
            red: !lambda |-
              float factor = id(volume_level).state / 24.0;
              return factor;
            
            green: !lambda |-
              float factor = id(volume_level).state / 24.0;
              return (1.0 - factor);
            
            blue: !lambda |-
              return 0.2;

        - light.addressable_set:
            id: circular_led
            range_from: !lambda |-
              int mapped_value = id(volume_level).state / 2;
              return (id(volume_level).state >= 24) ? 11 : mapped_value - 1;
            range_to: 12  # Ensure all LEDs above the current volume level turn off
            
            red: 0.0
            green: 0.0
            blue: 0.0

        - script.execute: turn_off_leds_delayed



# -------------------- LED TURN-OFF TIMER --------------------
script:
  - id: turn_off_leds_delayed
    mode: restart  
    then:
      - delay: 10s 
      - light.turn_off:
          id: circular_led