From 23ffc3ddfb1504a0f3f5ad7f3cc2f9ea1d3cddf6 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 25 Jul 2024 09:12:04 +1000 Subject: [PATCH] [lvgl] base implementation (#7116) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- CODEOWNERS | 1 + esphome/components/lvgl/__init__.py | 212 ++++++++++ esphome/components/lvgl/defines.py | 487 ++++++++++++++++++++++ esphome/components/lvgl/font.cpp | 76 ++++ esphome/components/lvgl/helpers.py | 70 ++++ esphome/components/lvgl/label.py | 34 ++ esphome/components/lvgl/lv_validation.py | 170 ++++++++ esphome/components/lvgl/lvcode.py | 237 +++++++++++ esphome/components/lvgl/lvgl_esphome.cpp | 129 ++++++ esphome/components/lvgl/lvgl_esphome.h | 119 ++++++ esphome/components/lvgl/lvgl_hal.h | 21 + esphome/components/lvgl/obj.py | 22 + esphome/components/lvgl/schemas.py | 260 ++++++++++++ esphome/components/lvgl/types.py | 64 +++ esphome/components/lvgl/widget.py | 347 +++++++++++++++ esphome/core/defines.h | 3 + platformio.ini | 1 + tests/components/lvgl/common.yaml | 0 tests/components/lvgl/lvgl-package.yaml | 24 ++ tests/components/lvgl/test.esp32-ard.yaml | 30 ++ tests/components/lvgl/test.esp32-idf.yaml | 52 +++ 21 files changed, 2359 insertions(+) create mode 100644 esphome/components/lvgl/__init__.py create mode 100644 esphome/components/lvgl/defines.py create mode 100644 esphome/components/lvgl/font.cpp create mode 100644 esphome/components/lvgl/helpers.py create mode 100644 esphome/components/lvgl/label.py create mode 100644 esphome/components/lvgl/lv_validation.py create mode 100644 esphome/components/lvgl/lvcode.py create mode 100644 esphome/components/lvgl/lvgl_esphome.cpp create mode 100644 esphome/components/lvgl/lvgl_esphome.h create mode 100644 esphome/components/lvgl/lvgl_hal.h create mode 100644 esphome/components/lvgl/obj.py create mode 100644 esphome/components/lvgl/schemas.py create mode 100644 esphome/components/lvgl/types.py create mode 100644 esphome/components/lvgl/widget.py create mode 100644 tests/components/lvgl/common.yaml create mode 100644 tests/components/lvgl/lvgl-package.yaml create mode 100644 tests/components/lvgl/test.esp32-ard.yaml create mode 100644 tests/components/lvgl/test.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index c5e144bdfa..2fc030453f 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -217,6 +217,7 @@ esphome/components/lock/* @esphome/core esphome/components/logger/* @esphome/core esphome/components/ltr390/* @latonita @sjtrny esphome/components/ltr_als_ps/* @latonita +esphome/components/lvgl/* @clydebarrow esphome/components/m5stack_8angle/* @rnauber esphome/components/matrix_keypad/* @ssieb esphome/components/max31865/* @DAVe3283 diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py new file mode 100644 index 0000000000..2f3bd69546 --- /dev/null +++ b/esphome/components/lvgl/__init__.py @@ -0,0 +1,212 @@ +import logging + +import esphome.codegen as cg +from esphome.components.display import Display +import esphome.config_validation as cv +from esphome.const import ( + CONF_AUTO_CLEAR_ENABLED, + CONF_BUFFER_SIZE, + CONF_ID, + CONF_LAMBDA, + CONF_PAGES, +) +from esphome.core import CORE, ID, Lambda +from esphome.cpp_generator import MockObj +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 .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 .widget import LvScrActType, Widget, add_widgets, set_obj_properties + +DOMAIN = "lvgl" +DEPENDENCIES = ("display",) +AUTO_LOAD = ("key_provider",) +CODEOWNERS = ("@clydebarrow",) +LOGGER = logging.getLogger(__name__) + +for widg in ( + label_spec, + obj_spec, +): + WIDGET_TYPES[widg.name] = widg + +lv_scr_act_spec = LvScrActType() +lv_scr_act = Widget.create( + None, ConstantLiteral("lv_scr_act()"), lv_scr_act_spec, {}, parent=None +) + +WIDGET_SCHEMA = any_widget_schema() + + +async def add_init_lambda(lv_component, init): + if init: + lamb = await cg.process_lambda(Lambda(init), [(lv_disp_t_ptr, "lv_disp")]) + cg.add(lv_component.add_init_lambda(lamb)) + + +lv_defines = {} # Dict of #defines to provide as build flags + + +def add_define(macro, value="1"): + if macro in lv_defines and lv_defines[macro] != value: + LOGGER.error( + "Redefinition of %s - was %s now %s", macro, lv_defines[macro], value + ) + lv_defines[macro] = value + + +def as_macro(macro, value): + if value is None: + return f"#define {macro}" + return f"#define {macro} {value}" + + +LV_CONF_FILENAME = "lv_conf.h" +LV_CONF_H_FORMAT = """\ +#pragma once +{} +""" + + +def generate_lv_conf_h(): + definitions = [as_macro(m, v) for m, v in lv_defines.items()] + definitions.sort() + return LV_CONF_H_FORMAT.format("\n".join(definitions)) + + +def final_validation(config): + global_config = full_config.get() + for display_id in config[df.CONF_DISPLAYS]: + path = global_config.get_path_for_id(display_id)[:-1] + display = global_config.get_config_for_path(path) + if CONF_LAMBDA in display: + raise cv.Invalid("Using lambda: in display config not compatible with LVGL") + if display[CONF_AUTO_CLEAR_ENABLED]: + raise cv.Invalid( + "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: + LOGGER.warning("buffer_size: may need to be reduced without PSRAM") + + +async def to_code(config): + cg.add_library("lvgl/lvgl", "8.4.0") + CORE.add_define("USE_LVGL") + # suppress default enabling of extra widgets + 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"') + + add_define("LV_LOG_LEVEL", f"LV_LOG_LEVEL_{config[df.CONF_LOG_LEVEL]}") + add_define("LV_COLOR_DEPTH", config[df.CONF_COLOR_DEPTH]) + for font in helpers.lv_fonts_used: + add_define(f"LV_FONT_{font.upper()}") + + if config[df.CONF_COLOR_DEPTH] == 16: + add_define( + "LV_COLOR_16_SWAP", + "1" if config[df.CONF_BYTE_ORDER] == "big_endian" else "0", + ) + add_define( + "LV_COLOR_CHROMA_KEY", + await lvalid.lv_color.process(config[df.CONF_TRANSPARENCY_KEY]), + ) + CORE.add_build_flag("-Isrc") + + 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) + for display in config[df.CONF_DISPLAYS]: + cg.add(lv_component.add_display(await cg.get_variable(display))) + + frac = config[CONF_BUFFER_SIZE] + if frac >= 0.75: + frac = 1 + elif frac >= 0.375: + frac = 2 + elif frac > 0.19: + frac = 4 + else: + frac = 8 + cg.add(lv_component.set_buffer_frac(int(frac))) + cg.add(lv_component.set_full_refresh(config[df.CONF_FULL_REFRESH])) + + for font in helpers.esphome_fonts_used: + 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: + add_define( + "LV_FONT_CUSTOM_DECLARE", f"LV_FONT_DECLARE(*{df.DEFAULT_ESPHOME_FONT})" + ) + globfont_id = ID( + df.DEFAULT_ESPHOME_FONT, + True, + type=lv_font_t.operator("ptr").operator("const"), + ) + cg.new_variable(globfont_id, MockObj(default_font)) + add_define("LV_FONT_DEFAULT", df.DEFAULT_ESPHOME_FONT) + else: + add_define("LV_FONT_DEFAULT", default_font) + + with LvContext(): + await set_obj_properties(lv_scr_act, config) + await add_widgets(lv_scr_act, config) + Widget.set_completed() + await add_init_lambda(lv_component, LvContext.get_code()) + for comp in helpers.lvgl_components_required: + CORE.add_define(f"USE_LVGL_{comp.upper()}") + for use in helpers.lv_uses: + add_define(f"LV_USE_{use.upper()}") + lv_conf_h_file = CORE.relative_src_path(LV_CONF_FILENAME) + write_file_if_changed(lv_conf_h_file, generate_lv_conf_h()) + CORE.add_build_flag("-DLV_CONF_H=1") + CORE.add_build_flag(f'-DLV_CONF_PATH="{LV_CONF_FILENAME}"') + + +def display_schema(config): + value = cv.ensure_list(cv.use_id(Display))(config) + return value or [cv.use_id(Display)(config)] + + +FINAL_VALIDATE_SCHEMA = final_validation + +CONFIG_SCHEMA = ( + cv.polling_component_schema("1s") + .extend(obj_schema("obj")) + .extend( + { + cv.GenerateID(CONF_ID): cv.declare_id(LvglComponent), + cv.GenerateID(df.CONF_DISPLAYS): display_schema, + cv.Optional(df.CONF_COLOR_DEPTH, default=16): cv.one_of(16), + cv.Optional(df.CONF_DEFAULT_FONT, default="montserrat_14"): lvalid.lv_font, + cv.Optional(df.CONF_FULL_REFRESH, default=False): cv.boolean, + cv.Optional(CONF_BUFFER_SIZE, default="100%"): cv.percentage, + cv.Optional(df.CONF_LOG_LEVEL, default="WARN"): cv.one_of( + *df.LOG_LEVELS, upper=True + ), + cv.Optional(df.CONF_BYTE_ORDER, default="big_endian"): cv.one_of( + "big_endian", "little_endian" + ), + cv.Optional(df.CONF_WIDGETS): cv.ensure_list(WIDGET_SCHEMA), + cv.Optional(df.CONF_TRANSPARENCY_KEY, default=0x000400): lvalid.lv_color, + } + ) +).add_extra(cv.has_at_least_one_key(CONF_PAGES, df.CONF_WIDGETS)) diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py new file mode 100644 index 0000000000..50bdac3865 --- /dev/null +++ b/esphome/components/lvgl/defines.py @@ -0,0 +1,487 @@ +""" +This is the base of the import tree for LVGL. It contains constant definitions used elsewhere. +Constants already defined in esphome.const are not duplicated here and must be imported where used. + +""" + +from esphome import codegen as cg, config_validation as cv +from esphome.core import ID, Lambda +from esphome.cpp_types import uint32 +from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor + +from .lvcode import ConstantLiteral + + +class LValidator: + """ + A validator for a particular type used in LVGL. Usable in configs as a validator, also + has `process()` to convert a value during code generation + """ + + def __init__(self, validator, rtype, idtype=None, idexpr=None, retmapper=None): + self.validator = validator + self.rtype = rtype + self.idtype = idtype + self.idexpr = idexpr + self.retmapper = retmapper + + def __call__(self, value): + if isinstance(value, cv.Lambda): + return cv.returning_lambda(value) + if self.idtype is not None and isinstance(value, ID): + return cv.use_id(self.idtype)(value) + return self.validator(value) + + async def process(self, value, args=()): + if value is None: + return None + if isinstance(value, Lambda): + return cg.RawExpression( + f"{await cg.process_lambda(value, args, return_type=self.rtype)}()" + ) + if self.idtype is not None and isinstance(value, ID): + return cg.RawExpression(f"{value}->{self.idexpr}") + if self.retmapper is not None: + return self.retmapper(value) + return cg.safe_exp(value) + + +class LvConstant(LValidator): + """ + Allow one of a list of choices, mapped to upper case, and prepend the choice with the prefix. + It's also permitted to include the prefix in the value + The property `one_of` has the single case validator, and `several_of` allows a list of constants. + """ + + def __init__(self, prefix: str, *choices): + self.prefix = prefix + self.choices = choices + prefixed_choices = [prefix + v for v in choices] + prefixed_validator = cv.one_of(*prefixed_choices, upper=True) + + @schema_extractor("one_of") + def validator(value): + if value == SCHEMA_EXTRACT: + return self.choices + if isinstance(value, str) and value.startswith(self.prefix): + return prefixed_validator(value) + return self.prefix + cv.one_of(*choices, upper=True)(value) + + super().__init__(validator, rtype=uint32) + self.one_of = LValidator(validator, uint32, retmapper=self.mapper) + self.several_of = LValidator( + cv.ensure_list(self.one_of), uint32, retmapper=self.mapper + ) + + def mapper(self, value, args=()): + if isinstance(value, list): + value = "|".join(value) + return ConstantLiteral(value) + + def extend(self, *choices): + """ + Extend an LVCconstant with additional choices. + :param choices: The extra choices + :return: A new LVConstant instance + """ + return LvConstant(self.prefix, *(self.choices + choices)) + + +# Widgets +CONF_LABEL = "label" + +# Parts +CONF_MAIN = "main" +CONF_SCROLLBAR = "scrollbar" +CONF_INDICATOR = "indicator" +CONF_KNOB = "knob" +CONF_SELECTED = "selected" +CONF_ITEMS = "items" +CONF_TICKS = "ticks" +CONF_TICK_STYLE = "tick_style" +CONF_CURSOR = "cursor" +CONF_TEXTAREA_PLACEHOLDER = "textarea_placeholder" + +LV_FONTS = list(f"montserrat_{s}" for s in range(8, 50, 2)) + [ + "dejavu_16_persian_hebrew", + "simsun_16_cjk", + "unscii_8", + "unscii_16", +] + +LV_EVENT = { + "PRESS": "PRESSED", + "SHORT_CLICK": "SHORT_CLICKED", + "LONG_PRESS": "LONG_PRESSED", + "LONG_PRESS_REPEAT": "LONG_PRESSED_REPEAT", + "CLICK": "CLICKED", + "RELEASE": "RELEASED", + "SCROLL_BEGIN": "SCROLL_BEGIN", + "SCROLL_END": "SCROLL_END", + "SCROLL": "SCROLL", + "FOCUS": "FOCUSED", + "DEFOCUS": "DEFOCUSED", + "READY": "READY", + "CANCEL": "CANCEL", +} + +LV_EVENT_TRIGGERS = tuple(f"on_{x.lower()}" for x in LV_EVENT) + + +LV_ANIM = LvConstant( + "LV_SCR_LOAD_ANIM_", + "NONE", + "OVER_LEFT", + "OVER_RIGHT", + "OVER_TOP", + "OVER_BOTTOM", + "MOVE_LEFT", + "MOVE_RIGHT", + "MOVE_TOP", + "MOVE_BOTTOM", + "FADE_IN", + "FADE_OUT", + "OUT_LEFT", + "OUT_RIGHT", + "OUT_TOP", + "OUT_BOTTOM", +) + +LOG_LEVELS = ( + "TRACE", + "INFO", + "WARN", + "ERROR", + "USER", + "NONE", +) + +LV_LONG_MODES = LvConstant( + "LV_LABEL_LONG_", + "WRAP", + "DOT", + "SCROLL", + "SCROLL_CIRCULAR", + "CLIP", +) + +STATES = ( + "default", + "checked", + "focused", + "focus_key", + "edited", + "hovered", + "pressed", + "scrolled", + "disabled", + "user_1", + "user_2", + "user_3", + "user_4", +) + +PARTS = ( + CONF_MAIN, + CONF_SCROLLBAR, + CONF_INDICATOR, + CONF_KNOB, + CONF_SELECTED, + CONF_ITEMS, + CONF_TICKS, + CONF_CURSOR, + CONF_TEXTAREA_PLACEHOLDER, +) + +KEYBOARD_MODES = LvConstant( + "LV_KEYBOARD_MODE_", + "TEXT_LOWER", + "TEXT_UPPER", + "SPECIAL", + "NUMBER", +) +ROLLER_MODES = LvConstant("LV_ROLLER_MODE_", "NORMAL", "INFINITE") +DIRECTIONS = LvConstant("LV_DIR_", "LEFT", "RIGHT", "BOTTOM", "TOP") +TILE_DIRECTIONS = DIRECTIONS.extend("HOR", "VER", "ALL") +CHILD_ALIGNMENTS = LvConstant( + "LV_ALIGN_", + "TOP_LEFT", + "TOP_MID", + "TOP_RIGHT", + "LEFT_MID", + "CENTER", + "RIGHT_MID", + "BOTTOM_LEFT", + "BOTTOM_MID", + "BOTTOM_RIGHT", +) + +SIBLING_ALIGNMENTS = LvConstant( + "LV_ALIGN_", + "OUT_LEFT_TOP", + "OUT_TOP_LEFT", + "OUT_TOP_MID", + "OUT_TOP_RIGHT", + "OUT_RIGHT_TOP", + "OUT_LEFT_MID", + "OUT_RIGHT_MID", + "OUT_LEFT_BOTTOM", + "OUT_BOTTOM_LEFT", + "OUT_BOTTOM_MID", + "OUT_BOTTOM_RIGHT", + "OUT_RIGHT_BOTTOM", +) +ALIGN_ALIGNMENTS = CHILD_ALIGNMENTS.extend(*SIBLING_ALIGNMENTS.choices) + +FLEX_FLOWS = LvConstant( + "LV_FLEX_FLOW_", + "ROW", + "COLUMN", + "ROW_WRAP", + "COLUMN_WRAP", + "ROW_REVERSE", + "COLUMN_REVERSE", + "ROW_WRAP_REVERSE", + "COLUMN_WRAP_REVERSE", +) + +OBJ_FLAGS = ( + "hidden", + "clickable", + "click_focusable", + "checkable", + "scrollable", + "scroll_elastic", + "scroll_momentum", + "scroll_one", + "scroll_chain_hor", + "scroll_chain_ver", + "scroll_chain", + "scroll_on_focus", + "scroll_with_arrow", + "snappable", + "press_lock", + "event_bubble", + "gesture_bubble", + "adv_hittest", + "ignore_layout", + "floating", + "overflow_visible", + "layout_1", + "layout_2", + "widget_1", + "widget_2", + "user_1", + "user_2", + "user_3", + "user_4", +) + +ARC_MODES = LvConstant("LV_ARC_MODE_", "NORMAL", "REVERSE", "SYMMETRICAL") +BAR_MODES = LvConstant("LV_BAR_MODE_", "NORMAL", "SYMMETRICAL", "RANGE") + +BTNMATRIX_CTRLS = ( + "HIDDEN", + "NO_REPEAT", + "DISABLED", + "CHECKABLE", + "CHECKED", + "CLICK_TRIG", + "POPOVER", + "RECOLOR", + "CUSTOM_1", + "CUSTOM_2", +) + +LV_BASE_ALIGNMENTS = ( + "START", + "CENTER", + "END", +) +LV_CELL_ALIGNMENTS = LvConstant( + "LV_GRID_ALIGN_", + *LV_BASE_ALIGNMENTS, +) +LV_GRID_ALIGNMENTS = LV_CELL_ALIGNMENTS.extend( + "STRETCH", + "SPACE_EVENLY", + "SPACE_AROUND", + "SPACE_BETWEEN", +) + +LV_FLEX_ALIGNMENTS = LvConstant( + "LV_FLEX_ALIGN_", + *LV_BASE_ALIGNMENTS, + "SPACE_EVENLY", + "SPACE_AROUND", + "SPACE_BETWEEN", +) + +LV_MENU_MODES = LvConstant( + "LV_MENU_HEADER_", + "TOP_FIXED", + "TOP_UNFIXED", + "BOTTOM_FIXED", +) + +LV_CHART_TYPES = ( + "NONE", + "LINE", + "BAR", + "SCATTER", +) +LV_CHART_AXES = ( + "PRIMARY_Y", + "SECONDARY_Y", + "PRIMARY_X", + "SECONDARY_X", +) + +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_ANTIALIAS = "antialias" +CONF_ARC_LENGTH = "arc_length" +CONF_AUTO_START = "auto_start" +CONF_BACKGROUND_STYLE = "background_style" +CONF_DECIMAL_PLACES = "decimal_places" +CONF_COLUMN = "column" +CONF_DIGITS = "digits" +CONF_DISP_BG_COLOR = "disp_bg_color" +CONF_DISP_BG_IMAGE = "disp_bg_image" +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_CONTROL = "control" +CONF_DEFAULT = "default" +CONF_DEFAULT_FONT = "default_font" +CONF_DIR = "dir" +CONF_DISPLAYS = "displays" +CONF_END_ANGLE = "end_angle" +CONF_END_VALUE = "end_value" +CONF_ENTER_BUTTON = "enter_button" +CONF_ENTRIES = "entries" +CONF_FLAGS = "flags" +CONF_FLEX_FLOW = "flex_flow" +CONF_FLEX_ALIGN_MAIN = "flex_align_main" +CONF_FLEX_ALIGN_CROSS = "flex_align_cross" +CONF_FLEX_ALIGN_TRACK = "flex_align_track" +CONF_FLEX_GROW = "flex_grow" +CONF_FULL_REFRESH = "full_refresh" +CONF_GRID_CELL_ROW_POS = "grid_cell_row_pos" +CONF_GRID_CELL_COLUMN_POS = "grid_cell_column_pos" +CONF_GRID_CELL_ROW_SPAN = "grid_cell_row_span" +CONF_GRID_CELL_COLUMN_SPAN = "grid_cell_column_span" +CONF_GRID_CELL_X_ALIGN = "grid_cell_x_align" +CONF_GRID_CELL_Y_ALIGN = "grid_cell_y_align" +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_MODE = "header_mode" +CONF_HOME = "home" +CONF_INDICATORS = "indicators" +CONF_KEY_CODE = "key_code" +CONF_LABEL_GAP = "label_gap" +CONF_LAYOUT = "layout" +CONF_LEFT_BUTTON = "left_button" +CONF_LINE_WIDTH = "line_width" +CONF_LOG_LEVEL = "log_level" +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 = "major" +CONF_MSGBOXES = "msgboxes" +CONF_OBJ = "obj" +CONF_OFFSET_X = "offset_x" +CONF_OFFSET_Y = "offset_y" +CONF_ONE_LINE = "one_line" +CONF_ON_SELECT = "on_select" +CONF_ONE_CHECKED = "one_checked" +CONF_NEXT = "next" +CONF_PAGE_WRAP = "page_wrap" +CONF_PASSWORD_MODE = "password_mode" +CONF_PIVOT_X = "pivot_x" +CONF_PIVOT_Y = "pivot_y" +CONF_PLACEHOLDER_TEXT = "placeholder_text" +CONF_POINTS = "points" +CONF_PREVIOUS = "previous" +CONF_REPEAT_COUNT = "repeat_count" +CONF_R_MOD = "r_mod" +CONF_RECOLOR = "recolor" +CONF_RIGHT_BUTTON = "right_button" +CONF_ROLLOVER = "rollover" +CONF_ROOT_BACK_BTN = "root_back_btn" +CONF_ROWS = "rows" +CONF_SCALES = "scales" +CONF_SCALE_LINES = "scale_lines" +CONF_SCROLLBAR_MODE = "scrollbar_mode" +CONF_SELECTED_INDEX = "selected_index" +CONF_SHOW_SNOW = "show_snow" +CONF_SPIN_TIME = "spin_time" +CONF_SRC = "src" +CONF_START_ANGLE = "start_angle" +CONF_START_VALUE = "start_value" +CONF_STATES = "states" +CONF_STRIDE = "stride" +CONF_STYLE = "style" +CONF_STYLE_ID = "style_id" +CONF_SKIP = "skip" +CONF_SYMBOL = "symbol" +CONF_TAB_ID = "tab_id" +CONF_TABS = "tabs" +CONF_TEXT = "text" +CONF_TILE = "tile" +CONF_TILE_ID = "tile_id" +CONF_TILES = "tiles" +CONF_TITLE = "title" +CONF_TOP_LAYER = "top_layer" +CONF_TRANSPARENCY_KEY = "transparency_key" +CONF_THEME = "theme" +CONF_VISIBLE_ROW_COUNT = "visible_row_count" +CONF_WIDGET = "widget" +CONF_WIDGETS = "widgets" +CONF_X = "x" +CONF_Y = "y" +CONF_ZOOM = "zoom" + +# Keypad keys + +LV_KEYS = LvConstant( + "LV_KEY_", + "UP", + "DOWN", + "RIGHT", + "LEFT", + "ESC", + "DEL", + "BACKSPACE", + "ENTER", + "NEXT", + "PREV", + "HOME", + "END", +) + + +# 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) diff --git a/esphome/components/lvgl/font.cpp b/esphome/components/lvgl/font.cpp new file mode 100644 index 0000000000..9c172f07f5 --- /dev/null +++ b/esphome/components/lvgl/font.cpp @@ -0,0 +1,76 @@ +#include "lvgl_esphome.h" + +#ifdef USE_LVGL_FONT +namespace esphome { +namespace lvgl { + +static const uint8_t *get_glyph_bitmap(const lv_font_t *font, uint32_t unicode_letter) { + auto *fe = (FontEngine *) font->dsc; + const auto *gd = fe->get_glyph_data(unicode_letter); + if (gd == nullptr) + return nullptr; + // esph_log_d(TAG, "Returning bitmap @ %X", (uint32_t)gd->data); + + return gd->data; +} + +static bool get_glyph_dsc_cb(const lv_font_t *font, lv_font_glyph_dsc_t *dsc, uint32_t unicode_letter, uint32_t next) { + auto *fe = (FontEngine *) font->dsc; + const auto *gd = fe->get_glyph_data(unicode_letter); + if (gd == nullptr) + return false; + dsc->adv_w = gd->offset_x + gd->width; + dsc->ofs_x = gd->offset_x; + dsc->ofs_y = fe->height - gd->height - gd->offset_y - fe->baseline; + dsc->box_w = gd->width; + dsc->box_h = gd->height; + dsc->is_placeholder = 0; + dsc->bpp = fe->bpp; + return true; +} + +FontEngine::FontEngine(font::Font *esp_font) : font_(esp_font) { + this->bpp = esp_font->get_bpp(); + this->lv_font_.dsc = this; + this->lv_font_.line_height = this->height = esp_font->get_height(); + this->lv_font_.base_line = this->baseline = this->lv_font_.line_height - esp_font->get_baseline(); + this->lv_font_.get_glyph_dsc = get_glyph_dsc_cb; + this->lv_font_.get_glyph_bitmap = get_glyph_bitmap; + this->lv_font_.subpx = LV_FONT_SUBPX_NONE; + this->lv_font_.underline_position = -1; + this->lv_font_.underline_thickness = 1; +} + +const lv_font_t *FontEngine::get_lv_font() { return &this->lv_font_; } + +const font::GlyphData *FontEngine::get_glyph_data(uint32_t unicode_letter) { + if (unicode_letter == last_letter_) + return this->last_data_; + uint8_t unicode[5]; + memset(unicode, 0, sizeof unicode); + if (unicode_letter > 0xFFFF) { + unicode[0] = 0xF0 + ((unicode_letter >> 18) & 0x7); + unicode[1] = 0x80 + ((unicode_letter >> 12) & 0x3F); + unicode[2] = 0x80 + ((unicode_letter >> 6) & 0x3F); + unicode[3] = 0x80 + (unicode_letter & 0x3F); + } else if (unicode_letter > 0x7FF) { + unicode[0] = 0xE0 + ((unicode_letter >> 12) & 0xF); + unicode[1] = 0x80 + ((unicode_letter >> 6) & 0x3F); + unicode[2] = 0x80 + (unicode_letter & 0x3F); + } else if (unicode_letter > 0x7F) { + unicode[0] = 0xC0 + ((unicode_letter >> 6) & 0x1F); + unicode[1] = 0x80 + (unicode_letter & 0x3F); + } else { + unicode[0] = unicode_letter; + } + int match_length; + int glyph_n = this->font_->match_next_glyph(unicode, &match_length); + if (glyph_n < 0) + return nullptr; + this->last_data_ = this->font_->get_glyphs()[glyph_n].get_glyph_data(); + this->last_letter_ = unicode_letter; + return this->last_data_; +} +} // namespace lvgl +} // namespace esphome +#endif // USES_LVGL_FONT diff --git a/esphome/components/lvgl/helpers.py b/esphome/components/lvgl/helpers.py new file mode 100644 index 0000000000..c8d4948fb1 --- /dev/null +++ b/esphome/components/lvgl/helpers.py @@ -0,0 +1,70 @@ +import re + +from esphome import config_validation as cv +from esphome.config import Config +from esphome.const import CONF_ARGS, CONF_FORMAT +from esphome.core import CORE, ID +from esphome.yaml_util import ESPHomeDataBase + +lv_uses = { + "USER_DATA", + "LOG", + "STYLE", + "FONT_PLACEHOLDER", + "THEME_DEFAULT", +} + + +def add_lv_use(*names): + for name in names: + lv_uses.add(name) + + +lv_fonts_used = set() +esphome_fonts_used = set() +REQUIRED_COMPONENTS = {} +lvgl_components_required = set() + + +def validate_printf(value): + cfmt = r""" + ( # start of capture group 1 + % # literal "%" + (?:[-+0 #]{0,5}) # optional flags + (?:\d+|\*)? # width + (?:\.(?:\d+|\*))? # precision + (?:h|l|ll|w|I|I32|I64)? # size + [cCdiouxXeEfgGaAnpsSZ] # type + ) + """ # noqa + matches = re.findall(cfmt, value[CONF_FORMAT], flags=re.X) + if len(matches) != len(value[CONF_ARGS]): + raise cv.Invalid( + f"Found {len(matches)} printf-patterns ({', '.join(matches)}), but {len(value[CONF_ARGS])} args were given!" + ) + return value + + +def get_line_marks(value) -> list: + """ + If possible, return a preprocessor directive to identify the line number where the given id was defined. + :param id: The id in question + :return: A list containing zero or more line directives + """ + path = None + if isinstance(value, ESPHomeDataBase): + path = value.esp_range + elif isinstance(value, ID) and isinstance(CORE.config, Config): + path = CORE.config.get_path_for_id(value)[:-1] + path = CORE.config.get_deepest_document_range_for_path(path) + if path is None: + return [] + return [path.start_mark.as_line_directive] + + +def requires_component(comp): + def validator(value): + lvgl_components_required.add(comp) + return cv.requires_component(comp)(value) + + return validator diff --git a/esphome/components/lvgl/label.py b/esphome/components/lvgl/label.py new file mode 100644 index 0000000000..5c4ae6ab0d --- /dev/null +++ b/esphome/components/lvgl/label.py @@ -0,0 +1,34 @@ +import esphome.config_validation as cv + +from .defines import CONF_LABEL, CONF_LONG_MODE, CONF_RECOLOR, 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 + + +class LabelType(WidgetType): + def __init__(self): + super().__init__( + CONF_LABEL, + TEXT_SCHEMA.extend( + { + cv.Optional(CONF_RECOLOR): lv_bool, + cv.Optional(CONF_LONG_MODE): LV_LONG_MODES.one_of, + } + ), + ) + + @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): + w.set_property(CONF_TEXT, await lv_text.process(value)) + w.set_property(CONF_LONG_MODE, config) + w.set_property(CONF_RECOLOR, config) + + +label_spec = LabelType() diff --git a/esphome/components/lvgl/lv_validation.py b/esphome/components/lvgl/lv_validation.py new file mode 100644 index 0000000000..1de63c30ce --- /dev/null +++ b/esphome/components/lvgl/lv_validation.py @@ -0,0 +1,170 @@ +import esphome.codegen as cg +from esphome.components.binary_sensor import BinarySensor +from esphome.components.color import ColorStruct +from esphome.components.font import Font +from esphome.components.sensor import Sensor +from esphome.components.text_sensor import TextSensor +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.helpers import cpp_string_escape +from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor + +from . import types as ty +from .defines import LV_FONTS, LValidator, LvConstant +from .helpers import ( + esphome_fonts_used, + lv_fonts_used, + lvgl_components_required, + requires_component, +) +from .lvcode import ConstantLiteral, lv_expr +from .types import lv_font_t + + +@schema_extractor("one_of") +def color(value): + if value == SCHEMA_EXTRACT: + return ["hex color value", "color ID"] + if isinstance(value, int): + return value + return cv.use_id(ColorStruct)(value) + + +def color_retmapper(value): + if isinstance(value, cv.Lambda): + return cv.returning_lambda(value) + if isinstance(value, int): + hexval = HexInt(value) + return lv_expr.color_hex(hexval) + # Must be an id + lvgl_components_required.add(CONF_COLOR) + return lv_expr.color_from(MockObj(value)) + + +def pixels_or_percent(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)) + # Will throw an exception if not a percentage. + return f"lv_pct({int(cv.percentage(value) * 100)})" + + +def zoom(value): + value = cv.float_range(0.1, 10.0)(value) + return int(value * 256) + + +def angle(value): + """ + Validation for an angle in degrees, converted to an integer representing 0.1deg units + :param value: The input in the range 0..360 + :return: An angle in 1/10 degree units. + """ + return int(cv.float_range(0.0, 360.0)(cv.angle(value)) * 10) + + +@schema_extractor("one_of") +def size(value): + """A size in one axis - one of "size_content", a number (pixels) or a percentage""" + if value == SCHEMA_EXTRACT: + return ["size_content", "pixels", "..%"] + if isinstance(value, str) and value.lower().endswith("px"): + value = cv.int_(value[:-2]) + if isinstance(value, str) and not value.endswith("%"): + if value.upper() == "SIZE_CONTENT": + 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)) + # Will throw an exception if not a percentage. + return f"lv_pct({int(cv.percentage(value) * 100)})" + + +@schema_extractor("one_of") +def opacity(value): + consts = LvConstant("LV_OPA_", "TRANSP", "COVER") + if value == SCHEMA_EXTRACT: + return consts.choices + value = cv.Any(cv.percentage, consts.one_of)(value) + if isinstance(value, float): + return int(value * 255) + return value + + +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()") + + +def lvms_validator_(value): + if value == "never": + value = "2147483647ms" + return cv.positive_time_period_milliseconds(value) + + +lv_milliseconds = LValidator( + lvms_validator_, + cg.int32, + retmapper=lambda x: x.total_milliseconds, +) + + +class TextValidator(LValidator): + def __init__(self): + super().__init__( + cv.string, + cg.const_char_ptr, + TextSensor, + "get_state().c_str()", + lambda s: cg.safe_exp(f"{s}"), + ) + + def __call__(self, value): + if isinstance(value, dict): + return value + return super().__call__(value) + + async def process(self, value, args=()): + if isinstance(value, dict): + args = [str(x) for x in value[CONF_ARGS]] + arg_expr = cg.RawExpression(",".join(args)) + format_str = cpp_string_escape(value[CONF_FORMAT]) + return f"str_sprintf({format_str}, {arg_expr}).c_str()" + return await super().process(value, args) + + +lv_text = TextValidator() +lv_float = LValidator(cv.float_, cg.float_, Sensor, "get_state()") +lv_int = LValidator(cv.int_, cg.int_, Sensor, "get_state()") + + +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 + + def validator(value): + if value == SCHEMA_EXTRACT: + return LV_FONTS + if isinstance(value, str) and value.lower() in LV_FONTS: + 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()") + + super().__init__(validator, lv_font_t) + + async def process(self, value, args=()): + return ConstantLiteral(value) + + +lv_font = LvFont() diff --git a/esphome/components/lvgl/lvcode.py b/esphome/components/lvgl/lvcode.py new file mode 100644 index 0000000000..13b4862b4d --- /dev/null +++ b/esphome/components/lvgl/lvcode.py @@ -0,0 +1,237 @@ +import abc +import logging +from typing import Union + +from esphome import codegen as cg +from esphome.core import ID, Lambda +from esphome.cpp_generator import ( + AssignmentExpression, + CallExpression, + Expression, + LambdaExpression, + Literal, + MockObj, + RawExpression, + RawStatement, + SafeExpType, + Statement, + VariableDeclarationExpression, + statement, +) + +from .helpers import get_line_marks + +_LOGGER = logging.getLogger(__name__) + + +class CodeContext(abc.ABC): + """ + A class providing a context for code generation. Generated code will be added to the + current context. A new context will stack on the current context, and restore it + when done. Used with the `with` statement. + """ + + code_context = None + + @abc.abstractmethod + def add(self, expression: Union[Expression, Statement]): + pass + + @staticmethod + def append(expression: Union[Expression, Statement]): + if CodeContext.code_context is not None: + CodeContext.code_context.add(expression) + return expression + + def __init__(self): + self.previous: Union[CodeContext | None] = None + + def __enter__(self): + self.previous = CodeContext.code_context + CodeContext.code_context = self + + def __exit__(self, *args): + CodeContext.code_context = self.previous + + +class MainContext(CodeContext): + """ + Code generation into the main() function + """ + + def add(self, expression: Union[Expression, Statement]): + return cg.add(expression) + + +class LvContext(CodeContext): + """ + Code generation into the LVGL initialisation code (called in `setup()`) + """ + + lv_init_code: list["Statement"] = [] + + @staticmethod + def lv_add(expression: Union[Expression, Statement]): + if isinstance(expression, Expression): + expression = statement(expression) + if not isinstance(expression, Statement): + raise ValueError( + f"Add '{expression}' must be expression or statement, not {type(expression)}" + ) + LvContext.lv_init_code.append(expression) + _LOGGER.debug("LV Adding: %s", expression) + return expression + + @staticmethod + def get_code(): + code = [] + for exp in LvContext.lv_init_code: + text = str(statement(exp)) + text = text.rstrip() + code.append(text) + return "\n".join(code) + "\n\n" + + def add(self, expression: Union[Expression, Statement]): + return LvContext.lv_add(expression) + + def set_style(self, prop): + return MockObj("lv_set_style_{prop}", "") + + +class LambdaContext(CodeContext): + """ + A context that will accumlate code for use in a lambda. + """ + + def __init__( + self, + parameters: list[tuple[SafeExpType, str]], + return_type: SafeExpType = None, + ): + super().__init__() + self.code_list: list[Statement] = [] + self.parameters = parameters + self.return_type = return_type + + def add(self, expression: Union[Expression, Statement]): + self.code_list.append(expression) + return expression + + async def code(self) -> LambdaExpression: + code_text = [] + for exp in self.code_list: + text = str(statement(exp)) + text = text.rstrip() + code_text.append(text) + return await cg.process_lambda( + Lambda("\n".join(code_text) + "\n\n"), + self.parameters, + return_type=self.return_type, + ) + + +class LocalVariable(MockObj): + """ + Create a local variable and enclose the code using it within a block. + """ + + def __init__(self, name, type, modifier=None, rhs=None): + base = ID(name, True, type) + super().__init__(base, "") + self.modifier = modifier + self.rhs = rhs + + def __enter__(self): + CodeContext.append(RawStatement("{")) + CodeContext.append( + VariableDeclarationExpression(self.base.type, self.modifier, self.base.id) + ) + if self.rhs is not None: + CodeContext.append(AssignmentExpression(None, "", self.base, self.rhs)) + return self.base + + def __exit__(self, *args): + CodeContext.append(RawStatement("}")) + + +class MockLv: + """ + A mock object that can be used to generate LVGL calls. + """ + + def __init__(self, base): + self.base = base + + def __getattr__(self, attr: str) -> "MockLv": + return MockLv(f"{self.base}{attr}") + + def append(self, expression): + CodeContext.append(expression) + + def __call__(self, *args: SafeExpType) -> "MockObj": + call = CallExpression(self.base, *args) + result = MockObj(call, "") + self.append(result) + return result + + def __str__(self): + return str(self.base) + + def __repr__(self): + return f"MockLv<{str(self.base)}>" + + def call(self, prop, *args): + call = CallExpression(RawExpression(f"{self.base}{prop}"), *args) + result = MockObj(call, "") + self.append(result) + return result + + def cond_if(self, expression: Expression): + CodeContext.append(RawExpression(f"if({expression}) {{")) + + def cond_else(self): + CodeContext.append(RawExpression("} else {")) + + def cond_endif(self): + CodeContext.append(RawExpression("}")) + + +class LvExpr(MockLv): + def __getattr__(self, attr: str) -> "MockLv": + return LvExpr(f"{self.base}{attr}") + + def append(self, expression): + pass + + +# Top level mock for generic lv_ calls to be recorded +lv = MockLv("lv_") +# Just generate an expression +lv_expr = LvExpr("lv_") +# Mock for lv_obj_ calls +lv_obj = MockLv("lv_obj_") + + +# equivalent to cg.add() for the lvgl init context +def lv_add(expression: Union[Expression, Statement]): + return CodeContext.append(expression) + + +def add_line_marks(where): + for mark in get_line_marks(where): + lv_add(cg.RawStatement(mark)) + + +def lv_assign(target, expression): + lv_add(RawExpression(f"{target} = {expression}")) + + +class ConstantLiteral(Literal): + __slots__ = ("constant",) + + def __init__(self, constant: str): + super().__init__() + self.constant = constant + + def __str__(self): + return self.constant diff --git a/esphome/components/lvgl/lvgl_esphome.cpp b/esphome/components/lvgl/lvgl_esphome.cpp new file mode 100644 index 0000000000..bdaf8a4f18 --- /dev/null +++ b/esphome/components/lvgl/lvgl_esphome.cpp @@ -0,0 +1,129 @@ +#include "esphome/core/defines.h" +#include "esphome/core/log.h" +#include "esphome/core/helpers.h" +#include "esphome/core/hal.h" +#include "lvgl_hal.h" +#include "lvgl_esphome.h" + +namespace esphome { +namespace lvgl { +static const char *const TAG = "lvgl"; + +lv_event_code_t lv_custom_event; // NOLINT +void LvglComponent::dump_config() { ESP_LOGCONFIG(TAG, "LVGL:"); } +void LvglComponent::draw_buffer_(const lv_area_t *area, const uint8_t *ptr) { + for (auto *display : this->displays_) { + display->draw_pixels_at(area->x1, area->y1, lv_area_get_width(area), lv_area_get_height(area), ptr, + display::COLOR_ORDER_RGB, LV_BITNESS, LV_COLOR_16_SWAP); + } +} + +void LvglComponent::flush_cb_(lv_disp_drv_t *disp_drv, const lv_area_t *area, lv_color_t *color_p) { + auto now = millis(); + this->draw_buffer_(area, (const uint8_t *) 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); +} + +void LvglComponent::setup() { + ESP_LOGCONFIG(TAG, "LVGL Setup starts"); +#if LV_USE_LOG + lv_log_register_print_cb(log_cb); +#endif + lv_init(); + lv_custom_event = static_cast(lv_event_register_id()); + auto *display = this->displays_[0]; + size_t buffer_pixels = display->get_width() * display->get_height() / this->buffer_frac_; + auto buf_bytes = buffer_pixels * LV_COLOR_DEPTH / 8; + auto *buf = lv_custom_mem_alloc(buf_bytes); + if (buf == nullptr) { + ESP_LOGE(TAG, "Malloc failed to allocate %zu bytes", buf_bytes); + this->mark_failed(); + this->status_set_error("Memory allocation failure"); + return; + } + lv_disp_draw_buf_init(&this->draw_buf_, buf, nullptr, buffer_pixels); + 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; + switch (display->get_rotation()) { + case display::DISPLAY_ROTATION_0_DEGREES: + break; + case display::DISPLAY_ROTATION_90_DEGREES: + this->disp_drv_.sw_rotate = true; + this->disp_drv_.rotated = LV_DISP_ROT_90; + break; + case display::DISPLAY_ROTATION_180_DEGREES: + this->disp_drv_.sw_rotate = true; + this->disp_drv_.rotated = LV_DISP_ROT_180; + break; + case display::DISPLAY_ROTATION_270_DEGREES: + this->disp_drv_.sw_rotate = true; + this->disp_drv_.rotated = LV_DISP_ROT_270; + break; + } + display->set_rotation(display::DISPLAY_ROTATION_0_DEGREES); + this->disp_drv_.hor_res = (lv_coord_t) display->get_width(); + this->disp_drv_.ver_res = (lv_coord_t) display->get_height(); + ESP_LOGV(TAG, "sw_rotate = %d, rotated=%d", this->disp_drv_.sw_rotate, this->disp_drv_.rotated); + this->disp_ = lv_disp_drv_register(&this->disp_drv_); + for (const auto &v : this->init_lambdas_) + v(this->disp_); + lv_disp_trig_activity(this->disp_); + ESP_LOGCONFIG(TAG, "LVGL Setup complete"); +} +} // namespace lvgl +} // namespace esphome + +size_t lv_millis(void) { return esphome::millis(); } + +#if defined(USE_HOST) || defined(USE_RP2040) || defined(USE_ESP8266) +void *lv_custom_mem_alloc(size_t size) { + auto *ptr = malloc(size); // NOLINT + if (ptr == nullptr) { + esphome::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 +static unsigned cap_bits = MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT; // NOLINT + +void *lv_custom_mem_alloc(size_t size) { + void *ptr; + ptr = heap_caps_malloc(size, cap_bits); + if (ptr == nullptr) { + cap_bits = MALLOC_CAP_8BIT; + ptr = heap_caps_malloc(size, cap_bits); + } + if (ptr == nullptr) { + esphome::ESP_LOGE(esphome::lvgl::TAG, "Failed to allocate %zu bytes", size); + return nullptr; + } +#ifdef ESPHOME_LOG_HAS_VERBOSE + esphome::ESP_LOGV(esphome::lvgl::TAG, "allocate %zu - > %p", size, ptr); +#endif + return ptr; +} + +void lv_custom_mem_free(void *ptr) { +#ifdef ESPHOME_LOG_HAS_VERBOSE + esphome::ESP_LOGV(esphome::lvgl::TAG, "free %p", ptr); +#endif + if (ptr == nullptr) + return; + heap_caps_free(ptr); +} + +void *lv_custom_mem_realloc(void *ptr, size_t size) { +#ifdef ESPHOME_LOG_HAS_VERBOSE + esphome::ESP_LOGV(esphome::lvgl::TAG, "realloc %p: %zu", ptr, size); +#endif + return heap_caps_realloc(ptr, size, cap_bits); +} +#endif diff --git a/esphome/components/lvgl/lvgl_esphome.h b/esphome/components/lvgl/lvgl_esphome.h new file mode 100644 index 0000000000..988c22917b --- /dev/null +++ b/esphome/components/lvgl/lvgl_esphome.h @@ -0,0 +1,119 @@ +#pragma once +#include "esphome/core/defines.h" +#ifdef USE_LVGL + +// required for clang-tidy +#ifndef LV_CONF_H +#define LV_CONF_SKIP 1 // NOLINT +#endif + +#include "esphome/components/display/display.h" +#include "esphome/components/display/display_color_utils.h" +#include "esphome/core/component.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" +#include +#include + +#ifdef USE_LVGL_FONT +#include "esphome/components/font/font.h" +#endif +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 +#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 +static const display::ColorBitness LV_BITNESS = display::ColorBitness::COLOR_BITNESS_332; +#endif + +// Parent class for things that wrap an LVGL object +class LvCompound { + public: + virtual void set_obj(lv_obj_t *lv_obj) { this->obj = lv_obj; } + lv_obj_t *obj{}; +}; + +using LvLambdaType = std::function; +using set_value_lambda_t = std::function; +using event_callback_t = void(_lv_event_t *); +using text_lambda_t = std::function; + +#ifdef USE_LVGL_FONT +class FontEngine { + public: + FontEngine(font::Font *esp_font); + const lv_font_t *get_lv_font(); + + const font::GlyphData *get_glyph_data(uint32_t unicode_letter); + uint16_t baseline{}; + uint16_t height{}; + uint8_t bpp{}; + + protected: + font::Font *font_{}; + uint32_t last_letter_{}; + const font::GlyphData *last_data_{}; + lv_font_t lv_font_{}; +}; +#endif // USE_LVGL_FONT + +class LvglComponent : public PollingComponent { + constexpr static const char *const TAG = "lvgl"; + + public: + static void 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); + } + + float get_setup_priority() const override { return setup_priority::PROCESSOR; } + static void log_cb(const char *buf) { + esp_log_printf_(ESPHOME_LOG_LEVEL_INFO, TAG, 0, "%.*s", (int) strlen(buf) - 1, buf); + } + static void rounder_cb(lv_disp_drv_t *disp_drv, lv_area_t *area) { + // make sure all coordinates are even + if (area->x1 & 1) + area->x1--; + if (!(area->x2 & 1)) + area->x2++; + if (area->y1 & 1) + area->y1--; + if (!(area->y2 & 1)) + area->y2++; + } + + void loop() override { lv_timer_handler_run_in_period(5); } + void setup() override; + + void update() override {} + + void add_display(display::Display *display) { this->displays_.push_back(display); } + void add_init_lambda(const std::function &lamb) { this->init_lambdas_.push_back(lamb); } + void dump_config() override; + 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_; } + + protected: + void draw_buffer_(const lv_area_t *area, const uint8_t *ptr); + void flush_cb_(lv_disp_drv_t *disp_drv, const lv_area_t *area, lv_color_t *color_p); + std::vector displays_{}; + lv_disp_draw_buf_t draw_buf_{}; + lv_disp_drv_t disp_drv_{}; + lv_disp_t *disp_{}; + + std::vector> init_lambdas_; + size_t buffer_frac_{1}; + bool full_refresh_{}; +}; + +} // namespace lvgl +} // namespace esphome + +#endif diff --git a/esphome/components/lvgl/lvgl_hal.h b/esphome/components/lvgl/lvgl_hal.h new file mode 100644 index 0000000000..754cc70391 --- /dev/null +++ b/esphome/components/lvgl/lvgl_hal.h @@ -0,0 +1,21 @@ +// +// 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/obj.py b/esphome/components/lvgl/obj.py new file mode 100644 index 0000000000..fba20bef36 --- /dev/null +++ b/esphome/components/lvgl/obj.py @@ -0,0 +1,22 @@ +from .defines import CONF_OBJ +from .types import lv_obj_t +from .widget import WidgetType + + +class ObjType(WidgetType): + """ + The base LVGL object. All other widgets inherit from this. + """ + + def __init__(self): + super().__init__(CONF_OBJ, schema={}, modify_schema={}) + + @property + def w_type(self): + return lv_obj_t + + async def to_code(self, w, config): + return [] + + +obj_spec = ObjType() diff --git a/esphome/components/lvgl/schemas.py b/esphome/components/lvgl/schemas.py new file mode 100644 index 0000000000..4ae5824151 --- /dev/null +++ b/esphome/components/lvgl/schemas.py @@ -0,0 +1,260 @@ +from esphome import config_validation as cv +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 .lv_validation import lv_font +from .types import WIDGET_TYPES, get_widget_type + +# A schema for text properties +TEXT_SCHEMA = cv.Schema( + { + cv.Optional(df.CONF_TEXT): cv.Any( + cv.All( + cv.Schema( + { + cv.Required(CONF_FORMAT): cv.string, + cv.Optional(CONF_ARGS, default=list): cv.ensure_list( + cv.lambda_ + ), + }, + ), + validate_printf, + ), + lvalid.lv_text, + ) + } +) + +# All LVGL styles and their validators +STYLE_PROPS = { + "align": df.CHILD_ALIGNMENTS.one_of, + "arc_opa": lvalid.opacity, + "arc_color": lvalid.lv_color, + "arc_rounded": lvalid.lv_bool, + "arc_width": cv.positive_int, + "anim_time": lvalid.lv_milliseconds, + "bg_color": lvalid.lv_color, + "bg_grad_color": lvalid.lv_color, + "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_main_stop": lvalid.stop_value, + "bg_opa": lvalid.opacity, + "border_color": lvalid.lv_color, + "border_opa": lvalid.opacity, + "border_post": lvalid.lv_bool, + "border_side": df.LvConstant( + "LV_BORDER_SIDE_", "NONE", "TOP", "BOTTOM", "LEFT", "RIGHT", "INTERNAL" + ).several_of, + "border_width": cv.positive_int, + "clip_corner": lvalid.lv_bool, + "height": lvalid.size, + "img_recolor": lvalid.lv_color, + "img_recolor_opa": lvalid.opacity, + "line_width": cv.positive_int, + "line_dash_width": cv.positive_int, + "line_dash_gap": cv.positive_int, + "line_rounded": lvalid.lv_bool, + "line_color": lvalid.lv_color, + "opa": lvalid.opacity, + "opa_layered": lvalid.opacity, + "outline_color": lvalid.lv_color, + "outline_opa": lvalid.opacity, + "outline_pad": lvalid.size, + "outline_width": lvalid.size, + "pad_all": lvalid.size, + "pad_bottom": lvalid.size, + "pad_column": lvalid.size, + "pad_left": lvalid.size, + "pad_right": lvalid.size, + "pad_row": lvalid.size, + "pad_top": lvalid.size, + "shadow_color": lvalid.lv_color, + "shadow_ofs_x": cv.int_, + "shadow_ofs_y": cv.int_, + "shadow_opa": lvalid.opacity, + "shadow_spread": cv.int_, + "shadow_width": cv.positive_int, + "text_align": df.LvConstant( + "LV_TEXT_ALIGN_", "LEFT", "CENTER", "RIGHT", "AUTO" + ).one_of, + "text_color": lvalid.lv_color, + "text_decor": df.LvConstant( + "LV_TEXT_DECOR_", "NONE", "UNDERLINE", "STRIKETHROUGH" + ).several_of, + "text_font": lv_font, + "text_letter_space": cv.positive_int, + "text_line_space": cv.positive_int, + "text_opa": lvalid.opacity, + "transform_angle": lvalid.angle, + "transform_height": lvalid.pixels_or_percent, + "transform_pivot_x": lvalid.pixels_or_percent, + "transform_pivot_y": lvalid.pixels_or_percent, + "transform_zoom": lvalid.zoom, + "translate_x": lvalid.pixels_or_percent, + "translate_y": lvalid.pixels_or_percent, + "max_height": lvalid.pixels_or_percent, + "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), + "width": lvalid.size, + "x": lvalid.pixels_or_percent, + "y": lvalid.pixels_or_percent, +} + +# Complete object style schema +STYLE_SCHEMA = cv.Schema({cv.Optional(k): v for k, v in STYLE_PROPS.items()}).extend( + { + cv.Optional(df.CONF_SCROLLBAR_MODE): df.LvConstant( + "LV_SCROLLBAR_MODE_", "OFF", "ON", "ACTIVE", "AUTO" + ).one_of, + } +) + +# Object states. Top level properties apply to MAIN +STATE_SCHEMA = cv.Schema( + {cv.Optional(state): STYLE_SCHEMA for state in df.STATES} +).extend(STYLE_SCHEMA) +# Setting object states +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_LIST = cv.ensure_list(df.LvConstant("LV_OBJ_FLAG_", *df.OBJ_FLAGS).one_of) + + +def part_schema(widget_type): + """ + 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,) + return cv.Schema({cv.Optional(part): STATE_SCHEMA for part in parts}).extend( + STATE_SCHEMA + ) + + +def obj_schema(widget_type: str): + """ + Create a schema for a widget type itself i.e. no allowance for children + :param widget_type: + :return: + """ + return ( + part_schema(widget_type) + .extend(FLAG_SCHEMA) + .extend(ALIGN_TO_SCHEMA) + .extend( + cv.Schema( + { + cv.Optional(CONF_STATE): SET_STATE_SCHEMA, + } + ) + ) + ) + + +ALIGN_TO_SCHEMA = { + cv.Optional(df.CONF_ALIGN_TO): cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(ty.lv_obj_t), + cv.Required(df.CONF_ALIGN): df.ALIGN_ALIGNMENTS.one_of, + cv.Optional(df.CONF_X, default=0): lvalid.pixels_or_percent, + cv.Optional(df.CONF_Y, default=0): lvalid.pixels_or_percent, + } + ) +} + + +# A style schema that can include text +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): + """ + Create a validator for a container given the widget type + :param schema: Base schema to extend + :param widget_type: + :return: + """ + + def validator(value): + result = schema + if w_sch := WIDGET_TYPES[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) + if value == SCHEMA_EXTRACT: + return result + return result(value) + + return validator + + +def container_schema(widget_type, 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. + :param widget_type: The widget type, e.g. "img" + :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)}) + if extras: + schema = schema.extend(extras) + # Delayed evaluation for recursion + return container_validator(schema, widget_type) + + +def widget_schema(widget_type, extras=None): + """ + Create a schema for a given widget type + :param widget_type: The name of the widget + :param extras: + :return: + """ + validator = container_schema(widget_type, extras=extras) + if required := REQUIRED_COMPONENTS.get(widget_type): + validator = cv.All(validator, requires_component(required)) + return cv.Exclusive(widget_type, df.CONF_WIDGETS), validator + + +# All widget schemas must be defined before this is called. + + +def any_widget_schema(extras=None): + """ + Generate schemas for all possible LVGL widgets. This is what implements the ability to have a list of any kind of + widget under the widgets: key. + + :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)) diff --git a/esphome/components/lvgl/types.py b/esphome/components/lvgl/types.py new file mode 100644 index 0000000000..3c043d266d --- /dev/null +++ b/esphome/components/lvgl/types.py @@ -0,0 +1,64 @@ +from esphome import codegen as cg +from esphome.core import ID + +from .defines import CONF_LABEL, CONF_OBJ, CONF_TEXT + +uint16_t_ptr = cg.uint16.operator("ptr") +lvgl_ns = cg.esphome_ns.namespace("lvgl") +char_ptr = cg.global_ns.namespace("char").operator("ptr") +void_ptr = cg.void.operator("ptr") +LvglComponent = lvgl_ns.class_("LvglComponent", cg.PollingComponent) +lv_event_code_t = cg.global_ns.namespace("lv_event_code_t") +FontEngine = lvgl_ns.class_("FontEngine") +LvCompound = lvgl_ns.class_("LvCompound") +lv_font_t = cg.global_ns.class_("lv_font_t") +lv_style_t = cg.global_ns.struct("lv_style_t") +lv_pseudo_button_t = lvgl_ns.class_("LvPseudoButton") +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") + + +# 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__( + *args, + largs=[(cg.std_string, "text")], + lvalue=lambda w: w.get_property("text")[0], + **kwargs, + ) + 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] + + +CUSTOM_EVENT = ID("lv_custom_event", False, type=lv_event_code_t) diff --git a/esphome/components/lvgl/widget.py b/esphome/components/lvgl/widget.py new file mode 100644 index 0000000000..44f277f1c3 --- /dev/null +++ b/esphome/components/lvgl/widget.py @@ -0,0 +1,347 @@ +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_GROUP, CONF_ID, CONF_STATE +from esphome.core import ID, TimePeriod +from esphome.coroutine import FakeAwaitable +from esphome.cpp_generator import MockObjClass + +from .defines import ( + CONF_DEFAULT, + CONF_MAIN, + CONF_SCROLLBAR_MODE, + CONF_WIDGETS, + OBJ_FLAGS, + PARTS, + STATES, + LValidator, + join_enums, +) +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 + +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()") + + def obj_creator(self, parent: MockObjClass, config: dict): + return [] + + async def to_code(self, w, config: dict): + return [] + + +class Widget: + """ + Represents a Widget. + """ + + widgets_completed = False + + @staticmethod + def set_completed(): + Widget.widgets_completed = True + + def __init__(self, var, wtype: WidgetType, config: dict = None, parent=None): + self.var = var + self.type = wtype + self.config = config + self.scale = 1.0 + self.step = 1.0 + self.range_from = -sys.maxsize + self.range_to = sys.maxsize + self.parent = parent + + @staticmethod + def create(name, var, wtype: WidgetType, config: dict = None, parent=None): + w = Widget(var, wtype, config, parent) + if name is not None: + widget_map[name] = w + return w + + @property + def obj(self): + if self.type.is_compound(): + return f"{self.var}->obj" + return self.var + + def add_state(self, *args): + return lv_obj.add_state(self.obj, *args) + + def clear_state(self, *args): + return lv_obj.clear_state(self.obj, *args) + + def add_flag(self, *args): + return lv_obj.add_flag(self.obj, *args) + + def clear_flag(self, *args): + return lv_obj.clear_flag(self.obj, *args) + + def set_property(self, prop, value, animated: bool = None, ltype=None): + if isinstance(value, dict): + value = value.get(prop) + if value is None: + return + if isinstance(value, TimePeriod): + value = value.total_milliseconds + ltype = ltype or self.__type_base() + if animated is None or self.type.animated is not True: + lv.call(f"{ltype}_set_{prop}", self.obj, value) + else: + lv.call( + f"{ltype}_set_{prop}", + self.obj, + value, + "LV_ANIM_ON" if animated else "LV_ANIM_OFF", + ) + + def get_property(self, prop, ltype=None): + ltype = ltype or self.__type_base() + return f"lv_{ltype}_get_{prop}({self.obj})" + + def set_style(self, prop, value, state): + if value is None: + return [] + return lv.call(f"obj_set_style_{prop}", self.obj, value, state) + + def __type_base(self): + wtype = self.type.w_type + base = str(wtype) + if base.startswith("Lv"): + return f"{wtype}".removeprefix("Lv").removesuffix("Type").lower() + return f"{wtype}".removeprefix("lv_").removesuffix("_t") + + def __str__(self): + return f"({self.var}, {self.type})" + + +# Map of widgets to their config, used for trigger generation +widget_map: dict[Any, Widget] = {} + + +def get_widget_generator(wid): + """ + Used to wait for a widget during code generation. + :param wid: + :return: + """ + while True: + if obj := widget_map.get(wid): + return obj + if Widget.widgets_completed: + raise Invalid( + f"Widget {wid} not found, yet all widgets should be defined by now" + ) + yield + + +async def get_widget(wid: ID) -> Widget: + if obj := widget_map.get(wid): + return obj + return await FakeAwaitable(get_widget_generator(wid)) + + +def collect_props(config): + """ + Collect all properties from a configuration + :param config: + :return: + """ + props = {} + for prop in [*ALL_STYLES, *OBJ_FLAGS, CONF_GROUP]: + if prop in config: + props[prop] = config[prop] + return props + + +def collect_states(config): + """ + Collect prperties for each state of a widget + :param config: + :return: + """ + states = {CONF_DEFAULT: collect_props(config)} + for state in STATES: + if state in config: + states[state] = collect_props(config[state]) + return states + + +def collect_parts(config): + """ + Collect properties and states for all widget parts + :param config: + :return: + """ + parts = {CONF_MAIN: collect_states(config)} + for part in PARTS: + if part in config: + parts[part] = collect_states(config[part]) + return parts + + +async def set_obj_properties(w: Widget, config): + """Generate a list of C++ statements to apply properties to an lv_obj_t""" + parts = collect_parts(config) + for part, states in parts.items(): + for state, props in states.items(): + lv_state = ConstantLiteral( + f"(int)LV_STATE_{state.upper()}|(int)LV_PART_{part.upper()}" + ) + 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) + w.set_style(prop, value, lv_state) + flag_clr = set() + flag_set = set() + props = parts[CONF_MAIN][CONF_DEFAULT] + for prop, value in {k: v for k, v in props.items() if k in OBJ_FLAGS}.items(): + if value: + flag_set.add(prop) + else: + flag_clr.add(prop) + if flag_set: + adds = join_enums(flag_set, "LV_OBJ_FLAG_") + w.add_flag(adds) + if flag_clr: + clrs = join_enums(flag_clr, "LV_OBJ_FLAG_") + w.clear_flag(clrs) + + if states := config.get(CONF_STATE): + adds = set() + clears = set() + lambs = {} + for key, value in states.items(): + if isinstance(value, cv.Lambda): + lambs[key] = value + elif value == "true": + adds.add(key) + else: + clears.add(key) + if adds: + adds = ConstantLiteral(join_enums(adds, "LV_STATE_")) + w.add_state(adds) + if clears: + clears = ConstantLiteral(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_) + state = ConstantLiteral(f"LV_STATE_{key.upper}") + lv.cond_if(lamb) + w.add_state(state) + lv.cond_else() + w.clear_state(state) + lv.cond_endif() + if scrollbar_mode := config.get(CONF_SCROLLBAR_MODE): + lv_obj.set_scrollbar_mode(w.obj, scrollbar_mode) + + +async def add_widgets(parent: Widget, config: dict): + """ + Add all widgets to an object + :param parent: The enclosing obj + :param config: The configuration + :return: + """ + for w in config.get(CONF_WIDGETS) or (): + w_type, w_cnfig = next(iter(w.items())) + await widget_to_code(w_cnfig, w_type, parent.obj) + + +async def widget_to_code(w_cnfig, w_type, parent): + """ + Converts a Widget definition to C code. + :param w_cnfig: The widget configuration + :param w_type: The Widget type + :param parent: The parent to which the widget should be added + :return: + """ + spec: WidgetType = WIDGET_TYPES[w_type] + creator = 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)) + else: + var = cg.Pvariable(wid, cg.nullptr, type_=lv_obj_t) + lv_assign(var, creator) + + widget = Widget.create(wid, var, spec, w_cnfig, parent) + await set_obj_properties(widget, w_cnfig) + await add_widgets(widget, w_cnfig) + await spec.to_code(widget, w_cnfig) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 4831ed2c9e..9d453260ab 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -38,6 +38,9 @@ #define USE_LIGHT #define USE_LOCK #define USE_LOGGER +#define USE_LVGL +#define USE_LVGL_FONT +#define USE_LVGL_IMAGE #define USE_MDNS #define USE_MEDIA_PLAYER #define USE_MQTT diff --git a/platformio.ini b/platformio.ini index fc7f35b6c3..baf0a85d73 100644 --- a/platformio.ini +++ b/platformio.ini @@ -42,6 +42,7 @@ lib_deps = pavlodn/HaierProtocol@0.9.31 ; haier ; 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 build_flags = -DESPHOME_LOG_LEVEL=ESPHOME_LOG_LEVEL_VERY_VERBOSE src_filter = diff --git a/tests/components/lvgl/common.yaml b/tests/components/lvgl/common.yaml new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml new file mode 100644 index 0000000000..856e7c3e9d --- /dev/null +++ b/tests/components/lvgl/lvgl-package.yaml @@ -0,0 +1,24 @@ +color: + - id: light_blue + hex: "3340FF" + +lvgl: + bg_color: light_blue + widgets: + - label: + text: Hello world + text_color: 0xFF8000 + align: center + text_font: montserrat_40 + border_post: true + + - label: + text: "Hello shiny day" + text_color: 0xFFFFFF + align: bottom_mid + text_font: space16 + +font: + - file: "gfonts://Roboto" + id: space16 + bpp: 4 diff --git a/tests/components/lvgl/test.esp32-ard.yaml b/tests/components/lvgl/test.esp32-ard.yaml new file mode 100644 index 0000000000..abfb324ea5 --- /dev/null +++ b/tests/components/lvgl/test.esp32-ard.yaml @@ -0,0 +1,30 @@ +spi: + clk_pin: 14 + mosi_pin: 13 + +i2c: + sda: GPIO18 + scl: GPIO19 + +display: + - platform: ili9xxx + model: st7789v + id: tft_display + dimensions: + width: 240 + height: 320 + transform: + swap_xy: false + mirror_x: true + mirror_y: true + data_rate: 80MHz + cs_pin: GPIO22 + dc_pin: GPIO21 + auto_clear_enabled: false + invert_colors: false + update_interval: never + +packages: + lvgl: !include lvgl-package.yaml + +<<: !include common.yaml diff --git a/tests/components/lvgl/test.esp32-idf.yaml b/tests/components/lvgl/test.esp32-idf.yaml new file mode 100644 index 0000000000..f159431b99 --- /dev/null +++ b/tests/components/lvgl/test.esp32-idf.yaml @@ -0,0 +1,52 @@ +spi: + clk_pin: 14 + mosi_pin: 13 + +i2c: + sda: GPIO18 + scl: GPIO19 + +display: + - platform: ili9xxx + model: st7789v + id: second_display + dimensions: + width: 240 + height: 320 + transform: + swap_xy: false + mirror_x: true + mirror_y: true + data_rate: 80MHz + cs_pin: GPIO20 + dc_pin: GPIO15 + auto_clear_enabled: false + invert_colors: false + update_interval: never + + - platform: ili9xxx + model: st7789v + id: tft_display + dimensions: + width: 240 + height: 320 + transform: + swap_xy: false + mirror_x: true + mirror_y: true + data_rate: 80MHz + cs_pin: GPIO22 + dc_pin: GPIO21 + auto_clear_enabled: false + invert_colors: false + update_interval: never + +packages: + lvgl: !include lvgl-package.yaml + +lvgl: + displays: + - tft_display + - second_display + +<<: !include common.yaml