Using native LVGL focus navigation

Here’s my config, it works. Can you suggest how to rework the focus between buttons using native LVGL methods instead of manual scripts?

esphome:
  name: lilka
  friendly_name: lilka
  on_boot:
    priority: 600
    then:
      - output.turn_on: display_power
      - delay: 100ms

esp32:
  board: esp32-s3-devkitc-1
  framework:
    type: esp-idf
    version: recommended
    sdkconfig_options:
      CONFIG_ESP32S3_DATA_CACHE_64KB: y
      CONFIG_ESP32S3_DATA_CACHE_LINE_64B: y
      CONFIG_SPIRAM_MODE_OCT: y
      CONFIG_SPIRAM: y

psram:
  mode: octal
  speed: 80MHz

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password
  power_save_mode: none
  ap:
    ssid: "Lilka Fallback Hotspot"
    password: "hnYImYM4O6I0"

captive_portal:

web_server:
  port: 80

api:
  encryption:
    key: "Yw3yq8lxsaze+jdggjAwxKpV4GD6gNZptvw4d6OPfOc="

logger:
  level: INFO
  logs:
    lvgl: WARN

ota:
  - platform: esphome
    password: "bb198feb99159fdaefc8092f10039acd"

output:
  - platform: gpio
    pin: 46
    id: display_power
    inverted: false

spi:
  clk_pin: 18
  mosi_pin: 17

display:
  - platform: mipi_spi
    model: ST7789V
    id: my_display
    dc_pin: 15
    cs_pin: 7
    update_interval: 2s
    rotation: 270
    invert_colors: true
    color_order: BGR
    pixel_mode: 16bit
    dimensions:
      width: 280
      height: 240
      offset_width: 20
      offset_height: 0
    auto_clear_enabled: false

lvgl:
  log_level: WARN
  buffer_size: 25%
  style_definitions:
    - id: style_btn_off
      bg_color: 0x808080
      text_color: 0x000000
      border_width: 0
      radius: 6
    - id: style_btn_on
      bg_color: 0x00FF00
      text_color: 0x000000
      border_width: 0
      radius: 6
    - id: style_focused
      border_color: 0xFFFFFF
      border_width: 3

  pages:
    - id: main_page
      bg_color: 0x000000
      widgets:
        - obj:
            align: CENTER
            width: 260
            height: 220
            bg_opa: TRANSP
            layout:
              type: FLEX
              flex_flow: COLUMN
              flex_align_main: CENTER
              flex_align_cross: CENTER
              pad_row: 10
            widgets:
              - button:
                  id: vana_button
                  width: 200
                  height: 50
                  checkable: true
                  styles: style_btn_off
                  widgets:
                    - label:
                        text: "Vana"
                        align: CENTER
                  on_click:
                    - homeassistant.action:
                        action: light.toggle
                        data:
                          entity_id: light.tz3000_qewo8dlz_ts0013_light

              - button:
                  id: komora_button
                  width: 200
                  height: 50
                  checkable: true
                  styles: style_btn_off
                  widgets:
                    - label:
                        text: "Komora"
                        align: CENTER
                  on_click:
                    - homeassistant.action:
                        action: light.toggle
                        data:
                          entity_id: light.tz3000_qewo8dlz_ts0013_light_2

              - button:
                  id: kitchen_button
                  width: 200
                  height: 50
                  checkable: true
                  styles: style_btn_off
                  widgets:
                    - label:
                        text: "Kitchen"
                        align: CENTER
                  on_click:
                    - homeassistant.action:
                        action: light.toggle
                        data:
                          entity_id: light.tz3000_qewo8dlz_ts0013_light_3

globals:
  - id: current_button_index
    type: int
    restore_value: no
    initial_value: '0'

