GUITION 4" 480x480 ESP32-S3-4848S040 Smart Display with LVGL

Hi everyone! I’ve created a repository with an external i18n component for ESPHome (will be used in the project). For those who want to play with it, here’s the repository.

I’ve also added a service to the display_tool repository that creates two sensors for weather forecasts, which can be easily parsed within ESPHome to generate weather forecasts (will also be used in the project).

UPD: A short video of the new design on a real device

I have trouble with the touchscreen on wake up.
If the backlight is off it will be turned on by a touch of the screen. However that touch can also trigger an unwanted button on the screen. To prevent that I try to wake up the screen, wait 1 sec and only then make it response to subsequent touches. For me the code seems to be right but in real life the results are not always what is expect and still there is a button activated because of the wake up touch. What is wrong with this or better, is there a other way to solve this?

globals:
  - id: lvgl_wake_guard
    type: bool
    restore_value: False
    initial_value: 'true'

lvgl:
  on_idle:
    timeout: !lambda "return (id(display_timeout).state * 1000);"
    then:
      if:
        condition: 
          lambda: 'return id(settings_display_auto_timeout_checkbox).state;'
        then:
          - globals.set:
              id: lvgl_wake_guard
              value: 'true'
          #- logger.log: "LVGL is idle"
          - light.turn_off: backlight
          - lvgl.pause:
  displays: my_display

touchscreen:
  platform: gt911
  transform:
    mirror_x: false
    mirror_y: false
  id: my_touchscreen
  display: my_display

  on_touch:
    then:
      - if:
          condition:
            lambda: 'return id(lvgl_wake_guard);'
          then:
            - lvgl.pause
            - light.turn_on:
                id: backlight
                transition_length: 1s
            - script.execute: lvgl_wake_release
          else:
            - logger.log: "Touch passed to LVGL"

script:
  - id: lvgl_wake_release
    mode: restart
    then:
#      - delay: 1s
      - lambda: |-
                        delay(1000);  // This is blocking
      - lvgl.resume:
      - lvgl.widget.redraw:
      - globals.set:
          id: lvgl_wake_guard
          value: 'false'

I have it done this way and it doesn’t cause any problems.

light:
  # Backlight
  - platform: monochromatic
    output: backlight_output
    name: Backlight
    id: display_backlight
    restore_mode: ALWAYS_ON
    on_turn_on:
      - if:
          condition: lvgl.is_paused
          then:
            - logger.log: "LVGL resuming by backlight on"
            - lvgl.resume:
            - lvgl.widget.redraw:
    on_turn_off:
      - if:
          condition:
            lambda: 'return id(display_timeout_number).state >= 0;'
          then:
            - logger.log: "Backlight off, pausing LVGL"
            - lvgl.pause:

Okay, I will try that.
But I am wondering what will happen in your on_touch then?

touchscreen:
  platform: gt911
  transform:
    mirror_x: false
    mirror_y: false
  id: my_touchscreen
  display: my_display

  on_touch:
     <what happens here>

Nothing


touchscreen:
  platform: gt911
  id: my_touchscreen
  transform:
    mirror_x: false
    mirror_y: false
  display: my_display
  on_release:
      - if:
          condition: lvgl.is_paused
          then:
            - logger.log: "LVGL resuming"
            - lvgl.resume:
            - lvgl.widget.redraw:
            - light.turn_on: display_backlight

  # on_touch:
  #   - lambda: |-
  #         ESP_LOGI("cal", "x=%d, y=%d, x_raw=%d, y_raw=%0d",
  #             touch.x,
  #             touch.y,
  #             touch.x_raw,
  #             touch.y_raw
  #             );

use on_release instead on_touch

Only turning off backlight will cause touch to remain active and will trigger, yes (done that…). To disable touch you must send lvgl to sleep when turning off backlight with lvgl.pause command, then wake up display with “on_release” command, as Alexey suggested.

