From 2341d510d39cc2bd9bfb8a93b64dc421d1d3e545 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 19 Mar 2026 17:31:33 +1000 Subject: [PATCH] [lvgl] Migrate to library v9.5.0 (#12312) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- .clang-tidy.hash | 2 +- esphome/components/font/__init__.py | 1 + esphome/components/font/font.cpp | 85 ++- esphome/components/font/font.h | 2 +- esphome/components/image/__init__.py | 94 ++- esphome/components/image/image.cpp | 28 +- esphome/components/image/image.h | 2 +- esphome/components/lvgl/__init__.py | 168 +++-- esphome/components/lvgl/automation.py | 97 ++- esphome/components/lvgl/defines.py | 188 +++++- esphome/components/lvgl/encoders.py | 2 +- esphome/components/lvgl/gradient.py | 50 +- esphome/components/lvgl/keypads.py | 2 +- esphome/components/lvgl/layout.py | 24 +- esphome/components/lvgl/light/lvgl_light.h | 2 +- esphome/components/lvgl/lv_validation.py | 117 ++-- esphome/components/lvgl/lvcode.py | 8 + esphome/components/lvgl/lvgl_esphome.cpp | 393 ++++++++---- esphome/components/lvgl/lvgl_esphome.h | 133 ++-- esphome/components/lvgl/lvgl_hal.h | 21 - esphome/components/lvgl/number/__init__.py | 6 +- esphome/components/lvgl/schemas.py | 62 +- esphome/components/lvgl/select/lvgl_select.h | 6 +- esphome/components/lvgl/styles.py | 43 +- esphome/components/lvgl/text/lvgl_text.h | 9 +- esphome/components/lvgl/touchscreens.py | 2 - esphome/components/lvgl/trigger.py | 4 +- esphome/components/lvgl/types.py | 154 +---- esphome/components/lvgl/widgets/__init__.py | 348 ++++++++--- esphome/components/lvgl/widgets/arc.py | 47 +- esphome/components/lvgl/widgets/button.py | 8 +- .../components/lvgl/widgets/buttonmatrix.py | 36 +- esphome/components/lvgl/widgets/canvas.py | 278 ++++++--- esphome/components/lvgl/widgets/container.py | 12 +- esphome/components/lvgl/widgets/img.py | 49 +- esphome/components/lvgl/widgets/label.py | 4 +- esphome/components/lvgl/widgets/led.py | 6 +- esphome/components/lvgl/widgets/line.py | 6 +- esphome/components/lvgl/widgets/lv_bar.py | 4 +- esphome/components/lvgl/widgets/meter.py | 585 +++++++++++++----- esphome/components/lvgl/widgets/msgbox.py | 173 +++--- esphome/components/lvgl/widgets/obj.py | 3 +- esphome/components/lvgl/widgets/qrcode.py | 47 +- esphome/components/lvgl/widgets/slider.py | 14 +- esphome/components/lvgl/widgets/spinner.py | 21 +- esphome/components/lvgl/widgets/tabview.py | 45 +- esphome/components/lvgl/widgets/tileview.py | 14 +- esphome/components/online_image/__init__.py | 9 + esphome/components/runtime_image/__init__.py | 3 +- .../runtime_image/runtime_image.cpp | 9 +- esphome/core/defines.h | 1 + esphome/idf_component.yml | 2 + platformio.ini | 5 +- tests/components/lvgl/lvgl-package.yaml | 75 ++- 54 files changed, 2312 insertions(+), 1197 deletions(-) delete mode 100644 esphome/components/lvgl/lvgl_hal.h diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 87b4ebb2c6..72023e511d 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -8e48e836c6fc196d3da000d46eb09db243b87fe33518a74e49c8e009d756074a +44c877ff43765562ac8298902bf2208799643b77facf09c1c0c3c8c4e17187eb diff --git a/esphome/components/font/__init__.py b/esphome/components/font/__init__.py index 2667dbdbdf..c8813bf1bc 100644 --- a/esphome/components/font/__init__.py +++ b/esphome/components/font/__init__.py @@ -552,6 +552,7 @@ async def to_code(config): """ # get the codepoints from glyphsets and flatten to a set of chrs. + cg.add_define("USE_FONT") point_set: set[str] = { chr(x) for x in flatten( diff --git a/esphome/components/font/font.cpp b/esphome/components/font/font.cpp index 5e3bf1dd20..ecf0ca6bdd 100644 --- a/esphome/components/font/font.cpp +++ b/esphome/components/font/font.cpp @@ -9,13 +9,87 @@ namespace font { static const char *const TAG = "font"; #ifdef USE_LVGL_FONT -const uint8_t *Font::get_glyph_bitmap(const lv_font_t *font, uint32_t unicode_letter) { - auto *fe = (Font *) font->dsc; - const auto *gd = fe->get_glyph_data_(unicode_letter); +static const uint8_t OPA4_TABLE[16] = {0, 17, 34, 51, 68, 85, 102, 119, 136, 153, 170, 187, 204, 221, 238, 255}; + +static const uint8_t OPA2_TABLE[4] = {0, 85, 170, 255}; + +const void *Font::get_glyph_bitmap(lv_font_glyph_dsc_t *dsc, lv_draw_buf_t *draw_buf) { + const auto *font = dsc->resolved_font; + auto *const fe = (Font *) font->dsc; + + const auto *gd = fe->get_glyph_data_(dsc->gid.index); if (gd == nullptr) { return nullptr; } - return gd->data; + + const uint8_t *bitmap_in = gd->data; + uint8_t *bitmap_out_tmp = draw_buf->data; + int32_t i = 0; + int32_t x, y; + uint32_t stride = lv_draw_buf_width_to_stride(gd->width, LV_COLOR_FORMAT_A8); + + switch (fe->get_bpp()) { + case 1: { + uint8_t mask = 0; + uint8_t byte = 0; + for (y = 0; y != gd->height; y++) { + for (x = 0; x != gd->width; x++) { + if (mask == 0) { + mask = 0x80; + byte = *bitmap_in++; + } + bitmap_out_tmp[x] = byte & mask ? 255 : 0; + mask >>= 1; + } + bitmap_out_tmp += stride; + } + } break; + + case 2: + for (y = 0; y != gd->height; y++) { + for (x = 0; x != gd->width; x++, i++) { + switch (i & 0x3) { + default: + bitmap_out_tmp[x] = OPA2_TABLE[(*bitmap_in) >> 6]; + break; + case 1: + bitmap_out_tmp[x] = OPA2_TABLE[((*bitmap_in) >> 4) & 0x3]; + break; + case 2: + bitmap_out_tmp[x] = OPA2_TABLE[((*bitmap_in) >> 2) & 0x3]; + break; + case 3: + bitmap_out_tmp[x] = OPA2_TABLE[((*bitmap_in) >> 0) & 0x3]; + bitmap_in++; + } + } + bitmap_out_tmp += stride; + } + break; + + case 4: + for (y = 0; y != gd->height; y++) { + for (x = 0; x != gd->width; x++, i++) { + i = i & 0x1; + if (i == 0) { + bitmap_out_tmp[x] = OPA4_TABLE[(*bitmap_in) >> 4]; + } else if (i == 1) { + bitmap_out_tmp[x] = OPA4_TABLE[(*bitmap_in) & 0xF]; + bitmap_in++; + } + } + bitmap_out_tmp += stride; + } + break; + + case 8: + memcpy(bitmap_out_tmp, bitmap_in, gd->width * gd->height); + break; + default: + ESP_LOGD(TAG, "Unknown bpp: %d", fe->get_bpp()); + break; + } + return draw_buf; } bool Font::get_glyph_dsc_cb(const lv_font_t *font, lv_font_glyph_dsc_t *dsc, uint32_t unicode_letter, uint32_t next) { @@ -30,7 +104,8 @@ bool Font::get_glyph_dsc_cb(const lv_font_t *font, lv_font_glyph_dsc_t *dsc, uin dsc->box_w = gd->width; dsc->box_h = gd->height; dsc->is_placeholder = 0; - dsc->bpp = fe->get_bpp(); + dsc->format = (lv_font_glyph_format_t) fe->get_bpp(); + dsc->gid.index = unicode_letter; return true; } diff --git a/esphome/components/font/font.h b/esphome/components/font/font.h index 262ded3be4..4a09d7314d 100644 --- a/esphome/components/font/font.h +++ b/esphome/components/font/font.h @@ -90,7 +90,7 @@ class Font uint8_t bpp_; // bits per pixel #ifdef USE_LVGL_FONT lv_font_t lv_font_{}; - static const uint8_t *get_glyph_bitmap(const lv_font_t *font, uint32_t unicode_letter); + static const void *get_glyph_bitmap(lv_font_glyph_dsc_t *dsc, lv_draw_buf_t *draw_buf); static bool get_glyph_dsc_cb(const lv_font_t *font, lv_font_glyph_dsc_t *dsc, uint32_t unicode_letter, uint32_t next); const Glyph *get_glyph_data_(uint32_t unicode_letter); uint32_t last_letter_{}; diff --git a/esphome/components/image/__init__.py b/esphome/components/image/__init__.py index 6ff75d7709..6fb0e46d93 100644 --- a/esphome/components/image/__init__.py +++ b/esphome/components/image/__init__.py @@ -28,6 +28,7 @@ from esphome.const import ( CONF_URL, ) from esphome.core import CORE, HexInt +from esphome.final_validate import full_config _LOGGER = logging.getLogger(__name__) @@ -84,7 +85,7 @@ class ImageEncoder: def __init__(self, width, height, transparency, dither, invert_alpha): """ - :param width: The image width in pixels + :param width: The image width in pixels (or bytes) :param height: The image height in pixels :param transparency: Transparency type :param dither: Dither method @@ -93,11 +94,12 @@ class ImageEncoder: self.transparency = transparency self.width = width self.height = height - self.data = [0 for _ in range(width * height)] + self.data = [0] * width * height self.dither = dither self.index = 0 self.invert_alpha = invert_alpha self.path = "" + self.big_endian = False def convert(self, image, path): """ @@ -119,12 +121,21 @@ class ImageEncoder: :return: """ + def end_image(self): + """ + Called at the end of the image. + :return: + """ + + def set_big_endian(self, big_endian: bool) -> None: + self.big_endian = big_endian + @classmethod def is_endian(cls) -> bool: """ Check if the image encoder supports endianness configuration """ - return getattr(cls, "set_big_endian", None) is not None + return False @classmethod def get_options(cls) -> list[str]: @@ -212,18 +223,21 @@ class ImageGrayscale(ImageEncoder): class ImageRGB565(ImageEncoder): def __init__(self, width, height, transparency, dither, invert_alpha): - stride = 3 if transparency == CONF_ALPHA_CHANNEL else 2 super().__init__( - width * stride, + width * 2, height, transparency, dither, invert_alpha, ) - self.big_endian = True + self.alpha = [0] * width * height - def set_big_endian(self, big_endian: bool) -> None: - self.big_endian = big_endian + @classmethod + def is_endian(cls) -> bool: + """ + Check if the image encoder supports endianness configuration + """ + return True def convert(self, image, path): return image.convert("RGBA") @@ -233,6 +247,9 @@ class ImageRGB565(ImageEncoder): r = r >> 3 g = g >> 2 b = b >> 3 + if self.invert_alpha: + a ^= 0xFF + self.alpha[self.index // 2] = a if self.transparency == CONF_CHROMA_KEY: if r == 0 and g == 1 and b == 0: g = 0 @@ -251,11 +268,10 @@ class ImageRGB565(ImageEncoder): self.index += 1 self.data[self.index] = rgb >> 8 self.index += 1 + + def end_image(self): if self.transparency == CONF_ALPHA_CHANNEL: - if self.invert_alpha: - a ^= 0xFF - self.data[self.index] = a - self.index += 1 + self.data.extend(self.alpha) class ImageRGB(ImageEncoder): @@ -281,11 +297,11 @@ class ImageRGB(ImageEncoder): r = 0 g = 1 b = 0 - self.data[self.index] = r + self.data[self.index] = b self.index += 1 self.data[self.index] = g self.index += 1 - self.data[self.index] = b + self.data[self.index] = r self.index += 1 if self.transparency == CONF_ALPHA_CHANNEL: if self.invert_alpha: @@ -655,6 +671,24 @@ def _config_schema(value): CONFIG_SCHEMA = _config_schema +def _final_validate(config): + """ + For LVGL 9 the default byte order for RGB565 images is little-endian + :param config: + :return: + """ + fv = full_config.get() + if "lvgl" in fv and not all(CONF_BYTE_ORDER in x for x in config): + config = config.copy() + for c in config: + if not c.get(CONF_BYTE_ORDER): + c[CONF_BYTE_ORDER] = "LITTLE_ENDIAN" + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + async def write_image(config, all_frames=False): path = Path(config[CONF_FILE]) if not path.is_file(): @@ -720,6 +754,7 @@ async def write_image(config, all_frames=False): for col in range(width): encoder.encode(pixels[row * width + col]) encoder.end_row() + encoder.end_image() rhs = [HexInt(x) for x in encoder.data] prog_arr = cg.progmem_array(config[CONF_RAW_DATA_ID], rhs) @@ -729,31 +764,24 @@ async def write_image(config, all_frames=False): return prog_arr, width, height, image_type, trans_value, frame_count -async def _image_to_code(entry): - """ - Convert a single image entry to code and return its metadata. - :param entry: The config entry for the image. - :return: An ImageMetaData object - """ - prog_arr, width, height, image_type, trans_value, _ = await write_image(entry) - cg.new_Pvariable(entry[CONF_ID], prog_arr, width, height, image_type, trans_value) - return ImageMetaData( - width, - height, - entry[CONF_TYPE], - entry[CONF_TRANSPARENCY], +def add_metadata(id: str, width: int, height: int, image_type: str, transparency): + all_metadata = CORE.data.setdefault(DOMAIN, {}).setdefault(KEY_METADATA, {}) + all_metadata[str(id)] = ImageMetaData( + width=width, height=height, image_type=image_type, transparency=transparency ) async def to_code(config): cg.add_define("USE_IMAGE") # By now the config will be a simple list. - # Use a subkey to allow for other data in the future - CORE.data[DOMAIN] = { - KEY_METADATA: { - entry[CONF_ID].id: await _image_to_code(entry) for entry in config - } - } + for entry in config: + prog_arr, width, height, image_type, trans_value, _ = await write_image(entry) + cg.new_Pvariable( + entry[CONF_ID], prog_arr, width, height, image_type, trans_value + ) + add_metadata( + entry[CONF_ID], width, height, entry[CONF_TYPE], entry[CONF_TRANSPARENCY] + ) def get_all_image_metadata() -> dict[str, ImageMetaData]: diff --git a/esphome/components/image/image.cpp b/esphome/components/image/image.cpp index 90e021467f..a6f9e35e2e 100644 --- a/esphome/components/image/image.cpp +++ b/esphome/components/image/image.cpp @@ -105,22 +105,22 @@ Color Image::get_pixel(int x, int y, const Color color_on, const Color color_off } } #ifdef USE_LVGL -lv_img_dsc_t *Image::get_lv_img_dsc() { +lv_image_dsc_t *Image::get_lv_image_dsc() { // lazily construct lvgl image_dsc. if (this->dsc_.data != this->data_start_) { this->dsc_.data = this->data_start_; - this->dsc_.header.always_zero = 0; - this->dsc_.header.reserved = 0; + this->dsc_.header.reserved_2 = 0; + this->dsc_.header.stride = this->get_width_stride(); this->dsc_.header.w = this->width_; this->dsc_.header.h = this->height_; this->dsc_.data_size = this->get_width_stride() * this->get_height(); switch (this->get_type()) { case IMAGE_TYPE_BINARY: - this->dsc_.header.cf = LV_IMG_CF_ALPHA_1BIT; + this->dsc_.header.cf = LV_COLOR_FORMAT_A1; break; case IMAGE_TYPE_GRAYSCALE: - this->dsc_.header.cf = LV_IMG_CF_ALPHA_8BIT; + this->dsc_.header.cf = LV_COLOR_FORMAT_A8; break; case IMAGE_TYPE_RGB: @@ -138,7 +138,7 @@ lv_img_dsc_t *Image::get_lv_img_dsc() { } #else this->dsc_.header.cf = - this->transparency_ == TRANSPARENCY_ALPHA_CHANNEL ? LV_IMG_CF_RGBA8888 : LV_IMG_CF_RGB888; + this->transparency_ == TRANSPARENCY_ALPHA_CHANNEL ? LV_COLOR_FORMAT_ARGB8888 : LV_COLOR_FORMAT_RGB888; #endif break; @@ -146,14 +146,10 @@ lv_img_dsc_t *Image::get_lv_img_dsc() { #if LV_COLOR_DEPTH == 16 switch (this->transparency_) { case TRANSPARENCY_ALPHA_CHANNEL: - this->dsc_.header.cf = LV_IMG_CF_TRUE_COLOR_ALPHA; - break; - case TRANSPARENCY_CHROMA_KEY: - this->dsc_.header.cf = LV_IMG_CF_TRUE_COLOR_CHROMA_KEYED; + this->dsc_.header.cf = LV_COLOR_FORMAT_RGB565A8; break; default: - this->dsc_.header.cf = LV_IMG_CF_TRUE_COLOR; - break; + this->dsc_.header.cf = LV_COLOR_FORMAT_RGB565; } #else this->dsc_.header.cf = @@ -173,8 +169,8 @@ bool Image::get_binary_pixel_(int x, int y) const { } Color Image::get_rgb_pixel_(int x, int y) const { const uint32_t pos = (x + y * this->width_) * this->bpp_ / 8; - Color color = Color(progmem_read_byte(this->data_start_ + pos + 0), progmem_read_byte(this->data_start_ + pos + 1), - progmem_read_byte(this->data_start_ + pos + 2), 0xFF); + Color color = Color(progmem_read_byte(this->data_start_ + pos + 2), progmem_read_byte(this->data_start_ + pos + 1), + progmem_read_byte(this->data_start_ + pos + 0), 0xFF); switch (this->transparency_) { case TRANSPARENCY_CHROMA_KEY: @@ -200,7 +196,7 @@ Color Image::get_rgb565_pixel_(int x, int y) const { auto a = 0xFF; switch (this->transparency_) { case TRANSPARENCY_ALPHA_CHANNEL: - a = progmem_read_byte(pos + 2); + a = progmem_read_byte(this->data_start_ + this->width_ * this->height_ * 2 + (x + y * this->width_)); break; case TRANSPARENCY_CHROMA_KEY: if (rgb565 == 0x0020) @@ -239,7 +235,7 @@ Image::Image(const uint8_t *data_start, int width, int height, ImageType type, T this->bpp_ = 8; break; case IMAGE_TYPE_RGB565: - this->bpp_ = transparency == TRANSPARENCY_ALPHA_CHANNEL ? 24 : 16; + this->bpp_ = 16; break; case IMAGE_TYPE_RGB: this->bpp_ = this->transparency_ == TRANSPARENCY_ALPHA_CHANNEL ? 32 : 24; diff --git a/esphome/components/image/image.h b/esphome/components/image/image.h index 4024ab1357..d4865570e4 100644 --- a/esphome/components/image/image.h +++ b/esphome/components/image/image.h @@ -41,7 +41,7 @@ class Image : public display::BaseImage { bool has_transparency() const { return this->transparency_ != TRANSPARENCY_OPAQUE; } #ifdef USE_LVGL - lv_img_dsc_t *get_lv_img_dsc(); + lv_image_dsc_t *get_lv_image_dsc(); #endif protected: bool get_binary_pixel_(int x, int y) const; diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index c9cad1ac90..c37a32ecca 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -1,12 +1,30 @@ import importlib -import logging from pathlib import Path import pkgutil from esphome.automation import build_automation, validate_automation import esphome.codegen as cg -from esphome.components.const import CONF_COLOR_DEPTH, CONF_DRAW_ROUNDING +from esphome.components.const import ( + CONF_BYTE_ORDER, + CONF_COLOR_DEPTH, + CONF_DRAW_ROUNDING, +) from esphome.components.display import Display +from esphome.components.esp32 import ( + VARIANT_ESP32P4, + add_idf_component, + add_idf_sdkconfig_option, + get_esp32_variant, +) +from esphome.components.image import ( + CONF_OPAQUE, + IMAGE_TYPE, + ImageBinary, + ImageGrayscale, + ImageRGB, + ImageRGB565, + get_image_metadata, +) from esphome.components.psram import DOMAIN as PSRAM_DOMAIN import esphome.config_validation as cv from esphome.const import ( @@ -21,7 +39,6 @@ from esphome.const import ( CONF_PAGES, CONF_TIMEOUT, CONF_TRIGGER_ID, - CONF_TYPE, ) from esphome.core import CORE, ID, Lambda from esphome.cpp_generator import MockObj @@ -30,8 +47,7 @@ from esphome.helpers import write_file_if_changed from esphome.yaml_util import load_yaml from . import defines as df, helpers, lv_validation as lvalid, widgets -from .automation import disp_update, focused_widgets, refreshed_widgets -from .defines import add_define +from .automation import focused_widgets, layers_to_code, lvgl_update, refreshed_widgets from .encoders import ( ENCODERS_CONFIG, encoders_to_code, @@ -45,12 +61,13 @@ from .lvcode import LvContext, LvglComponent, lvgl_static from .schemas import ( DISP_BG_SCHEMA, FULL_STYLE_SCHEMA, + STYLE_REMAP, WIDGET_TYPES, any_widget_schema, container_schema, obj_schema, ) -from .styles import add_top_layer, styles_to_code, theme_to_code +from .styles import styles_to_code, theme_to_code from .touchscreens import touchscreen_schema, touchscreens_to_code from .trigger import add_on_boot_triggers, generate_triggers from .types import IdleTrigger, PlainTrigger, lv_font_t, lv_group_t, lv_style_t, lvgl_ns @@ -58,7 +75,7 @@ from .widgets import ( LvScrActType, Widget, add_widgets, - get_scr_act, + get_screen_active, set_obj_properties, styles_used, ) @@ -84,7 +101,6 @@ DOMAIN = "lvgl" DEPENDENCIES = ["display"] AUTO_LOAD = ["key_provider"] CODEOWNERS = ["@clydebarrow"] -LOGGER = logging.getLogger(__name__) HELLO_WORLD_FILE = "hello_world.yaml" @@ -102,6 +118,7 @@ def as_macro(macro, value): return f"#define {macro} {value}" +LVGL_VERSION = "9.5.0" LV_CONF_FILENAME = "lv_conf.h" LV_CONF_H_FORMAT = """\ #pragma once @@ -110,7 +127,17 @@ LV_CONF_H_FORMAT = """\ def generate_lv_conf_h(): - definitions = [as_macro(m, v) for m, v in df.get_data(df.KEY_LV_DEFINES).items()] + # Get all possible LV_ config defines based on the widgets used in the config, and the standard LVGL options + all_defines = set( + df.LV_DEFINES + tuple(f"LV_USE_{w.upper()}" for w in WIDGET_TYPES) + ) + # Get the defines that are actually used based on the config + lv_defines = df.get_data(df.KEY_LV_DEFINES) + unused_defines = all_defines - set(lv_defines) + # Create the content of lv_conf.h with the used defines set to their value, and the unused defines disabled + definitions = [as_macro(m, v) for m, v in lv_defines.items()] + [ + as_macro(m, "0") for m in unused_defines + ] definitions.sort() return LV_CONF_H_FORMAT.format("\n".join(definitions)) @@ -133,7 +160,7 @@ def multi_conf_validate(configs: list[dict]): for item in ( CONF_LOG_LEVEL, CONF_COLOR_DEPTH, - df.CONF_BYTE_ORDER, + CONF_BYTE_ORDER, df.CONF_TRANSPARENCY_KEY, ): if base_config[item] != config[item]: @@ -166,14 +193,7 @@ def final_validation(config_list): ) buffer_frac = config[CONF_BUFFER_SIZE] if CORE.is_esp32 and buffer_frac > 0.5 and PSRAM_DOMAIN not in global_config: - LOGGER.warning("buffer_size: may need to be reduced without PSRAM") - for image_id in lv_images_used: - path = global_config.get_path_for_id(image_id)[:-1] - image_conf = global_config.get_config_for_path(path) - if image_conf[CONF_TYPE] in ("RGBA", "RGB24"): - raise cv.Invalid( - "Using RGBA or RGB24 in image config not compatible with LVGL", path - ) + df.LOGGER.warning("buffer_size: may need to be reduced without PSRAM") for w in focused_widgets: path = global_config.get_path_for_id(w) widget_conf = global_config.get_config_for_path(path[:-1]) @@ -205,39 +225,48 @@ def final_validation(config_list): async def to_code(configs): config_0 = configs[0] # Global configuration - cg.add_library("lvgl/lvgl", "8.4.0") + if CORE.is_esp32: + if get_esp32_variant() == VARIANT_ESP32P4: + add_idf_sdkconfig_option("CONFIG_LV_DRAW_BUF_ALIGN", 64) + # disable use of PPA for fills until upstream bugs fixed + df.add_define("LV_USE_PPA", "0") + df.add_define("LV_DRAW_BUF_ALIGN", "64") + else: + df.add_define("LV_DRAW_BUF_ALIGN", "32") + add_idf_component(name="lvgl/lvgl", ref=LVGL_VERSION) + else: + df.add_define("LV_DRAW_BUF_ALIGN", "1") + cg.add_library("lvgl/lvgl", LVGL_VERSION) + df.add_define("LV_DRAW_BUF_STRIDE_ALIGN", "1") + df.add_define("LV_USE_DRAW_SW", "1") + df.add_define("LV_USE_STDLIB_SPRINTF", "LV_STDLIB_CLIB") + df.add_define("LV_USE_STDLIB_STRING", "LV_STDLIB_CLIB") + df.add_define("LV_USE_STDLIB_MALLOC", "LV_STDLIB_CUSTOM") cg.add_define("USE_LVGL") # suppress default enabling of extra widgets - add_define("_LV_KCONFIG_PRESENT") + # cg.add_define("LV_KCONFIG_PRESENT") # Always enable - lots of things use it. - add_define("LV_DRAW_COMPLEX", "1") - add_define("LV_TICK_CUSTOM", "1") - add_define("LV_TICK_CUSTOM_INCLUDE", '"esphome/components/lvgl/lvgl_hal.h"') - add_define("LV_TICK_CUSTOM_SYS_TIME_EXPR", "(lv_millis())") - add_define("LV_MEM_CUSTOM", "1") - add_define("LV_MEM_CUSTOM_ALLOC", "lv_custom_mem_alloc") - add_define("LV_MEM_CUSTOM_FREE", "lv_custom_mem_free") - add_define("LV_MEM_CUSTOM_REALLOC", "lv_custom_mem_realloc") - add_define("LV_MEM_CUSTOM_INCLUDE", '"esphome/components/lvgl/lvgl_hal.h"') + df.add_define("LV_DRAW_SW_COMPLEX", "1") - add_define( + df.add_define( "LV_LOG_LEVEL", f"LV_LOG_LEVEL_{df.LV_LOG_LEVELS[config_0[CONF_LOG_LEVEL]]}", ) + df.add_define("LV_USE_LOG", "1") cg.add_define( "LVGL_LOG_LEVEL", cg.RawExpression(f"ESPHOME_LOG_LEVEL_{config_0[CONF_LOG_LEVEL]}"), ) - add_define("LV_COLOR_DEPTH", config_0[CONF_COLOR_DEPTH]) + df.add_define("LV_COLOR_DEPTH", config_0[CONF_COLOR_DEPTH]) for font in helpers.lv_fonts_used: - add_define(f"LV_FONT_{font.upper()}") + df.add_define(f"LV_FONT_{font.upper()}") if config_0[CONF_COLOR_DEPTH] == 16: - add_define( + df.add_define( "LV_COLOR_16_SWAP", - "1" if config_0[df.CONF_BYTE_ORDER] == "big_endian" else "0", + "1" if config_0[CONF_BYTE_ORDER] == "big_endian" else "0", ) - add_define( + df.add_define( "LV_COLOR_CHROMA_KEY", await lvalid.lv_color.process(config_0[df.CONF_TRANSPARENCY_KEY]), ) @@ -248,7 +277,7 @@ async def to_code(configs): await cg.get_variable(font) default_font = config_0[df.CONF_DEFAULT_FONT] if not lvalid.is_lv_font(default_font): - add_define( + df.add_define( "LV_FONT_CUSTOM_DECLARE", f"LV_FONT_DECLARE(*{df.DEFAULT_ESPHOME_FONT})" ) globfont_id = ID( @@ -262,9 +291,9 @@ async def to_code(configs): MockObj(await lvalid.lv_font.process(default_font), "->").get_lv_font(), static=False, ) - add_define("LV_FONT_DEFAULT", df.DEFAULT_ESPHOME_FONT) + df.add_define("LV_FONT_DEFAULT", df.DEFAULT_ESPHOME_FONT) else: - add_define("LV_FONT_DEFAULT", await lvalid.lv_font.process(default_font)) + df.add_define("LV_FONT_DEFAULT", await lvalid.lv_font.process(default_font)) cg.add(lvgl_static.esphome_lvgl_init()) default_group = get_default_group(config_0) @@ -293,8 +322,9 @@ async def to_code(configs): await cg.register_component(lv_component, config) Widget.create(config[CONF_ID], lv_component, LvScrActType(), config) - lv_scr_act = get_scr_act(lv_component) + lv_scr_act = get_screen_active(lv_component) async with LvContext(): + cg.add(lv_component.set_big_endian(config[CONF_BYTE_ORDER] == "big_endian")) await touchscreens_to_code(lv_component, config) await encoders_to_code(lv_component, config, default_group) await keypads_to_code(lv_component, config, default_group) @@ -304,9 +334,10 @@ async def to_code(configs): await set_obj_properties(lv_scr_act, config) await add_widgets(lv_scr_act, config) await add_pages(lv_component, config) - await add_top_layer(lv_component, config) + await layers_to_code(lv_component, config) + await lvgl_update(lv_component, config) await msgboxes_to_code(lv_component, config) - await disp_update(lv_component.get_disp(), config) + # await disp_update(lv_component.get_disp(), config) # Set this directly since we are limited in how many methods can be added to the Widget class. Widget.widgets_completed = True async with LvContext(): @@ -336,15 +367,53 @@ async def to_code(configs): # This must be done after all widgets are created for comp in helpers.lvgl_components_required: cg.add_define(f"USE_LVGL_{comp.upper()}") - if {"transform_angle", "transform_zoom"} & styles_used: - add_define("LV_COLOR_SCREEN_TRANSP", "1") + lv_image_formats = df.get_color_formats().copy() + if { + "transform_rotation", + "transform_scale", + "transform_scale_x", + "transform_scale_y", + } & styles_used: + df.add_define("LV_COLOR_SCREEN_TRANSP", "1") + lv_image_formats.add("ARGB8888") + lv_image_formats.add( + "RGB565" + ) # Currently always need RGB565 for the display buffer for use in helpers.lv_uses: - add_define(f"LV_USE_{use.upper()}") + df.add_define(f"LV_USE_{use.upper()}") cg.add_define(f"USE_LVGL_{use.upper()}") + + for image_id in lv_images_used: + await cg.get_variable(image_id) + metadata = get_image_metadata(image_id.id) + image_type = IMAGE_TYPE[metadata.image_type] + transparent = metadata.transparency != CONF_OPAQUE + if transparent: + # Internal draw layer will use ARGB8888 + lv_image_formats.add("ARGB8888") + if image_type == ImageBinary: + lv_image_formats.add("I1") + if image_type == ImageGrayscale: + lv_image_formats.add("A8") + if image_type == ImageRGB565: + lv_image_formats.add("RGB565A8" if transparent else "RGB565") + if image_type == ImageRGB: + lv_image_formats.add("ARGB8888" if transparent else "RGB8888") + if df.is_defined("LV_GRADIENT_MAX_STOPS"): + lv_image_formats.add("RGB888") + for fmt in lv_image_formats: + df.add_define(f"LV_DRAW_SW_SUPPORT_{fmt}", "1") lv_conf_h_file = CORE.relative_src_path(LV_CONF_FILENAME) write_file_if_changed(lv_conf_h_file, generate_lv_conf_h()) cg.add_build_flag("-DLV_CONF_H=1") - cg.add_build_flag(f'-DLV_CONF_PATH="{LV_CONF_FILENAME}"') + cg.add_build_flag(f'-DLV_CONF_PATH=\\"{LV_CONF_FILENAME}\\"') + + for prop in df.get_remapped_uses(): + df.LOGGER.warning( + "Property '%s' is deprecated, use '%s' instead", prop, STYLE_REMAP[prop] + ) + for warning in df.get_warnings(): + df.LOGGER.warning(warning) def display_schema(config): @@ -357,7 +426,9 @@ def display_schema(config): def add_hello_world(config): if df.CONF_WIDGETS not in config and CONF_PAGES not in config: - LOGGER.info("No pages or widgets configured, creating default hello_world page") + df.LOGGER.info( + "No pages or widgets configured, creating default hello_world page" + ) hello_world_path = Path(__file__).parent / HELLO_WORLD_FILE config[df.CONF_WIDGETS] = any_widget_schema()(load_yaml(hello_world_path)) return config @@ -395,8 +466,8 @@ LVGL_SCHEMA = cv.All( cv.Optional(CONF_LOG_LEVEL, default="WARN"): cv.one_of( *df.LV_LOG_LEVELS, upper=True ), - cv.Optional(df.CONF_BYTE_ORDER, default="big_endian"): cv.one_of( - "big_endian", "little_endian" + cv.Optional(CONF_BYTE_ORDER, default="big_endian"): cv.one_of( + "big_endian", "little_endian", lower=True ), cv.Optional(df.CONF_STYLE_DEFINITIONS): cv.ensure_list( cv.Schema({cv.Required(CONF_ID): cv.declare_id(lv_style_t)}).extend( @@ -424,6 +495,7 @@ LVGL_SCHEMA = cv.All( cv.Optional(df.CONF_MSGBOXES): cv.ensure_list(MSGBOX_SCHEMA), cv.Optional(df.CONF_PAGE_WRAP, default=True): lv_bool, cv.Optional(df.CONF_TOP_LAYER): container_schema(obj_spec), + cv.Optional(df.CONF_BOTTOM_LAYER): container_schema(obj_spec), cv.Optional( df.CONF_TRANSPARENCY_KEY, default=0x000400 ): lvalid.lv_color, diff --git a/esphome/components/lvgl/automation.py b/esphome/components/lvgl/automation.py index f9adca9c33..24579e5be8 100644 --- a/esphome/components/lvgl/automation.py +++ b/esphome/components/lvgl/automation.py @@ -10,18 +10,21 @@ from esphome.cpp_generator import TemplateArguments, get_variable from esphome.cpp_types import nullptr from .defines import ( - CONF_DISP_BG_COLOR, - CONF_DISP_BG_IMAGE, - CONF_DISP_BG_OPA, + CONF_BG_OPA, + CONF_BOTTOM_LAYER, CONF_EDITING, CONF_FREEZE, CONF_LVGL_ID, + CONF_MAIN, + CONF_OBJ, + CONF_SCROLLBAR, CONF_SHOW_SNOW, + CONF_TOP_LAYER, PARTS, - literal, - static_cast, + StaticCastExpression, + add_warning, ) -from .lv_validation import lv_bool, lv_color, lv_image, lv_milliseconds, opacity +from .lv_validation import lv_bool, lv_milliseconds from .lvcode import ( LVGL_COMP_ARG, UPDATE_EVENT, @@ -42,13 +45,13 @@ from .schemas import ( LIST_ACTION_SCHEMA, LVGL_SCHEMA, base_update_schema, + part_schema, ) from .types import ( LV_STATE, LvglAction, LvglCondition, ObjUpdateAction, - lv_disp_t, lv_group_t, lv_obj_base_t, lv_obj_t, @@ -56,7 +59,9 @@ from .types import ( ) from .widgets import ( Widget, - get_scr_act, + WidgetType, + add_widgets, + get_screen_active, get_widgets, set_obj_properties, wait_for_widgets, @@ -67,6 +72,41 @@ focused_widgets = set() refreshed_widgets = set() +async def layers_to_code(lv_component, config): + if top_conf := config.get(CONF_TOP_LAYER): + top_layer = lv_expr.display_get_layer_top(lv_component.get_disp()) + with LocalVariable("top_layer", lv_obj_t, top_layer) as top_layer_obj: + top_w = Widget(top_layer_obj, layer_spec, top_conf) + await set_obj_properties(top_w, top_conf) + await add_widgets(top_w, top_conf) + if bottom_conf := config.get(CONF_BOTTOM_LAYER): + bottom_layer = lv_expr.display_get_layer_bottom(lv_component.get_disp()) + with LocalVariable("bottom_layer", lv_obj_t, bottom_layer) as bottom_layer_obj: + bottom_w = Widget(bottom_layer_obj, layer_spec, bottom_conf) + await set_obj_properties(bottom_w, bottom_conf) + await add_widgets(bottom_w, bottom_conf) + + +async def lvgl_update(lv_component, config): + bottom = {k.removeprefix("disp_"): v for k, v in config.items() if k in DISP_PROPS} + if not bottom: + return + plural = len(bottom) != 1 + add_warning( + "The propert" + + ("ies " if plural else "y ") + + "'" + + "','".join(k for k in config if k in DISP_PROPS) + + "'" + + (" are " if plural else " is ") + + "deprecated, use 'bottom_layer' instead." + ) + # Preserve default opacity from 8.x + if CONF_BG_OPA not in bottom: + bottom[CONF_BG_OPA] = 1.0 + await layers_to_code(lv_component, {CONF_BOTTOM_LAYER: bottom}) + + async def action_to_code( widgets: list[Widget], action: Callable[[Widget], Any], @@ -151,25 +191,6 @@ async def lvgl_is_idle(config, condition_id, template_arg, args): return var -async def disp_update(disp, config: dict): - if ( - CONF_DISP_BG_COLOR not in config - and CONF_DISP_BG_IMAGE not in config - and CONF_DISP_BG_OPA not in config - ): - return - with LocalVariable("lv_disp_tmp", lv_disp_t, disp) as disp_temp: - if (bg_color := config.get(CONF_DISP_BG_COLOR)) is not None: - lv.disp_set_bg_color(disp_temp, await lv_color.process(bg_color)) - if bg_image := config.get(CONF_DISP_BG_IMAGE): - if bg_image == "none": - lv.disp_set_bg_image(disp_temp, static_cast("void *", "nullptr")) - else: - lv.disp_set_bg_image(disp_temp, await lv_image.process(bg_image)) - if (bg_opa := config.get(CONF_DISP_BG_OPA)) is not None: - lv.disp_set_bg_opa(disp_temp, await opacity.process(bg_opa)) - - @automation.register_action( "lvgl.widget.redraw", ObjUpdateAction, @@ -187,7 +208,7 @@ async def disp_update(disp, config: dict): async def obj_invalidate_to_code(config, action_id, template_arg, args): if CONF_LVGL_ID in config: lv_comp = await cg.get_variable(config[CONF_LVGL_ID]) - widgets = [get_scr_act(lv_comp)] + widgets = [get_screen_active(lv_comp)] else: widgets = await get_widgets(config) @@ -197,20 +218,30 @@ async def obj_invalidate_to_code(config, action_id, template_arg, args): return await action_to_code(widgets, do_invalidate, action_id, template_arg, args) +layer_spec = WidgetType(CONF_OBJ, lv_obj_t, (CONF_MAIN, CONF_SCROLLBAR), is_mock=True) + +DISP_PROPS = {str(x) for x in DISP_BG_SCHEMA.schema} + + @automation.register_action( "lvgl.update", LvglAction, - DISP_BG_SCHEMA.extend(LVGL_SCHEMA).add_extra( - cv.has_at_least_one_key(CONF_DISP_BG_COLOR, CONF_DISP_BG_IMAGE) + part_schema(layer_spec.parts) + .extend(LVGL_SCHEMA) + .extend(DISP_BG_SCHEMA) + .extend( + { + cv.Optional(CONF_TOP_LAYER): part_schema(layer_spec.parts), + cv.Optional(CONF_BOTTOM_LAYER): part_schema(layer_spec.parts), + } ), synchronous=True, ) async def lvgl_update_to_code(config, action_id, template_arg, args): widgets = await get_widgets(config, CONF_LVGL_ID) w = widgets[0] - disp = literal(f"{w.obj}->get_disp()") async with LambdaContext(LVGL_COMP_ARG, where=action_id) as context: - await disp_update(disp, config) + await lvgl_update(w.var, config) var = cg.new_Pvariable(action_id, template_arg, await context.get_lambda()) await cg.register_parented(var, w.var) return var @@ -336,7 +367,7 @@ async def widget_focus(config, action_id, template_arg, args): widget = await get_widgets(config) if widget: widget = widget[0] - group = static_cast( + group = StaticCastExpression( lv_group_t.operator("ptr"), lv_expr.obj_get_group(widget.obj) ) elif group := config.get(CONF_GROUP): diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index 91077a1ff4..d6d7a5e161 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -10,8 +10,12 @@ from typing import Any from esphome import codegen as cg, config_validation as cv from esphome.const import CONF_ITEMS from esphome.core import CORE, ID, Lambda -from esphome.cpp_generator import LambdaExpression, MockObj -from esphome.cpp_types import uint32 +from esphome.cpp_generator import ( + CallExpression, + LambdaExpression, + MockObj, + MockObjClass, +) from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor from esphome.types import Expression, SafeExpType @@ -21,8 +25,11 @@ LOGGER = logging.getLogger(__name__) lvgl_ns = cg.esphome_ns.namespace("lvgl") DOMAIN = "lvgl" +KEY_COLOR_FORMATS = "color_formats" KEY_LV_DEFINES = "lv_defines" +KEY_REMAPPED_USES = "remapped_uses" KEY_UPDATED_WIDGETS = "updated_widgets" +KEY_WARNINGS = "warnings" def get_data(key, default=None): @@ -33,10 +40,37 @@ def get_data(key, default=None): :return: """ return CORE.data.setdefault(DOMAIN, {}).setdefault( - key, default if default is not None else {} + key, {} if default is None else default ) +def get_warnings(): + return get_data(KEY_WARNINGS, set()) + + +def get_remapped_uses(): + return get_data(KEY_REMAPPED_USES, set()) + + +def get_color_formats(): + return get_data(KEY_COLOR_FORMATS, set()) + + +def add_warning(msg: str): + get_warnings().add(msg) + + +class StaticCastExpression(Expression): + __slots__ = ("type", "exp") + + def __init__(self, type: Any, exp: SafeExpType): + self.type = str(type) + self.exp = cg.safe_exp(exp) + + def __str__(self): + return f"static_cast<{self.type}>({self.exp})" + + def add_define(macro, value="1"): lv_defines = get_data(KEY_LV_DEFINES) value = str(value) @@ -47,27 +81,43 @@ def add_define(macro, value="1"): lv_defines[macro] = value +def is_defined(macro): + return macro in get_data(KEY_LV_DEFINES) + + def literal(arg) -> MockObj: if isinstance(arg, str): return MockObj(arg) return arg -def static_cast(type, value): - return literal(f"static_cast<{type}>({value})") +def addr(arg) -> MockObj: + return MockObj(f"&{arg}") def call_lambda(lamb: LambdaExpression): + """ + Given a lambda, either reduce to a simple expression or call it, possibly with parameters + from the surrounding context + :param lamb: + :return: + """ expr = lamb.content.strip() if expr.startswith("return") and expr.endswith(";"): - return expr[6:-1].strip() - # If lambda has parameters, call it with those parameter names + # Convert a lambda returning a simple expression to just that expression + expr = cg.RawExpression(expr[6:-1].strip()) + # Don't cast if the return type is a class + if isinstance(lamb.return_type, MockObjClass): + return expr + return StaticCastExpression(lamb.return_type, expr) + # If lambda has parameters, call it with their names # Parameter names come from hardcoded component code (like "x", "it", "event") # not from user input, so they're safe to use directly if lamb.parameters and lamb.parameters.parameters: - param_names = ", ".join(str(param.id) for param in lamb.parameters.parameters) - return f"{lamb}({param_names})" - return f"{lamb}()" + return CallExpression( + lamb, *[MockObj(x.id) for x in lamb.parameters.parameters] + ) + return CallExpression(lamb) class LValidator: @@ -76,7 +126,7 @@ class LValidator: has `process()` to convert a value during code generation """ - def __init__(self, validator, rtype, retmapper=None, requires=None): + def __init__(self, validator, rtype: MockObj, retmapper=None, requires=None): self.validator = validator self.rtype = rtype self.retmapper = retmapper @@ -99,10 +149,9 @@ class LValidator: from .lvcode import get_lambda_context_args args = args or get_lambda_context_args() - return cg.RawExpression( - call_lambda( - await cg.process_lambda(value, args, return_type=self.rtype) - ) + + return call_lambda( + await cg.process_lambda(value, args, return_type=self.rtype) ) if self.retmapper is not None: return self.retmapper(value) @@ -112,6 +161,8 @@ class LValidator: value = [ await cg.get_variable(x) if isinstance(x, ID) else x for x in value ] + if self.rtype is cg.int_: + value = int(value) return cg.safe_exp(value) @@ -122,10 +173,11 @@ class LvConstant(LValidator): The property `one_of` has the single case validator, and `several_of` allows a list of constants. """ - def __init__(self, prefix: str, *choices): + def __init__(self, prefix: str, *choices, typename=None): self.prefix = prefix - self.choices = choices - prefixed_choices = [prefix + v for v in choices] + self.choices = tuple(x.upper() for x in choices) + self.typename = typename or prefix.lower() + "t" + prefixed_choices = [prefix + v.upper() for v in choices] prefixed_validator = cv.one_of(*prefixed_choices, upper=True) @schema_extractor("one_of") @@ -136,24 +188,30 @@ class LvConstant(LValidator): return prefixed_validator(value) return self.prefix + cv.one_of(*choices, upper=True)(value) - super().__init__(validator, rtype=uint32) + super().__init__(validator, rtype=cg.uint32) self.retmapper = self.mapper - self.one_of = LValidator(validator, uint32, retmapper=self.mapper) + self.one_of = LValidator(validator, cg.uint32, retmapper=self.mapper) self.several_of = LValidator( - cv.ensure_list(self.one_of), uint32, retmapper=self.mapper + cv.ensure_list(self.one_of), cg.uint32, retmapper=self.mapper ) def mapper(self, value): if not isinstance(value, list): value = [value] - return literal( - "|".join( - [ - str(v) if str(v).startswith(self.prefix) else self.prefix + str(v) - for v in value - ] - ).upper() - ) + value = [ + ( + str(v).upper() + if str(v).startswith(self.prefix) + else self.prefix + str(v).upper() + ) + for v in value + ] + if len(value) == 1: + return literal(value[0]) + value = literal("|".join(value)) + if self.typename is None: + return value + return StaticCastExpression(self.typename, value) def extend(self, *choices): """ @@ -161,7 +219,14 @@ class LvConstant(LValidator): :param choices: The extra choices :return: A new LVConstant instance """ - return LvConstant(self.prefix, *(self.choices + choices)) + return LvConstant( + self.prefix, *(self.choices + choices), typename=self.typename + ) + + def __getattr__(self, item): + if item.upper() not in self.choices: + raise AttributeError(f"{item} not one of {self.choices}") + return self.mapper(item) # Parts @@ -277,11 +342,13 @@ PARTS = ( CONF_KNOB, CONF_SELECTED, CONF_ITEMS, - CONF_TICKS, + # CONF_TICKS, CONF_CURSOR, CONF_TEXTAREA_PLACEHOLDER, ) +LV_PART = LvConstant("LV_PART_", *(p.upper() for p in PARTS)) + KEYBOARD_MODES = LvConstant( "LV_KEYBOARD_MODE_", "TEXT_LOWER", @@ -359,6 +426,7 @@ OBJ_FLAGS = ( "overflow_visible", "layout_1", "layout_2", + "send_draw_task_events", "widget_1", "widget_2", "user_1", @@ -366,12 +434,14 @@ OBJ_FLAGS = ( "user_3", "user_4", ) +LV_OBJ_FLAG = LvConstant("LV_OBJ_FLAG_", *OBJ_FLAGS) ARC_MODES = LvConstant("LV_ARC_MODE_", "NORMAL", "REVERSE", "SYMMETRICAL") BAR_MODES = LvConstant("LV_BAR_MODE_", "NORMAL", "SYMMETRICAL", "RANGE") +SLIDER_MODES = LvConstant("LV_SLIDER_MODE_", "NORMAL", "SYMMETRICAL", "RANGE") BUTTONMATRIX_CTRLS = LvConstant( - "LV_BTNMATRIX_CTRL_", + "LV_BUTTONMATRIX_CTRL_", "HIDDEN", "NO_REPEAT", "DISABLED", @@ -434,12 +504,16 @@ CONF_ACCEPTED_CHARS = "accepted_chars" CONF_ADJUSTABLE = "adjustable" CONF_ALIGN = "align" CONF_ALIGN_TO = "align_to" +CONF_ANGLE_RANGE = "angle_range" CONF_ANIMATED = "animated" CONF_ANIMATION = "animation" +CONF_ANIMATIONS = "animations" CONF_ANTIALIAS = "antialias" CONF_ARC_LENGTH = "arc_length" CONF_AUTO_START = "auto_start" CONF_BACKGROUND_STYLE = "background_style" +CONF_BG_OPA = "bg_opa" +CONF_BOTTOM_LAYER = "bottom_layer" CONF_BUTTON_STYLE = "button_style" CONF_DECIMAL_PLACES = "decimal_places" CONF_COLUMN = "column" @@ -449,9 +523,11 @@ CONF_DISP_BG_IMAGE = "disp_bg_image" CONF_DISP_BG_OPA = "disp_bg_opa" CONF_BODY = "body" CONF_BUTTONS = "buttons" -CONF_BYTE_ORDER = "byte_order" CONF_CHANGE_RATE = "change_rate" CONF_CLOSE_BUTTON = "close_button" +CONF_COLOR_DEPTH = "color_depth" +CONF_COLOR_END = "color_end" +CONF_COLOR_START = "color_start" CONF_CONTAINER = "container" CONF_CONTROL = "control" CONF_DEFAULT_FONT = "default_font" @@ -483,8 +559,10 @@ CONF_GRID_COLUMN_ALIGN = "grid_column_align" CONF_GRID_COLUMNS = "grid_columns" CONF_GRID_ROW_ALIGN = "grid_row_align" CONF_GRID_ROWS = "grid_rows" +CONF_HEADER_BUTTONS = "header_buttons" CONF_HEADER_MODE = "header_mode" CONF_HOME = "home" +CONF_INDICATORS = "indicators" CONF_INITIAL_FOCUS = "initial_focus" CONF_SELECTED_DIGIT = "selected_digit" CONF_KEY_CODE = "key_code" @@ -496,6 +574,7 @@ CONF_LONG_PRESS_TIME = "long_press_time" CONF_LONG_PRESS_REPEAT_TIME = "long_press_repeat_time" CONF_LVGL_ID = "lvgl_id" CONF_LONG_MODE = "long_mode" +CONF_MAJOR_TICKS_STYLE = "major_ticks_style" CONF_MSGBOXES = "msgboxes" CONF_OBJ = "obj" CONF_ONE_CHECKED = "one_checked" @@ -517,12 +596,15 @@ CONF_PIVOT_Y = "pivot_y" CONF_PLACEHOLDER_TEXT = "placeholder_text" CONF_POINTS = "points" CONF_PREVIOUS = "previous" +CONF_RADIUS = "radius" CONF_REPEAT_COUNT = "repeat_count" CONF_RECOLOR = "recolor" CONF_RESUME_ON_INPUT = "resume_on_input" CONF_RIGHT_BUTTON = "right_button" CONF_ROLLOVER = "rollover" CONF_ROOT_BACK_BTN = "root_back_btn" +CONF_ROWS = "rows" +CONF_SCALE = "scale" CONF_SCALE_LINES = "scale_lines" CONF_SCROLLBAR_MODE = "scrollbar_mode" CONF_SCROLL_DIR = "scroll_dir" @@ -536,6 +618,7 @@ CONF_SRC = "src" CONF_START_ANGLE = "start_angle" CONF_START_VALUE = "start_value" CONF_STATES = "states" +CONF_STRIDE = "stride" CONF_STYLE = "style" CONF_STYLES = "styles" CONF_STYLE_DEFINITIONS = "style_definitions" @@ -544,6 +627,7 @@ CONF_SKIP = "skip" CONF_SYMBOL = "symbol" CONF_TAB_ID = "tab_id" CONF_TABS = "tabs" +CONF_TICK_STYLE = "tick_style" CONF_TIME_FORMAT = "time_format" CONF_TILE = "tile" CONF_TILE_ID = "tile_id" @@ -551,6 +635,8 @@ CONF_TILES = "tiles" CONF_TITLE = "title" CONF_TOP_LAYER = "top_layer" CONF_TOUCHSCREENS = "touchscreens" +CONF_TRANSFORM_ROTATION = "transform_rotation" +CONF_TRANSFORM_SCALE = "transform_scale" CONF_TRANSPARENCY_KEY = "transparency_key" CONF_THEME = "theme" CONF_UPDATE_ON_RELEASE = "update_on_release" @@ -578,6 +664,16 @@ LV_KEYS = LvConstant( "END", ) +LV_SCALE_MODE = LvConstant( + "LV_SCALE_MODE_", + "HORIZONTAL_TOP", + "HORIZONTAL_BOTTOM", + "VERTICAL_LEFT", + "VERTICAL_RIGHT", + "ROUND_INNER", + "ROUND_OUTER", +) + DEFAULT_ESPHOME_FONT = "esphome_lv_default_font" @@ -590,3 +686,29 @@ def join_enums(enums, prefix=""): if prefix: return literal("|".join(f"{prefix}{e.upper()}" for e in enums)) return literal("|".join(f"(int){e.upper()}" for e in enums)) + + +# fmt: off +LV_COLOR_FORMATS = ( + "RGB565", "SWAPPED", "RGB565A8", "RGB888", "XRGB8888", "ARGB8888", "PREMULTIPLIED", "L8", "AL88", "A8", "I1", +) + +LV_DEFINES = ( + "LV_USE_FREERTOS_TASK_NOTIFY", "LV_DRAW_BUF_STRIDE_ALIGN", "LV_USE_DRAW_SW", "LV_DRAW_SW_DRAW_UNIT_CNT", + "LV_DRAW_SW_COMPLEX", "LV_USE_DRAW_PXP", "LV_USE_PXP_DRAW_THREAD", "LV_USE_DRAW_G2D", + "LV_USE_G2D_DRAW_THREAD", "LV_VG_LITE_USE_BOX_SHADOW", "LV_VG_LITE_THORVG_16PIXELS_ALIGN", "LV_LOG_USE_TIMESTAMP", + "LV_LOG_USE_FILE_LINE", "LV_USE_OBJ_ID_BUILTIN", "LV_USE_OBJ_PROPERTY_NAME", "LV_ATTRIBUTE_MEM_ALIGN_SIZE", + "LV_FONT_MONTSERRAT_14", "LV_USE_FONT_PLACEHOLDER", "LV_WIDGETS_HAS_DEFAULT_VALUE", "LV_USE_ARCLABEL", + "LV_USE_CALENDAR", "LV_USE_CALENDAR_HEADER_ARROW", "LV_USE_CALENDAR_HEADER_DROPDOWN", "LV_USE_CHART", + "LV_USE_LIST", "LV_USE_MENU", "LV_USE_MSGBOX", "LV_USE_SCALE", + "LV_USE_TABLE", "LV_USE_SPAN", "LV_USE_WIN", "LV_USE_THEME_DEFAULT", + "LV_THEME_DEFAULT_GROW", "LV_USE_THEME_SIMPLE", "LV_USE_THEME_MONO", "LV_USE_FLEX", + "LV_USE_GRID", "LV_USE_PROFILER_BUILTIN", "LV_PROFILER_BUILTIN_DEFAULT_ENABLE", "LV_PROFILER_LAYOUT", + "LV_PROFILER_REFR", "LV_PROFILER_DRAW", "LV_PROFILER_INDEV", "LV_PROFILER_DECODER", + "LV_PROFILER_FONT", "LV_PROFILER_FS", "LV_PROFILER_TIMER", "LV_PROFILER_CACHE", + "LV_PROFILER_EVENT", "LV_USE_OBSERVER", "LV_IME_PINYIN_USE_DEFAULT_DICT", "LV_IME_PINYIN_USE_K9_MODE", + "LV_FILE_EXPLORER_QUICK_ACCESS", "LV_TEST_SCREENSHOT_CREATE_REFERENCE_IMAGE", "LV_LINUX_FBDEV_MMAP", + "LV_USE_NUTTX_MOUSE_MOVE_STEP", "LV_USE_GENERIC_MIPI", "LV_BUILD_EXAMPLES", "LV_BUILD_DEMOS", + "LV_WAYLAND_USE_EGL", "LV_WAYLAND_USE_G2D", "LV_WAYLAND_USE_SHM", "LV_LINUX_DRM_USE_EGL", + "LV_USE_LZ4", "LV_USE_THORVG", "LV_SDL_USE_EGL", "LV_USE_EGL", "LV_LABEL_LONG_TXT_HINT", "LV_LABEL_TEXT_SELECTION", +) + tuple(f"LV_DRAW_SW_SUPPORT_{f}" for f in LV_COLOR_FORMATS) diff --git a/esphome/components/lvgl/encoders.py b/esphome/components/lvgl/encoders.py index 259c344030..bafda8382e 100644 --- a/esphome/components/lvgl/encoders.py +++ b/esphome/components/lvgl/encoders.py @@ -71,7 +71,7 @@ async def encoders_to_code(var, config, default_group): lv_assign(group, lv_expr.group_create()) else: group = default_group - lv.indev_set_group(lv_expr.indev_drv_register(listener.get_drv()), group) + lv.indev_set_group(listener.get_drv(), group) async def initial_focus_to_code(config): diff --git a/esphome/components/lvgl/gradient.py b/esphome/components/lvgl/gradient.py index bc89470d47..f3ded6a518 100644 --- a/esphome/components/lvgl/gradient.py +++ b/esphome/components/lvgl/gradient.py @@ -7,12 +7,13 @@ from esphome.const import ( CONF_ID, CONF_POSITION, ) +from esphome.core import ID from esphome.cpp_generator import MockObj -from .defines import CONF_GRADIENTS, LV_DITHER, LV_GRAD_DIR, add_define -from .lv_validation import lv_color, lv_fraction -from .lvcode import lv_assign -from .types import lv_gradient_t +from .defines import CONF_GRADIENTS, CONF_OPA, LV_DITHER, add_define, add_warning +from .lv_validation import lv_color, lv_percentage, opacity +from .lvcode import lv +from .types import lv_color_t, lv_gradient_t, lv_opa_t CONF_STOPS = "stops" @@ -27,14 +28,17 @@ GRADIENT_SCHEMA = cv.ensure_list( cv.Schema( { cv.GenerateID(CONF_ID): cv.declare_id(lv_gradient_t), - cv.Optional(CONF_DIRECTION, default="NONE"): LV_GRAD_DIR.one_of, + cv.Required(CONF_DIRECTION): cv.one_of( + "HOR", "HORIZONTAL", "VER", "VERTICAL", upper=True + ), cv.Optional(CONF_DITHER, default="NONE"): LV_DITHER.one_of, cv.Required(CONF_STOPS): cv.All( [ cv.Schema( { cv.Required(CONF_COLOR): lv_color, - cv.Required(CONF_POSITION): lv_fraction, + cv.Optional(CONF_OPA, default=1.0): opacity, + cv.Required(CONF_POSITION): lv_percentage, } ) ], @@ -47,15 +51,31 @@ GRADIENT_SCHEMA = cv.ensure_list( async def gradients_to_code(config): max_stops = 2 + if any(CONF_DITHER in x for x in config.get(CONF_GRADIENTS, ())): + add_warning( + "The 'dither' option for gradients is not supported by LVGL 9.x and will be ignored" + ) for gradient in config.get(CONF_GRADIENTS, ()): var = MockObj(cg.new_Pvariable(gradient[CONF_ID]), "->") - max_stops = max(max_stops, len(gradient[CONF_STOPS])) - lv_assign(var.dir, await LV_GRAD_DIR.process(gradient[CONF_DIRECTION])) - lv_assign(var.dither, await LV_DITHER.process(gradient[CONF_DITHER])) - lv_assign(var.stops_count, len(gradient[CONF_STOPS])) - for index, stop in enumerate(gradient[CONF_STOPS]): - lv_assign(var.stops[index].color, await lv_color.process(stop[CONF_COLOR])) - lv_assign( - var.stops[index].frac, await lv_fraction.process(stop[CONF_POSITION]) - ) + idbase = gradient[CONF_ID].id + stops = gradient[CONF_STOPS] + max_stops = max(max_stops, len(stops)) + if gradient[CONF_DIRECTION].startswith("VER"): + lv.grad_vertical_init(var) + else: + lv.grad_horizontal_init(var) + stop_colors = cg.static_const_array( + ID(idbase + "_colors_", type=lv_color_t), + [await lv_color.process(x[CONF_COLOR]) for x in stops], + ) + stop_opacities = cg.static_const_array( + ID(idbase + "_opacities_", type=lv_opa_t), + [await opacity.process(x[CONF_OPA]) for x in stops], + ) + stop_positions = cg.static_const_array( + ID(idbase + "_positions_", type=cg.uint8), + [await lv_percentage.process(x[CONF_POSITION]) for x in stops], + ) + lv.grad_init_stops(var, stop_colors, stop_opacities, stop_positions, len(stops)) + add_define("LV_GRADIENT_MAX_STOPS", max_stops) diff --git a/esphome/components/lvgl/keypads.py b/esphome/components/lvgl/keypads.py index 5e2953d57f..7d8b3dd128 100644 --- a/esphome/components/lvgl/keypads.py +++ b/esphome/components/lvgl/keypads.py @@ -67,7 +67,7 @@ async def keypads_to_code(var, config, default_group): lv_assign(group, lv_expr.group_create()) else: group = default_group - lv.indev_set_group(lv_expr.indev_drv_register(listener.get_drv()), group) + lv.indev_set_group(listener.get_drv(), group) async def initial_focus_to_code(config): diff --git a/esphome/components/lvgl/layout.py b/esphome/components/lvgl/layout.py index b27a0b54a2..46026852af 100644 --- a/esphome/components/lvgl/layout.py +++ b/esphome/components/lvgl/layout.py @@ -88,8 +88,8 @@ grid_spec = cv.Any(size, LvConstant("LV_GRID_", "CONTENT").one_of, grid_free_spa GRID_CELL_SCHEMA = { cv.Optional(CONF_GRID_CELL_ROW_POS): cv.positive_int, cv.Optional(CONF_GRID_CELL_COLUMN_POS): cv.positive_int, - cv.Optional(CONF_GRID_CELL_ROW_SPAN, default=1): cv.positive_int, - cv.Optional(CONF_GRID_CELL_COLUMN_SPAN, default=1): cv.positive_int, + cv.Optional(CONF_GRID_CELL_ROW_SPAN): cv.int_range(min=1), + cv.Optional(CONF_GRID_CELL_COLUMN_SPAN): cv.int_range(min=1), cv.Optional(CONF_GRID_CELL_X_ALIGN): grid_alignments, cv.Optional(CONF_GRID_CELL_Y_ALIGN): grid_alignments, } @@ -198,12 +198,8 @@ class GridLayout(Layout): { cv.Optional(CONF_GRID_CELL_ROW_POS): cv.positive_int, cv.Optional(CONF_GRID_CELL_COLUMN_POS): cv.positive_int, - cv.Optional( - CONF_GRID_CELL_ROW_SPAN, default=1 - ): cv.positive_int, - cv.Optional( - CONF_GRID_CELL_COLUMN_SPAN, default=1 - ): cv.positive_int, + cv.Optional(CONF_GRID_CELL_ROW_SPAN): cv.int_range(min=1), + cv.Optional(CONF_GRID_CELL_COLUMN_SPAN): cv.int_range(min=1), cv.Optional( CONF_GRID_CELL_X_ALIGN, default="center" ): grid_alignments, @@ -231,8 +227,8 @@ class GridLayout(Layout): { cv.Optional(CONF_GRID_CELL_ROW_POS): cv.positive_int, cv.Optional(CONF_GRID_CELL_COLUMN_POS): cv.positive_int, - cv.Optional(CONF_GRID_CELL_ROW_SPAN, default=1): cv.positive_int, - cv.Optional(CONF_GRID_CELL_COLUMN_SPAN, default=1): cv.positive_int, + cv.Optional(CONF_GRID_CELL_ROW_SPAN): cv.int_range(min=1), + cv.Optional(CONF_GRID_CELL_COLUMN_SPAN): cv.int_range(min=1), cv.Optional(CONF_GRID_CELL_X_ALIGN): grid_alignments, cv.Optional(CONF_GRID_CELL_Y_ALIGN): grid_alignments, }, @@ -299,11 +295,13 @@ class GridLayout(Layout): w[CONF_GRID_CELL_ROW_POS] = row w[CONF_GRID_CELL_COLUMN_POS] = column - for i in range(w[CONF_GRID_CELL_ROW_SPAN]): - for j in range(w[CONF_GRID_CELL_COLUMN_SPAN]): + row_span = w.get(CONF_GRID_CELL_ROW_SPAN, 1) + column_span = w.get(CONF_GRID_CELL_COLUMN_SPAN, 1) + for i in range(row_span): + for j in range(column_span): if row + i >= rows or column + j >= columns: raise cv.Invalid( - f"Cell at {row}/{column} span {w[CONF_GRID_CELL_ROW_SPAN]}x{w[CONF_GRID_CELL_COLUMN_SPAN]} " + f"Cell at {row}/{column} span {row_span}x{column_span} " f"exceeds grid size {rows}x{columns}", [CONF_WIDGETS, index], ) diff --git a/esphome/components/lvgl/light/lvgl_light.h b/esphome/components/lvgl/light/lvgl_light.h index 569f9a03c0..7309df9763 100644 --- a/esphome/components/lvgl/light/lvgl_light.h +++ b/esphome/components/lvgl/light/lvgl_light.h @@ -38,7 +38,7 @@ class LVLight : public light::LightOutput { void set_value_(lv_color_t value) { lv_led_set_color(this->obj_, value); lv_led_on(this->obj_); - lv_event_send(this->obj_, lv_api_event, nullptr); + lv_obj_send_event(this->obj_, lv_api_event, nullptr); } lv_obj_t *obj_{}; optional initial_value_{}; diff --git a/esphome/components/lvgl/lv_validation.py b/esphome/components/lvgl/lv_validation.py index 3c1838219c..503730098e 100644 --- a/esphome/components/lvgl/lv_validation.py +++ b/esphome/components/lvgl/lv_validation.py @@ -30,6 +30,7 @@ from .defines import ( LV_FONTS, LValidator, LvConstant, + StaticCastExpression, call_lambda, literal, ) @@ -40,22 +41,28 @@ from .helpers import ( lv_fonts_used, requires_component, ) -from .types import lv_gradient_t +from .types import lv_gradient_t, lv_opa_t -opacity_consts = LvConstant("LV_OPA_", "TRANSP", "COVER") +LV_OPA = LvConstant("LV_OPA_", "TRANSP", "COVER") @schema_extractor("one_of") def opacity_validator(value): if value == SCHEMA_EXTRACT: - return opacity_consts.choices - value = cv.Any(cv.percentage, opacity_consts.one_of)(value) - if isinstance(value, float): - return int(value * 255) - return value + return LV_OPA.choices + value = cv.Any(cv.percentage, LV_OPA.one_of)(value) + if value == str(LV_OPA.COVER): + value = 1.0 + if value == str(LV_OPA.TRANSP): + value = 0.0 + return cv.float_range(0.0, 1.0)(value) -opacity = LValidator(opacity_validator, uint32, retmapper=literal) +opacity = LValidator( + opacity_validator, + lv_opa_t, + retmapper=lambda opa: StaticCastExpression(cg.uint8, opa * 255.0), +) COLOR_NAMES = { "aliceblue": 0xF0F8FF, @@ -244,7 +251,17 @@ def option_string(value): return value -lv_color = LValidator(color, ty.lv_color_t, retmapper=color_retmapper) +class LvColor(LValidator): + def __init__(self): + super().__init__(color, ty.lv_color_t, retmapper=color_retmapper) + + def __getattr__(self, item): + if item in COLOR_NAMES: + return color_retmapper(COLOR_NAMES[item]) + raise AttributeError(item) + + +lv_color = LvColor() def pixels_or_percent_validator(value): @@ -252,16 +269,17 @@ def pixels_or_percent_validator(value): if value == SCHEMA_EXTRACT: return ["pixels", "..%"] if isinstance(value, str) and value.lower().endswith("px"): - value = cv.int_(value[:-2]) + return cv.int_(value[:-2]) if isinstance(value, str) and re.match(r"^lv_pct\((\d+)\)$", value): - return value - value = cv.Any(cv.int_, cv.percentage)(value) - if isinstance(value, int): - return value - return f"lv_pct({int(value * 100)})" + return int(value[6:-1]) / 100.0 + return cv.Any(cv.int_, cv.possibly_negative_percentage)(value) -pixels_or_percent = LValidator(pixels_or_percent_validator, uint32, retmapper=literal) +pixels_or_percent = LValidator( + pixels_or_percent_validator, + uint32, + retmapper=lambda x: x if isinstance(x, int) else literal(f"lv_pct({int(x * 100)})"), +) def pixels_validator(value): @@ -282,15 +300,11 @@ def padding_validator(value): padding = LValidator(padding_validator, int32, retmapper=literal) -def zoom_validator(value): +def scale_validator(value): return cv.float_range(0.1, 10.0)(value) -def zoom_retmapper(value): - return int(value * 256) - - -zoom = LValidator(zoom_validator, uint32, retmapper=zoom_retmapper) +scale = LValidator(scale_validator, uint32, retmapper=lambda x: int(x * 256)) def angle(value): @@ -321,17 +335,23 @@ def size_validator(value): return pixels_or_percent_validator(value) -size = LValidator(size_validator, uint32, retmapper=literal) +size = LValidator( + size_validator, + uint32, + retmapper=lambda x: ( + literal(x) if isinstance(x, str) else pixels_or_percent.retmapper(x) + ), +) -radius_consts = LvConstant("LV_RADIUS_", "CIRCLE") +LV_RADIUS = LvConstant("LV_RADIUS_", "CIRCLE") @schema_extractor("one_of") def fraction_validator(value): if value == SCHEMA_EXTRACT: - return radius_consts.choices - value = cv.Any(size, cv.percentage, radius_consts.one_of)(value) + return LV_RADIUS.choices + value = cv.Any(size, cv.percentage, LV_RADIUS.one_of)(value) if isinstance(value, float): return int(value * 255) return value @@ -374,12 +394,6 @@ lv_image_list = LValidator( lv_bool = LValidator(cv.boolean, cg.bool_, retmapper=literal) -def lv_pct(value: int | float): - if isinstance(value, float): - value = int(value * 100) - return literal(f"lv_pct({value})") - - def lvms_validator_(value): if value == "never": value = "2147483647ms" @@ -424,30 +438,28 @@ class TextValidator(LValidator): if time_format := value.get(CONF_TIME_FORMAT): source = value[CONF_TIME] if isinstance(source, Lambda): - time_format = cpp_string_escape(time_format) - return cg.RawExpression( + source = MockObj( call_lambda( await cg.process_lambda(source, args, return_type=ESPTime) ) - + f".strftime({time_format}).c_str()" ) # must be an ID - source = await cg.get_variable(source) - return source.now().strftime(time_format).c_str() + else: + source = (await cg.get_variable(source)).now() + return source.strftime(time_format).c_str() if isinstance(value, Lambda): value = call_lambda( await cg.process_lambda(value, args, return_type=self.rtype) ) + textvalue = str(value) # Was the lambda call reduced to a string? - if value.endswith("c_str()") or ( - value.endswith('"') and value.startswith('"') + if textvalue.endswith("c_str()") or ( + textvalue.endswith('"') and textvalue.startswith('"') ): - pass - else: - # Either a std::string or a lambda call returning that. We need const char* - value = f"({value}).c_str()" - return cg.RawExpression(value) + return value + # Either a std::string or a lambda call returning that. We need const char* + return MockObj(f"({value}).c_str()") return await super().process(value, args) @@ -455,21 +467,24 @@ lv_text = TextValidator() lv_float = LValidator(cv.float_, cg.float_) lv_int = LValidator(cv.int_, cg.int_) lv_positive_int = LValidator(cv.positive_int, cg.int_) -lv_brightness = LValidator(cv.percentage, cg.float_, retmapper=lambda x: int(x * 255)) -def gradient_mapper(value): - return MockObj(value) +def _percentage_validator(value): + value = cv.Any(cv.percentage, cv.float_range(0.0, 1.0), cv.int_range(0, 255))(value) + if isinstance(value, int): + return value / 255.0 + return value -def gradient_validator(value): - return cv.use_id(lv_gradient_t)(value) +lv_percentage = LValidator( + _percentage_validator, cg.float_, retmapper=lambda x: int(x * 255) +) lv_gradient = LValidator( - validator=gradient_validator, + validator=cv.use_id(lv_gradient_t), rtype=lv_gradient_t, - retmapper=gradient_mapper, + retmapper=MockObj, ) diff --git a/esphome/components/lvgl/lvcode.py b/esphome/components/lvgl/lvcode.py index b79d1e88dd..146b261f26 100644 --- a/esphome/components/lvgl/lvcode.py +++ b/esphome/components/lvgl/lvcode.py @@ -168,6 +168,9 @@ class LambdaContext(CodeContext): def get_automation_parameters(self) -> list[tuple[SafeExpType, str]]: return self.parameters + def get_parameter(self, index: int): + return literal(self.parameters[index][1]) + async def __aenter__(self): await super().__aenter__() add_line_marks(self.where) @@ -250,10 +253,14 @@ class MockLv: A mock object that can be used to generate LVGL calls. """ + # Mapping for LVGL 9 + ATTR_MAP = {"event_send": "obj_send_event", "dither": "bg_dither_mode"} + def __init__(self, base): self.base = base def __getattr__(self, attr: str) -> "MockLv": + attr = MockLv.ATTR_MAP.get(attr, attr) return MockLv(f"{self.base}{attr}") def append(self, expression): @@ -307,6 +314,7 @@ class ReturnStatement(ExpressionStatement): class LvExpr(MockLv): def __getattr__(self, attr: str) -> "MockLv": + attr = MockLv.ATTR_MAP.get(attr, attr) return LvExpr(f"{self.base}{attr}") def append(self, expression): diff --git a/esphome/components/lvgl/lvgl_esphome.cpp b/esphome/components/lvgl/lvgl_esphome.cpp index 5400054bb1..b1618f77c4 100644 --- a/esphome/components/lvgl/lvgl_esphome.cpp +++ b/esphome/components/lvgl/lvgl_esphome.cpp @@ -2,16 +2,21 @@ #include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#include "lvgl_hal.h" #include "lvgl_esphome.h" +#include "core/lv_global.h" +#include "core/lv_obj_class_private.h" + #include -namespace esphome { -namespace lvgl { +static void *lv_alloc_draw_buf(size_t size, bool internal); +static void *draw_buf_alloc_cb(size_t size, lv_color_format_t color_format) { return lv_alloc_draw_buf(size, false); }; + +namespace esphome::lvgl { static const char *const TAG = "lvgl"; -static const size_t MIN_BUFFER_FRAC = 8; +static const size_t MIN_BUFFER_FRAC = 8; // buffer must be at least 1/8 of the display size +static const size_t MIN_BUFFER_SIZE = 2048; // Sensible minimum buffer size static const char *const EVENT_NAMES[] = { "NONE", @@ -61,7 +66,14 @@ static const char *const EVENT_NAMES[] = { "GET_SELF_SIZE", }; -std::string lv_event_code_name_for(uint8_t event_code) { +static const unsigned LOG_LEVEL_MAP[] = { + ESPHOME_LOG_LEVEL_DEBUG, ESPHOME_LOG_LEVEL_INFO, ESPHOME_LOG_LEVEL_WARN, + ESPHOME_LOG_LEVEL_ERROR, ESPHOME_LOG_LEVEL_ERROR, ESPHOME_LOG_LEVEL_NONE, + +}; + +std::string lv_event_code_name_for(lv_event_t *event) { + auto event_code = lv_event_get_code(event); if (event_code < sizeof(EVENT_NAMES) / sizeof(EVENT_NAMES[0])) { return EVENT_NAMES[event_code]; } @@ -71,11 +83,12 @@ std::string lv_event_code_name_for(uint8_t event_code) { return buf; } -static void rounder_cb(lv_disp_drv_t *disp_drv, lv_area_t *area) { +static void rounder_cb(lv_event_t *event) { + auto *comp = static_cast(lv_event_get_user_data(event)); + auto *area = static_cast(lv_event_get_param(event)); // cater for display driver chips with special requirements for bounds of partial // draw areas. Extend the draw area to satisfy: // * Coordinates must be a multiple of draw_rounding - auto *comp = static_cast(disp_drv->user_data); auto draw_rounding = comp->draw_rounding; // round down the start coordinates area->x1 = area->x1 / draw_rounding * draw_rounding; @@ -85,15 +98,14 @@ static void rounder_cb(lv_disp_drv_t *disp_drv, lv_area_t *area) { area->y2 = (area->y2 + draw_rounding) / draw_rounding * draw_rounding - 1; } -void LvglComponent::monitor_cb(lv_disp_drv_t *disp_drv, uint32_t time, uint32_t px) { - ESP_LOGVV(TAG, "Draw end: %" PRIu32 " pixels in %" PRIu32 " ms", px, time); - auto *comp = static_cast(disp_drv->user_data); +void LvglComponent::render_end_cb(lv_event_t *event) { + auto *comp = static_cast(lv_event_get_user_data(event)); comp->draw_end_(); } -void LvglComponent::render_start_cb(lv_disp_drv_t *disp_drv) { +void LvglComponent::render_start_cb(lv_event_t *event) { ESP_LOGVV(TAG, "Draw start"); - auto *comp = static_cast(disp_drv->user_data); + auto *comp = static_cast(lv_event_get_user_data(event)); comp->draw_start_(); } @@ -106,16 +118,15 @@ void LvglComponent::dump_config() { " Buffer size: %zu%%\n" " Rotation: %d\n" " Draw rounding: %d", - this->disp_drv_.hor_res, this->disp_drv_.ver_res, 100 / this->buffer_frac_, this->rotation, - (int) this->draw_rounding); + this->width_, this->height_, 100 / this->buffer_frac_, this->rotation, (int) this->draw_rounding); } void LvglComponent::set_paused(bool paused, bool show_snow) { this->paused_ = paused; this->show_snow_ = show_snow; - if (!paused && lv_scr_act() != nullptr) { - lv_disp_trig_activity(this->disp_); // resets the inactivity time - lv_obj_invalidate(lv_scr_act()); + if (!paused && lv_screen_active() != nullptr) { + lv_display_trigger_activity(this->disp_); // resets the inactivity time + lv_obj_invalidate(lv_screen_active()); } if (paused && this->pause_callback_ != nullptr) this->pause_callback_->trigger(); @@ -125,6 +136,14 @@ void LvglComponent::set_paused(bool paused, bool show_snow) { void LvglComponent::esphome_lvgl_init() { lv_init(); + // override draw buf alloc to ensure proper alignment for PPA + LV_GLOBAL_DEFAULT()->draw_buf_handlers.buf_malloc_cb = draw_buf_alloc_cb; + LV_GLOBAL_DEFAULT()->draw_buf_handlers.buf_free_cb = lv_free_core; + LV_GLOBAL_DEFAULT()->image_cache_draw_buf_handlers.buf_malloc_cb = draw_buf_alloc_cb; + LV_GLOBAL_DEFAULT()->image_cache_draw_buf_handlers.buf_free_cb = lv_free_core; + LV_GLOBAL_DEFAULT()->font_draw_buf_handlers.buf_malloc_cb = draw_buf_alloc_cb; + LV_GLOBAL_DEFAULT()->font_draw_buf_handlers.buf_free_cb = lv_free_core; + lv_tick_set_cb([] { return millis(); }); lv_update_event = static_cast(lv_event_register_id()); lv_api_event = static_cast(lv_event_register_id()); } @@ -149,7 +168,7 @@ void LvglComponent::add_event_cb(lv_obj_t *obj, event_callback_t callback, lv_ev void LvglComponent::add_page(LvPageType *page) { this->pages_.push_back(page); page->set_parent(this); - lv_disp_set_default(this->disp_); + lv_display_set_default(this->disp_); page->setup(this->pages_.size() - 1); } @@ -187,13 +206,13 @@ void LvglComponent::show_prev_page(lv_scr_load_anim_t anim, uint32_t time) { size_t LvglComponent::get_current_page() const { return this->current_page_; } bool LvPageType::is_showing() const { return this->parent_->get_current_page() == this->index; } -void LvglComponent::draw_buffer_(const lv_area_t *area, lv_color_t *ptr) { +void LvglComponent::draw_buffer_(const lv_area_t *area, lv_color_data *ptr) { auto width = lv_area_get_width(area); auto height = lv_area_get_height(area); auto height_rounded = (height + this->draw_rounding - 1) / this->draw_rounding * this->draw_rounding; auto x1 = area->x1; auto y1 = area->y1; - lv_color_t *dst = this->rotate_buf_; + lv_color_data *dst = reinterpret_cast(this->rotate_buf_); switch (this->rotation) { case display::DISPLAY_ROTATION_90_DEGREES: for (lv_coord_t x = height; x-- != 0;) { @@ -202,7 +221,7 @@ void LvglComponent::draw_buffer_(const lv_area_t *area, lv_color_t *ptr) { } } y1 = x1; - x1 = this->disp_drv_.ver_res - area->y1 - height; + x1 = this->height_ - area->y1 - height; height = width; width = height_rounded; break; @@ -213,8 +232,8 @@ void LvglComponent::draw_buffer_(const lv_area_t *area, lv_color_t *ptr) { dst[y * width + x] = *ptr++; } } - x1 = this->disp_drv_.hor_res - x1 - width; - y1 = this->disp_drv_.ver_res - y1 - height; + x1 = this->width_ - x1 - width; + y1 = this->height_ - y1 - height; break; case display::DISPLAY_ROTATION_270_DEGREES: @@ -224,7 +243,7 @@ void LvglComponent::draw_buffer_(const lv_area_t *area, lv_color_t *ptr) { } } x1 = y1; - y1 = this->disp_drv_.hor_res - area->x1 - width; + y1 = this->width_ - area->x1 - width; height = width; width = height_rounded; break; @@ -234,20 +253,19 @@ void LvglComponent::draw_buffer_(const lv_area_t *area, lv_color_t *ptr) { break; } for (auto *display : this->displays_) { - ESP_LOGV(TAG, "draw buffer x1=%d, y1=%d, width=%d, height=%d", x1, y1, width, height); display->draw_pixels_at(x1, y1, width, height, (const uint8_t *) dst, display::COLOR_ORDER_RGB, LV_BITNESS, - LV_COLOR_16_SWAP); + this->big_endian_); } } -void LvglComponent::flush_cb_(lv_disp_drv_t *disp_drv, const lv_area_t *area, lv_color_t *color_p) { +void LvglComponent::flush_cb_(lv_display_t *disp_drv, const lv_area_t *area, uint8_t *color_p) { if (!this->is_paused()) { auto now = millis(); - this->draw_buffer_(area, color_p); - ESP_LOGVV(TAG, "flush_cb, area=%d/%d, %d/%d took %dms", area->x1, area->y1, lv_area_get_width(area), - lv_area_get_height(area), (int) (millis() - now)); + this->draw_buffer_(area, reinterpret_cast(color_p)); + ESP_LOGV(TAG, "flush_cb, area=%d/%d, %d/%d took %dms", area->x1, area->y1, lv_area_get_width(area), + lv_area_get_height(area), (int) (millis() - now)); } - lv_disp_flush_ready(disp_drv); + lv_display_flush_ready(disp_drv); } IdleTrigger::IdleTrigger(LvglComponent *parent, TemplatableValue timeout) : timeout_(std::move(timeout)) { @@ -264,14 +282,14 @@ IdleTrigger::IdleTrigger(LvglComponent *parent, TemplatableValue timeo #ifdef USE_LVGL_TOUCHSCREEN LVTouchListener::LVTouchListener(uint16_t long_press_time, uint16_t long_press_repeat_time, LvglComponent *parent) { this->set_parent(parent); - lv_indev_drv_init(&this->drv_); - this->drv_.disp = parent->get_disp(); - this->drv_.long_press_repeat_time = long_press_repeat_time; - this->drv_.long_press_time = long_press_time; - this->drv_.type = LV_INDEV_TYPE_POINTER; - this->drv_.user_data = this; - this->drv_.read_cb = [](lv_indev_drv_t *d, lv_indev_data_t *data) { - auto *l = static_cast(d->user_data); + this->drv_ = lv_indev_create(); + lv_indev_set_type(this->drv_, LV_INDEV_TYPE_POINTER); + lv_indev_set_disp(this->drv_, parent->get_disp()); + lv_indev_set_long_press_time(this->drv_, long_press_time); + // long press repeat time TBD + lv_indev_set_user_data(this->drv_, this); + lv_indev_set_read_cb(this->drv_, [](lv_indev_t *d, lv_indev_data_t *data) { + auto *l = static_cast(lv_indev_get_user_data(d)); if (l->touch_pressed_) { data->point.x = l->touch_point_.x; data->point.y = l->touch_point_.y; @@ -279,7 +297,7 @@ LVTouchListener::LVTouchListener(uint16_t long_press_time, uint16_t long_press_r } else { data->state = LV_INDEV_STATE_RELEASED; } - }; + }); } void LVTouchListener::update(const touchscreen::TouchPoints_t &tpoints) { @@ -289,21 +307,78 @@ void LVTouchListener::update(const touchscreen::TouchPoints_t &tpoints) { } #endif // USE_LVGL_TOUCHSCREEN +#ifdef USE_LVGL_METER + +int16_t lv_get_needle_angle_for_value(lv_obj_t *obj, int value) { + auto *scale = lv_obj_get_parent(obj); + auto min_value = lv_scale_get_range_min_value(scale); + return ((value - min_value) * lv_scale_get_angle_range(scale) / (lv_scale_get_range_max_value(scale) - min_value) + + lv_scale_get_rotation((scale))) % + 360; +} + +void IndicatorLine::set_obj(lv_obj_t *lv_obj) { + LvCompound::set_obj(lv_obj); + lv_line_set_points(lv_obj, this->points_, 2); + lv_obj_add_event_cb( + lv_obj_get_parent(obj), + [](lv_event_t *e) { + auto *indicator = static_cast(lv_event_get_user_data(e)); + indicator->update_length_(); + ESP_LOGV(TAG, "Updated length, value = %d", indicator->angle_); + }, + LV_EVENT_SIZE_CHANGED, this); +} + +void IndicatorLine::set_value(int value) { + auto angle = lv_get_needle_angle_for_value(this->obj, value); + if (angle != this->angle_) { + this->angle_ = angle; + this->update_length_(); + } +} + +void IndicatorLine::update_length_() { + uint32_t actual_needle_length; + auto radius = lv_obj_get_width(lv_obj_get_parent(this->obj)) / 2; + auto length = lv_obj_get_style_length(this->obj, LV_PART_MAIN); + auto radial_offset = lv_obj_get_style_radial_offset(this->obj, LV_PART_MAIN); + if (LV_COORD_IS_PCT(radial_offset)) { + radial_offset = radius * LV_COORD_GET_PCT(radial_offset) / 100; + } + if (LV_COORD_IS_PCT(length)) { + actual_needle_length = radius * LV_COORD_GET_PCT(length) / 100; + } else if (length < 0) { + actual_needle_length = radius + length; + } else { + actual_needle_length = length; + } + auto x = lv_trigo_cos(this->angle_) / 32768.0f; + auto y = lv_trigo_sin(this->angle_) / 32768.0f; + this->points_[0].x = radius + radial_offset * x; + this->points_[0].y = radius + radial_offset * y; + this->points_[1].x = x * actual_needle_length + radius; + this->points_[1].y = y * actual_needle_length + radius; + lv_obj_refresh_self_size(this->obj); + lv_obj_invalidate(this->obj); +} +#endif + #ifdef USE_LVGL_KEY_LISTENER -LVEncoderListener::LVEncoderListener(lv_indev_type_t type, uint16_t lpt, uint16_t lprt) { - lv_indev_drv_init(&this->drv_); - this->drv_.type = type; - this->drv_.user_data = this; - this->drv_.long_press_time = lpt; - this->drv_.long_press_repeat_time = lprt; - this->drv_.read_cb = [](lv_indev_drv_t *d, lv_indev_data_t *data) { - auto *l = static_cast(d->user_data); +LVEncoderListener::LVEncoderListener(lv_indev_type_t type, uint16_t long_press_time, uint16_t long_press_repeat_time) { + this->drv_ = lv_indev_create(); + lv_indev_set_type(this->drv_, type); + lv_indev_set_long_press_time(this->drv_, long_press_time); + lv_indev_set_long_press_repeat_time(this->drv_, long_press_repeat_time); + lv_indev_set_user_data(this->drv_, this); + lv_indev_set_read_cb(this->drv_, [](lv_indev_t *d, lv_indev_data_t *data) { + auto *l = static_cast(lv_indev_get_user_data(d)); data->state = l->pressed_ ? LV_INDEV_STATE_PRESSED : LV_INDEV_STATE_RELEASED; data->key = l->key_; data->enc_diff = (int16_t) (l->count_ - l->last_count_); l->last_count_ = l->count_; data->continue_reading = false; - }; + }); } #endif // USE_LVGL_KEY_LISTENER @@ -325,7 +400,7 @@ void LvSelectable::set_selected_text(const std::string &text, lv_anim_enable_t a auto index = std::find(this->options_.begin(), this->options_.end(), text); if (index != this->options_.end()) { this->set_selected_index(index - this->options_.begin(), anim); - lv_event_send(this->obj, lv_api_event, nullptr); + lv_obj_send_event(this->obj, lv_api_event, nullptr); } } @@ -335,7 +410,7 @@ void LvSelectable::set_options(std::vector options) { index = options.size() - 1; this->options_ = std::move(options); this->set_option_string(join_string(this->options_).c_str()); - lv_event_send(this->obj, LV_EVENT_REFRESH, nullptr); + lv_obj_send_event(this->obj, LV_EVENT_REFRESH, nullptr); this->set_selected_index(index, LV_ANIM_OFF); } #endif // USE_LVGL_DROPDOWN || LV_USE_ROLLER @@ -346,17 +421,17 @@ void LvButtonMatrixType::set_obj(lv_obj_t *lv_obj) { lv_obj_add_event_cb( lv_obj, [](lv_event_t *event) { - auto *self = static_cast(event->user_data); + auto *self = static_cast(lv_event_get_user_data(event)); if (self->key_callback_.size() == 0) return; - auto key_idx = lv_btnmatrix_get_selected_btn(self->obj); - if (key_idx == LV_BTNMATRIX_BTN_NONE) + auto key_idx = lv_buttonmatrix_get_selected_button(self->obj); + if (key_idx == LV_BUTTONMATRIX_BUTTON_NONE) return; if (self->key_map_.count(key_idx) != 0) { self->send_key_(self->key_map_[key_idx]); return; } - const auto *str = lv_btnmatrix_get_btn_text(self->obj, key_idx); + const auto *str = lv_buttonmatrix_get_button_text(self->obj, key_idx); auto len = strlen(str); while (len--) self->send_key_(*str++); @@ -376,14 +451,14 @@ void LvKeyboardType::set_obj(lv_obj_t *lv_obj) { lv_obj_add_event_cb( lv_obj, [](lv_event_t *event) { - auto *self = static_cast(event->user_data); + auto *self = static_cast(lv_event_get_user_data(event)); if (self->key_callback_.size() == 0) return; - auto key_idx = lv_btnmatrix_get_selected_btn(self->obj); - if (key_idx == LV_BTNMATRIX_BTN_NONE) + auto key_idx = lv_buttonmatrix_get_selected_button(self->obj); + if (key_idx == LV_BUTTONMATRIX_BUTTON_NONE) return; - const char *txt = lv_btnmatrix_get_btn_text(self->obj, key_idx); + const char *txt = lv_buttonmatrix_get_button_text(self->obj, key_idx); if (txt == nullptr) return; for (const auto *kb_special_key : KB_SPECIAL_KEYS) { @@ -419,33 +494,29 @@ bool LvglComponent::is_paused() const { } void LvglComponent::write_random_() { - int iterations = 6 - lv_disp_get_inactive_time(this->disp_) / 60000; + int iterations = 6 - lv_display_get_inactive_time(this->disp_) / 60000; if (iterations <= 0) iterations = 1; while (iterations-- != 0) { - auto col = random_uint32() % this->disp_drv_.hor_res; + int32_t col = random_uint32() % this->width_; col = col / this->draw_rounding * this->draw_rounding; - auto row = random_uint32() % this->disp_drv_.ver_res; + int32_t row = random_uint32() % this->height_; row = row / this->draw_rounding * this->draw_rounding; - auto size = ((random_uint32() % 32) / this->draw_rounding + 2) * this->draw_rounding - 1; - // clamp size so the square fits within the draw buffer - if ((size + 1) * (size + 1) > this->draw_buf_.size) - size = static_cast(sqrtf(this->draw_buf_.size)) - 1; - lv_area_t area; - area.x1 = col; - area.y1 = row; - area.x2 = col + size; - area.y2 = row + size; - if (area.x2 >= this->disp_drv_.hor_res) - area.x2 = this->disp_drv_.hor_res - 1; - if (area.y2 >= this->disp_drv_.ver_res) - area.y2 = this->disp_drv_.ver_res - 1; + // size will be between 8 and 32, and a multiple of draw_rounding + int32_t size = (random_uint32() % 25 + 8) / this->draw_rounding * this->draw_rounding; + lv_area_t area{col, row, col + size - 1, row + size - 1}; + // clip to display bounds just in case + if (area.x2 >= this->width_) + area.x2 = this->width_ - 1; + if (area.y2 >= this->height_) + area.y2 = this->height_ - 1; + // line_len can't exceed 1024, and minimum buffer size is 2048, so this won't overflow the buffer size_t line_len = lv_area_get_width(&area) * lv_area_get_height(&area) / 2; for (size_t i = 0; i != line_len; i++) { - ((uint32_t *) (this->draw_buf_.buf1))[i] = random_uint32(); + ((uint32_t *) (this->draw_buf_))[i] = random_uint32(); } - this->draw_buffer_(&area, (lv_color_t *) this->draw_buf_.buf1); + this->draw_buffer_(&area, (lv_color_data *) this->draw_buf_); } } @@ -477,38 +548,32 @@ LvglComponent::LvglComponent(std::vector displays, float buf full_refresh_(full_refresh), resume_on_input_(resume_on_input), update_when_display_idle_(update_when_display_idle) { - lv_disp_draw_buf_init(&this->draw_buf_, nullptr, nullptr, 0); - lv_disp_drv_init(&this->disp_drv_); - this->disp_drv_.draw_buf = &this->draw_buf_; - this->disp_drv_.user_data = this; - this->disp_drv_.full_refresh = this->full_refresh_; - this->disp_drv_.flush_cb = static_flush_cb; - this->disp_drv_.rounder_cb = rounder_cb; - this->disp_ = lv_disp_drv_register(&this->disp_drv_); + this->disp_ = lv_display_create(240, 240); } void LvglComponent::setup() { auto *display = this->displays_[0]; auto rounding = this->draw_rounding; // cater for displays with dimensions that don't divide by the required rounding + this->width_ = display->get_width(); + this->height_ = display->get_height(); auto width = (display->get_width() + rounding - 1) / rounding * rounding; auto height = (display->get_height() + rounding - 1) / rounding * rounding; auto frac = this->buffer_frac_; if (frac == 0) frac = 1; - size_t buffer_pixels = width * height / frac; - auto buf_bytes = buffer_pixels * LV_COLOR_DEPTH / 8; + auto buf_bytes = clamp_at_least(width * height / frac * LV_COLOR_DEPTH / 8, MIN_BUFFER_SIZE); void *buffer = nullptr; + // for small buffers, try to allocate in internal memory first to improve performance if (this->buffer_frac_ >= MIN_BUFFER_FRAC / 2) - buffer = malloc(buf_bytes); // NOLINT + buffer = lv_alloc_draw_buf(buf_bytes, true); // NOLINT if (buffer == nullptr) - buffer = lv_custom_mem_alloc(buf_bytes); // NOLINT + buffer = lv_alloc_draw_buf(buf_bytes, false); // NOLINT // if specific buffer size not set and can't get 100%, try for a smaller one if (buffer == nullptr && this->buffer_frac_ == 0) { frac = MIN_BUFFER_FRAC; - buffer_pixels /= MIN_BUFFER_FRAC; buf_bytes /= MIN_BUFFER_FRAC; - buffer = lv_custom_mem_alloc(buf_bytes); // NOLINT + buffer = lv_alloc_draw_buf(buf_bytes, false); // NOLINT } this->buffer_frac_ = frac; if (buffer == nullptr) { @@ -516,13 +581,17 @@ void LvglComponent::setup() { this->mark_failed(); return; } - lv_disp_draw_buf_init(&this->draw_buf_, buffer, nullptr, buffer_pixels); - this->disp_drv_.hor_res = display->get_width(); - this->disp_drv_.ver_res = display->get_height(); - lv_disp_drv_update(this->disp_, &this->disp_drv_); + this->draw_buf_ = static_cast(buffer); + lv_display_set_resolution(this->disp_, this->width_, this->height_); + lv_display_set_color_format(this->disp_, LV_COLOR_FORMAT_RGB565); + lv_display_set_flush_cb(this->disp_, static_flush_cb); + lv_display_set_user_data(this->disp_, this); + lv_display_add_event_cb(this->disp_, rounder_cb, LV_EVENT_INVALIDATE_AREA, this); + lv_display_set_buffers(this->disp_, this->draw_buf_, nullptr, buf_bytes, + this->full_refresh_ ? LV_DISPLAY_RENDER_MODE_FULL : LV_DISPLAY_RENDER_MODE_PARTIAL); this->rotation = display->get_rotation(); if (this->rotation != display::DISPLAY_ROTATION_0_DEGREES) { - this->rotate_buf_ = static_cast(lv_custom_mem_alloc(buf_bytes)); // NOLINT + this->rotate_buf_ = static_cast(lv_alloc_draw_buf(buf_bytes, false)); // NOLINT if (this->rotate_buf_ == nullptr) { this->status_set_error(LOG_STR("Memory allocation failure")); this->mark_failed(); @@ -530,26 +599,28 @@ void LvglComponent::setup() { } } if (this->draw_start_callback_ != nullptr) { - this->disp_drv_.render_start_cb = render_start_cb; + lv_display_add_event_cb(this->disp_, render_start_cb, LV_EVENT_RENDER_START, this); } if (this->draw_end_callback_ != nullptr || this->update_when_display_idle_) { - this->disp_drv_.monitor_cb = monitor_cb; + lv_display_add_event_cb(this->disp_, render_end_cb, LV_EVENT_REFR_READY, this); } #if LV_USE_LOG - lv_log_register_print_cb([](const char *buf) { + lv_log_register_print_cb([](lv_log_level_t level, const char *buf) { auto next = strchr(buf, ')'); if (next != nullptr) buf = next + 1; while (isspace(*buf)) buf++; - esp_log_printf_(LVGL_LOG_LEVEL, TAG, 0, "%.*s", (int) strlen(buf) - 1, buf); + if (level >= sizeof(LOG_LEVEL_MAP) / sizeof(LOG_LEVEL_MAP[0])) + level = sizeof(LOG_LEVEL_MAP) / sizeof(LOG_LEVEL_MAP[0]) - 1; + esp_log_printf_(LOG_LEVEL_MAP[level], TAG, 0, "%.*s", (int) strlen(buf) - 1, buf); }); #endif // Rotation will be handled by our drawing function, so reset the display rotation. for (auto *disp : this->displays_) disp->set_rotation(display::DISPLAY_ROTATION_0_DEGREES); this->show_page(0, LV_SCR_LOAD_ANIM_NONE, 0); - lv_disp_trig_activity(this->disp_); + lv_display_trigger_activity(this->disp_); } void LvglComponent::update() { @@ -557,7 +628,7 @@ void LvglComponent::update() { if (this->is_paused()) { return; } - this->idle_callbacks_.call(lv_disp_get_inactive_time(this->disp_)); + this->idle_callbacks_.call(lv_display_get_inactive_time(this->disp_)); } void LvglComponent::loop() { @@ -565,41 +636,129 @@ void LvglComponent::loop() { if (this->paused_ && this->show_snow_) this->write_random_(); } else { - lv_timer_handler_run_in_period(5); +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + auto now = millis(); + lv_timer_handler(); + auto elapsed = millis() - now; + if (elapsed > 15) { + ESP_LOGV(TAG, "lv_timer_handler took %dms", (int) (millis() - now)); + } +#else + lv_timer_handler(); +#endif } } #ifdef USE_LVGL_ANIMIMG void lv_animimg_stop(lv_obj_t *obj) { - auto *animg = (lv_animimg_t *) obj; - int32_t duration = animg->anim.time; + int32_t duration = lv_animimg_get_duration(obj); lv_animimg_set_duration(obj, 0); lv_animimg_start(obj); lv_animimg_set_duration(obj, duration); } #endif -void LvglComponent::static_flush_cb(lv_disp_drv_t *disp_drv, const lv_area_t *area, lv_color_t *color_p) { - reinterpret_cast(disp_drv->user_data)->flush_cb_(disp_drv, area, color_p); +void LvglComponent::static_flush_cb(lv_display_t *disp_drv, const lv_area_t *area, uint8_t *color_p) { + reinterpret_cast(lv_display_get_user_data(disp_drv))->flush_cb_(disp_drv, area, color_p); } -} // namespace lvgl -} // namespace esphome -size_t lv_millis(void) { return esphome::millis(); } +#ifdef USE_LVGL_SCALE +/** + * Function to apply colors to ticks based on position + * @param e The event data + * @param color_start The color to apply to the first tick + * @param color_end The color to apply to the last tick + */ +void lv_scale_draw_event_cb(lv_event_t *e, uint16_t range_start, uint16_t range_end, lv_color_t color_start, + lv_color_t color_end, bool local) { + auto *scale = static_cast(lv_event_get_target(e)); + lv_draw_task_t *task = lv_event_get_draw_task(e); + + if (lv_draw_task_get_type(task) == LV_DRAW_TASK_TYPE_LINE) { + auto *line_dsc = static_cast(lv_draw_task_get_draw_dsc(task)); + auto tick = line_dsc->base.id1; + if (tick >= range_start && tick <= range_end) { + unsigned range = range_end - range_start; + if (local) { + tick -= range_start; + } else { + range = lv_scale_get_total_tick_count(scale) - 1; + } + if (range == 0) + range = 1; + auto ratio = (tick * 255) / range; + line_dsc->color = lv_color_mix(color_end, color_start, ratio); + } + } +} +#endif // USE_LVGL_SCALE + +static void lv_container_constructor(const lv_obj_class_t *class_p, lv_obj_t *obj) { + LV_TRACE_OBJ_CREATE("begin"); + LV_UNUSED(class_p); +} + +// Container class. Name is based on LVGL naming convention but upper case to keep ESPHome clang-tidy happy +const lv_obj_class_t LV_CONTAINER_CLASS = { + .base_class = &lv_obj_class, + .constructor_cb = lv_container_constructor, + .name = "lv_container", +}; + +lv_obj_t *lv_container_create(lv_obj_t *parent) { + lv_obj_t *obj = lv_obj_class_create_obj(&LV_CONTAINER_CLASS, parent); + lv_obj_class_init_obj(obj); + return obj; +} +} // namespace esphome::lvgl + +lv_result_t lv_mem_test_core() { return LV_RESULT_OK; } + +void lv_mem_init() {} + +void lv_mem_deinit() {} #if defined(USE_HOST) || defined(USE_RP2040) || defined(USE_ESP8266) -void *lv_custom_mem_alloc(size_t size) { +void *lv_malloc_core(size_t size) { auto *ptr = malloc(size); // NOLINT if (ptr == nullptr) { ESP_LOGE(esphome::lvgl::TAG, "Failed to allocate %zu bytes", size); } return ptr; } -void lv_custom_mem_free(void *ptr) { return free(ptr); } // NOLINT -void *lv_custom_mem_realloc(void *ptr, size_t size) { return realloc(ptr, size); } // NOLINT -#else +void lv_free_core(void *ptr) { return free(ptr); } // NOLINT +void *lv_realloc_core(void *ptr, size_t size) { return realloc(ptr, size); } // NOLINT + +void lv_mem_monitor_core(lv_mem_monitor_t *mon_p) { memset(mon_p, 0, sizeof(lv_mem_monitor_t)); } +static void *lv_alloc_draw_buf(size_t size, bool internal) { + return malloc(size); // NOLINT +} + +#elif defined(USE_ESP32) static unsigned cap_bits = MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT; // NOLINT -void *lv_custom_mem_alloc(size_t size) { +static void *lv_alloc_draw_buf(size_t size, bool internal) { + void *buffer; + size = ((size + LV_DRAW_BUF_ALIGN - 1) / LV_DRAW_BUF_ALIGN) * LV_DRAW_BUF_ALIGN; + buffer = heap_caps_aligned_alloc(LV_DRAW_BUF_ALIGN, size, internal ? MALLOC_CAP_8BIT : cap_bits); // NOLINT + if (buffer == nullptr) + ESP_LOGW(esphome::lvgl::TAG, "Failed to allocate %zu bytes for %sdraw buffer", size, internal ? "internal " : ""); + return buffer; +} + +void lv_mem_monitor_core(lv_mem_monitor_t *mon_p) { + multi_heap_info_t heap_info; + heap_caps_get_info(&heap_info, cap_bits); + mon_p->total_size = heap_info.total_allocated_bytes + heap_info.total_free_bytes; + mon_p->free_size = heap_info.total_free_bytes; + mon_p->max_used = heap_info.total_allocated_bytes; + mon_p->free_biggest_size = heap_info.largest_free_block; + mon_p->used_cnt = heap_info.allocated_blocks; + mon_p->free_cnt = heap_info.free_blocks; + mon_p->used_pct = heap_info.allocated_blocks * 100 / (heap_info.allocated_blocks + heap_info.free_blocks); + mon_p->frag_pct = 0; +} + +void *lv_malloc_core(size_t size) { void *ptr; ptr = heap_caps_malloc(size, cap_bits); if (ptr == nullptr) { @@ -614,14 +773,14 @@ void *lv_custom_mem_alloc(size_t size) { return ptr; } -void lv_custom_mem_free(void *ptr) { +void lv_free_core(void *ptr) { ESP_LOGV(esphome::lvgl::TAG, "free %p", ptr); if (ptr == nullptr) return; heap_caps_free(ptr); } -void *lv_custom_mem_realloc(void *ptr, size_t size) { +void *lv_realloc_core(void *ptr, size_t size) { ESP_LOGV(esphome::lvgl::TAG, "realloc %p: %zu", ptr, size); return heap_caps_realloc(ptr, size, cap_bits); } diff --git a/esphome/components/lvgl/lvgl_esphome.h b/esphome/components/lvgl/lvgl_esphome.h index aa8dd2fba5..4ce7296159 100644 --- a/esphome/components/lvgl/lvgl_esphome.h +++ b/esphome/components/lvgl/lvgl_esphome.h @@ -4,7 +4,7 @@ #ifdef USE_BINARY_SENSOR #include "esphome/components/binary_sensor/binary_sensor.h" #endif // USE_BINARY_SENSOR -#ifdef USE_LVGL_IMAGE +#ifdef USE_IMAGE #include "esphome/components/image/image.h" #endif // USE_LVGL_IMAGE #ifdef USE_LVGL_ROTARY_ENCODER @@ -19,16 +19,17 @@ #include "esphome/components/display/display.h" #include "esphome/components/display/display_color_utils.h" #include "esphome/core/component.h" -#include "esphome/core/log.h" + +#include #include #include #include #include -#ifdef USE_LVGL_FONT +#ifdef USE_FONT #include "esphome/components/font/font.h" #endif // USE_LVGL_FONT -#ifdef USE_LVGL_TOUCHSCREEN +#ifdef USE_TOUCHSCREEN #include "esphome/components/touchscreen/touchscreen.h" #endif // USE_LVGL_TOUCHSCREEN @@ -36,12 +37,24 @@ #include "esphome/components/key_provider/key_provider.h" #endif // USE_LVGL_BUTTONMATRIX -namespace esphome { -namespace lvgl { +namespace esphome::lvgl { + +#if LV_COLOR_DEPTH == 16 +using lv_color_data = uint16_t; +#endif +#if LV_COLOR_DEPTH == 32 +using lv_color_data = uint32_t; +#endif extern lv_event_code_t lv_api_event; // NOLINT extern lv_event_code_t lv_update_event; // NOLINT -extern std::string lv_event_code_name_for(uint8_t event_code); +extern std::string lv_event_code_name_for(lv_event_t *event); + +lv_obj_t *lv_container_create(lv_obj_t *parent); +#ifdef USE_LVGL_SCALE +void lv_scale_draw_event_cb(lv_event_t *e, uint16_t range_start, uint16_t range_end, lv_color_t color_start, + lv_color_t color_end, bool local); +#endif #if LV_COLOR_DEPTH == 16 static const display::ColorBitness LV_BITNESS = display::ColorBitness::COLOR_BITNESS_565; #elif LV_COLOR_DEPTH == 32 @@ -50,7 +63,7 @@ static const display::ColorBitness LV_BITNESS = display::ColorBitness::COLOR_BIT static const display::ColorBitness LV_BITNESS = display::ColorBitness::COLOR_BITNESS_332; #endif // LV_COLOR_DEPTH -#ifdef USE_LVGL_FONT +#if defined(USE_FONT) && defined(USE_LVGL_FONT) inline void lv_obj_set_style_text_font(lv_obj_t *obj, const font::Font *font, lv_style_selector_t part) { lv_obj_set_style_text_font(obj, font->get_lv_font(), part); } @@ -58,50 +71,37 @@ inline void lv_style_set_text_font(lv_style_t *style, const font::Font *font) { lv_style_set_text_font(style, font->get_lv_font()); } #endif -#ifdef USE_LVGL_IMAGE +#ifdef USE_IMAGE // Shortcut / overload, so that the source of an image can easily be updated // from within a lambda. -inline void lv_img_set_src(lv_obj_t *obj, esphome::image::Image *image) { - lv_img_set_src(obj, image->get_lv_img_dsc()); -} -inline void lv_disp_set_bg_image(lv_disp_t *disp, esphome::image::Image *image) { - lv_disp_set_bg_image(disp, image->get_lv_img_dsc()); +inline void lv_image_set_src(lv_obj_t *obj, esphome::image::Image *image) { + lv_image_set_src(obj, image->get_lv_image_dsc()); } -inline void lv_obj_set_style_bg_img_src(lv_obj_t *obj, esphome::image::Image *image, lv_style_selector_t selector) { - lv_obj_set_style_bg_img_src(obj, image->get_lv_img_dsc(), selector); +inline void lv_obj_set_style_bg_image_src(lv_obj_t *obj, esphome::image::Image *image, lv_style_selector_t selector) { + lv_obj_set_style_bg_image_src(obj, image->get_lv_image_dsc(), selector); } -#ifdef USE_LVGL_CANVAS -inline void lv_canvas_draw_img(lv_obj_t *canvas, lv_coord_t x, lv_coord_t y, image::Image *image, - lv_draw_img_dsc_t *dsc) { - lv_canvas_draw_img(canvas, x, y, image->get_lv_img_dsc(), dsc); -} -#endif - -#ifdef USE_LVGL_METER -inline lv_meter_indicator_t *lv_meter_add_needle_img(lv_obj_t *obj, lv_meter_scale_t *scale, esphome::image::Image *src, - lv_coord_t pivot_x, lv_coord_t pivot_y) { - return lv_meter_add_needle_img(obj, scale, src->get_lv_img_dsc(), pivot_x, pivot_y); -} -#endif // USE_LVGL_METER #endif // USE_LVGL_IMAGE #ifdef USE_LVGL_ANIMIMG inline void lv_animimg_set_src(lv_obj_t *img, std::vector images) { - auto *dsc = static_cast *>(lv_obj_get_user_data(img)); + auto *dsc = static_cast *>(lv_obj_get_user_data(img)); if (dsc == nullptr) { // object will be lazily allocated but never freed. - dsc = new std::vector(images.size()); // NOLINT + dsc = new std::vector(images.size()); // NOLINT lv_obj_set_user_data(img, dsc); } dsc->clear(); for (auto &image : images) { - dsc->push_back(image->get_lv_img_dsc()); + dsc->push_back(image->get_lv_image_dsc()); } lv_animimg_set_src(img, (const void **) dsc->data(), dsc->size()); } - #endif // USE_LVGL_ANIMIMG +#ifdef USE_LVGL_METER +int16_t lv_get_needle_angle_for_value(lv_obj_t *obj, int value); +#endif + // Parent class for things that wrap an LVGL object class LvCompound { public: @@ -130,7 +130,7 @@ class LvPageType : public Parented { using LvLambdaType = std::function; using set_value_lambda_t = std::function; -using event_callback_t = void(_lv_event_t *); +using event_callback_t = void(lv_event_t *); using text_lambda_t = std::function; template class ObjUpdateAction : public Action { @@ -152,7 +152,7 @@ class LvglComponent : public PollingComponent { public: LvglComponent(std::vector displays, float buffer_frac, bool full_refresh, int draw_rounding, bool resume_on_input, bool update_when_display_idle); - static void static_flush_cb(lv_disp_drv_t *disp_drv, const lv_area_t *area, lv_color_t *color_p); + static void static_flush_cb(lv_display_t *disp_drv, const lv_area_t *area, uint8_t *color_p); float get_setup_priority() const override { return setup_priority::PROCESSOR; } void setup() override; @@ -160,11 +160,11 @@ class LvglComponent : public PollingComponent { void loop() override; template void add_on_idle_callback(F &&callback) { this->idle_callbacks_.add(std::forward(callback)); } - static void monitor_cb(lv_disp_drv_t *disp_drv, uint32_t time, uint32_t px); - static void render_start_cb(lv_disp_drv_t *disp_drv); + static void render_end_cb(lv_event_t *event); + static void render_start_cb(lv_event_t *event); void dump_config() override; lv_disp_t *get_disp() { return this->disp_; } - lv_obj_t *get_scr_act() { return lv_disp_get_scr_act(this->disp_); } + lv_obj_t *get_screen_active() { return lv_display_get_screen_active(this->disp_); } // Pause or resume the display. // @param paused If true, pause the display. If false, resume the display. // @param show_snow If true, show the snow effect when paused. @@ -187,11 +187,13 @@ class LvglComponent : public PollingComponent { static void add_event_cb(lv_obj_t *obj, event_callback_t callback, lv_event_code_t event1, lv_event_code_t event2); static void add_event_cb(lv_obj_t *obj, event_callback_t callback, lv_event_code_t event1, lv_event_code_t event2, lv_event_code_t event3); + void add_page(LvPageType *page); void show_page(size_t index, lv_scr_load_anim_t anim, uint32_t time); void show_next_page(lv_scr_load_anim_t anim, uint32_t time); void show_prev_page(lv_scr_load_anim_t anim, uint32_t time); void set_page_wrap(bool wrap) { this->page_wrap_ = wrap; } + void set_big_endian(bool big_endian) { this->big_endian_ = big_endian; } size_t get_current_page() const; void set_focus_mark(lv_group_t *group) { this->focus_marks_[group] = lv_group_get_focused(group); } void restore_focus_mark(lv_group_t *group) { @@ -216,22 +218,25 @@ class LvglComponent : public PollingComponent { void draw_start_() const { this->draw_start_callback_->trigger(); } void write_random_(); - void draw_buffer_(const lv_area_t *area, lv_color_t *ptr); - void flush_cb_(lv_disp_drv_t *disp_drv, const lv_area_t *area, lv_color_t *color_p); + void draw_buffer_(const lv_area_t *area, lv_color_data *ptr); + void flush_cb_(lv_display_t *disp_drv, const lv_area_t *area, uint8_t *color_p); + std::vector displays_{}; size_t buffer_frac_{1}; bool full_refresh_{}; bool resume_on_input_{}; bool update_when_display_idle_{}; - lv_disp_draw_buf_t draw_buf_{}; - lv_disp_drv_t disp_drv_{}; - lv_disp_t *disp_{}; + uint8_t *draw_buf_{}; + lv_display_t *disp_{}; + uint16_t width_{}; + uint16_t height_{}; bool paused_{}; std::vector pages_{}; size_t current_page_{0}; bool show_snow_{}; bool page_wrap_{true}; + bool big_endian_{}; std::map focus_marks_{}; CallbackManager idle_callbacks_{}; @@ -239,7 +244,7 @@ class LvglComponent : public PollingComponent { Trigger<> *resume_callback_{}; Trigger<> *draw_start_callback_{}; Trigger<> *draw_end_callback_{}; - lv_color_t *rotate_buf_{}; + void *rotate_buf_{}; }; class IdleTrigger : public Trigger<> { @@ -278,19 +283,37 @@ class LVTouchListener : public touchscreen::TouchListener, public Parentedparent_->maybe_wakeup(); } - lv_indev_drv_t *get_drv() { return &this->drv_; } + lv_indev_t *get_drv() { return this->drv_; } protected: - lv_indev_drv_t drv_{}; + lv_indev_t *drv_{}; touchscreen::TouchPoint touch_point_{}; bool touch_pressed_{}; }; #endif // USE_LVGL_TOUCHSCREEN +#ifdef USE_LVGL_METER + +class IndicatorLine : public LvCompound { + public: + IndicatorLine() = default; + + void set_obj(lv_obj_t *lv_obj) override; + + void set_value(int value); + + private: + void update_length_(); + + int16_t angle_{}; + lv_point_precise_t points_[2]{}; +}; +#endif + #ifdef USE_LVGL_KEY_LISTENER class LVEncoderListener : public Parented { public: - LVEncoderListener(lv_indev_type_t type, uint16_t lpt, uint16_t lprt); + LVEncoderListener(lv_indev_type_t type, uint16_t long_press_time, uint16_t long_press_repeat_time); #ifdef USE_BINARY_SENSOR void add_button(binary_sensor::BinarySensor *button, lv_key_t key) { @@ -322,10 +345,10 @@ class LVEncoderListener : public Parented { } } - lv_indev_drv_t *get_drv() { return &this->drv_; } + lv_indev_t *get_drv() { return this->drv_; } protected: - lv_indev_drv_t drv_{}; + lv_indev_t *drv_{}; bool pressed_{}; int32_t count_{}; int32_t last_count_{}; @@ -336,14 +359,13 @@ class LVEncoderListener : public Parented { #ifdef USE_LVGL_LINE class LvLineType : public LvCompound { public: - std::vector get_points() { return this->points_; } - void set_points(std::vector points) { + void set_points(FixedVector points) { this->points_ = std::move(points); - lv_line_set_points(this->obj, this->points_.data(), this->points_.size()); + lv_line_set_points(this->obj, this->points_.begin(), this->points_.size()); } protected: - std::vector points_{}; + FixedVector points_{}; }; #endif #if defined(USE_LVGL_DROPDOWN) || defined(LV_USE_ROLLER) @@ -392,7 +414,7 @@ class LvRollerType : public LvSelectable { class LvButtonMatrixType : public key_provider::KeyProvider, public LvCompound { public: void set_obj(lv_obj_t *lv_obj) override; - uint16_t get_selected() { return lv_btnmatrix_get_selected_btn(this->obj); } + uint16_t get_selected() { return lv_buttonmatrix_get_selected_button(this->obj); } void set_key(size_t idx, uint8_t key) { this->key_map_[idx] = key; } protected: @@ -406,5 +428,4 @@ class LvKeyboardType : public key_provider::KeyProvider, public LvCompound { void set_obj(lv_obj_t *lv_obj) override; }; #endif // USE_LVGL_KEYBOARD -} // namespace lvgl -} // namespace esphome +} // namespace esphome::lvgl diff --git a/esphome/components/lvgl/lvgl_hal.h b/esphome/components/lvgl/lvgl_hal.h deleted file mode 100644 index 754cc70391..0000000000 --- a/esphome/components/lvgl/lvgl_hal.h +++ /dev/null @@ -1,21 +0,0 @@ -// -// Created by Clyde Stubbs on 20/9/2023. -// - -#pragma once - -#ifdef __cplusplus -#define EXTERNC extern "C" -#include -namespace esphome { -namespace lvgl {} -} // namespace esphome -#else -#define EXTERNC extern -#include -#endif - -EXTERNC size_t lv_millis(void); -EXTERNC void *lv_custom_mem_alloc(size_t size); -EXTERNC void lv_custom_mem_free(void *ptr); -EXTERNC void *lv_custom_mem_realloc(void *ptr, size_t size); diff --git a/esphome/components/lvgl/number/__init__.py b/esphome/components/lvgl/number/__init__.py index 98f8423b7c..c48e051eac 100644 --- a/esphome/components/lvgl/number/__init__.py +++ b/esphome/components/lvgl/number/__init__.py @@ -52,9 +52,9 @@ async def to_code(config): await value.get_lambda(), event_code, config[CONF_RESTORE_VALUE], - max_value=widget.get_max(), - min_value=widget.get_min(), - step=widget.get_step(), + max_value=widget.type.get_max(widget.config), + min_value=widget.type.get_min(widget.config), + step=widget.type.get_step(widget.config), ) async with LambdaContext(EVENT_ARG) as event: event.add(var.on_value()) diff --git a/esphome/components/lvgl/schemas.py b/esphome/components/lvgl/schemas.py index 2aeeedbd10..4e2bfeae85 100644 --- a/esphome/components/lvgl/schemas.py +++ b/esphome/components/lvgl/schemas.py @@ -29,6 +29,7 @@ from .defines import ( CONF_SCROLLBAR_MODE, CONF_TIME_FORMAT, LV_GRAD_DIR, + get_remapped_uses, ) from .helpers import CONF_IF_NAN, requires_component, validate_printf from .layout import ( @@ -42,16 +43,17 @@ from .lvcode import LvglComponent, lv_event_t_ptr from .types import ( LVEncoderListener, LvType, - WidgetType, lv_group_t, lv_obj_t, lv_pseudo_button_t, lv_style_t, ) +from .widgets import WidgetType # this will be populated later, in __init__.py to avoid circular imports. WIDGET_TYPES: dict = {} + TIME_TEXT_SCHEMA = cv.Schema( { cv.Required(CONF_TIME_FORMAT): cv.string, @@ -176,23 +178,28 @@ STYLE_PROPS = { "height": lvalid.size, "image_recolor": lvalid.lv_color, "image_recolor_opa": lvalid.opacity, - "line_width": lvalid.lv_positive_int, - "line_dash_width": lvalid.lv_positive_int, - "line_dash_gap": lvalid.lv_positive_int, - "line_rounded": lvalid.lv_bool, "line_color": lvalid.lv_color, + "line_dash_gap": lvalid.lv_positive_int, + "line_dash_width": lvalid.lv_positive_int, + "line_opa": lvalid.opacity, + "line_rounded": lvalid.lv_bool, + "line_width": lvalid.lv_positive_int, "opa": lvalid.opacity, "opa_layered": lvalid.opacity, "outline_color": lvalid.lv_color, "outline_opa": lvalid.opacity, "outline_pad": lvalid.padding, "outline_width": lvalid.pixels, + "length": lvalid.pixels_or_percent, "pad_all": lvalid.padding, "pad_bottom": lvalid.padding, "pad_left": lvalid.padding, "pad_right": lvalid.padding, "pad_top": lvalid.padding, + "radial_offset": lvalid.size, "shadow_color": lvalid.lv_color, + "shadow_offset_x": lvalid.lv_int, + "shadow_offset_y": lvalid.lv_int, "shadow_ofs_x": lvalid.lv_int, "shadow_ofs_y": lvalid.lv_int, "shadow_opa": lvalid.opacity, @@ -213,7 +220,13 @@ STYLE_PROPS = { "transform_height": lvalid.pixels_or_percent, "transform_pivot_x": lvalid.pixels_or_percent, "transform_pivot_y": lvalid.pixels_or_percent, - "transform_zoom": lvalid.zoom, + "transform_rotation": lvalid.lv_angle, + "transform_scale": lvalid.scale, + "transform_scale_x": lvalid.scale, + "transform_scale_y": lvalid.scale, + "transform_skew_x": lvalid.lv_angle, + "transform_skew_y": lvalid.lv_angle, + "transform_zoom": lvalid.scale, "translate_x": lvalid.pixels_or_percent, "translate_y": lvalid.pixels_or_percent, "max_height": lvalid.pixels_or_percent, @@ -227,15 +240,23 @@ STYLE_PROPS = { } STYLE_REMAP = { - "bg_image_opa": "bg_img_opa", - "bg_image_recolor": "bg_img_recolor", - "bg_image_recolor_opa": "bg_img_recolor_opa", - "bg_image_src": "bg_img_src", - "bg_image_tiled": "bg_img_tiled", - "image_recolor": "img_recolor", - "image_recolor_opa": "img_recolor_opa", + "transform_angle": "transform_rotation", + "transform_zoom": "transform_scale", + "zoom": "scale", + "angle": "rotation", + "shadow_ofs_x": "shadow_offset_x", + "shadow_ofs_y": "shadow_offset_y", + "r_mod": "length", } + +def remap_property(prop): + if prop in STYLE_REMAP: + get_remapped_uses().add(prop) + return STYLE_REMAP[prop] + return prop + + # Complete object style schema STYLE_SCHEMA = cv.Schema({cv.Optional(k): v for k, v in STYLE_PROPS.items()}).extend( { @@ -276,7 +297,7 @@ SET_STATE_SCHEMA = cv.Schema( ) # Setting object flags FLAG_SCHEMA = cv.Schema({cv.Optional(flag): lvalid.lv_bool for flag in df.OBJ_FLAGS}) -FLAG_LIST = cv.ensure_list(df.LvConstant("LV_OBJ_FLAG_", *df.OBJ_FLAGS).one_of) +FLAG_LIST = cv.ensure_list(df.LV_OBJ_FLAG.one_of) def part_schema(parts): @@ -418,6 +439,17 @@ ALL_STYLES = { } +def strip_defaults(schema: cv.Schema): + """ + Take a schema and remove any default values, also convert Required to Optional. + Useful for converting an object schema to a modify schema + :param schema: The original Schema + :return: A new schema with no defaults and all items optional + """ + + return cv.Schema({cv.Optional(k): v for k, v in schema.schema.items()}) + + def container_schema(widget_type: WidgetType, extras=None): """ Create a schema for a container widget of a given type. All obj properties are available, plus @@ -481,9 +513,9 @@ def any_widget_schema(extras=None): container_validator, requires_component(required) ) # Apply custom validation - value = widget_type.validate(value or {}) path = [key] if is_dict else [index, key] with prepend_path(path): + value = widget_type.validate(value or {}) result.append({key: container_validator(value)}) return result diff --git a/esphome/components/lvgl/select/lvgl_select.h b/esphome/components/lvgl/select/lvgl_select.h index ba03920a88..3b00310b67 100644 --- a/esphome/components/lvgl/select/lvgl_select.h +++ b/esphome/components/lvgl/select/lvgl_select.h @@ -6,7 +6,7 @@ #include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/preferences.h" -#include "../lvgl.h" +#include "esphome/components/lvgl/lvgl_esphome.h" namespace esphome { namespace lvgl { @@ -28,12 +28,12 @@ class LVGLSelect : public select::Select, public Component { lv_obj_add_event_cb( this->widget_->obj, [](lv_event_t *e) { - auto *it = static_cast(e->user_data); + auto *it = static_cast(lv_event_get_user_data(e)); it->set_options_(); }, LV_EVENT_REFRESH, this); auto lamb = [](lv_event_t *e) { - auto *self = static_cast(e->user_data); + auto *self = static_cast(lv_event_get_user_data(e)); self->publish(); }; lv_obj_add_event_cb(this->widget_->obj, lamb, LV_EVENT_VALUE_CHANGED, this); diff --git a/esphome/components/lvgl/styles.py b/esphome/components/lvgl/styles.py index b9801b4133..6f43e78f90 100644 --- a/esphome/components/lvgl/styles.py +++ b/esphome/components/lvgl/styles.py @@ -13,7 +13,7 @@ from .defines import ( ) from .helpers import add_lv_use from .lvcode import LambdaContext, LocalVariable, lv -from .schemas import ALL_STYLES, FULL_STYLE_SCHEMA, STYLE_REMAP +from .schemas import ALL_STYLES, FULL_STYLE_SCHEMA, remap_property from .types import ObjUpdateAction, lv_obj_t, lv_style_t from .widgets import ( Widget, @@ -26,6 +26,10 @@ from .widgets import ( from .widgets.obj import obj_spec +def has_style_props(config) -> bool: + return any(prop in config for prop in ALL_STYLES) + + async def style_set(svar, style): for prop, validator in ALL_STYLES.items(): if (value := style.get(prop)) is not None: @@ -33,22 +37,44 @@ async def style_set(svar, style): value = await validator.process(value) if isinstance(value, list): value = "|".join(value) - remapped_prop = STYLE_REMAP.get(prop, prop) - lv.call(f"style_set_{remapped_prop}", svar, literal(value)) + lv.call(f"style_set_{remap_property(prop)}", svar, literal(value)) -async def create_style(style, id_name): +async def create_style(id_name, style=None): style_id = ID(id_name, True, lv_style_t) svar = cg.new_Pvariable(style_id) lv.style_init(svar) - await style_set(svar, style) + if style: + await style_set(svar, style) return svar +class LVStyle: + """ + A class to lazily create a named style + """ + + named_styles = {} + + def __init__(self, id_name, style=None): + self.id_name = id_name + self.style = style + self._style_var = None + + async def get_var(self): + if self._style_var is None: + self._style_var = await create_style(self.id_name + "_style", self.style) + return self._style_var + + @classmethod + def get_style(cls, id_name): + return cls.named_styles.setdefault(id_name, LVStyle(id_name)) + + async def styles_to_code(config): """Convert styles to C__ code.""" for style in config.get(CONF_STYLE_DEFINITIONS, ()): - await create_style(style, style[CONF_ID].id) + await create_style(style[CONF_ID].id, style) @automation.register_action( @@ -81,8 +107,7 @@ async def theme_to_code(config): for part, states in collect_parts(style).items(): styles[part] = { state: await create_style( - props, - "_lv_theme_style_" + w_name + "_" + part + "_" + state, + "_lv_theme_style_" + w_name + "_" + part + "_" + state, props ) for state, props in states.items() } @@ -90,7 +115,7 @@ async def theme_to_code(config): async def add_top_layer(lv_component, config): - top_layer = lv.disp_get_layer_top(lv_component.get_disp()) + top_layer = lv.disp_get_layer_top(lv_component.var.get_disp()) if top_conf := config.get(CONF_TOP_LAYER): with LocalVariable("top_layer", lv_obj_t, top_layer) as top_layer_obj: top_w = Widget(top_layer_obj, obj_spec, top_conf) diff --git a/esphome/components/lvgl/text/lvgl_text.h b/esphome/components/lvgl/text/lvgl_text.h index 4c380d69a2..eacf69b6ec 100644 --- a/esphome/components/lvgl/text/lvgl_text.h +++ b/esphome/components/lvgl/text/lvgl_text.h @@ -1,19 +1,16 @@ #pragma once -#include - #include "esphome/components/text/text.h" #include "esphome/core/automation.h" #include "esphome/core/component.h" -#include "esphome/core/preferences.h" namespace esphome { namespace lvgl { class LVGLText : public text::Text { public: - void set_control_lambda(std::function control_lambda) { - this->control_lambda_ = std::move(control_lambda); + void set_control_lambda(const std::function &control_lambda) { + this->control_lambda_ = control_lambda; if (this->initial_state_.has_value()) { this->control_lambda_(this->initial_state_.value()); this->initial_state_.reset(); @@ -28,7 +25,7 @@ class LVGLText : public text::Text { this->initial_state_ = value; } } - std::function control_lambda_{}; + std::function control_lambda_{}; optional initial_state_{}; }; diff --git a/esphome/components/lvgl/touchscreens.py b/esphome/components/lvgl/touchscreens.py index f2dd013f6d..0eb9f22f12 100644 --- a/esphome/components/lvgl/touchscreens.py +++ b/esphome/components/lvgl/touchscreens.py @@ -10,7 +10,6 @@ from .defines import ( CONF_TOUCHSCREENS, ) from .helpers import lvgl_components_required -from .lvcode import lv from .schemas import PRESS_TIME from .types import LVTouchListener @@ -40,5 +39,4 @@ async def touchscreens_to_code(lv_component, config): lpt = tconf[CONF_LONG_PRESS_TIME].total_milliseconds lprt = tconf[CONF_LONG_PRESS_REPEAT_TIME].total_milliseconds listener = cg.new_Pvariable(tconf[CONF_ID], lpt, lprt, lv_component) - lv.indev_drv_register(listener.get_drv()) cg.add(touchscreen.register_listener(listener)) diff --git a/esphome/components/lvgl/trigger.py b/esphome/components/lvgl/trigger.py index 2f8b454ec4..c5ad4d402e 100644 --- a/esphome/components/lvgl/trigger.py +++ b/esphome/components/lvgl/trigger.py @@ -30,7 +30,7 @@ from .lvcode import ( lvgl_static, ) from .types import LV_EVENT -from .widgets import LvScrActType, get_scr_act, widget_map +from .widgets import LvScrActType, get_screen_active, widget_map async def add_on_boot_triggers(triggers): @@ -48,7 +48,7 @@ async def generate_triggers(): for w in widget_map.values(): if isinstance(w.type, LvScrActType): - w = get_scr_act(w.var) + w = get_screen_active(w.var) if w.config: for event, conf in { diff --git a/esphome/components/lvgl/types.py b/esphome/components/lvgl/types.py index 09d40bb7ef..03739f3ff1 100644 --- a/esphome/components/lvgl/types.py +++ b/esphome/components/lvgl/types.py @@ -1,15 +1,9 @@ -import sys - from esphome import automation, codegen as cg -from esphome.automation import register_action -from esphome.config_validation import Schema -from esphome.const import CONF_MAX_VALUE, CONF_MIN_VALUE, CONF_TEXT, CONF_VALUE -from esphome.core import EsphomeError -from esphome.cpp_generator import MockObj, MockObjClass +from esphome.const import CONF_TEXT, CONF_VALUE +from esphome.cpp_generator import MockObj from esphome.cpp_types import esphome_ns from .defines import lvgl_ns -from .lvcode import lv_expr class LvType(cg.MockObjClass): @@ -26,6 +20,10 @@ class LvType(cg.MockObjClass): return None return [arg[0] for arg in self.args] + @property + def name(self): + return self.base.removeprefix("lv_").removesuffix("_t") + class LvNumber(LvType): def __init__(self, *args): @@ -63,6 +61,7 @@ lv_obj_base_t = cg.global_ns.class_("lv_obj_t", lv_pseudo_button_t) lv_obj_t_ptr = lv_obj_base_t.operator("ptr") lv_disp_t = cg.global_ns.struct("lv_disp_t") lv_color_t = cg.global_ns.struct("lv_color_t") +lv_opa_t = cg.global_ns.struct("lv_opa_t") lv_group_t = cg.global_ns.struct("lv_group_t") LVTouchListener = lvgl_ns.class_("LVTouchListener") LVEncoderListener = lvgl_ns.class_("LVEncoderListener") @@ -70,10 +69,11 @@ lv_obj_t = LvType("lv_obj_t") lv_page_t = LvType("LvPageType", parents=(LvCompound,)) lv_img_t = LvType("lv_img_t") lv_gradient_t = LvType("lv_grad_dsc_t") +lv_event_t = LvType("lv_event_t") LV_EVENT = MockObj(base="LV_EVENT_", op="") LV_STATE = MockObj(base="LV_STATE_", op="") -LV_BTNMATRIX_CTRL = MockObj(base="LV_BTNMATRIX_CTRL_", op="") +LV_BTNMATRIX_CTRL = MockObj(base="LV_BUTTONMATRIX_CTRL_", op="") class LvText(LvType): @@ -93,7 +93,7 @@ class LvBoolean(LvType): super().__init__( *args, largs=[(cg.bool_, "x")], - lvalue=lambda w: w.is_checked(), + lvalue=lambda w: w.has_state(LV_STATE.CHECKED), has_on_value=True, **kwargs, ) @@ -110,137 +110,3 @@ class LvSelect(LvType): parents=parens, **kwargs, ) - - -class WidgetType: - """ - Describes a type of Widget, e.g. "bar" or "line" - """ - - def __init__( - self, - name: str, - w_type: LvType, - parts: tuple, - schema=None, - modify_schema=None, - lv_name=None, - is_mock: bool = False, - ): - """ - :param name: The widget name, e.g. "bar" - :param w_type: The C type of the widget - :param parts: What parts this widget supports - :param schema: The config schema for defining a widget - :param modify_schema: A schema to update the widget, defaults to the same as the schema - :param lv_name: The name of the LVGL widget in the LVGL library, if different from the name - :param is_mock: Whether this widget is a mock widget, i.e. not a real LVGL widget - """ - self.name = name - self.lv_name = lv_name or name - self.w_type = w_type - self.parts = parts - if not isinstance(schema, Schema): - schema = Schema(schema or {}) - self.schema = schema - if modify_schema is None: - modify_schema = schema - if not isinstance(modify_schema, Schema): - modify_schema = Schema(modify_schema) - self.modify_schema = modify_schema - self.mock_obj = MockObj(f"lv_{self.lv_name}", "_") - - # Local import to avoid circular import - from .automation import update_to_code - from .schemas import WIDGET_TYPES, base_update_schema - - if not is_mock: - if self.name in WIDGET_TYPES: - raise EsphomeError(f"Duplicate definition of widget type '{self.name}'") - WIDGET_TYPES[self.name] = self - - # Register the update action automatically, adding widget-specific properties - register_action( - f"lvgl.{self.name}.update", - ObjUpdateAction, - base_update_schema(self, self.parts).extend(self.modify_schema), - synchronous=True, - )(update_to_code) - - @property - def animated(self): - return False - - @property - def required_component(self): - return None - - def is_compound(self): - return self.w_type.inherits_from(LvCompound) - - async def to_code(self, w, config: dict): - """ - Generate code for a given widget - :param w: The widget - :param config: Its configuration - """ - - async def obj_creator(self, parent: MockObjClass, config: dict): - """ - Create an instance of the widget type - :param parent: The parent to which it should be attached - :param config: Its configuration - :return: Generated code as a single text line - """ - return lv_expr.call(f"{self.lv_name}_create", parent) - - def on_create(self, var: MockObj, config: dict): - """ - Called from to_code when the widget is created, to set up any initial properties - :param var: The variable representing the widget - :param config: Its configuration - """ - - def get_uses(self): - """ - Get a list of other widgets used by this one - :return: - """ - return () - - def get_max(self, config: dict): - return sys.maxsize - - def get_min(self, config: dict): - return -sys.maxsize - - def get_step(self, config: dict): - return 1 - - def get_scale(self, config: dict): - return 1.0 - - def validate(self, value): - """ - Provides an opportunity for custom validation for a given widget type - :param value: - :return: - """ - return value - - def final_validate(self, widget, update_config, widget_config, path): - """ - Allow final validation for a given widget type update action - :param widget: A widget - :param update_config: The configuration for the update action - :param widget_config: The configuration for the widget itself - :param path: The path to the widget, for error reporting - """ - - -class NumberType(WidgetType): - def get_max(self, config: dict): - return int(config.get(CONF_MAX_VALUE, 100)) - - def get_min(self, config: dict): - return int(config.get(CONF_MIN_VALUE, 0)) diff --git a/esphome/components/lvgl/widgets/__init__.py b/esphome/components/lvgl/widgets/__init__.py index b1d157325b..a2a8cf2129 100644 --- a/esphome/components/lvgl/widgets/__init__.py +++ b/esphome/components/lvgl/widgets/__init__.py @@ -2,9 +2,18 @@ import sys from typing import Any from esphome import codegen as cg, config_validation as cv -from esphome.config_validation import Invalid -from esphome.const import CONF_DEFAULT, CONF_GROUP, CONF_ID, CONF_STATE, CONF_TYPE -from esphome.core import ID, TimePeriod +from esphome.automation import register_action +from esphome.config_validation import Invalid, Schema +from esphome.const import ( + CONF_DEFAULT, + CONF_GROUP, + CONF_ID, + CONF_MAX_VALUE, + CONF_MIN_VALUE, + CONF_STATE, + CONF_TYPE, +) +from esphome.core import ID, EsphomeError, TimePeriod from esphome.coroutine import FakeAwaitable from esphome.cpp_generator import MockObj @@ -21,6 +30,7 @@ from ..defines import ( CONF_MAIN, CONF_PAD_COLUMN, CONF_PAD_ROW, + CONF_SCALE, CONF_STYLES, CONF_WIDGETS, OBJ_FLAGS, @@ -44,8 +54,15 @@ from ..lvcode import ( lv_obj, lv_Pvariable, ) -from ..schemas import ALL_STYLES, OBJ_PROPERTIES, STYLE_REMAP, WIDGET_TYPES -from ..types import LV_STATE, LvType, WidgetType, lv_coord_t, lv_obj_t, lv_obj_t_ptr +from ..types import ( + LV_STATE, + LvCompound, + LvType, + ObjUpdateAction, + lv_coord_t, + lv_obj_t, + lv_obj_t_ptr, +) EVENT_LAMB = "event_lamb__" @@ -53,6 +70,171 @@ theme_widget_map = {} styles_used = set() +class WidgetType: + """ + Describes a type of Widget, e.g. "bar" or "line" + """ + + def __init__( + self, + name: str, + w_type: LvType, + parts: tuple, + schema=None, + modify_schema=None, + lv_name=None, + is_mock: bool = False, + ): + """ + :param name: The widget name, e.g. "bar" + :param w_type: The C type of the widget + :param parts: What parts this widget supports + :param schema: The config schema for defining a widget + :param modify_schema: A schema to update the widget, defaults to the same as the schema + :param lv_name: The name of the LVGL widget in the LVGL library, if different from the name + :param is_mock: Whether this widget is a mock widget, i.e. not a real LVGL widget + """ + self.name = name + self.lv_name = lv_name or name + self.w_type = w_type + self.parts = parts + if not isinstance(schema, Schema): + schema = Schema(schema or {}) + self.schema = schema + if modify_schema is None: + modify_schema = schema + if not isinstance(modify_schema, Schema): + modify_schema = Schema(modify_schema) + self.modify_schema = modify_schema + self.mock_obj = MockObj(f"lv_{self.lv_name}", "_") + + # Local import to avoid circular import + from ..automation import update_to_code + from ..schemas import WIDGET_TYPES, base_update_schema + + if not is_mock: + if self.name in WIDGET_TYPES: + raise EsphomeError(f"Duplicate definition of widget type '{self.name}'") + WIDGET_TYPES[self.name] = self + + # Register the update action automatically, adding widget-specific properties + register_action( + f"lvgl.{self.name}.update", + ObjUpdateAction, + base_update_schema(self, self.parts).extend(self.modify_schema), + synchronous=True, + )(update_to_code) + + @property + def animated(self): + return False + + @property + def required_component(self): + return None + + def is_compound(self): + return self.w_type.inherits_from(LvCompound) + + async def create_to_code(self, config: dict, parent: MockObj) -> "Widget": + """ + Generate code for a widget creation. + :param config: The configuration for the widget + :param parent: The parent to which it should be attached + """ + + creator = await self.obj_creator(parent, config) + add_lv_use(self.name) + add_lv_use(*self.get_uses()) + wid = config[CONF_ID] + add_line_marks(wid) + if self.is_compound(): + var = cg.new_Pvariable(wid) + lv_add(var.set_obj(creator)) + await self.on_create(var.obj, config) + else: + var = lv_Pvariable(lv_obj_t, wid) + lv_assign(var, creator) + await self.on_create(var, config) + + w = Widget.create(wid, var, self, config) + if theme := theme_widget_map.get(self.w_type.name): + for part, states in theme.items(): + part = "LV_PART_" + part.upper() + for state, style in states.items(): + state = "LV_STATE_" + state.upper() + if state == "LV_STATE_DEFAULT": + lv_state = literal(part) + elif part == "LV_PART_MAIN": + lv_state = literal(state) + else: + lv_state = join_enums((state, part)) + w.add_style(style, lv_state) + await set_obj_properties(w, config) + await add_widgets(w, config) + await self.to_code(w, config) + return w + + async def to_code(self, w: "Widget", config: dict): + """ + Update a widget, also called when creating + :param config: + :return: + """ + + async def obj_creator(self, parent: MockObj, config: dict): + """ + Create an instance of the widget type + :param parent: The parent to which it should be attached + :param config: Its configuration + :return: Generated code as a single text line + """ + return lv_expr.call(f"{self.lv_name}_create", parent) + + async def on_create(self, var: MockObj, config: dict): + """ + Called from to_code when the widget is created, to set up any initial properties + :param var: The variable representing the widget + :param config: Its configuration + """ + + def get_uses(self): + """ + Get a list of other widgets used by this one + :return: + """ + return () + + def get_max(self, config: dict): + return sys.maxsize + + def get_min(self, config: dict): + return -sys.maxsize + + def get_step(self, config: dict): + return 1 + + def get_scale(self, config: dict): + return 1.0 + + def validate(self, value): + """ + Provides an opportunity for custom validation for a given widget type + :param value: + :return: + """ + return value + + def final_validate(self, widget, update_config, widget_config, path): + """ + Allow final validation for a given widget type update action + :param widget: A widget + :param update_config: The configuration for the update action + :param widget_config: The configuration for the widget itself + :param path: The path to the widget, for error reporting + """ + + class Widget: """ Represents a Widget. @@ -74,19 +256,25 @@ class Widget: self.obj = var self.outer = None self.move_to_foreground = False + # Properties for linear equations + self.slope = None + self.y_int = None @staticmethod def create(name, var, wtype: WidgetType, config: dict = None): w = Widget(var, wtype, config) - if name is not None: - widget_map[name] = w + widget_map[name] = w return w def add_state(self, state): + if "|" in state: + state = f"(lv_state_t)({state})" return lv_obj.add_state(self.obj, literal(state)) def clear_state(self, state): - return lv_obj.clear_state(self.obj, literal(state)) + if "|" in state: + state = f"(lv_state_t)({state})" + return lv_obj.remove_state(self.obj, literal(state)) def has_state(self, state): return (lv_expr.obj_get_state(self.obj) & literal(state)) != 0 @@ -98,12 +286,23 @@ class Widget: return self.has_state(LV_STATE.CHECKED) def add_flag(self, flag): + if "|" in flag: + flag = f"(lv_obj_flag_t)({flag})" return lv_obj.add_flag(self.obj, literal(flag)) def clear_flag(self, flag): - return lv_obj.clear_flag(self.obj, literal(flag)) + if "|" in flag: + flag = f"(lv_obj_flag_t)({flag})" + return lv_obj.remove_flag(self.obj, literal(flag)) - async def set_property(self, prop, value, animated: bool = None, lv_name=None): + def add_style(self, style_id, state=LV_STATE.DEFAULT): + if "|" in state: + state = f"(lv_state_t)({state})" + lv_obj.add_style(self.obj, MockObj(style_id), literal(state)) + + async def set_property( + self, prop, value, animated: bool = None, lv_name=None, processor=None + ): """ Set a property of the widget. :param prop: The property name @@ -111,18 +310,28 @@ class Widget: :param animated: If the change should be animated :param lv_name: The base type of the widget e.g. "obj" """ + + from ..schemas import ALL_STYLES, remap_property + if isinstance(value, dict): value = value.get(prop) - if isinstance(ALL_STYLES.get(prop), LValidator): - value = await ALL_STYLES[prop].process(value) - else: - value = literal(value) - if value is None: + if value is None: + return + if not processor and isinstance(ALL_STYLES.get(prop), LValidator): + processor = ALL_STYLES[prop] + if isinstance(processor, LValidator): + processor = processor.process + if processor: + value = await processor(value) + elif value is None: return + prop = remap_property(prop) if isinstance(value, TimePeriod): value = value.total_milliseconds - if isinstance(value, str): + elif isinstance(value, str): value = literal(value) + elif isinstance(value, ID): + value = MockObj(value) lv_name = lv_name or self.type.lv_name if animated is None or self.type.animated is not True: lv.call(f"{lv_name}_set_{prop}", self.obj, value) @@ -138,10 +347,12 @@ class Widget: ltype = ltype or self.__type_base() return cg.RawExpression(f"lv_{ltype}_get_{prop}({self.obj})") - def set_style(self, prop, value, state): + def set_style(self, prop, value, state=LV_STATE.DEFAULT): if value is None: return styles_used.add(prop) + if isinstance(value, str): + value = literal(value) lv.call(f"obj_set_style_{prop}", self.obj, value, state) def __type_base(self): @@ -189,15 +400,6 @@ class Widget: """ return - def get_max(self): - return self.type.get_max(self.config) - - def get_min(self): - return self.type.get_min(self.config) - - def get_step(self): - return self.type.get_step(self.config) - def get_scale(self): return self.type.get_scale(self.config) @@ -212,14 +414,14 @@ class LvScrActType(WidgetType): """ def __init__(self): - super().__init__("lv_scr_act()", lv_obj_t, (), is_mock=True) + super().__init__("lv_screen_active()", lv_obj_t, (), is_mock=True) async def to_code(self, w, config: dict): - return [] + pass -def get_scr_act(lv_comp: MockObj) -> Widget: - return Widget.create(None, lv_comp.get_scr_act(), LvScrActType(), {}) +def get_screen_active(lv_comp: MockObj) -> Widget: + return Widget(lv_comp.get_screen_active(), LvScrActType(), {}) def get_widget_generator(wid): @@ -271,10 +473,17 @@ def collect_props(config): :param config: :return: """ + + from ..schemas import ALL_STYLES + props = {} for prop in [*ALL_STYLES, *OBJ_FLAGS, CONF_STYLES, CONF_GROUP]: if prop in config: - props[prop] = config[prop] + if prop == CONF_SCALE: + props[CONF_SCALE + "_x"] = config[prop] + props[CONF_SCALE + "_y"] = config[prop] + else: + props[prop] = config[prop] return props @@ -304,34 +513,41 @@ def collect_parts(config): return parts +def _size_to_str(value): + if isinstance(value, float): + return f"lv_pct({int(value * 100)})" + return str(value) + + async def set_obj_properties(w: Widget, config): """Generate a list of C++ statements to apply properties to an lv_obj_t""" + + from ..schemas import ALL_STYLES, OBJ_PROPERTIES, remap_property + if layout := config.get(CONF_LAYOUT): layout_type: str = layout[CONF_TYPE] add_lv_use(layout_type) lv_obj.set_layout(w.obj, literal(f"LV_LAYOUT_{layout_type.upper()}")) if (pad_row := layout.get(CONF_PAD_ROW)) is not None: - w.set_style(CONF_PAD_ROW, pad_row, 0) + w.set_style(CONF_PAD_ROW, pad_row) if (pad_column := layout.get(CONF_PAD_COLUMN)) is not None: - w.set_style(CONF_PAD_COLUMN, pad_column, 0) + w.set_style(CONF_PAD_COLUMN, pad_column) if layout_type == TYPE_GRID: wid = config[CONF_ID] - rows = [str(x) for x in layout[CONF_GRID_ROWS]] + rows = [_size_to_str(x) for x in layout[CONF_GRID_ROWS]] rows = "{" + ",".join(rows) + ", LV_GRID_TEMPLATE_LAST}" row_id = ID(f"{wid}_row_dsc", is_declaration=True, type=lv_coord_t) row_array = cg.static_const_array(row_id, cg.RawExpression(rows)) - w.set_style("grid_row_dsc_array", row_array, 0) - columns = [str(x) for x in layout[CONF_GRID_COLUMNS]] + w.set_style("grid_row_dsc_array", row_array) + columns = [_size_to_str(x) for x in layout[CONF_GRID_COLUMNS]] columns = "{" + ",".join(columns) + ", LV_GRID_TEMPLATE_LAST}" column_id = ID(f"{wid}_column_dsc", is_declaration=True, type=lv_coord_t) column_array = cg.static_const_array(column_id, cg.RawExpression(columns)) - w.set_style("grid_column_dsc_array", column_array, 0) + w.set_style("grid_column_dsc_array", column_array) w.set_style( - CONF_GRID_COLUMN_ALIGN, literal(layout.get(CONF_GRID_COLUMN_ALIGN)), 0 - ) - w.set_style( - CONF_GRID_ROW_ALIGN, literal(layout.get(CONF_GRID_ROW_ALIGN)), 0 + CONF_GRID_COLUMN_ALIGN, literal(layout.get(CONF_GRID_COLUMN_ALIGN)) ) + w.set_style(CONF_GRID_ROW_ALIGN, literal(layout.get(CONF_GRID_ROW_ALIGN))) if layout_type == TYPE_FLEX: lv_obj.set_flex_flow(w.obj, literal(layout[CONF_FLEX_FLOW])) main = literal(layout[CONF_FLEX_ALIGN_MAIN]) @@ -353,13 +569,13 @@ async def set_obj_properties(w: Widget, config): else: lv_state = join_enums((state, part)) for style_id in props.get(CONF_STYLES, ()): - lv_obj.add_style(w.obj, MockObj(style_id), lv_state) + w.add_style(style_id, lv_state) for prop, value in { k: v for k, v in props.items() if k in ALL_STYLES }.items(): if isinstance(ALL_STYLES[prop], LValidator): value = await ALL_STYLES[prop].process(value) - prop_r = STYLE_REMAP.get(prop, prop) + prop_r = remap_property(prop) w.set_style(prop_r, value, lv_state) if group := config.get(CONF_GROUP): group = await cg.get_variable(group) @@ -429,7 +645,7 @@ async def add_widgets(parent: Widget, config: dict): await widget_to_code(w_cnfig, w_type, parent.obj) -async def widget_to_code(w_cnfig, w_type: WidgetType, parent): +async def widget_to_code(w_cnfig, w_type: WidgetType | str, parent) -> Widget: """ Converts a Widget definition to C code. :param w_cnfig: The widget configuration @@ -437,34 +653,18 @@ async def widget_to_code(w_cnfig, w_type: WidgetType, parent): :param parent: The parent to which the widget should be added :return: """ - spec: WidgetType = WIDGET_TYPES[w_type] - creator = await spec.obj_creator(parent, w_cnfig) - add_lv_use(spec.name) - add_lv_use(*spec.get_uses()) - wid = w_cnfig[CONF_ID] - add_line_marks(wid) - if spec.is_compound(): - var = cg.new_Pvariable(wid) - lv_add(var.set_obj(creator)) - spec.on_create(var.obj, w_cnfig) - else: - var = lv_Pvariable(lv_obj_t, wid) - lv_assign(var, creator) - spec.on_create(var, w_cnfig) - w = Widget.create(wid, var, spec, w_cnfig) - if theme := theme_widget_map.get(w_type): - for part, states in theme.items(): - part = "LV_PART_" + part.upper() - for state, style in states.items(): - state = "LV_STATE_" + state.upper() - if state == "LV_STATE_DEFAULT": - lv_state = literal(part) - elif part == "LV_PART_MAIN": - lv_state = literal(state) - else: - lv_state = join_enums((state, part)) - lv.obj_add_style(w.obj, style, lv_state) - await set_obj_properties(w, w_cnfig) - await add_widgets(w, w_cnfig) - await spec.to_code(w, w_cnfig) + from ..schemas import WIDGET_TYPES + + spec: WidgetType = ( + w_type if isinstance(w_type, WidgetType) else WIDGET_TYPES[w_type] + ) + return await spec.create_to_code(w_cnfig, parent) + + +class NumberType(WidgetType): + def get_max(self, config: dict): + return int(config.get(CONF_MAX_VALUE, 100)) + + def get_min(self, config: dict): + return int(config.get(CONF_MIN_VALUE, 0)) diff --git a/esphome/components/lvgl/widgets/arc.py b/esphome/components/lvgl/widgets/arc.py index 34ac9c51f7..9eaf3dadce 100644 --- a/esphome/components/lvgl/widgets/arc.py +++ b/esphome/components/lvgl/widgets/arc.py @@ -18,7 +18,8 @@ from ..defines import ( CONF_KNOB, CONF_MAIN, CONF_START_ANGLE, - literal, + LV_OBJ_FLAG, + LV_PART, ) from ..lv_validation import ( get_start_value, @@ -28,8 +29,8 @@ from ..lv_validation import ( lv_positive_int, ) from ..lvcode import lv, lv_expr, lv_obj -from ..types import LvNumber, NumberType -from . import Widget +from ..types import LvNumber +from . import NumberType, Widget CONF_ARC = "arc" ARC_SCHEMA = cv.Schema( @@ -71,39 +72,17 @@ class ArcType(NumberType): ) async def to_code(self, w: Widget, config): - if CONF_MIN_VALUE in config and CONF_MAX_VALUE in config: - max_value = await lv_int.process(config[CONF_MAX_VALUE]) - min_value = await lv_int.process(config[CONF_MIN_VALUE]) - lv.arc_set_range(w.obj, min_value, max_value) - elif CONF_MIN_VALUE in config: - max_value = w.get_property(CONF_MAX_VALUE) - min_value = await lv_int.process(config[CONF_MIN_VALUE]) - lv.arc_set_range(w.obj, min_value, max_value) - elif CONF_MAX_VALUE in config: - max_value = await lv_int.process(config[CONF_MAX_VALUE]) - min_value = w.get_property(CONF_MIN_VALUE) - lv.arc_set_range(w.obj, min_value, max_value) - - await w.set_property( - "bg_start_angle", - await lv_angle_degrees.process(config.get(CONF_START_ANGLE)), - ) - await w.set_property( - "bg_end_angle", await lv_angle_degrees.process(config.get(CONF_END_ANGLE)) - ) - await w.set_property( - CONF_ROTATION, await lv_angle_degrees.process(config.get(CONF_ROTATION)) - ) - await w.set_property(CONF_MODE, config) - await w.set_property( - CONF_CHANGE_RATE, - await lv_positive_int.process(config.get(CONF_CHANGE_RATE)), - ) - + for prop, validator in ARC_MODIFY_SCHEMA.schema.items(): + if prop != CONF_VALUE: + # start_angle and end_angle are mapped to bg_start_angle and bg_end_angle + prop = str(prop) + if prop.endswith("_angle"): + prop = "bg_" + prop + await w.set_property(prop, config, processor=validator) if CONF_ADJUSTABLE in config: if not config[CONF_ADJUSTABLE]: - lv_obj.remove_style(w.obj, nullptr, literal("LV_PART_KNOB")) - w.clear_flag("LV_OBJ_FLAG_CLICKABLE") + lv_obj.remove_style(w.obj, nullptr, LV_PART.KNOB) + w.clear_flag(LV_OBJ_FLAG.CLICKABLE) elif CONF_GROUP not in config: # For some reason arc does not get automatically added to the default group lv.group_add_obj(lv_expr.group_get_default(), w.obj) diff --git a/esphome/components/lvgl/widgets/button.py b/esphome/components/lvgl/widgets/button.py index 5f2910174f..b943a4d9aa 100644 --- a/esphome/components/lvgl/widgets/button.py +++ b/esphome/components/lvgl/widgets/button.py @@ -7,11 +7,11 @@ from ..helpers import add_lv_use from ..lv_validation import lv_text from ..lvcode import lv, lv_expr from ..schemas import TEXT_SCHEMA -from ..types import LvBoolean, WidgetType -from . import Widget +from ..types import LvBoolean +from . import Widget, WidgetType from .label import label_spec -lv_button_t = LvBoolean("lv_btn_t") +lv_button_t = LvBoolean("lv_button_t") class ButtonType(WidgetType): @@ -30,7 +30,7 @@ class ButtonType(WidgetType): def get_uses(self): return ("btn",) - def on_create(self, var: MockObj, config: dict): + async def on_create(self, var: MockObj, config: dict): if CONF_TEXT in config: lv.label_create(var) return var diff --git a/esphome/components/lvgl/widgets/buttonmatrix.py b/esphome/components/lvgl/widgets/buttonmatrix.py index f94f12b69b..f5ae0deba9 100644 --- a/esphome/components/lvgl/widgets/buttonmatrix.py +++ b/esphome/components/lvgl/widgets/buttonmatrix.py @@ -118,15 +118,15 @@ class MatrixButton(Widget): def has_state(self, state): state = self.map_ctrls(state) - return lv_expr.btnmatrix_has_btn_ctrl(self.obj, self.index, state) + return lv_expr.buttonmatrix_has_button_ctrl(self.obj, self.index, state) def add_state(self, state): state = self.map_ctrls(state) - return lv.btnmatrix_set_btn_ctrl(self.obj, self.index, state) + return lv.buttonmatrix_set_button_ctrl(self.obj, self.index, state) def clear_state(self, state): state = self.map_ctrls(state) - return lv.btnmatrix_clear_btn_ctrl(self.obj, self.index, state) + return lv.buttonmatrix_clear_button_ctrl(self.obj, self.index, state) def is_pressed(self): return self.is_selected() & self.parent.has_state(LV_STATE.PRESSED) @@ -161,7 +161,7 @@ async def get_button_data(config, buttonmatrix: Widget): text_list.append(button_conf.get(CONF_TEXT) or "") key_list.append(button_conf.get(CONF_KEY_CODE) or 0) width_list.append(button_conf[CONF_WIDTH]) - ctrl = ["LV_BTNMATRIX_CTRL_CLICK_TRIG"] + ctrl = ["CLICK_TRIG"] for item in button_conf.get(CONF_CONTROL, ()): ctrl.extend([k for k, v in item.items() if v]) ctrl_list.append(await BUTTONMATRIX_CTRLS.process(ctrl)) @@ -187,7 +187,7 @@ class ButtonMatrixType(WidgetType): (CONF_MAIN, CONF_ITEMS), BUTTONMATRIX_SCHEMA, {}, - lv_name="btnmatrix", + lv_name="buttonmatrix", ) async def to_code(self, w: Widget, config): @@ -199,22 +199,22 @@ class ButtonMatrixType(WidgetType): ) text_id = config[CONF_BUTTON_TEXT_LIST_ID] text_id = cg.static_const_array(text_id, text_list) - lv.btnmatrix_set_map(w.obj, text_id) + lv.buttonmatrix_set_map(w.obj, text_id) set_btn_data(w.obj, ctrl_list, width_list) - lv.btnmatrix_set_one_checked(w.obj, config[CONF_ONE_CHECKED]) + lv.buttonmatrix_set_one_checked(w.obj, config[CONF_ONE_CHECKED]) for index, key in enumerate(key_list): if key != 0: lv_add(w.var.set_key(index, key)) def get_uses(self): - return ("btnmatrix",) + return ("buttonmatrix",) def set_btn_data(obj, ctrl_list, width_list): for index, ctrl in enumerate(ctrl_list): - lv.btnmatrix_set_btn_ctrl(obj, index, ctrl) + lv.buttonmatrix_set_button_ctrl(obj, index, ctrl) for index, width in enumerate(width_list): - lv.btnmatrix_set_btn_width(obj, index, width) + lv.buttonmatrix_set_button_width(obj, index, width) buttonmatrix_spec = ButtonMatrixType() @@ -253,25 +253,21 @@ async def button_update_to_code(config, action_id, template_arg, args): async def do_button_update(w): if (width := config.get(CONF_WIDTH)) is not None: - lv.btnmatrix_set_btn_width(w.obj, w.index, width) + lv.buttonmatrix_set_button_width(w.obj, w.index, width) if config.get(CONF_SELECTED): - lv.btnmatrix_set_selected_btn(w.obj, w.index) + lv.buttonmatrix_set_selected_button(w.obj, w.index) if controls := config.get(CONF_CONTROL): adds = [] clrs = [] for item in controls: - adds.extend( - [f"LV_BTNMATRIX_CTRL_{k.upper()}" for k, v in item.items() if v] - ) - clrs.extend( - [f"LV_BTNMATRIX_CTRL_{k.upper()}" for k, v in item.items() if not v] - ) + adds.extend([f"{k.upper()}" for k, v in item.items() if v]) + clrs.extend([f"{k.upper()}" for k, v in item.items() if not v]) if adds: - lv.btnmatrix_set_btn_ctrl( + lv.buttonmatrix_set_button_ctrl( w.obj, w.index, await BUTTONMATRIX_CTRLS.process(adds) ) if clrs: - lv.btnmatrix_clear_btn_ctrl( + lv.buttonmatrix_clear_button_ctrl( w.obj, w.index, await BUTTONMATRIX_CTRLS.process(clrs) ) diff --git a/esphome/components/lvgl/widgets/canvas.py b/esphome/components/lvgl/widgets/canvas.py index 50cc8b0af6..c670e3732c 100644 --- a/esphome/components/lvgl/widgets/canvas.py +++ b/esphome/components/lvgl/widgets/canvas.py @@ -1,3 +1,22 @@ +""" +LVGL 9.4 Canvas Widget Implementation + +This module implements the canvas widget for LVGL 9.4. Key changes from LVGL 8.4: + +1. Buffer allocation: + - LV_IMG_CF_TRUE_COLOR → LV_COLOR_FORMAT_RGB565 + - LV_IMG_CF_TRUE_COLOR_ALPHA → LV_COLOR_FORMAT_ARGB8888 + - LV_CANVAS_BUF_SIZE_TRUE_COLOR → LV_CANVAS_BUF_SIZE(w, h, bpp, stride) + +2. Drawing API: + - All lv_canvas_draw_* functions removed + - Use layer-based drawing: lv_canvas_init_layer() / lv_canvas_finish_layer() + - Draw using low-level lv_draw_* functions (rect, line, arc, image, label) + +3. Pixel operations: + - lv_canvas_set_px_color + lv_canvas_set_px_opa → lv_canvas_set_px(color, opa) +""" + from esphome import automation, codegen as cg, config_validation as cv from esphome.components.display_menu_base import CONF_LABEL from esphome.const import ( @@ -9,7 +28,7 @@ from esphome.const import ( CONF_X, CONF_Y, ) -from esphome.cpp_generator import Literal, MockObj +from esphome.cpp_types import FixedVector from ..automation import action_to_code from ..defines import ( @@ -19,8 +38,11 @@ from ..defines import ( CONF_PIVOT_X, CONF_PIVOT_Y, CONF_POINTS, + CONF_RADIUS, CONF_SRC, CONF_START_ANGLE, + addr, + get_color_formats, literal, ) from ..lv_validation import ( @@ -34,18 +56,20 @@ from ..lv_validation import ( size, ) from ..lvcode import LocalVariable, lv, lv_assign, lv_expr -from ..schemas import STYLE_PROPS, STYLE_REMAP, TEXT_SCHEMA, point_schema -from ..types import LvType, ObjUpdateAction, WidgetType -from . import Widget, get_widgets -from .line import lv_point_t, process_coord +from ..schemas import STYLE_PROPS, TEXT_SCHEMA, point_schema, remap_property +from ..types import LvType, ObjUpdateAction +from . import Widget, WidgetType, get_widgets +from .img import CONF_IMAGE +from .line import lv_point_precise_t, process_coord CONF_CANVAS = "canvas" CONF_BUFFER_ID = "buffer_id" CONF_MAX_WIDTH = "max_width" CONF_TRANSPARENT = "transparent" -CONF_RADIUS = "radius" +CONF_DRAW_BUF_ID = "draw_buf_id" lv_canvas_t = LvType("lv_canvas_t") +lv_draw_buf_t = LvType("lv_draw_buf_t") class CanvasType(WidgetType): @@ -59,32 +83,44 @@ class CanvasType(WidgetType): cv.Required(CONF_WIDTH): size, cv.Required(CONF_HEIGHT): size, cv.Optional(CONF_TRANSPARENT, default=False): cv.boolean, + cv.GenerateID(CONF_DRAW_BUF_ID): cv.declare_id(lv_draw_buf_t), } ), + modify_schema={}, ) def get_uses(self): - return "img", CONF_LABEL + return CONF_IMAGE, CONF_LABEL async def to_code(self, w: Widget, config): width = config[CONF_WIDTH] height = config[CONF_HEIGHT] - use_alpha = "_ALPHA" if config[CONF_TRANSPARENT] else "" - buf_size = literal( - f"LV_CANVAS_BUF_SIZE_TRUE_COLOR{use_alpha}({width}, {height})" + # LVGL 9.4: Use LV_COLOR_FORMAT instead of LV_IMG_CF + # RGB565 is 16-bit (2 bytes per pixel), ARGB8888 is 32-bit (4 bytes per pixel) + if config[CONF_TRANSPARENT]: + color_format = "LV_COLOR_FORMAT_ARGB8888" + get_color_formats().add("ARGB8888") + else: + color_format = "LV_COLOR_FORMAT_NATIVE" + + # LVGL 9.4: LV_CANVAS_BUF_SIZE(width, height, bits_per_pixel, stride) + # stride is 0 for default (width * bytes_per_pixel) + draw_buf = cg.new_Pvariable(config[CONF_DRAW_BUF_ID]) + buf_size = literal(f"LV_DRAW_BUF_SIZE({width}, {height}, {color_format})") + lv.draw_buf_init( + draw_buf, + width, + height, + literal(color_format), + 0, + lv_expr.malloc_core(buf_size), + literal(buf_size), ) - with LocalVariable("buf", cg.void, lv_expr.custom_mem_alloc(buf_size)) as buf: - cg.add(cg.RawExpression(f"memset({buf}, 0, {buf_size});")) - lv.canvas_set_buffer( - w.obj, - buf, - width, - height, - literal(f"LV_IMG_CF_TRUE_COLOR{use_alpha}"), - ) + lv.draw_buf_set_flag(draw_buf, literal("LV_IMAGE_FLAGS_MODIFIABLE")) + lv.canvas_set_draw_buf(w.obj, draw_buf) -canvas_spec = CanvasType() +CanvasType() @automation.register_action( @@ -117,7 +153,7 @@ async def canvas_fill(config, action_id, template_arg, args): { cv.GenerateID(CONF_ID): cv.use_id(lv_canvas_t), cv.Required(CONF_COLOR): lv_color, - cv.Optional(CONF_OPA): opacity, + cv.Optional(CONF_OPA, default="COVER"): opacity, cv.Required(CONF_POINTS): cv.ensure_list(point_schema), }, ), @@ -126,7 +162,7 @@ async def canvas_fill(config, action_id, template_arg, args): async def canvas_set_pixel(config, action_id, template_arg, args): widget = await get_widgets(config) color = await lv_color.process(config[CONF_COLOR]) - opa = await opacity.process(config.get(CONF_OPA)) + opa = await opacity.process(config.get(CONF_OPA), "COVER") points = [ ( await pixels.process(p[CONF_X]), @@ -136,25 +172,11 @@ async def canvas_set_pixel(config, action_id, template_arg, args): ] async def do_set_pixels(w: Widget): - if isinstance(color, MockObj): - for point in points: - x, y = point - lv.canvas_set_px_color(w.obj, x, y, color) - else: - with LocalVariable("color", "lv_color_t", color, modifier="") as color_var: - for point in points: - x, y = point - lv.canvas_set_px_color(w.obj, x, y, color_var) - if opa: - if isinstance(opa, Literal): - for point in points: - x, y = point - lv.canvas_set_px_opa(w.obj, x, y, opa) - else: - with LocalVariable("opa", "lv_opa_t", opa, modifier="") as opa_var: - for point in points: - x, y = point - lv.canvas_set_px_opa(w.obj, x, y, opa_var) + # LVGL 9.4: lv_canvas_set_px combines color and opacity + # Could optimize this for lambda values + for point in points: + x, y = point + lv.canvas_set_px(w.obj, x, y, color, opa) return await action_to_code( widget, do_set_pixels, action_id, template_arg, args, config @@ -178,18 +200,21 @@ async def draw_to_code(config, dsc_type, props, do_draw, action_id, template_arg y = await pixels.process(config.get(CONF_Y)) async def action_func(w: Widget): - with LocalVariable("dsc", f"lv_draw_{dsc_type}_dsc_t", modifier="") as dsc: - dsc_addr = literal(f"&{dsc}") - lv.call(f"draw_{dsc_type}_dsc_init", dsc_addr) - if CONF_OPA in config: - opa = await opacity.process(config[CONF_OPA]) - lv_assign(dsc.opa, opa) - for prop, validator in props.items(): - if prop in config: - value = await validator.process(config[prop]) - mapped_prop = STYLE_REMAP.get(prop, prop) - lv_assign(getattr(dsc, mapped_prop), value) - await do_draw(w, x, y, dsc_addr) + # LVGL 9.4: Create a layer for drawing on canvas + with LocalVariable("layer", "lv_layer_t", modifier="") as layer: + lv.canvas_init_layer(w.obj, addr(layer)) + with LocalVariable("dsc", f"lv_draw_{dsc_type}_dsc_t", modifier="") as dsc: + lv.call(f"draw_{dsc_type}_dsc_init", addr(dsc)) + if CONF_OPA in config: + opa = await opacity.process(config[CONF_OPA]) + lv_assign(dsc.opa, opa) + for prop, validator in props.items(): + if prop in config: + value = await validator.process(config[prop]) + mapped_prop = remap_property(prop) + lv_assign(getattr(dsc, mapped_prop), value) + await do_draw(addr(layer), x, y, dsc) + lv.canvas_finish_layer(w.obj, addr(layer)) return await action_to_code( widget, action_func, action_id, template_arg, args, config @@ -212,6 +237,8 @@ RECT_PROPS = { "outline_opa", "shadow_color", "shadow_width", + "shadow_offset_x", + "shadow_offset_y", "shadow_ofs_x", "shadow_ofs_y", "shadow_spread", @@ -220,6 +247,24 @@ RECT_PROPS = { } +def _draw_line(layer, dsc, points): + # LVGL 9.4: Use lv_draw_line for each line segment + with ( + LocalVariable( + "points", FixedVector.template(lv_point_precise_t), points, modifier="" + ) as points_var, + LocalVariable("i", "uint32_t", literal("0"), modifier="") as i, + ): + # Draw lines between consecutive points + lv.append( + cg.RawStatement(f"for ({i} = 0; {i} != {points_var}.size() - 1; {i}++) {{") + ) + lv_assign(dsc.p1, points_var[i]) + lv_assign(dsc.p2, points_var[i + 1]) + lv.draw_line(layer, addr(dsc)) + lv.append(cg.RawStatement("}")) + + @automation.register_action( "lvgl.canvas.draw_rectangle", ObjUpdateAction, @@ -237,8 +282,14 @@ async def canvas_draw_rect(config, action_id, template_arg, args): width = await pixels.process(config[CONF_WIDTH]) height = await pixels.process(config[CONF_HEIGHT]) - async def do_draw_rect(w: Widget, x, y, dsc_addr): - lv.canvas_draw_rect(w.obj, x, y, width, height, dsc_addr) + async def do_draw_rect(layer, x, y, dsc): + # LVGL 9.4: Use lv_draw_rect with area + with LocalVariable("area", "lv_area_t", modifier="") as area: + lv_assign(area.x1, x) + lv_assign(area.y1, y) + lv_assign(area.x2, literal(f"{x} + {width} - 1")) + lv_assign(area.y2, literal(f"{y} + {height} - 1")) + lv.draw_rect(layer, addr(dsc), addr(area)) return await draw_to_code( config, "rect", RECT_PROPS, do_draw_rect, action_id, template_arg, args @@ -277,21 +328,55 @@ async def canvas_draw_text(config, action_id, template_arg, args): text = await lv_text.process(config[CONF_TEXT]) max_width = await pixels.process(config[CONF_MAX_WIDTH]) - async def do_draw_text(w: Widget, x, y, dsc_addr): - lv.canvas_draw_text(w.obj, x, y, max_width, dsc_addr, text) + async def do_draw_text(layer, x, y, dsc): + # LVGL 9.4: Use lv_draw_label with area and hint + with LocalVariable("area", "lv_area_t", modifier="") as area: + lv_assign(area.x1, x) + lv_assign(area.y1, y) + lv_assign(area.x2, literal(f"{x} + {max_width} - 1")) + lv_assign(area.y2, literal(f"{y} + LV_COORD_MAX")) + lv_assign(dsc.text, text) + lv.draw_label(layer, addr(dsc), addr(area)) return await draw_to_code( config, "label", TEXT_PROPS, do_draw_text, action_id, template_arg, args ) -IMG_PROPS = { - "angle": STYLE_PROPS["transform_angle"], - "zoom": STYLE_PROPS["transform_zoom"], - "recolor": STYLE_PROPS["image_recolor"], - "recolor_opa": STYLE_PROPS["image_recolor_opa"], - "opa": STYLE_PROPS["opa"], -} +IMG_PROPS = ( + "angle", + "rotation", + "scale_x", + "scale_y", + "skew_x", + "skew_y", + "scale", + "zoom", + "recolor", + "recolor_opa", + "opa", +) + + +def _scale_map(config): + config = {remap_property(p): v for p, v in config.items()} + if "scale" in config and {"scale_x", "scale_y"} & config.keys(): + raise cv.Invalid("Cannot specify both scale and scale_x/scale_y") + if "scale" in config: + config.update({"scale_x": config["scale"], "scale_y": config["scale"]}) + del config["scale"] + return config + + +def _get_prop_validator(prop): + return STYLE_PROPS.get(f"transform_{remap_property(prop)}") or STYLE_PROPS.get(prop) + + +def _prop_validator(prop): + def validator(value): + return _get_prop_validator(prop)(value) + + return validator @automation.register_action( @@ -303,9 +388,9 @@ IMG_PROPS = { cv.Required(CONF_SRC): lv_image, cv.Optional(CONF_PIVOT_X, default=0): pixels, cv.Optional(CONF_PIVOT_Y, default=0): pixels, - **{cv.Optional(prop): validator for prop, validator in IMG_PROPS.items()}, + **{cv.Optional(prop): _prop_validator(prop) for prop in IMG_PROPS}, } - ), + ).add_extra(_scale_map), synchronous=True, ) async def canvas_draw_image(config, action_id, template_arg, args): @@ -313,15 +398,29 @@ async def canvas_draw_image(config, action_id, template_arg, args): pivot_x = await pixels.process(config[CONF_PIVOT_X]) pivot_y = await pixels.process(config[CONF_PIVOT_Y]) - async def do_draw_image(w: Widget, x, y, dsc_addr): - dsc = MockObj(f"(*{dsc_addr})") + async def do_draw_image(layer, x, y, dsc): + # LVGL 9.4: Use lv_draw_image with area + lv_assign(dsc.src, src.get_lv_image_dsc()) if pivot_x or pivot_y: # pylint :disable=no-member - lv_assign(dsc.pivot, literal(f"{{{pivot_x}, {pivot_y}}}")) - lv.canvas_draw_img(w.obj, x, y, src, dsc_addr) + lv_assign(dsc.pivot.x, pivot_x) + lv_assign(dsc.pivot.y, pivot_y) + with LocalVariable("area", "lv_area_t", modifier="") as area: + lv_assign(area.x1, x) + lv_assign(area.y1, y) + # Image size will be determined from the image descriptor + lv_assign(area.x2, x) + lv_assign(area.y2, y) + lv.draw_image(layer, addr(dsc), addr(area)) return await draw_to_code( - config, "img", IMG_PROPS, do_draw_image, action_id, template_arg, args + config, + "image", + {prop: _get_prop_validator(prop) for prop in IMG_PROPS}, + do_draw_image, + action_id, + template_arg, + args, ) @@ -354,11 +453,8 @@ async def canvas_draw_line(config, action_id, template_arg, args): for p in config[CONF_POINTS] ] - async def do_draw_line(w: Widget, x, y, dsc_addr): - with LocalVariable( - "points", cg.std_vector.template(lv_point_t), points, modifier="" - ) as points_var: - lv.canvas_draw_line(w.obj, points_var.data(), points_var.size(), dsc_addr) + async def do_draw_line(layer, _x, _y, dsc): + _draw_line(layer, dsc, points) return await draw_to_code( config, "line", LINE_PROPS, do_draw_line, action_id, template_arg, args @@ -382,14 +478,20 @@ async def canvas_draw_polygon(config, action_id, template_arg, args): [await process_coord(p[CONF_X]), await process_coord(p[CONF_Y])] for p in config[CONF_POINTS] ] + # Close the polygon + points.append(points[0]) - async def do_draw_polygon(w: Widget, x, y, dsc_addr): - with LocalVariable( - "points", cg.std_vector.template(lv_point_t), points, modifier="" - ) as points_var: - lv.canvas_draw_polygon( - w.obj, points_var.data(), points_var.size(), dsc_addr - ) + async def do_draw_polygon(layer, x, y, dsc): + # LVGL 9.4: Draw polygon using line drawing in a closed path + # Note: This draws outline only. For filled polygons, would need different approach + # Convert rect descriptor to line descriptor for polygon outline + with LocalVariable("line_dsc", "lv_draw_line_dsc_t", modifier="") as line_dsc: + lv.draw_line_dsc_init(addr(line_dsc)) + # Copy border properties from rect descriptor to line descriptor + lv_assign(line_dsc.color, dsc.border_color) + lv_assign(line_dsc.width, dsc.border_width) + lv_assign(line_dsc.opa, dsc.border_opa) + _draw_line(layer, line_dsc, points) return await draw_to_code( config, "rect", RECT_PROPS, do_draw_polygon, action_id, template_arg, args @@ -422,8 +524,14 @@ async def canvas_draw_arc(config, action_id, template_arg, args): start_angle = await lv_angle_degrees.process(config[CONF_START_ANGLE]) end_angle = await lv_angle_degrees.process(config[CONF_END_ANGLE]) - async def do_draw_arc(w: Widget, x, y, dsc_addr): - lv.canvas_draw_arc(w.obj, x, y, radius, start_angle, end_angle, dsc_addr) + async def do_draw_arc(layer, x, y, dsc): + # LVGL 9.4: Use lv_draw_arc with center point + lv_assign(dsc.center.x, x) + lv_assign(dsc.center.y, y) + lv_assign(dsc.start_angle, start_angle) + lv_assign(dsc.end_angle, end_angle) + lv_assign(dsc.radius, radius) + lv.draw_arc(layer, addr(dsc)) return await draw_to_code( config, "arc", ARC_PROPS, do_draw_arc, action_id, template_arg, args diff --git a/esphome/components/lvgl/widgets/container.py b/esphome/components/lvgl/widgets/container.py index 2ac1a3b244..427b46affa 100644 --- a/esphome/components/lvgl/widgets/container.py +++ b/esphome/components/lvgl/widgets/container.py @@ -1,11 +1,10 @@ import esphome.config_validation as cv from esphome.const import CONF_HEIGHT, CONF_WIDTH -from esphome.cpp_generator import MockObj -from ..defines import CONF_CONTAINER, CONF_MAIN, CONF_OBJ, CONF_SCROLLBAR +from ..defines import CONF_CONTAINER, CONF_MAIN, CONF_SCROLLBAR from ..lv_validation import size -from ..lvcode import lv -from ..types import WidgetType, lv_obj_t +from ..types import lv_obj_t +from . import WidgetType CONTAINER_SCHEMA = cv.Schema( { @@ -28,12 +27,9 @@ class ContainerType(WidgetType): (CONF_MAIN, CONF_SCROLLBAR), schema=CONTAINER_SCHEMA, modify_schema={}, - lv_name=CONF_OBJ, + lv_name=CONF_CONTAINER, ) self.styles = {} - def on_create(self, var: MockObj, config: dict): - lv.obj_remove_style_all(var) - container_spec = ContainerType() diff --git a/esphome/components/lvgl/widgets/img.py b/esphome/components/lvgl/widgets/img.py index 8ec18e3033..ed6fd30c09 100644 --- a/esphome/components/lvgl/widgets/img.py +++ b/esphome/components/lvgl/widgets/img.py @@ -1,17 +1,22 @@ import esphome.config_validation as cv -from esphome.const import CONF_ANGLE, CONF_MODE, CONF_OFFSET_X, CONF_OFFSET_Y +from esphome.const import ( + CONF_ANGLE, + CONF_MODE, + CONF_OFFSET_X, + CONF_OFFSET_Y, + CONF_ROTATION, +) from ..defines import ( CONF_ANTIALIAS, CONF_MAIN, CONF_PIVOT_X, CONF_PIVOT_Y, + CONF_SCALE, CONF_SRC, CONF_ZOOM, - LvConstant, ) -from ..lv_validation import lv_angle, lv_bool, lv_image, size, zoom -from ..lvcode import lv +from ..lv_validation import lv_angle, lv_bool, lv_image, scale, size from ..types import lv_img_t from . import Widget, WidgetType from .label import CONF_LABEL @@ -22,14 +27,14 @@ BASE_IMG_SCHEMA = cv.Schema( { cv.Optional(CONF_PIVOT_X): size, cv.Optional(CONF_PIVOT_Y): size, - cv.Optional(CONF_ANGLE): lv_angle, - cv.Optional(CONF_ZOOM): zoom, + cv.Exclusive(CONF_ANGLE, CONF_ROTATION): lv_angle, + cv.Exclusive(CONF_ROTATION, CONF_ROTATION): lv_angle, + cv.Exclusive(CONF_ZOOM, CONF_SCALE): scale, + cv.Exclusive(CONF_SCALE, CONF_SCALE): scale, cv.Optional(CONF_OFFSET_X): size, cv.Optional(CONF_OFFSET_Y): size, cv.Optional(CONF_ANTIALIAS): lv_bool, - cv.Optional(CONF_MODE): LvConstant( - "LV_IMG_SIZE_MODE_", "VIRTUAL", "REAL" - ).one_of, + cv.Optional(CONF_MODE): cv.invalid(f"{CONF_MODE} is not supported in LVGL 9.x"), } ) @@ -54,33 +59,15 @@ class ImgType(WidgetType): (CONF_MAIN,), IMG_SCHEMA, IMG_MODIFY_SCHEMA, - lv_name="img", ) def get_uses(self): - return "img", CONF_LABEL + return CONF_IMAGE, CONF_LABEL async def to_code(self, w: Widget, config): - if src := config.get(CONF_SRC): - lv.img_set_src(w.obj, await lv_image.process(src)) - if (pivot_x := config.get(CONF_PIVOT_X)) and ( - pivot_y := config.get(CONF_PIVOT_Y) - ): - lv.img_set_pivot( - w.obj, await size.process(pivot_x), await size.process(pivot_y) - ) - if (cf_angle := config.get(CONF_ANGLE)) is not None: - lv.img_set_angle(w.obj, await lv_angle.process(cf_angle)) - if (img_zoom := config.get(CONF_ZOOM)) is not None: - lv.img_set_zoom(w.obj, await zoom.process(img_zoom)) - if (offset := config.get(CONF_OFFSET_X)) is not None: - lv.img_set_offset_x(w.obj, await size.process(offset)) - if (offset := config.get(CONF_OFFSET_Y)) is not None: - lv.img_set_offset_y(w.obj, await size.process(offset)) - if CONF_ANTIALIAS in config: - lv.img_set_antialias(w.obj, await lv_bool.process(config[CONF_ANTIALIAS])) - if mode := config.get(CONF_MODE): - await w.set_property("size_mode", mode) + await w.set_property(CONF_SRC, await lv_image.process(config.get(CONF_SRC))) + for prop, validator in BASE_IMG_SCHEMA.schema.items(): + await w.set_property(prop, config, processor=validator) img_spec = ImgType() diff --git a/esphome/components/lvgl/widgets/label.py b/esphome/components/lvgl/widgets/label.py index 8afd8d610f..bb5900b8c9 100644 --- a/esphome/components/lvgl/widgets/label.py +++ b/esphome/components/lvgl/widgets/label.py @@ -11,8 +11,8 @@ from ..defines import ( ) from ..lv_validation import lv_bool, lv_text from ..schemas import TEXT_SCHEMA -from ..types import LvText, WidgetType -from . import Widget +from ..types import LvText +from . import Widget, WidgetType CONF_LABEL = "label" diff --git a/esphome/components/lvgl/widgets/led.py b/esphome/components/lvgl/widgets/led.py index 647973c9b7..f0092debaa 100644 --- a/esphome/components/lvgl/widgets/led.py +++ b/esphome/components/lvgl/widgets/led.py @@ -2,7 +2,7 @@ import esphome.config_validation as cv from esphome.const import CONF_BRIGHTNESS, CONF_COLOR, CONF_LED from ..defines import CONF_MAIN -from ..lv_validation import lv_brightness, lv_color +from ..lv_validation import lv_color, lv_percentage from ..lvcode import lv from ..types import LvType from . import Widget, WidgetType @@ -10,7 +10,7 @@ from . import Widget, WidgetType LED_SCHEMA = cv.Schema( { cv.Optional(CONF_COLOR): lv_color, - cv.Optional(CONF_BRIGHTNESS): lv_brightness, + cv.Optional(CONF_BRIGHTNESS): lv_percentage, } ) @@ -23,7 +23,7 @@ class LedType(WidgetType): if (color := config.get(CONF_COLOR)) is not None: lv.led_set_color(w.obj, await lv_color.process(color)) if (brightness := config.get(CONF_BRIGHTNESS)) is not None: - lv.led_set_brightness(w.obj, await lv_brightness.process(brightness)) + lv.led_set_brightness(w.obj, await lv_percentage.process(brightness)) led_spec = LedType() diff --git a/esphome/components/lvgl/widgets/line.py b/esphome/components/lvgl/widgets/line.py index 57cb965737..a9b202163f 100644 --- a/esphome/components/lvgl/widgets/line.py +++ b/esphome/components/lvgl/widgets/line.py @@ -14,6 +14,7 @@ CONF_POINTS = "points" CONF_POINT_LIST_ID = "point_list_id" lv_point_t = cg.global_ns.struct("lv_point_t") +lv_point_precise_t = cg.global_ns.struct("lv_point_precise_t") LINE_SCHEMA = { @@ -23,10 +24,7 @@ LINE_SCHEMA = { async def process_coord(coord): if isinstance(coord, Lambda): - coord = call_lambda(await cg.process_lambda(coord, [], return_type=lv_coord_t)) - if not coord.endswith("()"): - coord = f"static_cast({coord})" - return cg.RawExpression(coord) + return call_lambda(await cg.process_lambda(coord, [], return_type=lv_coord_t)) return cg.safe_exp(coord) diff --git a/esphome/components/lvgl/widgets/lv_bar.py b/esphome/components/lvgl/widgets/lv_bar.py index f0fdd6d278..d339807746 100644 --- a/esphome/components/lvgl/widgets/lv_bar.py +++ b/esphome/components/lvgl/widgets/lv_bar.py @@ -11,8 +11,8 @@ from ..defines import ( ) from ..lv_validation import animated, lv_int from ..lvcode import lv -from ..types import LvNumber, NumberType -from . import Widget +from ..types import LvNumber +from . import NumberType, Widget # Note this file cannot be called "bar.py" because that name is disallowed. diff --git a/esphome/components/lvgl/widgets/meter.py b/esphome/components/lvgl/widgets/meter.py index b7e3af9a78..d45371b3a7 100644 --- a/esphome/components/lvgl/widgets/meter.py +++ b/esphome/components/lvgl/widgets/meter.py @@ -1,9 +1,11 @@ from esphome import automation import esphome.codegen as cg +from esphome.components.image import get_image_metadata import esphome.config_validation as cv from esphome.const import ( CONF_COLOR, CONF_COUNT, + CONF_HEIGHT, CONF_ID, CONF_ITEMS, CONF_LENGTH, @@ -13,22 +15,39 @@ from esphome.const import ( CONF_ROTATION, CONF_VALUE, CONF_WIDTH, + CONF_X, ) +from esphome.cpp_generator import MockObj +from esphome.cpp_types import nullptr +from .. import obj_spec, set_obj_properties from ..automation import action_to_code from ..defines import ( + CHILD_ALIGNMENTS, + CONF_ALIGN, + CONF_CONTAINER, CONF_END_VALUE, CONF_INDICATOR, + CONF_LINE_WIDTH, CONF_MAIN, CONF_OPA, CONF_PIVOT_X, CONF_PIVOT_Y, + CONF_RADIUS, + CONF_SCALE, CONF_SRC, CONF_START_VALUE, CONF_TICKS, + LV_OBJ_FLAG, + LV_PART, + LV_SCALE_MODE, + get_remapped_uses, + get_warnings, ) -from ..helpers import add_lv_use, lvgl_components_required +from ..helpers import add_lv_use from ..lv_validation import ( + LV_OPA, + LV_RADIUS, get_end_value, get_start_value, lv_angle_degrees, @@ -36,105 +55,168 @@ from ..lv_validation import ( lv_color, lv_float, lv_image, + lv_int, opacity, + padding, + pixels, + pixels_or_percent, + pixels_or_percent_validator, requires_component, size, ) -from ..lvcode import LocalVariable, lv, lv_assign, lv_expr, lv_obj -from ..types import LvType, ObjUpdateAction -from . import Widget, WidgetType, get_widgets +from ..lvcode import LambdaContext, LocalVariable, lv, lv_add, lv_expr, lv_obj +from ..schemas import STATE_SCHEMA +from ..styles import LVStyle +from ..types import ( + LV_EVENT, + LvCompound, + LvType, + ObjUpdateAction, + lv_event_t, + lv_img_t, + lv_obj_t, +) +from . import Widget, WidgetType, get_widgets, widget_to_code from .arc import CONF_ARC from .img import CONF_IMAGE from .line import CONF_LINE -from .obj import obj_spec CONF_ANGLE_RANGE = "angle_range" CONF_COLOR_END = "color_end" CONF_COLOR_START = "color_start" +CONF_DRAW_TICKS_ON_TOP = "draw_ticks_on_top" +CONF_IMAGE_ID = "image_id" CONF_INDICATORS = "indicators" +CONF_LINE_ID = "line_id" CONF_LABEL_GAP = "label_gap" CONF_MAJOR = "major" CONF_METER = "meter" +CONF_PIVOT = "pivot" CONF_R_MOD = "r_mod" +CONF_RADIAL_OFFSET = "radial_offset" CONF_SCALES = "scales" CONF_STRIDE = "stride" CONF_TICK_STYLE = "tick_style" +# LVGL 9.4 Migration: Use scale widget instead of removed meter widget +# +# The lv_meter widget was removed in LVGL 9.4 and replaced with the more +# flexible lv_scale widget. This implementation emulates meter functionality +# using the scale widget with the following mappings: +# +# - lv_meter -> lv_scale (set to LV_SCALE_MODE_ROUND_OUTER for circular meters) +# - lv_meter_scale -> scale configuration (range, ticks, etc.) +# - lv_meter_indicator -> lv_scale_section (colored ranges on the scale) +# + + +# For compatibility, keep meter types but map to scale +lv_scale_t = LvType("lv_obj_t") lv_meter_t = LvType("lv_meter_t") -lv_meter_indicator_t = cg.global_ns.struct("lv_meter_indicator_t") -lv_meter_indicator_t_ptr = lv_meter_indicator_t.operator("ptr") - - -def pixels(value): - """A size in one axis in pixels""" - if isinstance(value, str) and value.lower().endswith("px"): - return cv.int_(value[:-2]) - return cv.int_(value) +lv_scale_section_t = LvType("lv_scale_section_t") +lv_meter_indicator_t = LvType("lv_meter_indicator_t") +lv_meter_indicator_ticks_t = LvType( + "lv_scale_section_t", parents=(lv_meter_indicator_t,) +) +lv_meter_indicator_arc_t = LvType("lv_scale_section_t", parents=(lv_meter_indicator_t,)) +lv_meter_indicator_line_t = LvType( + "IndicatorLine", + parents=( + LvCompound, + lv_meter_indicator_t, + ), +) +lv_meter_indicator_image_t = LvType("lv_image_t", parents=(lv_meter_indicator_t,)) +DEFAULT_LABEL_GAP = 10 # Default label gap for major ticks added by LVGL INDICATOR_LINE_SCHEMA = cv.Schema( { - cv.Optional(CONF_WIDTH, default=4): size, + cv.Optional(CONF_WIDTH, default=4): cv.int_, cv.Optional(CONF_COLOR, default=0): lv_color, - cv.Optional(CONF_R_MOD, default=0): size, - cv.Optional(CONF_VALUE): lv_float, - cv.Optional(CONF_OPA): opacity, + cv.Optional(CONF_R_MOD): padding, + cv.Optional(CONF_LENGTH): pixels_or_percent_validator, + cv.Optional(CONF_RADIAL_OFFSET, 0): pixels_or_percent_validator, + cv.Optional(CONF_VALUE, default=0.0): lv_float, + cv.Optional(CONF_OPA, default=1.0): opacity, } -) +).add_extra(cv.has_at_most_one_key(CONF_R_MOD, CONF_LENGTH)) + + +class ScaleType(WidgetType): + """ + Will migrate to scale.py in due course + """ + + def __init__(self): + super().__init__( + CONF_SCALE, + lv_scale_t, + (CONF_MAIN, CONF_ITEMS, CONF_INDICATOR), + {}, + is_mock=True, + ) + + +scale_spec = ScaleType() + INDICATOR_IMG_SCHEMA = cv.Schema( { cv.Required(CONF_SRC): lv_image, - cv.Required(CONF_PIVOT_X): pixels, - cv.Required(CONF_PIVOT_Y): pixels, + cv.Optional(CONF_PIVOT_X, default=0): pixels, + cv.Optional(CONF_PIVOT_Y): pixels, cv.Optional(CONF_VALUE): lv_float, - cv.Optional(CONF_OPA): opacity, + cv.Optional(CONF_OPA, default=1.0): opacity, } ) INDICATOR_ARC_SCHEMA = cv.Schema( { - cv.Optional(CONF_WIDTH, default=4): size, + cv.Optional(CONF_WIDTH, default=4): cv.int_, cv.Optional(CONF_COLOR, default=0): lv_color, - cv.Optional(CONF_R_MOD, default=0): size, - cv.Exclusive(CONF_VALUE, CONF_VALUE): lv_float, - cv.Exclusive(CONF_START_VALUE, CONF_VALUE): lv_float, + cv.Optional(CONF_R_MOD): padding, + cv.Optional(CONF_VALUE): lv_float, + cv.Optional(CONF_START_VALUE): lv_float, cv.Optional(CONF_END_VALUE): lv_float, cv.Optional(CONF_OPA): opacity, } -) +).add_extra(cv.has_at_most_one_key(CONF_VALUE, CONF_START_VALUE)) + INDICATOR_TICKS_SCHEMA = cv.Schema( { - cv.Optional(CONF_WIDTH, default=4): size, + cv.Optional(CONF_WIDTH, default=4): cv.int_, cv.Optional(CONF_COLOR_START, default=0): lv_color, cv.Optional(CONF_COLOR_END): lv_color, - cv.Exclusive(CONF_VALUE, CONF_VALUE): lv_float, - cv.Exclusive(CONF_START_VALUE, CONF_VALUE): lv_float, + cv.Optional(CONF_VALUE): lv_float, + cv.Optional(CONF_START_VALUE): lv_float, cv.Optional(CONF_END_VALUE): lv_float, cv.Optional(CONF_LOCAL, default=False): lv_bool, } -) +).add_extra(cv.has_at_most_one_key(CONF_VALUE, CONF_START_VALUE)) + INDICATOR_SCHEMA = cv.Schema( { cv.Exclusive(CONF_LINE, CONF_INDICATORS): INDICATOR_LINE_SCHEMA.extend( { - cv.GenerateID(): cv.declare_id(lv_meter_indicator_t), + cv.GenerateID(): cv.declare_id(lv_meter_indicator_line_t), } ), cv.Exclusive(CONF_IMAGE, CONF_INDICATORS): cv.All( INDICATOR_IMG_SCHEMA.extend( { - cv.GenerateID(): cv.declare_id(lv_meter_indicator_t), + cv.GenerateID(): cv.declare_id(lv_meter_indicator_image_t), + cv.GenerateID(CONF_IMAGE_ID): cv.declare_id(lv_img_t), } ), requires_component("image"), ), cv.Exclusive(CONF_ARC, CONF_INDICATORS): INDICATOR_ARC_SCHEMA.extend( { - cv.GenerateID(): cv.declare_id(lv_meter_indicator_t), + cv.GenerateID(): cv.declare_id(lv_meter_indicator_arc_t), } ), cv.Exclusive(CONF_TICK_STYLE, CONF_INDICATORS): INDICATOR_TICKS_SCHEMA.extend( { - cv.GenerateID(): cv.declare_id(lv_meter_indicator_t), + cv.GenerateID(): cv.declare_id(lv_meter_indicator_ticks_t), } ), } @@ -142,32 +224,96 @@ INDICATOR_SCHEMA = cv.Schema( SCALE_SCHEMA = cv.Schema( { + cv.GenerateID(): cv.declare_id(lv_scale_t), cv.Optional(CONF_TICKS): cv.Schema( { cv.Optional(CONF_COUNT, default=12): cv.positive_int, - cv.Optional(CONF_WIDTH, default=2): size, + cv.Optional(CONF_WIDTH, default=2): cv.positive_int, cv.Optional(CONF_LENGTH, default=10): size, + cv.Optional(CONF_RADIAL_OFFSET, default=0): size, cv.Optional(CONF_COLOR, default=0x808080): lv_color, cv.Optional(CONF_MAJOR): cv.Schema( { cv.Optional(CONF_STRIDE, default=3): cv.positive_int, cv.Optional(CONF_WIDTH, default=5): size, cv.Optional(CONF_LENGTH, default="15%"): size, + cv.Optional(CONF_RADIAL_OFFSET, default=0): size, cv.Optional(CONF_COLOR, default=0): lv_color, cv.Optional(CONF_LABEL_GAP, default=4): size, } ), } ), - cv.Optional(CONF_RANGE_FROM, default=0.0): cv.float_, - cv.Optional(CONF_RANGE_TO, default=100.0): cv.float_, - cv.Optional(CONF_ANGLE_RANGE, default=270): cv.int_range(0, 360), - cv.Optional(CONF_ROTATION): lv_angle_degrees, + cv.Optional(CONF_RANGE_FROM, default=0.0): lv_int, + cv.Optional(CONF_RANGE_TO, default=100.0): lv_int, + cv.Optional(CONF_ANGLE_RANGE, default=270): lv_angle_degrees, + cv.Optional(CONF_ROTATION, default=0): lv_angle_degrees, cv.Optional(CONF_INDICATORS): cv.ensure_list(INDICATOR_SCHEMA), + cv.Optional(CONF_DRAW_TICKS_ON_TOP, default=True): bool, } ) -METER_SCHEMA = {cv.Optional(CONF_SCALES): cv.ensure_list(SCALE_SCHEMA)} +METER_SCHEMA = { + cv.Optional(CONF_PIVOT): STATE_SCHEMA, + cv.Optional(CONF_INDICATOR): STATE_SCHEMA, + cv.Optional(CONF_SCALES): cv.ensure_list(SCALE_SCHEMA), +} + +LIGHT_STYLE = LVStyle( + "lv_meter_light", + { + "bg_opa": 1.0, + "bg_color": 0xEEEEEE, + "line_width": 1, + "line_color": 0xEEEEEE, + "arc_width": 2, + "arc_color": 0xEEEEEE, + "pad_all": 10, + "border_width": 2, + "border_color": 0xEEEEEE, + "radius": "LV_RADIUS_CIRCLE", + }, +) + +PIVOT_STYLE = { + CONF_RADIUS: LV_RADIUS.CIRCLE, + CONF_ALIGN: CHILD_ALIGNMENTS.CENTER, + "bg_color": 0x000000, + "bg_opa": 1.0, + CONF_WIDTH: 15, + CONF_HEIGHT: 15, +} + + +line_indicator_type = WidgetType( + CONF_INDICATOR, + lv_meter_indicator_line_t, + (CONF_MAIN,), + lv_name=CONF_LINE, + is_mock=True, +) + + +class SectionType(WidgetType): + def __init__(self): + super().__init__( + "scale_section", + lv_meter_indicator_arc_t, + (CONF_MAIN,), + is_mock=True, + lv_name="scale_section", + ) + + +arc_indicator_type = SectionType() + +image_indicator_type = WidgetType( + CONF_INDICATOR, + lv_meter_indicator_image_t, + (CONF_MAIN,), + lv_name=CONF_IMAGE, + is_mock=True, +) class MeterType(WidgetType): @@ -175,111 +321,240 @@ class MeterType(WidgetType): super().__init__( CONF_METER, lv_meter_t, + # Note that mapping from 8.x to 9.x, indicator styling is applied to needles, and tick styling + # is migrated to indicator (CONF_MAIN, CONF_INDICATOR, CONF_TICKS, CONF_ITEMS), METER_SCHEMA, + lv_name=CONF_CONTAINER, ) - async def to_code(self, w: Widget, config): - """For a meter object, create and set parameters""" + def get_uses(self): + return CONF_SCALE, CONF_LINE - lvgl_components_required.add(CONF_METER) + def validate(self, value): + return cv.has_at_most_one_key(CONF_INDICATOR, CONF_PIVOT)(value) + + async def on_create(self, var: MockObj, config: dict): + # Remove theme styling from outer container + lv.obj_add_style(var, await LIGHT_STYLE.get_var(), LV_PART.MAIN) + + async def create_to_code(self, config: dict, parent: MockObj): + """For a meter object using scale widget, create and set parameters""" + + add_lv_use(*self.get_uses()) + outer_config = config.copy() + indicator_config = {CONF_INDICATOR: outer_config.pop(CONF_TICKS, {})} + w = await super().create_to_code(outer_config, parent) var = w.obj + + # LVGL 9.4 scale widget setup + # Background style will be applied. for scale_conf in config.get(CONF_SCALES, ()): - rotation = 90 + (360 - scale_conf[CONF_ANGLE_RANGE]) / 2 - if CONF_ROTATION in scale_conf: - rotation = await lv_angle_degrees.process(scale_conf[CONF_ROTATION]) - with LocalVariable( - "meter_var", "lv_meter_scale_t", lv_expr.meter_add_scale(var) - ) as meter_var: - lv.meter_set_scale_range( - var, - meter_var, - scale_conf[CONF_RANGE_FROM], - scale_conf[CONF_RANGE_TO], - scale_conf[CONF_ANGLE_RANGE], - rotation, - ) - if ticks := scale_conf.get(CONF_TICKS): - color = await lv_color.process(ticks[CONF_COLOR]) - lv.meter_set_scale_ticks( - var, - meter_var, - ticks[CONF_COUNT], - await size.process(ticks[CONF_WIDTH]), - await size.process(ticks[CONF_LENGTH]), - color, + scale_var = cg.Pvariable(scale_conf[CONF_ID], lv_expr.scale_create(var)) + percent100 = await pixels_or_percent.process(1.0) + lv_obj.set_style_height(scale_var, percent100, LV_PART.MAIN) + lv_obj.set_style_width(scale_var, percent100, LV_PART.MAIN) + lv_obj.set_style_align(scale_var, CHILD_ALIGNMENTS.CENTER, LV_PART.MAIN) + lv_obj.set_style_bg_opa(scale_var, LV_OPA.TRANSP, LV_PART.MAIN) + lv_obj.set_style_radius(scale_var, LV_RADIUS.CIRCLE, 0) + await set_obj_properties(Widget(scale_var, scale_spec), indicator_config) + + lv.scale_set_mode(scale_var, LV_SCALE_MODE.ROUND_INNER) + # Set the scale range + range_from = await lv_int.process(scale_conf[CONF_RANGE_FROM]) + range_to = await lv_int.process(scale_conf[CONF_RANGE_TO]) + lv.scale_set_range(scale_var, range_from, range_to) + + angle_range = await lv_angle_degrees.process(scale_conf[CONF_ANGLE_RANGE]) + rotation = await lv_angle_degrees.process(scale_conf[CONF_ROTATION]) + # Set angle range + lv.scale_set_angle_range( + scale_var, + angle_range, + ) + + # Set rotation if specified + if rotation: + lv.scale_set_rotation(scale_var, rotation) + + # Handle indicators as sections + for indicator in scale_conf.get(CONF_INDICATORS, ()): + (t, v) = next(iter(indicator.items())) + iid = v[CONF_ID] + + # Enable getting the meter to which this belongs. + + # Set section range based on indicator values + start_value = await get_start_value(v) or scale_conf[CONF_RANGE_FROM] + end_value = await get_end_value(v) or scale_conf[CONF_RANGE_TO] + + # Create and apply styles based on indicator type + if t == CONF_ARC: + props = { + "arc_width": v[CONF_WIDTH], + "arc_color": v[CONF_COLOR], + "arc_rounded": v.get("arc_rounded", False), + } + if (opa := v.get(CONF_OPA)) is not None: + props["arc_opa"] = opa + if CONF_R_MOD in v: + get_warnings().add( + "The 'r_mod' indicator property is not supported in LVGL 9.x and will be ignored." + ) + arc_style = LVStyle(f"meter_arc_{iid.id}", props) + tvar = cg.Pvariable(iid, lv_expr.scale_add_section(scale_var)) + lv.scale_section_set_style( + tvar, LV_PART.MAIN, await arc_style.get_var() ) - if CONF_MAJOR in ticks: - major = ticks[CONF_MAJOR] - lv.meter_set_scale_major_ticks( - var, - meter_var, - major[CONF_STRIDE], - await size.process(major[CONF_WIDTH]), - await size.process(major[CONF_LENGTH]), - await lv_color.process(major[CONF_COLOR]), - await size.process(major[CONF_LABEL_GAP]), - ) - for indicator in scale_conf.get(CONF_INDICATORS, ()): - (t, v) = next(iter(indicator.items())) - iid = v[CONF_ID] - ivar = cg.Pvariable(iid, cg.nullptr, type_=lv_meter_indicator_t) - # Enable getting the meter to which this belongs. - wid = Widget.create(iid, var, obj_spec, v) - wid.obj = ivar - if t == CONF_LINE: - color = await lv_color.process(v[CONF_COLOR]) - lv_assign( - ivar, - lv_expr.meter_add_needle_line( - var, - meter_var, - await size.process(v[CONF_WIDTH]), - color, - await size.process(v[CONF_R_MOD]), - ), - ) - if t == CONF_ARC: - color = await lv_color.process(v[CONF_COLOR]) - lv_assign( - ivar, - lv_expr.meter_add_arc( - var, - meter_var, - await size.process(v[CONF_WIDTH]), - color, - await size.process(v[CONF_R_MOD]), - ), - ) - if t == CONF_TICK_STYLE: - color_start = await lv_color.process(v[CONF_COLOR_START]) - color_end = await lv_color.process( - v.get(CONF_COLOR_END) or color_start - ) - lv_assign( - ivar, - lv_expr.meter_add_scale_lines( - var, - meter_var, + lw = Widget(tvar, arc_indicator_type) + await set_indicator_values(lw, v) + + if t == CONF_TICK_STYLE: + # No object created for this + color_start = await lv_color.process(v[CONF_COLOR_START]) + color_end = await lv_color.process(v[CONF_COLOR_END]) + local = v[CONF_LOCAL] + if color_start and color_end: + async with LambdaContext( + [(lv_event_t.operator("ptr"), "e")] + ) as lambda_: + lv.scale_draw_event_cb( + lambda_.get_parameter(0), + start_value, + end_value, color_start, color_end, - v[CONF_LOCAL], - await size.process(v[CONF_WIDTH]), - ), + local, + ) + lv_obj.add_event_cb( + scale_var, + await lambda_.get_lambda(), + LV_EVENT.DRAW_TASK_ADDED, + nullptr, ) - if t == CONF_IMAGE: - add_lv_use("img") - lv_assign( - ivar, - lv_expr.meter_add_needle_img( - var, - meter_var, - await lv_image.process(v[CONF_SRC]), - v[CONF_PIVOT_X], - v[CONF_PIVOT_Y], - ), - ) - await set_indicator_values(var, ivar, v) + lv.obj_add_flag(scale_var, LV_OBJ_FLAG.SEND_DRAW_TASK_EVENTS) + + if t == CONF_LINE: + # Needle represented by a line + if CONF_LENGTH in v: + length = v[CONF_LENGTH] + elif r_mod := v.get(CONF_R_MOD): + get_remapped_uses().add(CONF_R_MOD) + length = -abs(r_mod) + else: + length = 1.0 + props = { + CONF_ID: v[CONF_ID], + CONF_OPA: v[CONF_OPA], + CONF_LINE_WIDTH: v[CONF_WIDTH], + "line_color": v[CONF_COLOR], + "line_rounded": True, + CONF_ALIGN: CHILD_ALIGNMENTS.TOP_LEFT, + CONF_LENGTH: length, + CONF_RADIAL_OFFSET: v[CONF_RADIAL_OFFSET], + } + lw = await widget_to_code(props, line_indicator_type, scale_var) + await set_indicator_values(lw, v) + + if t == CONF_IMAGE: + add_lv_use(CONF_IMAGE) + src = v[CONF_SRC] + src_data = get_image_metadata(src.id) + pivot_x = await pixels.process(v[CONF_PIVOT_X]) + pivot_y = await pixels.process( + v.get(CONF_PIVOT_Y, src_data.height // 2) + ) + props = { + CONF_X: src_data.width // 2 - pivot_x, + "transform_pivot_x": pivot_x, + "transform_pivot_y": pivot_y, + CONF_SRC: src, + CONF_OPA: v[CONF_OPA], + CONF_ID: v[CONF_ID], + CONF_ALIGN: CHILD_ALIGNMENTS.CENTER, + } + iw = await widget_to_code(props, image_indicator_type, scale_var) + await iw.set_property(CONF_SRC, await lv_image.process(src)) + await set_indicator_values(iw, v) + + if ticks := scale_conf.get(CONF_TICKS): + # Set total tick count + lv.scale_set_total_tick_count(scale_var, ticks[CONF_COUNT]) + lv.scale_set_draw_ticks_on_top( + scale_var, scale_conf[CONF_DRAW_TICKS_ON_TOP] + ) + + # Set tick styling + lv_obj.set_style_length( + scale_var, await size.process(ticks[CONF_LENGTH]), LV_PART.ITEMS + ) + lv_obj.set_style_line_width( + scale_var, await size.process(ticks[CONF_WIDTH]), LV_PART.ITEMS + ) + lv_obj.set_style_radial_offset( + scale_var, + await size.process(ticks[CONF_RADIAL_OFFSET]), + LV_PART.ITEMS, + ) + lv_obj.set_style_line_color( + scale_var, + await lv_color.process(ticks[CONF_COLOR]), + LV_PART.ITEMS, + ) + + # Hide the scale line + lv.obj_set_style_arc_opa(scale_var, LV_OPA.TRANSP, LV_PART.MAIN) + if CONF_MAJOR in ticks: + major = ticks[CONF_MAJOR] + # Set major tick frequency + lv.scale_set_major_tick_every(scale_var, major[CONF_STRIDE]) + + # Enable labels for major ticks + lv.scale_set_label_show(scale_var, True) + + # Set major tick styling + lv_obj.set_style_length( + scale_var, + await size.process(major[CONF_LENGTH]), + LV_PART.INDICATOR, + ) + lv_obj.set_style_radial_offset( + scale_var, + await size.process(ticks[CONF_RADIAL_OFFSET]), + LV_PART.INDICATOR, + ) + lv_obj.set_style_line_width( + scale_var, + await size.process(major[CONF_WIDTH]), + LV_PART.INDICATOR, + ) + lv_obj.set_style_line_color( + scale_var, + await lv_color.process(major[CONF_COLOR]), + LV_PART.INDICATOR, + ) + + # Set label gap (padding) + label_gap = await size.process(major[CONF_LABEL_GAP]) + if isinstance(label_gap, int): + label_gap -= DEFAULT_LABEL_GAP + lv_obj.set_style_pad_radial( + scale_var, + label_gap, + LV_PART.INDICATOR, + ) + else: + lv.scale_set_major_tick_every(scale_var, 0) + else: + lv.scale_set_total_tick_count(scale_var, 0) + + # Add a pivot + # Get the default style + pivot_style = PIVOT_STYLE.copy() + pivot_style.update(config.get(CONF_INDICATOR, config.get(CONF_PIVOT, {}))) + with LocalVariable("pivot", lv_obj_t, lv_expr.container_create(var)) as pivot: + pw = Widget(pivot, obj_spec, pivot_style) + await set_obj_properties(pw, pivot_style) meter_spec = MeterType() @@ -303,23 +578,39 @@ async def indicator_update_to_code(config, action_id, template_arg, args): widget = await get_widgets(config) async def set_value(w: Widget): - await set_indicator_values(w.var, w.obj, config) + await set_indicator_values(w, config) return await action_to_code( widget, set_value, action_id, template_arg, args, config ) -async def set_indicator_values(meter, indicator, config): +async def set_indicator_values(indicator: Widget, config): + """Update scale section values (replaces meter indicator values)""" start_value = await get_start_value(config) end_value = await get_end_value(config) - if start_value is not None: - if end_value is None: - lv.meter_set_indicator_value(meter, indicator, start_value) - else: - lv.meter_set_indicator_start_value(meter, indicator, start_value) - if end_value is not None: - lv.meter_set_indicator_end_value(meter, indicator, end_value) - if (opa := config.get(CONF_OPA)) is not None: - lv_assign(indicator.opa, await opacity.process(opa)) - lv_obj.invalidate(meter) + if indicator.type is arc_indicator_type: + # For scale sections, we update the range + if start_value is not None and end_value is not None: + lv.scale_section_set_range(indicator.obj, start_value, end_value) + elif start_value is not None: + # If only start value, use it as both start and end (single point) + lv.scale_section_set_range(indicator.obj, start_value, start_value) + elif end_value is not None: + # If only end value, assume range from 0 to end_value + lv.scale_section_set_range(indicator.obj, 0, end_value) + return + + if start_value is None: + return + if indicator.type is line_indicator_type: + # Line needle + lv_add(indicator.var.set_value(start_value)) + return + if indicator.type is image_indicator_type: + # Needle represented by an image + lv_obj.set_style_transform_rotation( + indicator.obj, + lv.get_needle_angle_for_value(indicator.obj, start_value) * 10, + LV_PART.MAIN, + ) diff --git a/esphome/components/lvgl/widgets/msgbox.py b/esphome/components/lvgl/widgets/msgbox.py index 82b2442378..af27ee7553 100644 --- a/esphome/components/lvgl/widgets/msgbox.py +++ b/esphome/components/lvgl/widgets/msgbox.py @@ -1,7 +1,7 @@ -from esphome import config_validation as cv -from esphome.const import CONF_BUTTON, CONF_ID, CONF_ITEMS, CONF_TEXT +from esphome import codegen as cg, config_validation as cv +from esphome.const import CONF_BUTTON, CONF_ID, CONF_TEXT from esphome.core import ID -from esphome.cpp_generator import new_Pvariable, static_const_array +from esphome.cpp_generator import MockObjClass from esphome.cpp_types import nullptr from ..defines import ( @@ -9,40 +9,82 @@ from ..defines import ( CONF_BUTTON_STYLE, CONF_BUTTONS, CONF_CLOSE_BUTTON, + CONF_HEADER_BUTTONS, + CONF_MAIN, CONF_MSGBOXES, + CONF_SRC, CONF_TITLE, + LV_OBJ_FLAG, TYPE_FLEX, + add_warning, literal, ) -from ..helpers import add_lv_use, lvgl_components_required -from ..lv_validation import lv_bool, lv_pct, lv_text -from ..lvcode import ( - EVENT_ARG, - LambdaContext, - LocalVariable, - lv, - lv_add, - lv_assign, - lv_expr, - lv_obj, - lv_Pvariable, -) -from ..schemas import STYLE_SCHEMA, STYLED_TEXT_SCHEMA, container_schema, part_schema -from ..types import LV_EVENT, char_ptr, lv_obj_t -from . import Widget, add_widgets, set_obj_properties -from .button import button_spec -from .buttonmatrix import ( - BUTTONMATRIX_BUTTON_SCHEMA, - CONF_BUTTON_TEXT_LIST_ID, - buttonmatrix_spec, - get_button_data, - lv_buttonmatrix_t, - set_btn_data, +from ..helpers import add_lv_use +from ..lv_validation import lv_bool, lv_image, lv_text, pixels_or_percent +from ..lvcode import EVENT_ARG, LambdaContext, LocalVariable, lv, lv_expr, lv_obj +from ..schemas import ( + STYLE_SCHEMA, + STYLED_TEXT_SCHEMA, + TEXT_SCHEMA, + container_schema, + part_schema, ) +from ..styles import LVStyle +from ..types import LV_EVENT, lv_obj_t +from . import Widget, WidgetType, add_widgets, set_obj_properties, widget_to_code +from .button import button_spec, lv_button_t from .label import CONF_LABEL from .obj import obj_spec CONF_MSGBOX = "msgbox" + +OUTER_STYLE = LVStyle( + "msgbox_outer", + { + "bg_opa": 128, + "bg_color": "black", + "border_width": 0, + "pad_all": 0, + "radius": 0, + }, +) + + +class FooterButtonType(WidgetType): + def __init__(self): + super().__init__( + CONF_BUTTON, lv_button_t, (CONF_MAIN,), TEXT_SCHEMA, is_mock=True + ) + + async def obj_creator(self, parent: MockObjClass, config: dict): + return lv_expr.msgbox_add_footer_button(parent, config[CONF_TEXT]) + + +footer_button_spec = FooterButtonType() + + +class HeaderButtonType(WidgetType): + def __init__(self): + super().__init__( + CONF_BUTTON, + lv_button_t, + (CONF_MAIN,), + cv.Schema( + { + cv.Required(CONF_SRC): lv_image, + } + ), + is_mock=True, + ) + + async def obj_creator(self, parent: MockObjClass, config: dict): + return lv_expr.msgbox_add_header_button( + parent, await lv_image.process(config[CONF_SRC]) + ) + + +header_button_spec = HeaderButtonType() + MSGBOX_SCHEMA = container_schema( obj_spec, STYLE_SCHEMA.extend( @@ -50,10 +92,14 @@ MSGBOX_SCHEMA = container_schema( cv.GenerateID(CONF_ID): cv.declare_id(lv_obj_t), cv.Required(CONF_TITLE): STYLED_TEXT_SCHEMA, cv.Optional(CONF_BODY, default=""): STYLED_TEXT_SCHEMA, - cv.Optional(CONF_BUTTONS): cv.ensure_list(BUTTONMATRIX_BUTTON_SCHEMA), - cv.Optional(CONF_BUTTON_STYLE): part_schema(buttonmatrix_spec.parts), + cv.Optional(CONF_BUTTONS): cv.ensure_list( + container_schema(footer_button_spec) + ), + cv.Optional(CONF_HEADER_BUTTONS): cv.ensure_list( + container_schema(header_button_spec) + ), cv.Optional(CONF_CLOSE_BUTTON, default=True): lv_bool, - cv.GenerateID(CONF_BUTTON_TEXT_LIST_ID): cv.declare_id(char_ptr), + cv.Optional(CONF_BUTTON_STYLE): part_schema(button_spec.parts), } ), ) @@ -62,7 +108,9 @@ MSGBOX_SCHEMA = container_schema( async def msgbox_to_code(top_layer, conf): """ Construct a message box. This consists of a full-screen translucent background enclosing a centered container - with an optional title, body, close button and a button matrix. And any other widgets the user cares to add + with an optional title, body, close button and a set of footer buttons. + Header buttons can be added - they can be image buttons only. + The body of the message box may have any widgets the user wants to add. :param conf: The config data :return: code to add to the init lambda """ @@ -71,60 +119,42 @@ async def msgbox_to_code(top_layer, conf): CONF_BUTTON, CONF_LABEL, CONF_MSGBOX, - *buttonmatrix_spec.get_uses(), *button_spec.get_uses(), ) - lvgl_components_required.add("BUTTONMATRIX") - messagebox_id = conf[CONF_ID] - outer_id = f"{messagebox_id.id}_outer" - outer = lv_Pvariable(lv_obj_t, messagebox_id.id + "_outer") - buttonmatrix = new_Pvariable( - ID( - f"{messagebox_id.id}_buttonmatrix_", - is_declaration=True, - type=lv_buttonmatrix_t, + if CONF_BUTTON_STYLE in conf: + add_warning( + "'button_style' for msgbox is deprecated - style the buttons directly." ) - ) - msgbox = lv_Pvariable(lv_obj_t, messagebox_id.id) - outer_widget = Widget.create(outer_id, outer, obj_spec, conf) + messagebox_id = conf[CONF_ID] + outer_id = ID(f"{messagebox_id.id}_outer", type=lv_obj_t) + outer = cg.Pvariable(outer_id, lv_expr.obj_create(top_layer)) + outer_widget = Widget.create(outer_id.id, outer, obj_spec, conf) + msgbox = cg.Pvariable(messagebox_id, lv_expr.msgbox_create(outer)) outer_widget.move_to_foreground = True msgbox_widget = Widget.create(messagebox_id, msgbox, obj_spec, conf) msgbox_widget.outer = outer_widget - buttonmatrix_widget = Widget.create( - str(buttonmatrix), buttonmatrix, buttonmatrix_spec, conf - ) - text_list, ctrl_list, width_list, _ = await get_button_data( - (conf,), buttonmatrix_widget - ) - text_id = conf[CONF_BUTTON_TEXT_LIST_ID] - text_list = static_const_array(text_id, text_list) text = await lv_text.process(conf[CONF_BODY].get(CONF_TEXT, "")) title = await lv_text.process(conf[CONF_TITLE].get(CONF_TEXT, "")) close_button = conf[CONF_CLOSE_BUTTON] - lv_assign(outer, lv_expr.obj_create(top_layer)) - lv_obj.set_width(outer, lv_pct(100)) - lv_obj.set_height(outer, lv_pct(100)) - lv_obj.set_style_bg_opa(outer, 128, 0) - lv_obj.set_style_bg_color(outer, literal("lv_color_black()"), 0) - lv_obj.set_style_border_width(outer, 0, 0) - lv_obj.set_style_pad_all(outer, 0, 0) - lv_obj.set_style_radius(outer, 0, 0) - outer_widget.add_flag("LV_OBJ_FLAG_HIDDEN") - lv_assign( - msgbox, lv_expr.msgbox_create(outer, title, text, text_list, close_button) - ) + percent100 = await pixels_or_percent.process(1.0) + lv_obj.set_size(outer, percent100, percent100) + outer_widget.add_style(await OUTER_STYLE.get_var()) + outer_widget.add_flag(LV_OBJ_FLAG.HIDDEN) + lv.msgbox_add_title(msgbox, title) + lv.msgbox_add_text(msgbox, text) lv_obj.set_style_align(msgbox, literal("LV_ALIGN_CENTER"), 0) - lv_add(buttonmatrix.set_obj(lv_expr.msgbox_get_btns(msgbox))) - if button_style := conf.get(CONF_BUTTON_STYLE): - button_style = {CONF_ITEMS: button_style} - await set_obj_properties(buttonmatrix_widget, button_style) await set_obj_properties(msgbox_widget, conf) await add_widgets(msgbox_widget, conf) + for button in conf.get(CONF_BUTTONS, ()): + await widget_to_code(button, footer_button_spec, msgbox) + for button in conf.get(CONF_HEADER_BUTTONS, ()): + await widget_to_code(button, header_button_spec, msgbox) + async with LambdaContext(EVENT_ARG, where=messagebox_id) as close_action: - outer_widget.add_flag("LV_OBJ_FLAG_HIDDEN") + outer_widget.add_flag(LV_OBJ_FLAG.HIDDEN) if close_button: with LocalVariable( - "close_btn_", lv_obj_t, lv_expr.msgbox_get_close_btn(msgbox) + "close_btn_", lv_obj_t, lv_expr.msgbox_add_close_button(msgbox) ) as close_btn: lv_obj.remove_event_cb(close_btn, nullptr) lv_obj.add_event_cb( @@ -138,9 +168,6 @@ async def msgbox_to_code(top_layer, conf): outer, await close_action.get_lambda(), LV_EVENT.CLICKED, nullptr ) - if len(ctrl_list) != 0 or len(width_list) != 0: - set_btn_data(buttonmatrix.obj, ctrl_list, width_list) - async def msgboxes_to_code(lv_component, config): top_layer = lv.disp_get_layer_top(lv_component.get_disp()) diff --git a/esphome/components/lvgl/widgets/obj.py b/esphome/components/lvgl/widgets/obj.py index ab22a5ce86..079a0734ef 100644 --- a/esphome/components/lvgl/widgets/obj.py +++ b/esphome/components/lvgl/widgets/obj.py @@ -1,5 +1,6 @@ from ..defines import CONF_MAIN, CONF_OBJ, CONF_SCROLLBAR -from ..types import WidgetType, lv_obj_t +from ..types import lv_obj_t +from . import WidgetType class ObjType(WidgetType): diff --git a/esphome/components/lvgl/widgets/qrcode.py b/esphome/components/lvgl/widgets/qrcode.py index ad46f67c6b..82c4370543 100644 --- a/esphome/components/lvgl/widgets/qrcode.py +++ b/esphome/components/lvgl/widgets/qrcode.py @@ -1,14 +1,15 @@ import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import CONF_SIZE, CONF_TEXT -from esphome.cpp_generator import MockObjClass -from ..defines import CONF_MAIN -from ..lv_validation import lv_color, lv_text -from ..lvcode import LocalVariable, lv, lv_expr +from ..defines import CONF_MAIN, get_color_formats +from ..lv_validation import color, lv_color, lv_int, lv_text +from ..lvcode import LocalVariable, lv from ..schemas import TEXT_SCHEMA -from ..types import WidgetType, lv_obj_t -from . import Widget +from ..types import lv_obj_t +from . import Widget, WidgetType +from .canvas import CONF_CANVAS +from .img import CONF_IMAGE CONF_QRCODE = "qrcode" CONF_DARK_COLOR = "dark_color" @@ -16,9 +17,16 @@ CONF_LIGHT_COLOR = "light_color" QRCODE_SCHEMA = { **TEXT_SCHEMA, - cv.Optional(CONF_DARK_COLOR, default="black"): lv_color, - cv.Optional(CONF_LIGHT_COLOR, default="white"): lv_color, - cv.Required(CONF_SIZE): cv.int_, + cv.Optional(CONF_DARK_COLOR, default="black"): color, + cv.Optional(CONF_LIGHT_COLOR, default="white"): color, + cv.Required(CONF_SIZE): lv_int, +} + +QRCODE_MODIFY_SCHEMA = { + **TEXT_SCHEMA, + cv.Optional(CONF_DARK_COLOR): lv_color, + cv.Optional(CONF_LIGHT_COLOR): lv_color, + cv.Optional(CONF_SIZE): lv_int, } @@ -29,20 +37,25 @@ class QrCodeType(WidgetType): lv_obj_t, (CONF_MAIN,), QRCODE_SCHEMA, - modify_schema=TEXT_SCHEMA, + modify_schema=QRCODE_MODIFY_SCHEMA, ) def get_uses(self): - return "canvas", "img", "label" - - async def obj_creator(self, parent: MockObjClass, config: dict): - dark_color = await lv_color.process(config[CONF_DARK_COLOR]) - light_color = await lv_color.process(config[CONF_LIGHT_COLOR]) - size = config[CONF_SIZE] - return lv_expr.call("qrcode_create", parent, size, dark_color, light_color) + return CONF_CANVAS, CONF_IMAGE async def to_code(self, w: Widget, config): + get_color_formats().add("ARGB8888") + await w.set_property( + CONF_LIGHT_COLOR, await lv_color.process(config.get(CONF_LIGHT_COLOR)) + ) + await w.set_property( + CONF_DARK_COLOR, await lv_color.process(config.get(CONF_DARK_COLOR)) + ) + await w.set_property(CONF_SIZE, await lv_int.process(config.get(CONF_SIZE))) if (value := config.get(CONF_TEXT)) is not None: + if isinstance(value, str): + lv.qrcode_update(w.obj, value, len(value)) + return value = await lv_text.process(value) with LocalVariable("qr_text", cg.std_string, value, modifier="") as str_obj: lv.qrcode_update(w.obj, str_obj.c_str(), str_obj.size()) diff --git a/esphome/components/lvgl/widgets/slider.py b/esphome/components/lvgl/widgets/slider.py index d5017668e4..85096c302a 100644 --- a/esphome/components/lvgl/widgets/slider.py +++ b/esphome/components/lvgl/widgets/slider.py @@ -2,18 +2,18 @@ import esphome.config_validation as cv from esphome.const import CONF_MAX_VALUE, CONF_MIN_VALUE, CONF_MODE, CONF_VALUE from ..defines import ( - BAR_MODES, CONF_ANIMATED, CONF_INDICATOR, CONF_KNOB, CONF_MAIN, + SLIDER_MODES, literal, ) -from ..helpers import add_lv_use from ..lv_validation import animated, get_start_value, lv_float from ..lvcode import lv -from ..types import LvNumber, NumberType -from . import Widget +from ..types import LvNumber +from . import NumberType, Widget +from .label import CONF_LABEL from .lv_bar import CONF_BAR CONF_SLIDER = "slider" @@ -29,7 +29,7 @@ SLIDER_SCHEMA = cv.Schema( cv.Optional(CONF_VALUE): lv_float, cv.Optional(CONF_MIN_VALUE, default=0): cv.int_, cv.Optional(CONF_MAX_VALUE, default=100): cv.int_, - cv.Optional(CONF_MODE, default="NORMAL"): BAR_MODES.one_of, + cv.Optional(CONF_MODE, default="NORMAL"): SLIDER_MODES.one_of, cv.Optional(CONF_ANIMATED, default=True): animated, } ) @@ -49,8 +49,10 @@ class SliderType(NumberType): def animated(self): return True + def get_uses(self): + return (CONF_BAR, CONF_LABEL) + async def to_code(self, w: Widget, config): - add_lv_use(CONF_BAR) if CONF_MIN_VALUE in config: # not modify case lv.slider_set_range(w.obj, config[CONF_MIN_VALUE], config[CONF_MAX_VALUE]) diff --git a/esphome/components/lvgl/widgets/spinner.py b/esphome/components/lvgl/widgets/spinner.py index 83aac25a59..0e409ba3b7 100644 --- a/esphome/components/lvgl/widgets/spinner.py +++ b/esphome/components/lvgl/widgets/spinner.py @@ -1,9 +1,8 @@ import esphome.config_validation as cv -from esphome.cpp_generator import MockObjClass from ..defines import CONF_ARC_LENGTH, CONF_INDICATOR, CONF_MAIN, CONF_SPIN_TIME from ..lv_validation import lv_angle_degrees, lv_milliseconds -from ..lvcode import lv_expr +from ..lvcode import lv from ..types import LvType from . import Widget, WidgetType from .arc import CONF_ARC @@ -12,8 +11,10 @@ CONF_SPINNER = "spinner" SPINNER_SCHEMA = cv.Schema( { - cv.Required(CONF_ARC_LENGTH): lv_angle_degrees, - cv.Required(CONF_SPIN_TIME): lv_milliseconds, + cv.Optional(CONF_ARC_LENGTH, default=200): cv.All( + lv_angle_degrees, cv.int_range(min=0, max=360) + ), + cv.Optional(CONF_SPIN_TIME, default="2s"): lv_milliseconds, } ) @@ -25,19 +26,17 @@ class SpinnerType(WidgetType): LvType("lv_spinner_t"), (CONF_MAIN, CONF_INDICATOR), SPINNER_SCHEMA, - {}, ) async def to_code(self, w: Widget, config): - return [] + spin_time = await lv_milliseconds.process(config.get(CONF_SPIN_TIME)) + arc_length = int(config[CONF_ARC_LENGTH]) + if arc_length < 180: + arc_length += 180 + lv.spinner_set_anim_params(w.obj, spin_time, arc_length) def get_uses(self): return (CONF_ARC,) - async def obj_creator(self, parent: MockObjClass, config: dict): - spin_time = await lv_milliseconds.process(config[CONF_SPIN_TIME]) - arc_length = await lv_angle_degrees.process(config[CONF_ARC_LENGTH]) - return lv_expr.call("spinner_create", parent, spin_time, arc_length) - spinner_spec = SpinnerType() diff --git a/esphome/components/lvgl/widgets/tabview.py b/esphome/components/lvgl/widgets/tabview.py index cd7cf7b471..60ba664f04 100644 --- a/esphome/components/lvgl/widgets/tabview.py +++ b/esphome/components/lvgl/widgets/tabview.py @@ -1,8 +1,14 @@ from esphome import automation import esphome.codegen as cg import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_INDEX, CONF_NAME, CONF_POSITION, CONF_SIZE -from esphome.cpp_generator import MockObjClass +from esphome.const import ( + CONF_ID, + CONF_INDEX, + CONF_ITEMS, + CONF_NAME, + CONF_POSITION, + CONF_SIZE, +) from ..automation import action_to_code from ..defines import ( @@ -15,10 +21,11 @@ from ..defines import ( literal, ) from ..lv_validation import animated, lv_int, size -from ..lvcode import LocalVariable, lv, lv_assign, lv_expr +from ..lvcode import LocalVariable, lv, lv_assign, lv_expr, lv_obj from ..schemas import container_schema, part_schema from ..types import LV_EVENT, LvType, ObjUpdateAction, lv_obj_t, lv_obj_t_ptr from . import Widget, WidgetType, add_widgets, get_widgets, set_obj_properties +from .button import button_spec from .buttonmatrix import buttonmatrix_spec from .obj import obj_spec @@ -69,6 +76,10 @@ class TabviewType(WidgetType): return "btnmatrix", TYPE_FLEX async def to_code(self, w: Widget, config: dict): + await w.set_property( + "tab_bar_position", await DIRECTIONS.process(config[CONF_POSITION]) + ) + await w.set_property("tab_bar_size", await size.process(config[CONF_SIZE])) for tab_conf in config[CONF_TABS]: w_id = tab_conf[CONF_ID] tab_obj = cg.Pvariable(w_id, cg.nullptr, type_=lv_tab_t) @@ -76,25 +87,27 @@ class TabviewType(WidgetType): lv_assign(tab_obj, lv_expr.tabview_add_tab(w.obj, tab_conf[CONF_NAME])) await set_obj_properties(tab_widget, tab_conf) await add_widgets(tab_widget, tab_conf) - if button_style := config.get(CONF_TAB_STYLE): + tab_style = config.get(CONF_TAB_STYLE, {}) + tab_items_style = tab_style.get(CONF_ITEMS, {}) + if tab_style: with LocalVariable( - "tabview_btnmatrix", lv_obj_t, rhs=lv_expr.tabview_get_tab_btns(w.obj) - ) as btnmatrix_obj: - await set_obj_properties(Widget(btnmatrix_obj, obj_spec), button_style) + "tabview_bar", lv_obj_t, rhs=lv_expr.tabview_get_tab_bar(w.obj) + ) as bar_obj: + tab_bar = Widget(bar_obj, obj_spec) + await set_obj_properties(tab_bar, tab_style) + if tab_items_style: + for index, tab_conf in enumerate(config[CONF_TABS]): + await set_obj_properties( + Widget(lv_obj.get_child(bar_obj, index), button_spec), + tab_items_style, + ) + if content_style := config.get(CONF_CONTENT_STYLE): with LocalVariable( "tabview_content", lv_obj_t, rhs=lv_expr.tabview_get_content(w.obj) ) as content_obj: await set_obj_properties(Widget(content_obj, obj_spec), content_style) - async def obj_creator(self, parent: MockObjClass, config: dict): - return lv_expr.call( - "tabview_create", - parent, - await DIRECTIONS.process(config[CONF_POSITION]), - await size.process(config[CONF_SIZE]), - ) - tabview_spec = TabviewType() @@ -117,6 +130,6 @@ async def tabview_select(config, action_id, template_arg, args): async def do_select(w: Widget): lv.tabview_set_act(w.obj, index, literal(config[CONF_ANIMATED])) - lv.event_send(w.obj, LV_EVENT.VALUE_CHANGED, cg.nullptr) + lv.obj_send_event(w.obj, LV_EVENT.VALUE_CHANGED, cg.nullptr) return await action_to_code(widget, do_select, action_id, template_arg, args) diff --git a/esphome/components/lvgl/widgets/tileview.py b/esphome/components/lvgl/widgets/tileview.py index 430a386d2e..dadaef7d07 100644 --- a/esphome/components/lvgl/widgets/tileview.py +++ b/esphome/components/lvgl/widgets/tileview.py @@ -15,7 +15,7 @@ from ..defines import ( TILE_DIRECTIONS, literal, ) -from ..lv_validation import animated, lv_int, lv_pct +from ..lv_validation import animated, lv_int, pixels_or_percent from ..lvcode import lv, lv_assign, lv_expr, lv_obj, lv_Pvariable from ..schemas import container_schema from ..types import LV_EVENT, LvType, ObjUpdateAction, lv_obj_t, lv_obj_t_ptr @@ -68,17 +68,19 @@ class TileviewType(WidgetType): w_id = tile_conf[CONF_ID] tile_obj = lv_Pvariable(lv_obj_t, w_id) tile = Widget.create(w_id, tile_obj, tile_spec, tile_conf) - dirs = tile_conf[CONF_DIR] - if isinstance(dirs, list): - dirs = "|".join(dirs) + dirs = await TILE_DIRECTIONS.process(tile_conf[CONF_DIR]) row_pos = tile_conf[CONF_ROW] col_pos = tile_conf[CONF_COLUMN] lv_assign( tile_obj, - lv_expr.tileview_add_tile(w.obj, col_pos, row_pos, literal(dirs)), + lv_expr.tileview_add_tile(w.obj, col_pos, row_pos, dirs), ) # Bugfix for LVGL 8.x - lv_obj.set_pos(tile_obj, lv_pct(col_pos * 100), lv_pct(row_pos * 100)) + lv_obj.set_pos( + tile_obj, + await pixels_or_percent.process(float(col_pos)), + await pixels_or_percent.process(float(row_pos)), + ) await set_obj_properties(tile, tile_conf) await add_widgets(tile, tile_conf) if tiles: diff --git a/esphome/components/online_image/__init__.py b/esphome/components/online_image/__init__.py index 35a9de3537..292e2bb3bb 100644 --- a/esphome/components/online_image/__init__.py +++ b/esphome/components/online_image/__init__.py @@ -5,12 +5,14 @@ import esphome.codegen as cg from esphome.components import runtime_image from esphome.components.const import CONF_REQUEST_HEADERS from esphome.components.http_request import CONF_HTTP_REQUEST_ID, HttpRequestComponent +from esphome.components.image import CONF_TRANSPARENCY, add_metadata import esphome.config_validation as cv from esphome.const import ( CONF_BUFFER_SIZE, CONF_ID, CONF_ON_ERROR, CONF_TRIGGER_ID, + CONF_TYPE, CONF_URL, ) from esphome.core import Lambda @@ -131,6 +133,13 @@ async def online_image_action_to_code(config, action_id, template_arg, args): async def to_code(config): # Use the enhanced helper function to get all runtime image parameters settings = await runtime_image.process_runtime_image_config(config) + add_metadata( + config[CONF_ID], + settings.width, + settings.height, + config[CONF_TYPE], + config[CONF_TRANSPARENCY], + ) url = config[CONF_URL] var = cg.new_Pvariable( diff --git a/esphome/components/runtime_image/__init__.py b/esphome/components/runtime_image/__init__.py index 3ae35cc5f1..8db69aa53e 100644 --- a/esphome/components/runtime_image/__init__.py +++ b/esphome/components/runtime_image/__init__.py @@ -181,7 +181,8 @@ async def process_runtime_image_config(config: dict) -> RuntimeImageSettings: transparent = get_transparency_enum(config.get(CONF_TRANSPARENCY, "OPAQUE")) # Get byte order (True for big endian, False for little endian) - byte_order_big_endian = config.get(CONF_BYTE_ORDER) != "LITTLE_ENDIAN" + # If unspecified, use little endian + byte_order_big_endian = config.get(CONF_BYTE_ORDER) == "BIG_ENDIAN" # Get placeholder if specified placeholder = None diff --git a/esphome/components/runtime_image/runtime_image.cpp b/esphome/components/runtime_image/runtime_image.cpp index 5a4a2ea7d6..2ebe67c3a5 100644 --- a/esphome/components/runtime_image/runtime_image.cpp +++ b/esphome/components/runtime_image/runtime_image.cpp @@ -106,7 +106,7 @@ void RuntimeImage::draw_pixel(int x, int y, const Color &color) { break; } case image::IMAGE_TYPE_RGB565: { - uint32_t pos = this->get_position_(x, y); + const size_t pos = (x + y * this->buffer_width_) * 2; Color mapped_color = color; this->map_chroma_key(mapped_color); uint16_t rgb565 = display::ColorUtil::color_to_565(mapped_color); @@ -118,7 +118,8 @@ void RuntimeImage::draw_pixel(int x, int y, const Color &color) { this->buffer_[pos + 1] = static_cast((rgb565 >> 8) & 0xFF); } if (this->transparency_ == image::TRANSPARENCY_ALPHA_CHANNEL) { - this->buffer_[pos + 2] = color.w; + const size_t alpha_pos = pos / 2 + this->buffer_width_ * this->buffer_height_ * 2; + this->buffer_[alpha_pos] = color.w; } break; } @@ -283,6 +284,10 @@ size_t RuntimeImage::resize_buffer_(int width, int height) { } size_t RuntimeImage::get_buffer_size_(int width, int height) const { + if (this->get_type() == image::IMAGE_TYPE_RGB565 && this->transparency_ == image::TRANSPARENCY_ALPHA_CHANNEL) { + // Add extra alpha channel for RGB565 with alpha + return width * height * 3; + } return (this->get_bpp() * width + 7u) / 8u * height; } diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 09269ea1b4..c817f8ef27 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -92,6 +92,7 @@ #define USE_LVGL_MSGBOX #define USE_LVGL_ROLLER #define USE_LVGL_ROTARY_ENCODER +#define USE_LVGL_SCALE #define USE_LVGL_SLIDER #define USE_LVGL_SPAN #define USE_LVGL_SPINBOX diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 620dc6131d..5cfa8532ff 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -77,3 +77,5 @@ dependencies: - if: "idf_version >=6.0.0 && target in [esp32s2, esp32s3, esp32p4]" esp32async/asynctcp: version: 3.4.91 + lvgl/lvgl: + version: 9.5.0 diff --git a/platformio.ini b/platformio.ini index 3c3d62ef76..c5a4c630df 100644 --- a/platformio.ini +++ b/platformio.ini @@ -42,7 +42,6 @@ lib_deps_base = https://github.com/esphome/TinyGPSPlus.git#v1.1.0 ; gps ; This is using the repository until a new release is published to PlatformIO https://github.com/Sensirion/arduino-gas-index-algorithm.git#3.2.1 ; Sensirion Gas Index Algorithm Arduino Library - lvgl/lvgl@8.4.0 ; lvgl lib_deps = ${common.lib_deps_base} @@ -120,6 +119,7 @@ lib_deps = ESP8266mDNS ; mdns (Arduino built-in) DNSServer ; captive_portal (Arduino built-in) droscy/esp_wireguard@0.4.2 ; wireguard + lvgl/lvgl@9.5.0 ; lvgl build_flags = ${common:arduino.build_flags} @@ -204,6 +204,7 @@ lib_deps = ayushsharma82/RPAsyncTCP@1.3.2 ; async_tcp bblanchon/ArduinoJson@7.4.2 ; json ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base + lvgl/lvgl@9.5.0 ; lvgl build_flags = ${common:arduino.build_flags} -DUSE_RP2040 @@ -221,6 +222,7 @@ lib_deps = bblanchon/ArduinoJson@7.4.2 ; json ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base droscy/esp_wireguard@0.4.2 ; wireguard + lvgl/lvgl@9.5.0 ; lvgl build_flags = ${common:arduino.build_flags} -DUSE_LIBRETINY @@ -242,6 +244,7 @@ build_flags = lib_deps = ${common.lib_deps_base} bblanchon/ArduinoJson@7.4.2 ; json + lvgl/lvgl@9.5.0 ; lvgl ; All the actual environments are defined below. diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index 3635fc710f..7d96b12a01 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -42,6 +42,11 @@ lvgl: disp_bg_color: color_id disp_bg_image: cat_image disp_bg_opa: cover + bottom_layer: + widgets: + - obj: + bg_color: 0x000000 + bg_opa: cover theme: obj: border_width: 1 @@ -49,12 +54,14 @@ lvgl: gradients: - id: color_bar direction: hor - dither: err_diff + # dither: err_diff stops: - color: 0xFF0000 position: 0 + opa: 100% - color: 0xFFFF00 position: 42 + opa: 80% - color: 0x00FF00 position: 84 - color: 0x00FFFF @@ -156,6 +163,16 @@ lvgl: offset_x: !lambda return 20; offset_y: !lambda return 20; antialias: !lambda return true; + - id: msgbox_with_header_buttons + title: Header Buttons Test + body: + text: Testing header buttons + header_buttons: + - src: cat_image + on_click: + logger.log: Header button clicked + buttons: + - text: OK - id: simple_msgbox title: Simple @@ -199,11 +216,11 @@ lvgl: text: Unloaded - lvgl.label.update: id: msgbox_label - text: "" # Empty text + text: "" # Empty text on_all_events: logger.log: format: "Event %s" - args: ['lv_event_code_name_for(event->code).c_str()'] + args: ['lv_event_code_name_for(event).c_str()'] skip: true layout: type: Flex @@ -241,7 +258,7 @@ lvgl: on_all_events: - logger.log: format: "Event %s" - args: ['lv_event_code_name_for(event->code).c_str()'] + args: ['lv_event_code_name_for(event).c_str()'] - lvgl.animimg.update: id: anim_img src: !lambda "return {dog_image, cat_image};" @@ -306,7 +323,6 @@ lvgl: anim_time: 1s bg_color: light_blue bg_grad_color: light_blue - bg_dither_mode: ordered bg_grad_dir: hor bg_grad_stop: 128 bg_image_opa: transp @@ -354,10 +370,18 @@ lvgl: text_line_space: 4 text_opa: cover transform_angle: 180 + transform_rotation: 90 transform_height: 100 transform_pivot_x: 50% transform_pivot_y: 50% transform_zoom: 0.5 + transform_scale: 2.0 + transform_scale_x: 1.5 + transform_scale_y: 0.8 + transform_skew_x: 10 + transform_skew_y: 20 + shadow_offset_x: 3 + shadow_offset_y: 3 translate_x: 10 translate_y: 10 max_height: 100 @@ -549,14 +573,16 @@ lvgl: arc_length: 120 spin_time: 2s align: left_mid + - spinner: + align: right_mid + send_draw_task_events: true - image: id: lv_image src: cat_image align: top_left y: "50" - mode: real - zoom: 2.0 - angle: 45 + scale: 2.0 + rotation: 45 - tileview: id: tileview_id scrollbar_mode: active @@ -661,8 +687,11 @@ lvgl: src: cat_image x: 100 y: 100 - angle: 90 - zoom: 2.0 + rotation: 90 + scale_x: 2.0 + scale_y: 1.5 + skew_x: 10 + skew_y: 5 pivot_x: 25 pivot_y: 25 - lvgl.canvas.draw_line: @@ -710,6 +739,9 @@ lvgl: text: format: "A string with a number %d" args: ['(int)(random_uint32() % 1000)'] + size: 120 + dark_color: navy + light_color: white - slider: min_value: 0 @@ -928,7 +960,6 @@ lvgl: grid_cell_row_pos: 0 grid_cell_column_pos: 0 src: !lambda return dog_image; - mode: virtual on_click: then: - lvgl.tabview.select: @@ -1023,10 +1054,18 @@ lvgl: text_color: 0xFFFFFF scales: - ticks: - width: !lambda return 1; + width: 1 count: 61 length: 20% + radial_offset: 5 color: 0xFFFFFF + major: + stride: 5 + width: 2 + length: 8 + color: 0xC0C0C0 + radial_offset: 3 + label_gap: 6 range_from: 0 range_to: 60 angle_range: 360 @@ -1037,15 +1076,15 @@ lvgl: end_value: 60 color_start: 0x0000bd color_end: 0xbd0000 - width: !lambda return 1; + width: 1 - line: opa: 50% id: minute_hand color: 0xFF0000 - r_mod: !lambda return -1; - width: !lambda return 3; - - - angle_range: 330 + length: 99% + radial_offset: 2 + width: 1 + - angle_range: 330 rotation: 300 range_from: 1 range_to: 12 @@ -1069,7 +1108,7 @@ lvgl: value: 180 width: 4 color: 0xA0A0A0 - r_mod: -20 + length: 80% opa: 0% - id: page3 layout: Horizontal