script:
  - id: update_focus
    then:
      - lvgl.widget.update:
          id: vana_button
          border_width: !lambda 'return id(current_button_index) == 0 ? 3 : 0;'
      - lvgl.widget.update:
          id: komora_button
          border_width: !lambda 'return id(current_button_index) == 1 ? 3 : 0;'
      - lvgl.widget.update:
          id: kitchen_button
          border_width: !lambda 'return id(current_button_index) == 2 ? 3 : 0;'

binary_sensor:
  # Home Assistant світла
  - platform: homeassistant
    id: vana_light
    entity_id: light.tz3000_qewo8dlz_ts0013_light
    internal: true
    on_state:
      then:
        - lvgl.widget.update:
            id: vana_button
            state:
              checked: !lambda 'return id(vana_light).state;'

  - platform: homeassistant
    id: komora_light
    entity_id: light.tz3000_qewo8dlz_ts0013_light_2
    internal: true
    on_state:
      then:
        - lvgl.widget.update:
            id: komora_button
            state:
              checked: !lambda 'return id(komora_light).state;'

  - platform: homeassistant
    id: kitchen_light
    entity_id: light.tz3000_qewo8dlz_ts0013_light_3
    internal: true
    on_state:
      then:
        - lvgl.widget.update:
            id: kitchen_button
            state:
              checked: !lambda 'return id(kitchen_light).state;'

  # Фізичні кнопки
  - platform: gpio
    pin:
      number: 38
      mode: INPUT_PULLUP
      inverted: true
    id: button_up
    on_press:
      - lambda: |-
          id(current_button_index)--;
          if (id(current_button_index) < 0) id(current_button_index) = 2;
      - script.execute: update_focus

  - platform: gpio
    pin:
      number: 41
      mode: INPUT_PULLUP
      inverted: true
    id: button_down
    on_press:
      - lambda: |-
          id(current_button_index)++;
          if (id(current_button_index) > 2) id(current_button_index) = 0;
      - script.execute: update_focus

  - platform: gpio
    pin:
      number: 0
      mode: INPUT_PULLUP
      inverted: true
    id: button_select
    on_press:
      then:
        - homeassistant.action:
            action: light.toggle
            data:
              entity_id: !lambda |-
                if (id(current_button_index) == 0) return "light.tz3000_qewo8dlz_ts0013_light";
                if (id(current_button_index) == 1) return "light.tz3000_qewo8dlz_ts0013_light_2";
                return "light.tz3000_qewo8dlz_ts0013_light_3";

You would use an encoder - see the docs especially the tips following.

Yes, I looked through it, but I don’t understand whether I can optimize my code without increasing the number of lines. I want the configuration to be clear. Any advice?

Yes, set up the encoder and remove all your custom focus handling.

1 Like

The issue is that I don’t have a hardware rotary encoder with A/B signals. I’m using three separate physical buttons (up, down, select). When I try to connect them via lvgl.encoders, ESPHome reports an error saying that the sensor parameter is required, and buttons can’t be used as an encoder.

So I can’t simply “set up the encoder” — ESPHome doesn’t allow emulating an encoder using buttons. If you have an example of how to properly connect three buttons as an input device for LVGL without using an encoder, I would really appreciate it.

esphome:
  name: lilka
  friendly_name: lilka
  on_boot:
    priority: 600
    then:
      - output.turn_on: display_power
      - delay: 100ms

esp32:
  board: esp32-s3-devkitc-1
  framework:
    type: esp-idf
    version: recommended
    sdkconfig_options:
      CONFIG_ESP32S3_DATA_CACHE_64KB: y
      CONFIG_ESP32S3_DATA_CACHE_LINE_64B: y
      CONFIG_SPIRAM_MODE_OCT: y
      CONFIG_SPIRAM: y

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password
  power_save_mode: none
  ap:
    ssid: "Lilka Fallback Hotspot"
    password: "hnYImYM4O6I0"

captive_portal:

web_server:
  port: 80

api:
  encryption:
    key: "Yw3yq8lxsaze+jdggjAwxKpV4GD6gNZptvw4d6OPfOc="

logger:
  level: INFO
  logs:
    lvgl: WARN