Otherwise i use “on_long_press” instead “on_click” on other buttons, because i get unwanted random wakeups or triggers with “on_click” commands. I have set “long_press_time” to 50ms, it’s enough to eliminate those random wakeups.

Thanks for the quick reply and explanation. :+1:
I got it working after implementing the suggested changes.

Thanks to your help this project got the “We are satisfied” tick from the family.

Nice place to put the display. Do you also describe your project from a hardware perspective somewhere?

Looks great do you have some simple demo code I can run that will pull and display a weather forecast using this library?

Hi! It’s unlikely to be simple. But I can share my code this evening. The tricky part is that the sensor data needs to be parsed and processed. I haven’t reached the final optimization stage yet, but I think you’ll figure it out.

For anyone that want to replicate this easy build

Material

1 pc LED Lamp.
The cheapest you can find. This one was E5,- at the local discount shop called 'action" in The Netherlands. Just dissemble the gooseneck with the light and the tip contact switch and offer them a new home

1 Power supply, 5V.
2A would be just fine.
I wanted a clean build with just a power cord so I used this

And of course a Guiton 4" display

This is the 3D print STL file
Thingiverse link

So, this is how it works for me now:

globals:

  - id: forecast_daily_count
    type: int
    initial_value: '0'
  
  - id: forecast_daily_timestamps
    type: time_t[10]
  
  - id: forecast_daily_temps
    type: float[10]
  
  - id: forecast_daily_conditions
    type: std::string[10]
  
  - id: forecast_hourly_count
    type: int
    initial_value: '0'
  
  - id: forecast_hourly_timestamps
    type: time_t[15]
  
  - id: forecast_hourly_temps
    type: float[15]
  
  - id: forecast_hourly_conditions
    type: std::string[15]


sensor:
  # Weather forecast daily count
  - platform: homeassistant
    id: weather_forecast_daily_count
    entity_id: sensor.display_tools_forecasts_daily
    internal: true

  # Weather forecast hourly count
  - platform: homeassistant
    id: weather_forecast_hourly_count
    entity_id: sensor.display_tools_forecasts_hourly
    internal: true

text_sensor:
  # Weather forecast daily sensor
  - platform: homeassistant
    id: weather_forecast_daily_sensor
    entity_id: sensor.display_tools_forecasts_daily
    attribute: forecasts
    internal: true
    on_value:
      then:
        - script.execute: parse_daily_forecasts

  # Weather forecast hourly sensor
  - platform: homeassistant
    id: weather_forecast_hourly_sensor
    entity_id: sensor.display_tools_forecasts_hourly
    attribute: forecasts
    internal: true
    on_value:
      then:
        - script.execute: parse_hourly_forecasts

