From 7c1aa771aaa9c9ea2f2c42a0b981ef8267a9d664 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Tue, 30 Jul 2024 09:41:34 +1000 Subject: [PATCH] LVGL stage 2 (#7129) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/lvgl/__init__.py | 38 +++++--- esphome/components/lvgl/btn.py | 25 +++++ esphome/components/lvgl/defines.py | 9 +- esphome/components/lvgl/helpers.py | 1 - esphome/components/lvgl/label.py | 21 ++-- esphome/components/lvgl/lv_validation.py | 79 ++++++++++++--- esphome/components/lvgl/lvgl_esphome.cpp | 6 ++ esphome/components/lvgl/lvgl_esphome.h | 59 ++++++++++-- esphome/components/lvgl/obj.py | 11 +-- esphome/components/lvgl/schemas.py | 64 +++++++------ esphome/components/lvgl/touchscreens.py | 46 +++++++++ esphome/components/lvgl/types.py | 112 +++++++++++++++++----- esphome/components/lvgl/widget.py | 73 ++------------ esphome/core/defines.h | 1 + tests/components/lvgl/common.yaml | 10 ++ tests/components/lvgl/logo-text.svg | 25 +++++ tests/components/lvgl/lvgl-package.yaml | 102 +++++++++++++++++++- tests/components/lvgl/test.esp32-idf.yaml | 4 +- 18 files changed, 503 insertions(+), 183 deletions(-) create mode 100644 esphome/components/lvgl/btn.py create mode 100644 esphome/components/lvgl/touchscreens.py create mode 100644 tests/components/lvgl/logo-text.svg diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 2f3bd69546..c454a61957 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -16,13 +16,20 @@ from esphome.final_validate import full_config from esphome.helpers import write_file_if_changed from . import defines as df, helpers, lv_validation as lvalid +from .btn import btn_spec from .label import label_spec from .lvcode import ConstantLiteral, LvContext - -# from .menu import menu_spec from .obj import obj_spec -from .schemas import WIDGET_TYPES, any_widget_schema, obj_schema -from .types import FontEngine, LvglComponent, lv_disp_t_ptr, lv_font_t, lvgl_ns +from .schemas import any_widget_schema, obj_schema +from .touchscreens import touchscreen_schema, touchscreens_to_code +from .types import ( + WIDGET_TYPES, + FontEngine, + LvglComponent, + lv_disp_t_ptr, + lv_font_t, + lvgl_ns, +) from .widget import LvScrActType, Widget, add_widgets, set_obj_properties DOMAIN = "lvgl" @@ -31,11 +38,8 @@ AUTO_LOAD = ("key_provider",) CODEOWNERS = ("@clydebarrow",) LOGGER = logging.getLogger(__name__) -for widg in ( - label_spec, - obj_spec, -): - WIDGET_TYPES[widg.name] = widg +for w_type in (label_spec, obj_spec, btn_spec): + WIDGET_TYPES[w_type.name] = w_type lv_scr_act_spec = LvScrActType() lv_scr_act = Widget.create( @@ -93,7 +97,7 @@ def final_validation(config): "Using auto_clear_enabled: true in display config not compatible with LVGL" ) buffer_frac = config[CONF_BUFFER_SIZE] - if not CORE.is_host and buffer_frac > 0.5 and "psram" not in global_config: + if CORE.is_esp32 and buffer_frac > 0.5 and "psram" not in global_config: LOGGER.warning("buffer_size: may need to be reduced without PSRAM") @@ -132,7 +136,7 @@ async def to_code(config): cg.add_global(lvgl_ns.using) lv_component = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(lv_component, config) - Widget.create(config[CONF_ID], lv_component, WIDGET_TYPES[df.CONF_OBJ], config) + Widget.create(config[CONF_ID], lv_component, obj_spec, config) for display in config[df.CONF_DISPLAYS]: cg.add(lv_component.add_display(await cg.get_variable(display))) @@ -152,7 +156,7 @@ async def to_code(config): await cg.get_variable(font) cg.new_Pvariable(ID(f"{font}_engine", True, type=FontEngine), MockObj(font)) default_font = config[df.CONF_DEFAULT_FONT] - if default_font not in helpers.lv_fonts_used: + if not lvalid.is_lv_font(default_font): add_define( "LV_FONT_CUSTOM_DECLARE", f"LV_FONT_DECLARE(*{df.DEFAULT_ESPHOME_FONT})" ) @@ -161,12 +165,15 @@ async def to_code(config): True, type=lv_font_t.operator("ptr").operator("const"), ) - cg.new_variable(globfont_id, MockObj(default_font)) + cg.new_variable( + globfont_id, MockObj(await lvalid.lv_font.process(default_font)) + ) add_define("LV_FONT_DEFAULT", df.DEFAULT_ESPHOME_FONT) else: - add_define("LV_FONT_DEFAULT", default_font) + add_define("LV_FONT_DEFAULT", await lvalid.lv_font.process(default_font)) with LvContext(): + await touchscreens_to_code(lv_component, config) await set_obj_properties(lv_scr_act, config) await add_widgets(lv_scr_act, config) Widget.set_completed() @@ -190,7 +197,7 @@ FINAL_VALIDATE_SCHEMA = final_validation CONFIG_SCHEMA = ( cv.polling_component_schema("1s") - .extend(obj_schema("obj")) + .extend(obj_schema(obj_spec)) .extend( { cv.GenerateID(CONF_ID): cv.declare_id(LvglComponent), @@ -207,6 +214,7 @@ CONFIG_SCHEMA = ( ), cv.Optional(df.CONF_WIDGETS): cv.ensure_list(WIDGET_SCHEMA), cv.Optional(df.CONF_TRANSPARENCY_KEY, default=0x000400): lvalid.lv_color, + cv.GenerateID(df.CONF_TOUCHSCREENS): touchscreen_schema, } ) ).add_extra(cv.has_at_least_one_key(CONF_PAGES, df.CONF_WIDGETS)) diff --git a/esphome/components/lvgl/btn.py b/esphome/components/lvgl/btn.py new file mode 100644 index 0000000000..4f5f88d9e6 --- /dev/null +++ b/esphome/components/lvgl/btn.py @@ -0,0 +1,25 @@ +from esphome.const import CONF_BUTTON +from esphome.cpp_generator import MockObjClass + +from .defines import CONF_MAIN +from .types import LvBoolean, WidgetType + + +class BtnType(WidgetType): + def __init__(self): + super().__init__(CONF_BUTTON, LvBoolean("lv_btn_t"), (CONF_MAIN,)) + + async def to_code(self, w, config): + return [] + + def obj_creator(self, parent: MockObjClass, config: dict): + """ + LVGL 8 calls buttons `btn` + """ + return f"lv_btn_create({parent})" + + def get_uses(self): + return ("btn",) + + +btn_spec = BtnType() diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index 50bdac3865..a2b4ac13fb 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -446,6 +446,7 @@ CONF_TILE_ID = "tile_id" CONF_TILES = "tiles" CONF_TITLE = "title" CONF_TOP_LAYER = "top_layer" +CONF_TOUCHSCREENS = "touchscreens" CONF_TRANSPARENCY_KEY = "transparency_key" CONF_THEME = "theme" CONF_VISIBLE_ROW_COUNT = "visible_row_count" @@ -474,14 +475,8 @@ LV_KEYS = LvConstant( ) -# list of widgets and the parts allowed -WIDGET_PARTS = { - CONF_LABEL: (CONF_MAIN, CONF_SCROLLBAR, CONF_SELECTED), - CONF_OBJ: (CONF_MAIN,), -} - DEFAULT_ESPHOME_FONT = "esphome_lv_default_font" def join_enums(enums, prefix=""): - return "|".join(f"(int){prefix}{e.upper()}" for e in enums) + return ConstantLiteral("|".join(f"(int){prefix}{e.upper()}" for e in enums)) diff --git a/esphome/components/lvgl/helpers.py b/esphome/components/lvgl/helpers.py index c8d4948fb1..d67739155c 100644 --- a/esphome/components/lvgl/helpers.py +++ b/esphome/components/lvgl/helpers.py @@ -22,7 +22,6 @@ def add_lv_use(*names): lv_fonts_used = set() esphome_fonts_used = set() -REQUIRED_COMPONENTS = {} lvgl_components_required = set() diff --git a/esphome/components/lvgl/label.py b/esphome/components/lvgl/label.py index 5c4ae6ab0d..0498f39474 100644 --- a/esphome/components/lvgl/label.py +++ b/esphome/components/lvgl/label.py @@ -1,16 +1,27 @@ import esphome.config_validation as cv -from .defines import CONF_LABEL, CONF_LONG_MODE, CONF_RECOLOR, CONF_TEXT, LV_LONG_MODES +from .defines import ( + CONF_LABEL, + CONF_LONG_MODE, + CONF_MAIN, + CONF_RECOLOR, + CONF_SCROLLBAR, + CONF_SELECTED, + CONF_TEXT, + LV_LONG_MODES, +) from .lv_validation import lv_bool, lv_text from .schemas import TEXT_SCHEMA -from .types import lv_label_t -from .widget import Widget, WidgetType +from .types import LvText, WidgetType +from .widget import Widget class LabelType(WidgetType): def __init__(self): super().__init__( CONF_LABEL, + LvText("lv_label_t"), + (CONF_MAIN, CONF_SCROLLBAR, CONF_SELECTED), TEXT_SCHEMA.extend( { cv.Optional(CONF_RECOLOR): lv_bool, @@ -19,10 +30,6 @@ class LabelType(WidgetType): ), ) - @property - def w_type(self): - return lv_label_t - async def to_code(self, w: Widget, config): """For a text object, create and set text""" if value := config.get(CONF_TEXT): diff --git a/esphome/components/lvgl/lv_validation.py b/esphome/components/lvgl/lv_validation.py index 1de63c30ce..533dc582f0 100644 --- a/esphome/components/lvgl/lv_validation.py +++ b/esphome/components/lvgl/lv_validation.py @@ -8,6 +8,7 @@ import esphome.config_validation as cv from esphome.const import CONF_ARGS, CONF_COLOR, CONF_FORMAT from esphome.core import HexInt from esphome.cpp_generator import MockObj +from esphome.cpp_types import uint32 from esphome.helpers import cpp_string_escape from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor @@ -23,6 +24,28 @@ from .lvcode import ConstantLiteral, lv_expr from .types import lv_font_t +def literal_mapper(value, args=()): + if isinstance(value, str): + return ConstantLiteral(value) + return value + + +opacity_consts = 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 + + +opacity = LValidator(opacity_validator, uint32, retmapper=literal_mapper) + + @schema_extractor("one_of") def color(value): if value == SCHEMA_EXTRACT: @@ -43,16 +66,24 @@ def color_retmapper(value): return lv_expr.color_from(MockObj(value)) -def pixels_or_percent(value): +lv_color = LValidator(color, ty.lv_color_t, retmapper=color_retmapper) + + +def pixels_or_percent_validator(value): """A length in one axis - either a number (pixels) or a percentage""" if value == SCHEMA_EXTRACT: return ["pixels", "..%"] if isinstance(value, int): - return str(cv.int_(value)) + return cv.int_(value) # Will throw an exception if not a percentage. return f"lv_pct({int(cv.percentage(value) * 100)})" +pixels_or_percent = LValidator( + pixels_or_percent_validator, uint32, retmapper=literal_mapper +) + + def zoom(value): value = cv.float_range(0.1, 10.0)(value) return int(value * 256) @@ -68,7 +99,7 @@ def angle(value): @schema_extractor("one_of") -def size(value): +def size_validator(value): """A size in one axis - one of "size_content", a number (pixels) or a percentage""" if value == SCHEMA_EXTRACT: return ["size_content", "pixels", "..%"] @@ -79,28 +110,42 @@ def size(value): return "LV_SIZE_CONTENT" raise cv.Invalid("must be 'size_content', a pixel position or a percentage") if isinstance(value, int): - return str(cv.int_(value)) + return cv.int_(value) # Will throw an exception if not a percentage. return f"lv_pct({int(cv.percentage(value) * 100)})" +size = LValidator(size_validator, uint32, retmapper=literal_mapper) + +radius_consts = LvConstant("LV_RADIUS_", "CIRCLE") + + @schema_extractor("one_of") -def opacity(value): - consts = LvConstant("LV_OPA_", "TRANSP", "COVER") +def radius_validator(value): if value == SCHEMA_EXTRACT: - return consts.choices - value = cv.Any(cv.percentage, consts.one_of)(value) + return radius_consts.choices + value = cv.Any(size, cv.percentage, radius_consts.one_of)(value) if isinstance(value, float): return int(value * 255) return value +def id_name(value): + if value == SCHEMA_EXTRACT: + return "id" + return cv.validate_id_name(value) + + +radius = LValidator(radius_validator, uint32, retmapper=literal_mapper) + + def stop_value(value): return cv.int_range(0, 255)(value) -lv_color = LValidator(color, ty.lv_color_t, retmapper=color_retmapper) -lv_bool = LValidator(cv.boolean, cg.bool_, BinarySensor, "get_state()") +lv_bool = LValidator( + cv.boolean, cg.bool_, BinarySensor, "get_state()", retmapper=literal_mapper +) def lvms_validator_(value): @@ -145,26 +190,32 @@ lv_float = LValidator(cv.float_, cg.float_, Sensor, "get_state()") lv_int = LValidator(cv.int_, cg.int_, Sensor, "get_state()") +def is_lv_font(font): + return isinstance(font, str) and font.lower() in LV_FONTS + + class LvFont(LValidator): def __init__(self): def lv_builtin_font(value): fontval = cv.one_of(*LV_FONTS, lower=True)(value) lv_fonts_used.add(fontval) - return "&lv_font_" + fontval + return fontval def validator(value): if value == SCHEMA_EXTRACT: return LV_FONTS - if isinstance(value, str) and value.lower() in LV_FONTS: + if is_lv_font(value): return lv_builtin_font(value) fontval = cv.use_id(Font)(value) esphome_fonts_used.add(fontval) - return requires_component("font")(f"{fontval}_engine->get_lv_font()") + return requires_component("font")(fontval) super().__init__(validator, lv_font_t) async def process(self, value, args=()): - return ConstantLiteral(value) + if is_lv_font(value): + return ConstantLiteral(f"&lv_font_{value}") + return ConstantLiteral(f"{value}_engine->get_lv_font()") lv_font = LvFont() diff --git a/esphome/components/lvgl/lvgl_esphome.cpp b/esphome/components/lvgl/lvgl_esphome.cpp index bdaf8a4f18..74a1b0e7af 100644 --- a/esphome/components/lvgl/lvgl_esphome.cpp +++ b/esphome/components/lvgl/lvgl_esphome.cpp @@ -38,7 +38,9 @@ void LvglComponent::setup() { auto buf_bytes = buffer_pixels * LV_COLOR_DEPTH / 8; auto *buf = lv_custom_mem_alloc(buf_bytes); if (buf == nullptr) { +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_ERROR ESP_LOGE(TAG, "Malloc failed to allocate %zu bytes", buf_bytes); +#endif this->mark_failed(); this->status_set_error("Memory allocation failure"); return; @@ -85,7 +87,9 @@ size_t lv_millis(void) { return esphome::millis(); } void *lv_custom_mem_alloc(size_t size) { auto *ptr = malloc(size); // NOLINT if (ptr == nullptr) { +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_ERROR esphome::ESP_LOGE(esphome::lvgl::TAG, "Failed to allocate %zu bytes", size); +#endif } return ptr; } @@ -102,7 +106,9 @@ void *lv_custom_mem_alloc(size_t size) { ptr = heap_caps_malloc(size, cap_bits); } if (ptr == nullptr) { +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_ERROR esphome::ESP_LOGE(esphome::lvgl::TAG, "Failed to allocate %zu bytes", size); +#endif return nullptr; } #ifdef ESPHOME_LOG_HAS_VERBOSE diff --git a/esphome/components/lvgl/lvgl_esphome.h b/esphome/components/lvgl/lvgl_esphome.h index 988c22917b..a884a27042 100644 --- a/esphome/components/lvgl/lvgl_esphome.h +++ b/esphome/components/lvgl/lvgl_esphome.h @@ -18,23 +18,27 @@ #ifdef USE_LVGL_FONT #include "esphome/components/font/font.h" #endif +#ifdef USE_LVGL_TOUCHSCREEN +#include "esphome/components/touchscreen/touchscreen.h" +#endif // USE_LVGL_TOUCHSCREEN + namespace esphome { namespace lvgl { extern lv_event_code_t lv_custom_event; // NOLINT #ifdef USE_LVGL_COLOR -static lv_color_t lv_color_from(Color color) { return lv_color_make(color.red, color.green, color.blue); } -#endif +inline lv_color_t lv_color_from(Color color) { return lv_color_make(color.red, color.green, color.blue); } +#endif // USE_LVGL_COLOR #if LV_COLOR_DEPTH == 16 static const display::ColorBitness LV_BITNESS = display::ColorBitness::COLOR_BITNESS_565; #elif LV_COLOR_DEPTH == 32 static const display::ColorBitness LV_BITNESS = display::ColorBitness::COLOR_BITNESS_888; -#else +#else // LV_COLOR_DEPTH static const display::ColorBitness LV_BITNESS = display::ColorBitness::COLOR_BITNESS_332; -#endif +#endif // LV_COLOR_DEPTH // Parent class for things that wrap an LVGL object -class LvCompound { +class LvCompound final { public: virtual void set_obj(lv_obj_t *lv_obj) { this->obj = lv_obj; } lv_obj_t *obj{}; @@ -99,6 +103,14 @@ class LvglComponent : public PollingComponent { void set_full_refresh(bool full_refresh) { this->full_refresh_ = full_refresh; } void set_buffer_frac(size_t frac) { this->buffer_frac_ = frac; } lv_disp_t *get_disp() { return this->disp_; } + void set_paused(bool paused, bool show_snow) { + this->paused_ = paused; + if (!paused && lv_scr_act() != nullptr) { + lv_disp_trig_activity(this->disp_); // resets the inactivity time + lv_obj_invalidate(lv_scr_act()); + } + } + bool is_paused() const { return this->paused_; } protected: void draw_buffer_(const lv_area_t *area, const uint8_t *ptr); @@ -107,13 +119,48 @@ class LvglComponent : public PollingComponent { lv_disp_draw_buf_t draw_buf_{}; lv_disp_drv_t disp_drv_{}; lv_disp_t *disp_{}; + bool paused_{}; std::vector> init_lambdas_; size_t buffer_frac_{1}; bool full_refresh_{}; }; +#ifdef USE_LVGL_TOUCHSCREEN +class LVTouchListener : public touchscreen::TouchListener, public Parented { + public: + LVTouchListener(uint16_t long_press_time, uint16_t long_press_repeat_time) { + lv_indev_drv_init(&this->drv_); + 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); + if (l->touch_pressed_) { + data->point.x = l->touch_point_.x; + data->point.y = l->touch_point_.y; + data->state = LV_INDEV_STATE_PRESSED; + } else { + data->state = LV_INDEV_STATE_RELEASED; + } + }; + } + void update(const touchscreen::TouchPoints_t &tpoints) override { + this->touch_pressed_ = !this->parent_->is_paused() && !tpoints.empty(); + if (this->touch_pressed_) + this->touch_point_ = tpoints[0]; + } + void release() override { touch_pressed_ = false; } + lv_indev_drv_t *get_drv() { return &this->drv_; } + + protected: + lv_indev_drv_t drv_{}; + touchscreen::TouchPoint touch_point_{}; + bool touch_pressed_{}; +}; +#endif // USE_LVGL_TOUCHSCREEN } // namespace lvgl } // namespace esphome -#endif +#endif // USE_LVGL diff --git a/esphome/components/lvgl/obj.py b/esphome/components/lvgl/obj.py index fba20bef36..92c4f63d2d 100644 --- a/esphome/components/lvgl/obj.py +++ b/esphome/components/lvgl/obj.py @@ -1,6 +1,5 @@ -from .defines import CONF_OBJ -from .types import lv_obj_t -from .widget import WidgetType +from .defines import CONF_MAIN, CONF_OBJ +from .types import WidgetType, lv_obj_t class ObjType(WidgetType): @@ -9,11 +8,7 @@ class ObjType(WidgetType): """ def __init__(self): - super().__init__(CONF_OBJ, schema={}, modify_schema={}) - - @property - def w_type(self): - return lv_obj_t + super().__init__(CONF_OBJ, lv_obj_t, (CONF_MAIN,), schema={}, modify_schema={}) async def to_code(self, w, config): return [] diff --git a/esphome/components/lvgl/schemas.py b/esphome/components/lvgl/schemas.py index 4ae5824151..9f6d984545 100644 --- a/esphome/components/lvgl/schemas.py +++ b/esphome/components/lvgl/schemas.py @@ -3,15 +3,9 @@ from esphome.const import CONF_ARGS, CONF_FORMAT, CONF_ID, CONF_STATE, CONF_TYPE from esphome.schema_extractors import SCHEMA_EXTRACT from . import defines as df, lv_validation as lvalid, types as ty -from .defines import WIDGET_PARTS -from .helpers import ( - REQUIRED_COMPONENTS, - add_lv_use, - requires_component, - validate_printf, -) +from .helpers import add_lv_use, requires_component, validate_printf from .lv_validation import lv_font -from .types import WIDGET_TYPES, get_widget_type +from .types import WIDGET_TYPES, WidgetType # A schema for text properties TEXT_SCHEMA = cv.Schema( @@ -46,9 +40,9 @@ STYLE_PROPS = { "bg_dither_mode": df.LvConstant("LV_DITHER_", "NONE", "ORDERED", "ERR_DIFF").one_of, "bg_grad_dir": df.LvConstant("LV_GRAD_DIR_", "NONE", "HOR", "VER").one_of, "bg_grad_stop": lvalid.stop_value, - "bg_img_opa": lvalid.opacity, - "bg_img_recolor": lvalid.lv_color, - "bg_img_recolor_opa": lvalid.opacity, + "bg_image_opa": lvalid.opacity, + "bg_image_recolor": lvalid.lv_color, + "bg_image_recolor_opa": lvalid.opacity, "bg_main_stop": lvalid.stop_value, "bg_opa": lvalid.opacity, "border_color": lvalid.lv_color, @@ -60,8 +54,8 @@ STYLE_PROPS = { "border_width": cv.positive_int, "clip_corner": lvalid.lv_bool, "height": lvalid.size, - "img_recolor": lvalid.lv_color, - "img_recolor_opa": lvalid.opacity, + "image_recolor": lvalid.lv_color, + "image_recolor_opa": lvalid.opacity, "line_width": cv.positive_int, "line_dash_width": cv.positive_int, "line_dash_gap": cv.positive_int, @@ -108,12 +102,21 @@ STYLE_PROPS = { "max_width": lvalid.pixels_or_percent, "min_height": lvalid.pixels_or_percent, "min_width": lvalid.pixels_or_percent, - "radius": cv.Any(lvalid.size, df.LvConstant("LV_RADIUS_", "CIRCLE").one_of), + "radius": lvalid.radius, "width": lvalid.size, "x": lvalid.pixels_or_percent, "y": lvalid.pixels_or_percent, } +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", + "image_recolor": "img_recolor", + "image_recolor_opa": "img_recolor_opa", +} + # Complete object style schema STYLE_SCHEMA = cv.Schema({cv.Optional(k): v for k, v in STYLE_PROPS.items()}).extend( { @@ -132,25 +135,23 @@ SET_STATE_SCHEMA = cv.Schema( {cv.Optional(state): lvalid.lv_bool for state in df.STATES} ) # Setting object flags -FLAG_SCHEMA = cv.Schema({cv.Optional(flag): cv.boolean for flag in df.OBJ_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) -def part_schema(widget_type): +def part_schema(widget_type: WidgetType): """ Generate a schema for the various parts (e.g. main:, indicator:) of a widget type :param widget_type: The type of widget to generate for :return: """ - parts = WIDGET_PARTS.get(widget_type) - if parts is None: - parts = (df.CONF_MAIN,) + parts = widget_type.parts return cv.Schema({cv.Optional(part): STATE_SCHEMA for part in parts}).extend( STATE_SCHEMA ) -def obj_schema(widget_type: str): +def obj_schema(widget_type: WidgetType): """ Create a schema for a widget type itself i.e. no allowance for children :param widget_type: @@ -187,13 +188,12 @@ STYLED_TEXT_SCHEMA = cv.maybe_simple_value( STYLE_SCHEMA.extend(TEXT_SCHEMA), key=df.CONF_TEXT ) - ALL_STYLES = { **STYLE_PROPS, } -def container_validator(schema, widget_type): +def container_validator(schema, widget_type: WidgetType): """ Create a validator for a container given the widget type :param schema: Base schema to extend @@ -203,13 +203,16 @@ def container_validator(schema, widget_type): def validator(value): result = schema - if w_sch := WIDGET_TYPES[widget_type].schema: + if w_sch := widget_type.schema: result = result.extend(w_sch) if value and (layout := value.get(df.CONF_LAYOUT)): if not isinstance(layout, dict): raise cv.Invalid("Layout value must be a dict") ltype = layout.get(CONF_TYPE) add_lv_use(ltype) + result = result.extend( + {cv.Optional(df.CONF_WIDGETS): cv.ensure_list(any_widget_schema())} + ) if value == SCHEMA_EXTRACT: return result return result(value) @@ -217,7 +220,7 @@ def container_validator(schema, widget_type): return validator -def container_schema(widget_type, extras=None): +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 the extras passed in, plus any defined for the specific widget being specified. @@ -225,15 +228,16 @@ def container_schema(widget_type, extras=None): :param extras: Additional options to be made available, e.g. layout properties for children :return: The schema for this type of widget. """ - lv_type = get_widget_type(widget_type) - schema = obj_schema(widget_type).extend({cv.GenerateID(): cv.declare_id(lv_type)}) + schema = obj_schema(widget_type).extend( + {cv.GenerateID(): cv.declare_id(widget_type.w_type)} + ) if extras: schema = schema.extend(extras) # Delayed evaluation for recursion return container_validator(schema, widget_type) -def widget_schema(widget_type, extras=None): +def widget_schema(widget_type: WidgetType, extras=None): """ Create a schema for a given widget type :param widget_type: The name of the widget @@ -241,9 +245,9 @@ def widget_schema(widget_type, extras=None): :return: """ validator = container_schema(widget_type, extras=extras) - if required := REQUIRED_COMPONENTS.get(widget_type): + if required := widget_type.required_component: validator = cv.All(validator, requires_component(required)) - return cv.Exclusive(widget_type, df.CONF_WIDGETS), validator + return cv.Exclusive(widget_type.name, df.CONF_WIDGETS), validator # All widget schemas must be defined before this is called. @@ -257,4 +261,4 @@ def any_widget_schema(extras=None): :param extras: Additional schema to be applied to each generated one :return: """ - return cv.Any(dict(widget_schema(wt, extras) for wt in WIDGET_PARTS)) + return cv.Any(dict(widget_schema(wt, extras) for wt in WIDGET_TYPES.values())) diff --git a/esphome/components/lvgl/touchscreens.py b/esphome/components/lvgl/touchscreens.py new file mode 100644 index 0000000000..a0d4a3e4ad --- /dev/null +++ b/esphome/components/lvgl/touchscreens.py @@ -0,0 +1,46 @@ +import esphome.codegen as cg +from esphome.components.touchscreen import CONF_TOUCHSCREEN_ID, Touchscreen +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.core import CORE, TimePeriod + +from .defines import ( + CONF_LONG_PRESS_REPEAT_TIME, + CONF_LONG_PRESS_TIME, + CONF_TOUCHSCREENS, +) +from .helpers import lvgl_components_required +from .lv_validation import lv_milliseconds +from .lvcode import lv +from .types import LVTouchListener + +PRESS_TIME = cv.All(lv_milliseconds, cv.Range(max=TimePeriod(milliseconds=65535))) +CONF_TOUCHSCREEN = "touchscreen" +TOUCHSCREENS_CONFIG = cv.maybe_simple_value( + { + cv.Required(CONF_TOUCHSCREEN_ID): cv.use_id(Touchscreen), + cv.Optional(CONF_LONG_PRESS_TIME, default="400ms"): PRESS_TIME, + cv.Optional(CONF_LONG_PRESS_REPEAT_TIME, default="100ms"): PRESS_TIME, + cv.GenerateID(): cv.declare_id(LVTouchListener), + }, + key=CONF_TOUCHSCREEN_ID, +) + + +def touchscreen_schema(config): + value = cv.ensure_list(TOUCHSCREENS_CONFIG)(config) + if value or CONF_TOUCHSCREEN not in CORE.loaded_integrations: + return value + return [TOUCHSCREENS_CONFIG(config)] + + +async def touchscreens_to_code(var, config): + for tconf in config.get(CONF_TOUCHSCREENS) or (): + lvgl_components_required.add(CONF_TOUCHSCREEN) + touchscreen = await cg.get_variable(tconf[CONF_TOUCHSCREEN_ID]) + 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) + await cg.register_parented(listener, var) + lv.indev_drv_register(listener.get_drv()) + cg.add(touchscreen.register_listener(listener)) diff --git a/esphome/components/lvgl/types.py b/esphome/components/lvgl/types.py index 3c043d266d..60291ea54a 100644 --- a/esphome/components/lvgl/types.py +++ b/esphome/components/lvgl/types.py @@ -1,7 +1,22 @@ from esphome import codegen as cg from esphome.core import ID +from esphome.cpp_generator import MockObjClass + +from .defines import CONF_TEXT + + +class LvType(cg.MockObjClass): + def __init__(self, *args, **kwargs): + parens = kwargs.pop("parents", ()) + super().__init__(*args, parents=parens + (lv_obj_base_t,)) + self.args = kwargs.pop("largs", [(lv_obj_t_ptr, "obj")]) + self.value = kwargs.pop("lvalue", lambda w: w.obj) + self.has_on_value = kwargs.pop("has_on_value", False) + self.value_property = None + + def get_arg_type(self): + return self.args[0][0] if len(self.args) else None -from .defines import CONF_LABEL, CONF_OBJ, CONF_TEXT uint16_t_ptr = cg.uint16.operator("ptr") lvgl_ns = cg.esphome_ns.namespace("lvgl") @@ -18,25 +33,15 @@ 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_ptr = cg.global_ns.struct("lv_disp_t").operator("ptr") lv_color_t = cg.global_ns.struct("lv_color_t") +LVTouchListener = lvgl_ns.class_("LVTouchListener") +LVEncoderListener = lvgl_ns.class_("LVEncoderListener") +lv_obj_t = LvType("lv_obj_t") # this will be populated later, in __init__.py to avoid circular imports. WIDGET_TYPES: dict = {} -class LvType(cg.MockObjClass): - def __init__(self, *args, **kwargs): - parens = kwargs.pop("parents", ()) - super().__init__(*args, parents=parens + (lv_obj_base_t,)) - self.args = kwargs.pop("largs", [(lv_obj_t_ptr, "obj")]) - self.value = kwargs.pop("lvalue", lambda w: w.obj) - self.has_on_value = kwargs.pop("has_on_value", False) - self.value_property = None - - def get_arg_type(self): - return self.args[0][0] if len(self.args) else None - - class LvText(LvType): def __init__(self, *args, **kwargs): super().__init__( @@ -48,17 +53,74 @@ class LvText(LvType): self.value_property = CONF_TEXT -lv_obj_t = LvType("lv_obj_t") -lv_label_t = LvText("lv_label_t") - -LV_TYPES = { - CONF_LABEL: lv_label_t, - CONF_OBJ: lv_obj_t, -} - - -def get_widget_type(typestr: str) -> LvType: - return LV_TYPES[typestr] +class LvBoolean(LvType): + def __init__(self, *args, **kwargs): + super().__init__( + *args, + largs=[(cg.bool_, "x")], + lvalue=lambda w: w.is_checked(), + has_on_value=True, + **kwargs, + ) CUSTOM_EVENT = ID("lv_custom_event", False, type=lv_event_code_t) + + +class WidgetType: + """ + Describes a type of Widget, e.g. "bar" or "line" + """ + + def __init__(self, name, w_type, parts, schema=None, modify_schema=None): + """ + :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 + """ + self.name = name + self.w_type = w_type + self.parts = parts + self.schema = schema or {} + if modify_schema is None: + self.modify_schema = schema + else: + self.modify_schema = modify_schema + + @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 + :return: Generated code as a list of text lines + """ + raise NotImplementedError(f"No to_code defined for {self.name}") + + 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 f"lv_{self.name}_create({parent})" + + def get_uses(self): + """ + Get a list of other widgets used by this one + :return: + """ + return () diff --git a/esphome/components/lvgl/widget.py b/esphome/components/lvgl/widget.py index 44f277f1c3..4755d8b21d 100644 --- a/esphome/components/lvgl/widget.py +++ b/esphome/components/lvgl/widget.py @@ -21,78 +21,19 @@ from .defines import ( ) from .helpers import add_lv_use from .lvcode import ConstantLiteral, add_line_marks, lv, lv_add, lv_assign, lv_obj -from .schemas import ALL_STYLES -from .types import WIDGET_TYPES, LvCompound, lv_obj_t +from .schemas import ALL_STYLES, STYLE_REMAP +from .types import WIDGET_TYPES, WidgetType, lv_obj_t EVENT_LAMB = "event_lamb__" -class WidgetType: - """ - Describes a type of Widget, e.g. "bar" or "line" - """ - - def __init__(self, name, schema=None, modify_schema=None): - """ - :param name: The widget name, e.g. "bar" - :param schema: The config schema for defining a widget - :param modify_schema: A schema to update the widget - """ - self.name = name - self.schema = schema or {} - if modify_schema is None: - self.modify_schema = schema - else: - self.modify_schema = modify_schema - - @property - def animated(self): - return False - - @property - def w_type(self): - """ - Get the type associated with this widget - :return: - """ - return lv_obj_t - - 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 - :return: Generated code as a list of text lines - """ - raise NotImplementedError(f"No to_code defined for {self.name}") - - 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 f"lv_{self.name}_create({parent})" - - def get_uses(self): - """ - Get a list of other widgets used by this one - :return: - """ - return () - - class LvScrActType(WidgetType): """ A "widget" representing the active screen. """ def __init__(self): - super().__init__("lv_scr_act()") + super().__init__("lv_scr_act()", lv_obj_t, ()) def obj_creator(self, parent: MockObjClass, config: dict): return [] @@ -263,7 +204,9 @@ async def set_obj_properties(w: Widget, config): }.items(): if isinstance(ALL_STYLES[prop], LValidator): value = await ALL_STYLES[prop].process(value) - w.set_style(prop, value, lv_state) + # Remapping for backwards compatibility of style names + prop_r = STYLE_REMAP.get(prop, prop) + w.set_style(prop_r, value, lv_state) flag_clr = set() flag_set = set() props = parts[CONF_MAIN][CONF_DEFAULT] @@ -291,10 +234,10 @@ async def set_obj_properties(w: Widget, config): else: clears.add(key) if adds: - adds = ConstantLiteral(join_enums(adds, "LV_STATE_")) + adds = join_enums(adds, "LV_STATE_") w.add_state(adds) if clears: - clears = ConstantLiteral(join_enums(clears, "LV_STATE_")) + clears = join_enums(clears, "LV_STATE_") w.clear_state(clears) for key, value in lambs.items(): lamb = await cg.process_lambda(value, [], return_type=cg.bool_) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 9d453260ab..6ba5b64761 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -41,6 +41,7 @@ #define USE_LVGL #define USE_LVGL_FONT #define USE_LVGL_IMAGE +#define USE_LVGL_TOUCHSCREEN #define USE_MDNS #define USE_MEDIA_PLAYER #define USE_MQTT diff --git a/tests/components/lvgl/common.yaml b/tests/components/lvgl/common.yaml index e69de29bb2..8b92f8caa7 100644 --- a/tests/components/lvgl/common.yaml +++ b/tests/components/lvgl/common.yaml @@ -0,0 +1,10 @@ +touchscreen: + - platform: ft63x6 + id: tft_touch + display: tft_display + update_interval: 50ms + threshold: 1 + calibration: + x_max: 240 + y_max: 320 + diff --git a/tests/components/lvgl/logo-text.svg b/tests/components/lvgl/logo-text.svg new file mode 100644 index 0000000000..4950806a36 --- /dev/null +++ b/tests/components/lvgl/logo-text.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index 856e7c3e9d..696c749876 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -1,9 +1,10 @@ -color: - - id: light_blue - hex: "3340FF" - lvgl: + log_level: TRACE bg_color: light_blue + touchscreens: + - touchscreen_id: tft_touch + long_press_repeat_time: 200ms + long_press_time: 500ms widgets: - label: text: Hello world @@ -17,8 +18,101 @@ lvgl: text_color: 0xFFFFFF align: bottom_mid text_font: space16 + - obj: + align: center + arc_opa: COVER + arc_color: 0xFF0000 + arc_rounded: false + arc_width: 3 + 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 + bg_image_recolor: light_blue + bg_image_recolor_opa: 50% + bg_main_stop: 0 + bg_opa: 20% + border_color: 0x00FF00 + border_opa: cover + border_post: true + border_side: [bottom, left] + border_width: 4 + clip_corner: false + height: 50% + image_recolor: light_blue + image_recolor_opa: cover + line_width: 10 + line_dash_width: 10 + line_dash_gap: 10 + line_rounded: false + line_color: light_blue + opa: cover + opa_layered: cover + outline_color: light_blue + outline_opa: cover + outline_pad: 10px + outline_width: 10px + pad_all: 10px + pad_bottom: 10px + pad_column: 10px + pad_left: 10px + pad_right: 10px + pad_row: 10px + pad_top: 10px + shadow_color: light_blue + shadow_ofs_x: 5 + shadow_ofs_y: 5 + shadow_opa: cover + shadow_spread: 5 + shadow_width: 10 + text_align: auto + text_color: light_blue + text_decor: [underline, strikethrough] + text_font: montserrat_18 + text_letter_space: 4 + text_line_space: 4 + text_opa: cover + transform_angle: 180 + transform_height: 100 + transform_pivot_x: 50% + transform_pivot_y: 50% + transform_zoom: 0.5 + translate_x: 10 + translate_y: 10 + max_height: 100 + max_width: 200 + min_height: 20% + min_width: 20% + radius: circle + width: 10px + x: 100 + y: 120 + - button: + width: 20% + height: 10% + pressed: + bg_color: light_blue + widgets: + - label: + text: Button font: - file: "gfonts://Roboto" id: space16 bpp: 4 + +image: + - id: cat_img + resize: 256x48 + file: $component_dir/logo-text.svg + - id: dog_img + file: $component_dir/logo-text.svg + resize: 256x48 + type: TRANSPARENT_BINARY + +color: + - id: light_blue + hex: "3340FF" diff --git a/tests/components/lvgl/test.esp32-idf.yaml b/tests/components/lvgl/test.esp32-idf.yaml index f159431b99..eab75b05f3 100644 --- a/tests/components/lvgl/test.esp32-idf.yaml +++ b/tests/components/lvgl/test.esp32-idf.yaml @@ -19,7 +19,9 @@ display: mirror_y: true data_rate: 80MHz cs_pin: GPIO20 - dc_pin: GPIO15 + dc_pin: + number: GPIO15 + ignore_strapping_warning: true auto_clear_enabled: false invert_colors: false update_interval: never