ota:
  - platform: esphome
    password: "bb198feb99159fdaefc8092f10039acd"

output:
  - platform: gpio
    pin: 46
    id: display_power
    inverted: false

spi:
  clk_pin: 18
  mosi_pin: 17

display:
  - platform: mipi_spi
    model: ST7789V
    id: my_display
    dc_pin: 15
    cs_pin: 7
    update_interval: 2s
    rotation: 270
    invert_colors: true
    color_order: BGR
    pixel_mode: 16bit
    dimensions:
      width: 280
      height: 240
      offset_width: 20
      offset_height: 0
    auto_clear_enabled: false

lvgl:
  log_level: WARN
  buffer_size: 25%

  encoders:
    - group: main_group
      enter_button: button_select
      left_button: button_up
      right_button: button_down
      initial_focus: bathroom_button

  style_definitions:
    - id: style_btn_off
      bg_color: 0x808080
      text_color: 0x000000
      border_width: 0
      radius: 6

    - id: style_btn_on
      bg_color: 0x00FF00
      text_color: 0x000000
      border_width: 0
      radius: 6

    - id: style_focused
      border_color: 0xFFFFFF
      border_width: 3

  pages:
    - id: main_page
      bg_color: 0x000000
      widgets:
        - obj:
            align: CENTER
            width: 260
            height: 220
            bg_opa: TRANSP
            layout:
              type: FLEX
              flex_flow: COLUMN
              flex_align_main: CENTER
              flex_align_cross: CENTER
              pad_row: 10
            widgets:
              - button:
                  id: bathroom_button
                  group: main_group
                  width: 200
                  height: 50
                  checkable: true
                  styles: [style_btn_off, style_focused]
                  widgets:
                    - label:
                        text: "Bathroom"
                        align: CENTER
                  on_click:
                    - homeassistant.action:
                        action: light.toggle
                        data:
                          entity_id: light.tz3000_qewo8dlz_ts0013_light

              - button:
                  id: pantry_button
                  group: main_group
                  width: 200
                  height: 50
                  checkable: true
                  styles: [style_btn_off, style_focused]
                  widgets:
                    - label:
                        text: "Pantry"
                        align: CENTER
                  on_click:
                    - homeassistant.action:
                        action: light.toggle
                        data:
                          entity_id: light.tz3000_qewo8dlz_ts0013_light_2

              - button:
                  id: kitchen_button
                  group: main_group
                  width: 200
                  height: 50
                  checkable: true
                  styles: [style_btn_off, style_focused]
                  widgets:
                    - label:
                        text: "Kitchen"
                        align: CENTER
                  on_click:
                    - homeassistant.action:
                        action: light.toggle
                        data:
                          entity_id: light.tz3000_qewo8dlz_ts0013_light_3

binary_sensor:
  - platform: homeassistant
    id: bathroom_light
    entity_id: light.tz3000_qewo8dlz_ts0013_light
    internal: true
    on_state:
      then:
        - lvgl.widget.update:
            id: bathroom_button
            state:
              checked: !lambda 'return id(bathroom_light).state;'

  - platform: homeassistant
    id: pantry_light
    entity_id: light.tz3000_qewo8dlz_ts0013_light_2
    internal: true
    on_state:
      then:
        - lvgl.widget.update:
            id: pantry_button
            state:
              checked: !lambda 'return id(pantry_light).state;'

  - platform: homeassistant
    id: kitchen_light
    entity_id: light.tz3000_qewo8dlz_ts0013_light_3
    internal: true
    on_state:
      then:
        - lvgl.widget.update:
            id: kitchen_button
            state:
              checked: !lambda 'return id(kitchen_light).state;'

  - platform: gpio
    pin:
      number: 38
      mode: INPUT_PULLUP
      inverted: true
    id: button_up

  - platform: gpio
    pin:
      number: 41
      mode: INPUT_PULLUP
      inverted: true
    id: button_down

  - platform: gpio
    pin:
      number: 40
      mode: INPUT_PULLUP
      inverted: true
    id: button_select