script:

  - id: parse_daily_forecasts
    then:
      - lambda: |-
          ESP_LOGI("weather", "=== Parsing DAILY Forecasts ===");
          
          std::string json_str = id(weather_forecast_daily_sensor).state;
          
          if (json_str.empty() || json_str == "unknown") {
            ESP_LOGW("weather", "No daily forecast data");
            id(forecast_daily_count) = 0;
            return;
          }
          
          ESP_LOGI("weather", "Daily JSON: %d bytes", json_str.length());
          
          JsonDocument doc;
          DeserializationError error = deserializeJson(doc, json_str);
          
          if (error) {
            ESP_LOGE("weather", "Parse error: %s", error.c_str());
            id(forecast_daily_count) = 0;
            return;
          }
          
          JsonArray forecasts = doc.as<JsonArray>();
          if (forecasts.isNull()) {
            ESP_LOGE("weather", "Not an array!");
            id(forecast_daily_count) = 0;
            return;
          }
          
          int count = std::min((int)forecasts.size(), 6);
          id(forecast_daily_count) = count;
          
          ESP_LOGI("weather", "Processing %d daily forecasts", count);
          
          auto local_now = id(sntp_time).now();
          auto utc_now = id(sntp_time).utcnow();
          
          int32_t offset_hours = local_now.hour - utc_now.hour;
          if (offset_hours > 12) offset_hours -= 24;
          if (offset_hours < -12) offset_hours += 24;
          
          int32_t tz_offset = offset_hours * 3600 + 
                            (local_now.minute - utc_now.minute) * 60 + 
                            (local_now.second - utc_now.second);
          
          ESP_LOGI("parse_debug", "Detected TZ offset: %+d seconds (%+d hours)", 
                  tz_offset, offset_hours);
          
          for (int i = 0; i < count; i++) {
            JsonObject forecast = forecasts[i];
            const char* dt_str = forecast["datetime"];
            
            if (dt_str) {
              ESP_LOGI("parse_debug", "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
              ESP_LOGI("parse_debug", "📅 Raw datetime: %s", dt_str);
              
              struct tm tm = {};
              if (strptime(dt_str, "%Y-%m-%dT%H:%M:%S", &tm)) {
                time_t local_ts = mktime(&tm);
          
                time_t utc_timestamp = local_ts + tz_offset;
                
                id(forecast_daily_timestamps)[i] = utc_timestamp;
                
                ESP_LOGI("parse_debug", "🕐 Parsed time: %04d-%02d-%02d %02d:%02d:%02d",
                        tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday,
                        tm.tm_hour, tm.tm_min, tm.tm_sec);
                ESP_LOGI("parse_debug", "   mktime result (as MSK): %ld", local_ts);
                ESP_LOGI("parse_debug", "✅ UTC Timestamp: %ld (offset: %+d)", 
                        utc_timestamp, tz_offset);
                
                struct tm check_utc;
                gmtime_r(&utc_timestamp, &check_utc);
                ESP_LOGI("parse_debug", "   Check UTC: %04d-%02d-%02d %02d:%02d:%02d (wday: %d)",
                        check_utc.tm_year + 1900, check_utc.tm_mon + 1, check_utc.tm_mday,
                        check_utc.tm_hour, check_utc.tm_min, check_utc.tm_sec,
                        check_utc.tm_wday);
                
                time_t local_display = utc_timestamp + tz_offset;
                struct tm check_local;
                gmtime_r(&local_display, &check_local);
                ESP_LOGI("parse_debug", "   Check Local: %04d-%02d-%02d %02d:%02d:%02d (wday: %d)",
                        check_local.tm_year + 1900, check_local.tm_mon + 1, check_local.tm_mday,
                        check_local.tm_hour, check_local.tm_min, check_local.tm_sec,
                        check_local.tm_wday);
                
                ESP_LOGI("parse_debug", "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
              } else {
                ESP_LOGE("parse_debug", "❌ Failed to parse: %s", dt_str);
                id(forecast_daily_timestamps)[i] = 0;
              }
            } else {
              ESP_LOGW("parse_debug", "⚠️ No datetime for forecast %d", i);
              id(forecast_daily_timestamps)[i] = 0;
            }
            
            id(forecast_daily_temps)[i] = forecast["temperature"] | 0.0f;
            id(forecast_daily_conditions)[i] = forecast["condition"] | "sunny";
            
            ESP_LOGD("weather", "[%d] %.1f°C, %s", 
                    i, id(forecast_daily_temps)[i], 
                    id(forecast_daily_conditions)[i].c_str());
          }
          
          ESP_LOGI("weather", "✅ Daily parsing complete");
          
      - script.execute: update_daily_display


  - id: parse_hourly_forecasts
    then:
      - lambda: |-
          ESP_LOGI("weather", "=== Parsing HOURLY Forecasts ===");
          
          std::string json_str = id(weather_forecast_hourly_sensor).state;
          
          if (json_str.empty() || json_str == "unknown") {
            ESP_LOGW("weather", "No hourly forecast data");
            id(forecast_hourly_count) = 0;
            return;
          }
          
          ESP_LOGI("weather", "Hourly JSON: %d bytes", json_str.length());
          
          JsonDocument doc;
          DeserializationError error = deserializeJson(doc, json_str);
          
          if (error) {
            ESP_LOGE("weather", "Parse error: %s", error.c_str());
            id(forecast_hourly_count) = 0;
            return;
          }
          
          JsonArray forecasts = doc.as<JsonArray>();
          if (forecasts.isNull()) {
            ESP_LOGE("weather", "Not an array!");
            id(forecast_hourly_count) = 0;
            return;
          }
          
          int count = forecasts.size();
          id(forecast_hourly_count) = count;
          
          ESP_LOGI("weather", "Processing %d hourly forecasts", count);
          
          auto local_now = id(sntp_time).now();
          auto utc_now = id(sntp_time).utcnow();
          
          int32_t offset_hours = local_now.hour - utc_now.hour;
          if (offset_hours > 12) offset_hours -= 24;
          if (offset_hours < -12) offset_hours += 24;
          
          int32_t tz_offset = offset_hours * 3600 + 
                            (local_now.minute - utc_now.minute) * 60 + 
                            (local_now.second - utc_now.second);
          
          ESP_LOGI("parse_debug", "Detected TZ offset: %+d seconds (%+d hours)", 
                  tz_offset, offset_hours);
          
          for (int i = 0; i < count; i++) {
            JsonObject forecast = forecasts[i];
            const char* dt_str = forecast["datetime"];
            
            if (dt_str) {
              ESP_LOGI("parse_debug", "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
              ESP_LOGI("parse_debug", "📅 Raw datetime: %s", dt_str);
              
              struct tm tm = {};
              if (strptime(dt_str, "%Y-%m-%dT%H:%M:%S", &tm)) {

                time_t local_ts = mktime(&tm);
                
                time_t utc_timestamp = local_ts + tz_offset;
                
                id(forecast_hourly_timestamps)[i] = utc_timestamp;
                
                ESP_LOGI("parse_debug", "🕐 Parsed time: %04d-%02d-%02d %02d:%02d:%02d",
                        tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday,
                        tm.tm_hour, tm.tm_min, tm.tm_sec);
                ESP_LOGI("parse_debug", "   mktime result (as MSK): %ld", local_ts);
                ESP_LOGI("parse_debug", "✅ UTC Timestamp: %ld (offset: %+d)", 
                        utc_timestamp, tz_offset);
                
                struct tm check_utc;
                gmtime_r(&utc_timestamp, &check_utc);
                ESP_LOGI("parse_debug", "   Check UTC: %04d-%02d-%02d %02d:%02d:%02d",
                        check_utc.tm_year + 1900, check_utc.tm_mon + 1, check_utc.tm_mday,
                        check_utc.tm_hour, check_utc.tm_min, check_utc.tm_sec);
                
                time_t local_display = utc_timestamp + tz_offset;
                struct tm check_local;
                gmtime_r(&local_display, &check_local);
                ESP_LOGI("parse_debug", "   Check Local: %04d-%02d-%02d %02d:%02d:%02d",
                        check_local.tm_year + 1900, check_local.tm_mon + 1, check_local.tm_mday,
                        check_local.tm_hour, check_local.tm_min, check_local.tm_sec);
                
                ESP_LOGI("parse_debug", "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
              } else {
                ESP_LOGE("parse_debug", "❌ Failed to parse: %s", dt_str);
                id(forecast_hourly_timestamps)[i] = 0;
              }
            } else {
              ESP_LOGW("parse_debug", "⚠️ No datetime for forecast %d", i);
              id(forecast_hourly_timestamps)[i] = 0;
            }
            
            id(forecast_hourly_temps)[i] = forecast["temperature"] | 0.0f;
            id(forecast_hourly_conditions)[i] = forecast["condition"] | "sunny";
            
            ESP_LOGD("weather", "[%d] %s: %.1f°C, %s", 
                    i, dt_str ? dt_str : "unknown",
                    id(forecast_hourly_temps)[i], 
                    id(forecast_hourly_conditions)[i].c_str());
          }
          
          ESP_LOGI("weather", "✅ Hourly parsing complete");
          
      - script.execute: update_hourly_display


  - id: update_daily_display
    then:
      - lambda: |-
          ESP_LOGI("weather", "=== Updating DAILY Display ===");
          
          if (id(forecast_daily_count) == 0) return;
          if (!id(weather_forecast_daily)) return;
          
          static esphome::lvgl::FontEngine fe_nunito_16(id(nunito_16));
          
          lv_obj_clean(id(weather_forecast_daily));
          
          auto get_icon = [](const std::string& c) -> esphome::image::Image* {
            if (c == "clear-night") return id(clear_night_small_img);
            if (c == "cloudy") return id(cloudy_small_img);
            if (c == "fog") return id(fog_small_img);
            if (c == "hail") return id(hail_small_img);
            if (c == "lightning") return id(lightning_small_img);
            if (c == "lightning-rainy") return id(lightning_rainy_small_img);
            if (c == "partlycloudy") return id(partlycloudy_sun_small_img);
            if (c == "pouring") return id(pouring_small_img);
            if (c == "rainy") return id(rainy_small_img);
            if (c == "snowy") return id(snowy_small_img);
            if (c == "snowy-rainy") return id(snowy_rainy_small_img);
            if (c == "sunny") return id(sunny_small_img);
            if (c == "windy") return id(windy_small_img);
            if (c == "windy-variant") return id(windy_variant_small_img);
            return id(sunny_small_img);
          };
          
          auto get_day_name = [](int index) -> const char* {
            auto time_now = id(sntp_time).now();
            int today_wday = time_now.day_of_week;
            int forecast_wday = (today_wday + index - 1) % 7 + 1;
            
            const char* day_keys[] = {
              "", "day_of_week_short.sunday", "day_of_week_short.monday",
              "day_of_week_short.tuesday", "day_of_week_short.wednesday",
              "day_of_week_short.thursday", "day_of_week_short.friday",
              "day_of_week_short.saturday"
            };
            
            if (forecast_wday < 1 || forecast_wday > 7) return "???";
            static std::string result;
            result = id(i18n_translations).translate(day_keys[forecast_wday]);
            return result.c_str();
          };
          
          lv_obj_t* cont = lv_obj_create(id(weather_forecast_daily));
          if (!cont) return;
          
          lv_obj_set_size(cont, 408, 150);
          lv_obj_align(cont, LV_ALIGN_CENTER, 0, 5);
          lv_obj_set_style_bg_opa(cont, LV_OPA_TRANSP, 0);
          lv_obj_set_style_border_width(cont, 0, 0);
          lv_obj_set_style_pad_all(cont, 0, 0);
          // lv_obj_set_style_pad_row(cont, 0, 0);
          lv_obj_set_style_pad_column(cont, 0, 0);
          lv_obj_set_flex_flow(cont, LV_FLEX_FLOW_ROW);
          // lv_obj_set_flex_align(cont, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
          
          for (int i = 0; i < id(forecast_daily_count); i++) {
            lv_obj_t* item = lv_obj_create(cont);
            if (!item) continue;
            
            lv_obj_set_size(item, 68, 120);
            lv_obj_set_style_bg_opa(item, LV_OPA_TRANSP, 0);
            lv_obj_set_style_border_width(item, 0, 0);
            lv_obj_set_style_pad_all(item, 0, 0);
            
            lv_obj_t* day_label = lv_label_create(item);
            if (day_label) {
              lv_label_set_text(day_label, get_day_name(i));
              lv_obj_align(day_label, LV_ALIGN_TOP_MID, 0, 0);
              lv_obj_set_style_text_font(day_label, fe_nunito_16.get_lv_font(), 0);
              lv_obj_set_style_text_color(day_label, lv_color_hex(0x9BA2BC), 0);
            }
            
            lv_obj_t* icon = lv_img_create(item);
            if (icon) {
              lv_img_set_src(icon, get_icon(id(forecast_daily_conditions)[i]));
              lv_obj_align(icon, LV_ALIGN_CENTER, 0, 0);
            }
            
            lv_obj_t* temp_label = lv_label_create(item);
            if (temp_label) {
              char temp_str[10];
              snprintf(temp_str, sizeof(temp_str), "%.0f°", id(forecast_daily_temps)[i]);
              lv_label_set_text(temp_label, temp_str);
              lv_obj_align(temp_label, LV_ALIGN_BOTTOM_MID, 0, 0);
              lv_obj_set_style_text_font(temp_label, fe_nunito_16.get_lv_font(), 0);
              lv_obj_set_style_text_color(temp_label, lv_color_hex(0x9BA2BC), 0);
            }
          }
          
          ESP_LOGI("weather", "✅ Created %d daily items", id(forecast_daily_count));


  - id: update_hourly_display
    then:
      - lambda: |-
          ESP_LOGI("weather", "=== Updating HOURLY Display ===");
          
          if (id(forecast_hourly_count) == 0) return;
          if (!id(weather_forecast_hourly)) return;
          
          static esphome::lvgl::FontEngine fe_nunito_16(id(nunito_16));
          
          lv_obj_clean(id(weather_forecast_hourly));
          
          auto get_icon = [](const std::string& c) -> esphome::image::Image* {
            if (c == "clear-night") return id(clear_night_small_img);
            if (c == "cloudy") return id(cloudy_small_img);
            if (c == "fog") return id(fog_small_img);
            if (c == "hail") return id(hail_small_img);
            if (c == "lightning") return id(lightning_small_img);
            if (c == "lightning-rainy") return id(lightning_rainy_small_img);
            if (c == "partlycloudy") return id(partlycloudy_sun_small_img);
            if (c == "pouring") return id(pouring_small_img);
            if (c == "rainy") return id(rainy_small_img);
            if (c == "snowy") return id(snowy_small_img);
            if (c == "snowy-rainy") return id(snowy_rainy_small_img);
            if (c == "sunny") return id(sunny_small_img);
            if (c == "windy") return id(windy_small_img);
            if (c == "windy-variant") return id(windy_variant_small_img);
            return id(sunny_small_img);
          };
          
          auto get_time_str = [](time_t utc_timestamp) -> std::string {
            if (utc_timestamp == 0) return "--:--";
            
            auto local_now = id(sntp_time).now();
            auto utc_now = id(sntp_time).utcnow();
            
            int32_t offset_hours = local_now.hour - utc_now.hour;
            if (offset_hours > 12) offset_hours -= 24;
            if (offset_hours < -12) offset_hours += 24;
            
            int32_t tz_offset = offset_hours * 3600 + 
                              (local_now.minute - utc_now.minute) * 60 + 
                              (local_now.second - utc_now.second);
            
            time_t local_timestamp = utc_timestamp + tz_offset;
            
            struct tm local_tm;
            gmtime_r(&local_timestamp, &local_tm);
            
            char buf[10];
            snprintf(buf, sizeof(buf), "%02d:%02d", local_tm.tm_hour, local_tm.tm_min);
            return std::string(buf);
          };

          lv_obj_t* cont = lv_obj_create(id(weather_forecast_hourly));
          if (!cont) return;
          
          lv_obj_set_size(cont, LV_SIZE_CONTENT, 150);
          lv_obj_set_style_bg_opa(cont, LV_OPA_TRANSP, 0);
          lv_obj_set_style_border_width(cont, 0, 0);
          lv_obj_set_style_pad_all(cont, 0, 0);
          lv_obj_set_style_pad_column(cont, 0, 0);
          
          lv_obj_set_flex_flow(cont, LV_FLEX_FLOW_ROW);
          
          lv_obj_set_scrollbar_mode(cont, LV_SCROLLBAR_MODE_AUTO);
          lv_obj_set_scroll_dir(cont, LV_DIR_HOR);
          
          for (int i = 0; i < id(forecast_hourly_count); i++) {
            lv_obj_t* item = lv_obj_create(cont);
            if (!item) continue;
            
            lv_obj_set_size(item, 68, 120);
            lv_obj_set_style_bg_opa(item, LV_OPA_TRANSP, 0);
            lv_obj_set_style_border_width(item, 0, 0);
            lv_obj_set_style_pad_all(item, 2, 0);
            
            lv_obj_clear_flag(item, LV_OBJ_FLAG_SCROLLABLE);
            
            lv_obj_t* time_label = lv_label_create(item);
            if (time_label) {
              std::string time_str = get_time_str(id(forecast_hourly_timestamps)[i]);
              lv_label_set_text(time_label, time_str.c_str());
              lv_obj_align(time_label, LV_ALIGN_TOP_MID, 0, 0);
              lv_obj_set_style_text_font(time_label, fe_nunito_16.get_lv_font(), 0);
              lv_obj_set_style_text_color(time_label, lv_color_hex(0x9BA2BC), 0);
            }
            
            lv_obj_t* icon = lv_img_create(item);
            if (icon) {
              lv_img_set_src(icon, get_icon(id(forecast_hourly_conditions)[i]));
              lv_obj_align(icon, LV_ALIGN_CENTER, 0, 0);
            }
            
            lv_obj_t* temp_label = lv_label_create(item);
            if (temp_label) {
              char temp_str[10];
              snprintf(temp_str, sizeof(temp_str), "%.0f°", id(forecast_hourly_temps)[i]);
              lv_label_set_text(temp_label, temp_str);
              lv_obj_align(temp_label, LV_ALIGN_BOTTOM_MID, 0, 0);
              lv_obj_set_style_text_font(temp_label, fe_nunito_16.get_lv_font(), 0);
              lv_obj_set_style_text_color(temp_label, lv_color_hex(0x9BA2BC), 0);
            }
          }
          
          ESP_LOGI("weather", "✅ Created %d hourly items with scroll", id(forecast_hourly_count));

lvgl:
                    # example
                    - obj:
                        id: weather_forecast_daily
                        hidden: false
                        width: 408
                        height: 160
                        align: bottom_mid
                        clickable: false
                        radius: 20
                        pad_all: 0
                        bg_opa: transp
                        border_opa: transp
                        border_width: 0
                        shadow_opa: transp
                        layout:
                          type: flex
                          flex_flow: column
                          flex_align_main: center
                          flex_align_cross: center

                    - obj:
                        id: weather_forecast_hourly
                        hidden: true
                        width: 408
                        height: 160
                        align: bottom_mid
                        clickable: false
                        radius: 20
                        pad_all: 0
                        bg_opa: transp
                        border_opa: transp
                        border_width: 0
                        shadow_opa: transp
                        layout:
                          type: flex
                          flex_flow: column
                          flex_align_main: center
                          flex_align_cross: center


              # example
              - button:
                  id: weather_widget_btn
                  clickable: true
                  x: -110
                  width: 50
                  height: 50
                  align: right_mid
                  bg_opa: transp
                  shadow_opa: transp
                  widgets:
                    - label:
                        align: center
                        text_color: color_misty_blue
                        text_font: mdi_icons_28
                        text: "${weather_icon}"
                  on_click:
                    - homeassistant.action:
                        action: display_tools.get_forecasts
                        data:
                          entity_id: "${weather_entity}"
                          type: "daily"
                    - homeassistant.action:
                        action: display_tools.get_forecasts
                        data:
                          entity_id: "${weather_entity}"
                          type: "hourly"

Essentially, the difficulty is that the sensor data is an array, which still needs to be parsed for datetime and timezone. Plus, the scripts create dynamic widgets using native LVGL. But oddly enough, this is probably the simplest way to work with forecasts.

@Grijzekop I will try to capture a video next week.

Look forward to your finished project. Been following your progress and excited about the customization aspect of your new code.

Thanks) This weekend, I hope to finish the main page and possibly some basic functionality for the light domain. Once the full functionality for the light domain is ready, I’ll commit it to the dev branch.

The main widget is in the final stages, everything is ready, but I can’t seem to restore the screen lock state after rebooting. Everything seems to be working correctly; sometimes it works, but most of the time it doesn’t. Judging by the logs, the variable state appears to be written to the ESP32 memory, but it doesn’t return upon boot! I don’t think there’s any point in wasting time on this right now, especially since the firmware is still in dev mode. I’ll probably publish the initial work to the dev branch this weekend and then refine it, perhaps with your help and feedback.

Thanks for the great work. Your project helps me a lot with understanding more about the lvgl and esphome.
Btw, it would be great if we can add kind of proximity sensor hardware to this display. I see some cheap tuya base display using IR sensor for this. Anyone has idea or advice on this?

I managed to finalize the single-page thermostat, built with reusable elements — arcs, labels, and buttons.
The biggest issue I had was adjusting the timing, where the execution speed of the on_value function of the adjustment arc wasn’t fast enough to update all elements before the interrupt generated by the LVGL arc, especially if the user dragged the knob quickly.

Due to the limitations of EspHome, I had to find various optimizations: precomputing startup parameters in many global variables, moving non-critical functions to on_release, and calling verification and validation functions so that the thermostat’s logic wouldn’t get messed up. If only I had access to the second core — I wouldn’t have wanted anything else from EspHome!

Here’s the idea:

  • The order of presets cannot be changed; it must always be Home (1) – Away (2) – Return Home (3) – Sleep (4) (on weekends, we only have Weekend Home and Sleep).
  • Presets can cross midnight, which means any circular permutation is allowed. For example, Sleep can become the first preset of the day for people who go to bed late, resulting in the order 4-1-2-3, which is a valid circular permutation. Likewise, Return Home or Away can also cross midnight, so we could have 3-4-1-2 or 2-3-4-1 as valid daily orders. In other words, presets cannot skip over each other to form illogical sequences such as Home – Return – Away – Sleep (you can’t return home if you never left, and you can’t go to sleep if you’re away!).
  • Given the limitations of EspHome and LVGL arc, crossing midnight can only be done using the + and buttons. For example, if a preset adjusted with the arc reaches 23:55, and the user presses +, the preset jumps to 00:00, from where the user can continue adjusting via the arc’s knob.
  • If a preset adjusted via arc, button, or Home Assistant encounters another preset it’s not allowed to skip, it pushes that preset forward so that the minimum distance (set in substitutions) is maintained. This happens in cascade — meaning that if the pushed preset encounters another one, it pushes that one too, maintaining the defined spacing. Now you can probably see why the on_value function wasn’t fast enough: there were too many updates happening — arcs, knobs, labels, and colors. The idea of the minimum distance comes from the real behavior of a thermostat — in my case, underfloor heating/cooling — where it makes no sense for a preset to last less than 30–60 minutes due to thermal inertia.

I’ve attached a few photos, including one of the custom card in Home Assistant.
I’ll update the code on GitHub once I clean and organize it, because right now it looks terrible.

In the meantime, I’m trying to switch to the Zigbee version, on the Waveshare ESP32-P4-86-Panel, but due to the current limitations of the ESP Zigbee SDKs, I’ve hit another nightmare here too. So far, I managed to implement a basic dual thermostat on the C6 (I’m using both a mini C6 module and the C6 from the Waveshare module, before starting on the P4 code), as an end device — which means I can’t pair the thermostat with a Zigbee temperature sensor. The latest Zigbee library doesn’t work correctly on Router or Coordinator roles, so for now, I’ll use a wired sensor connected to the P4.

Anyway, my goal is also to migrate the thermostat for Guiton S3 to Zigbee, using a mini C6 module — possibly leveraging the SD card’s SPI connection — but that’s still a long way off!