Failed config

lvgl: [source /config/lilka.yaml:76]
  log_level: WARN
  buffer_size: 25%
  encoders: 
    
    'sensor' is a required option for [encoders].
    - group: main_group
      enter_button: button_select
      
      [left_button] is an invalid option for [encoders]. Did you mean [enter_button]?
      left_button: button_up
      
      [right_button] is an invalid option for [encoders]. Did you mean [enter_button]?
      right_button: button_down
      initial_focus: bathroom_button
  style_definitions: 
  encoders:
    - enter_button: enter_key
      sensor:
        left_button: left_key
        right_button: right_key
1 Like

Now my configuration with the encoder compiles. Thank you very much. Could you help me fix another issue? All the buttons on the screen are focused (surrounded by a white frame), and pressing the up or down buttons does nothing.

esphome:
  name: lilka
  friendly_name: lilka
  on_boot:
    priority: 600
    then:
      - output.turn_on: display_power
      - delay: 100ms

esp32:
  board: esp32-s3-devkitc-1
  framework:
    type: esp-idf
    version: recommended
    sdkconfig_options:
      CONFIG_ESP32S3_DATA_CACHE_64KB: y
      CONFIG_ESP32S3_DATA_CACHE_LINE_64B: y
      CONFIG_SPIRAM_MODE_OCT: y
      CONFIG_SPIRAM: y

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password
  power_save_mode: none
  ap:
    ssid: "Lilka Fallback Hotspot"
    password: "hnYImYM4O6I0"

captive_portal:

web_server:
  port: 80

api:
  encryption:
    key: "Yw3yq8lxsaze+jdggjAwxKpV4GD6gNZptvw4d6OPfOc="

logger:
  level: INFO
  logs:
    lvgl: WARN

ota:
  - platform: esphome
    password: "bb198feb99159fdaefc8092f10039acd"

output:
  - platform: gpio
    pin: 46
    id: display_power
    inverted: false

spi:
  clk_pin: 18
  mosi_pin: 17

display:
  - platform: mipi_spi
    model: ST7789V
    id: my_display
    dc_pin: 15
    cs_pin: 7
    update_interval: 2s
    rotation: 270
    invert_colors: true
    color_order: BGR
    pixel_mode: 16bit
    dimensions:
      width: 280
      height: 240
      offset_width: 20
      offset_height: 0
    auto_clear_enabled: false

lvgl:
  log_level: WARN
  buffer_size: 25%
  
  encoders:
    - enter_button: enter_key
      sensor:
        left_button: left_key
        right_button: right_key
  
  default_group: main_group

  style_definitions:
    - id: style_btn_off
      bg_color: 0x808080
      text_color: 0x000000
      border_width: 0
      radius: 6

    - id: style_btn_on
      bg_color: 0x00FF00
      text_color: 0x000000
      border_width: 0
      radius: 6

    - id: style_focused
      border_color: 0xFFFFFF
      border_width: 3

  pages:
    - id: main_page
      bg_color: 0x000000
      widgets:
        - obj:
            align: CENTER
            width: 260
            height: 220
            bg_opa: TRANSP
            layout:
              type: FLEX
              flex_flow: COLUMN
              flex_align_main: CENTER
              flex_align_cross: CENTER
              pad_row: 10
            widgets:
              - button:
                  id: bathroom_button
                  group: main_group
                  width: 200
                  height: 50
                  checkable: true
                  styles: [style_btn_off, style_focused]
                  widgets:
                    - label:
                        text: "Bathroom"
                        align: CENTER
                  on_click:
                    - homeassistant.action:
                        action: light.toggle
                        data:
                          entity_id: light.tz3000_qewo8dlz_ts0013_light

              - button:
                  id: pantry_button
                  group: main_group
                  width: 200
                  height: 50
                  checkable: true
                  styles: [style_btn_off, style_focused]
                  widgets:
                    - label:
                        text: "Pantry"
                        align: CENTER
                  on_click:
                    - homeassistant.action:
                        action: light.toggle
                        data:
                          entity_id: light.tz3000_qewo8dlz_ts0013_light_2

              - button:
                  id: kitchen_button
                  group: main_group
                  width: 200
                  height: 50
                  checkable: true
                  styles: [style_btn_off, style_focused]
                  widgets:
                    - label:
                        text: "Kitchen"
                        align: CENTER
                  on_click:
                    - homeassistant.action:
                        action: light.toggle
                        data:
                          entity_id: light.tz3000_qewo8dlz_ts0013_light_3

binary_sensor:
  - platform: homeassistant
    id: bathroom_light
    entity_id: light.tz3000_qewo8dlz_ts0013_light
    internal: true
    on_state:
      then:
        - lvgl.widget.update:
            id: bathroom_button
            state:
              checked: !lambda 'return id(bathroom_light).state;'

  - platform: homeassistant
    id: pantry_light
    entity_id: light.tz3000_qewo8dlz_ts0013_light_2
    internal: true
    on_state:
      then:
        - lvgl.widget.update:
            id: pantry_button
            state:
              checked: !lambda 'return id(pantry_light).state;'

  - platform: homeassistant
    id: kitchen_light
    entity_id: light.tz3000_qewo8dlz_ts0013_light_3
    internal: true
    on_state:
      then:
        - lvgl.widget.update:
            id: kitchen_button
            state:
              checked: !lambda 'return id(kitchen_light).state;'

  - platform: gpio
    pin:
      number: 38
      mode: INPUT_PULLUP
      inverted: true
    id: left_key

  - platform: gpio
    pin:
      number: 41
      mode: INPUT_PULLUP
      inverted: true
    id: right_key

  - platform: gpio
    pin:
      number: 0
      mode: INPUT_PULLUP
      inverted: true
    id: enter_key

You specified a default group but didn’t assign it to the encoder. Unless you need multiple groups, just remove all the group information and leave all the widgets and the encoder in the default group.

You don’t have any styling for the focused button, so you won’t see focus changes (you applied the “focused” style to all buttons unconditionally.)

Unrelated, but you don’t need those sdkconfig_options.

lvgl:
  encoders:
    - enter_button: enter_key
      id: encoder_id
      sensor:
        left_button: left_key
        right_button: right_key

  theme:
    label:
      align: center
      text_align: center
    button:
      width: 100%
      bg_color: green
      text_color: black
      border_width: 2
      border_opa: transp
      radius: 6
      align: center
      focus_key:
        border_color: white
        border_opa: cover

  pages:
    - id: main_page
      bg_color: black
      widgets:
        - container:
            width: 80%
            align: CENTER
            bg_opa: TRANSP
            pad_all: 10
            layout:
              type: FLEX
              flex_flow: COLUMN
              flex_align_main: CENTER
              flex_align_track: CENTER
              flex_align_cross: CENTER
              pad_row: 10
            widgets:
              - button:
                  id: bathroom_button
                  checkable: true
                  widgets:
                    label:
                      text: "Bathroom"
                  on_click:
                    - homeassistant.action:
                        action: light.toggle
                        data:
                          entity_id: light.tz3000_qewo8dlz_ts0013_light

              - button:
                  id: pantry_button
                  checkable: true
                  widgets:
                    label:
                      text: "Pantry"
                  on_click:
                    - homeassistant.action:
                        action: light.toggle
                        data:
                          entity_id: light.tz3000_qewo8dlz_ts0013_light_2

              - button:
                  id: kitchen_button
                  checkable: true
                  widgets:
                    label:
                      text: "Kitchen"
                  on_click:
                    - homeassistant.action:
                        action: light.toggle
                        data:
                          entity_id: light.tz3000_qewo8dlz_ts0013_light_3
1 Like

It works! Thank you for your help! https://youtu.be/m2ZnhPG_efg

esphome:
  name: lilka
  friendly_name: lilka
  on_boot:
    priority: 600
    then:
      - output.turn_on: display_power
      - delay: 100ms

esp32:
  board: esp32-s3-devkitc-1
  framework:
    type: esp-idf
    version: recommended

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password
  power_save_mode: none
  ap:
    ssid: "Lilka Fallback Hotspot"
    password: "hnYImYM4O6I0"

captive_portal:
web_server:
api:
  encryption:
    key: "Yw3yq8lxsaze+jdggjAwxKpV4GD6gNZptvw4d6OPfOc="

logger:
  level: INFO
  logs:
    lvgl: WARN

ota:
  - platform: esphome
    password: "bb198feb99159fdaefc8092f10039acd"

output:
  - platform: gpio
    pin: 46
    id: display_power

spi:
  clk_pin: 18
  mosi_pin: 17

display:
  - platform: mipi_spi
    model: ST7789V
    id: my_display
    dc_pin: 15
    cs_pin: 7
    update_interval: 2s
    rotation: 270
    invert_colors: true
    color_order: BGR
    pixel_mode: 16bit
    dimensions:
      width: 280
      height: 240
      offset_width: 20
    auto_clear_enabled: false

lvgl:
  encoders:
    - enter_button: enter_key
      id: encoder_id
      sensor:
        left_button: left_key
        right_button: right_key
  theme:
    label:
      align: center
      text_align: center
      text_font: MONTSERRAT_24
    button:
      width: 80%
      bg_color: grey
      text_color: black
      border_width: 2
      border_opa: transp
      radius: 6
      focus_key:
        border_color: white
        border_opa: cover
  pages:
    - id: main_page
      bg_color: black
      pad_all: 20
      layout:
        type: FLEX
        flex_flow: COLUMN
        flex_align_main: SPACE_EVENLY
        flex_align_cross: CENTER
      widgets:
        - button:
            id: bathroom_button
            checkable: true
            widgets:
              - label:
                  text: "Bathroom"
            on_click:
              - homeassistant.action:
                  action: light.toggle
                  data:
                    entity_id: light.tz3000_qewo8dlz_ts0013_light
        - button:
            id: pantry_button
            checkable: true
            widgets:
              - label:
                  text: "Pantry"
            on_click:
              - homeassistant.action:
                  action: light.toggle
                  data:
                    entity_id: light.tz3000_qewo8dlz_ts0013_light_2
        - button:
            id: kitchen_button
            checkable: true
            widgets:
              - label:
                  text: "Kitchen"
            on_click:
              - homeassistant.action:
                  action: light.toggle
                  data:
                    entity_id: light.tz3000_qewo8dlz_ts0013_light_3

binary_sensor:
  - platform: homeassistant
    id: bathroom_light
    entity_id: light.tz3000_qewo8dlz_ts0013_light
    internal: true
    on_state:
      - lvgl.widget.update:
          id: bathroom_button
          state:
            checked: !lambda 'return id(bathroom_light).state;'
  - platform: homeassistant
    id: pantry_light
    entity_id: light.tz3000_qewo8dlz_ts0013_light_2
    internal: true
    on_state:
      - lvgl.widget.update:
          id: pantry_button
          state:
            checked: !lambda 'return id(pantry_light).state;'
  - platform: homeassistant
    id: kitchen_light
    entity_id: light.tz3000_qewo8dlz_ts0013_light_3
    internal: true
    on_state:
      - lvgl.widget.update:
          id: kitchen_button
          state:
            checked: !lambda 'return id(kitchen_light).state;'
  - platform: gpio
    pin:
      number: 38
      mode: INPUT_PULLUP
      inverted: true
    id: left_key
  - platform: gpio
    pin:
      number: 41
      mode: INPUT_PULLUP
      inverted: true
    id: right_key
  - platform: gpio
    pin:
      number: 0
      mode: INPUT_PULLUP
      inverted: true
    id: enter_key
1 Like