diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index aa6935c5fc..861999d0b7 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -41,10 +41,7 @@ from .lv_validation import lv_bool, lv_images_used from .lvcode import LvContext, LvglComponent, lvgl_static from .schemas import ( DISP_BG_SCHEMA, - FLEX_OBJ_SCHEMA, FULL_STYLE_SCHEMA, - GRID_CELL_SCHEMA, - LAYOUT_SCHEMAS, WIDGET_TYPES, any_widget_schema, container_schema, @@ -78,6 +75,7 @@ from .widgets.button import button_spec from .widgets.buttonmatrix import buttonmatrix_spec from .widgets.canvas import canvas_spec from .widgets.checkbox import checkbox_spec +from .widgets.container import container_spec from .widgets.dropdown import dropdown_spec from .widgets.img import img_spec from .widgets.keyboard import keyboard_spec @@ -130,20 +128,10 @@ for w_type in ( tileview_spec, qr_code_spec, canvas_spec, + container_spec, ): WIDGET_TYPES[w_type.name] = w_type -WIDGET_SCHEMA = any_widget_schema() - -LAYOUT_SCHEMAS[df.TYPE_GRID] = { - cv.Optional(df.CONF_WIDGETS): cv.ensure_list(any_widget_schema(GRID_CELL_SCHEMA)) -} -LAYOUT_SCHEMAS[df.TYPE_FLEX] = { - cv.Optional(df.CONF_WIDGETS): cv.ensure_list(any_widget_schema(FLEX_OBJ_SCHEMA)) -} -LAYOUT_SCHEMAS[df.TYPE_NONE] = { - cv.Optional(df.CONF_WIDGETS): cv.ensure_list(any_widget_schema()) -} for w_type in WIDGET_TYPES.values(): register_action( f"lvgl.{w_type.name}.update", @@ -410,7 +398,7 @@ def display_schema(config): def add_hello_world(config): if df.CONF_WIDGETS not in config and CONF_PAGES not in config: LOGGER.info("No pages or widgets configured, creating default hello_world page") - config[df.CONF_WIDGETS] = cv.ensure_list(WIDGET_SCHEMA)(get_hello_world()) + config[df.CONF_WIDGETS] = any_widget_schema()(get_hello_world()) return config @@ -450,6 +438,7 @@ LVGL_SCHEMA = cv.All( ), } ), + cv.Optional(CONF_PAGES): cv.ensure_list(container_schema(page_spec)), **{ cv.Optional(x): validate_automation( { @@ -459,12 +448,6 @@ LVGL_SCHEMA = cv.All( ) for x in SIMPLE_TRIGGERS }, - cv.Exclusive(df.CONF_WIDGETS, CONF_PAGES): cv.ensure_list( - WIDGET_SCHEMA - ), - cv.Exclusive(CONF_PAGES, CONF_PAGES): cv.ensure_list( - container_schema(page_spec) - ), cv.Optional(df.CONF_MSGBOXES): cv.ensure_list(MSGBOX_SCHEMA), cv.Optional(df.CONF_PAGE_WRAP, default=True): lv_bool, cv.Optional(df.CONF_TOP_LAYER): container_schema(obj_spec), diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index 3241ba9c3f..f2bcb6cc06 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -394,6 +394,8 @@ LV_FLEX_ALIGNMENTS = LvConstant( "SPACE_BETWEEN", ) +LV_FLEX_CROSS_ALIGNMENTS = LV_FLEX_ALIGNMENTS.extend("STRETCH") + LV_MENU_MODES = LvConstant( "LV_MENU_HEADER_", "TOP_FIXED", @@ -436,6 +438,7 @@ CONF_BUTTONS = "buttons" CONF_BYTE_ORDER = "byte_order" CONF_CHANGE_RATE = "change_rate" CONF_CLOSE_BUTTON = "close_button" +CONF_CONTAINER = "container" CONF_CONTROL = "control" CONF_DEFAULT_FONT = "default_font" CONF_DEFAULT_GROUP = "default_group" diff --git a/esphome/components/lvgl/layout.py b/esphome/components/lvgl/layout.py new file mode 100644 index 0000000000..0aed525e16 --- /dev/null +++ b/esphome/components/lvgl/layout.py @@ -0,0 +1,357 @@ +import re + +import esphome.config_validation as cv +from esphome.const import CONF_HEIGHT, CONF_TYPE, CONF_WIDTH + +from .defines import ( + CONF_FLEX_ALIGN_CROSS, + CONF_FLEX_ALIGN_MAIN, + CONF_FLEX_ALIGN_TRACK, + CONF_FLEX_FLOW, + CONF_FLEX_GROW, + CONF_GRID_CELL_COLUMN_POS, + CONF_GRID_CELL_COLUMN_SPAN, + CONF_GRID_CELL_ROW_POS, + CONF_GRID_CELL_ROW_SPAN, + CONF_GRID_CELL_X_ALIGN, + CONF_GRID_CELL_Y_ALIGN, + CONF_GRID_COLUMN_ALIGN, + CONF_GRID_COLUMNS, + CONF_GRID_ROW_ALIGN, + CONF_GRID_ROWS, + CONF_LAYOUT, + CONF_PAD_COLUMN, + CONF_PAD_ROW, + CONF_WIDGETS, + FLEX_FLOWS, + LV_CELL_ALIGNMENTS, + LV_FLEX_ALIGNMENTS, + LV_FLEX_CROSS_ALIGNMENTS, + LV_GRID_ALIGNMENTS, + TYPE_FLEX, + TYPE_GRID, + TYPE_NONE, + LvConstant, +) +from .lv_validation import padding, size + +cell_alignments = LV_CELL_ALIGNMENTS.one_of +grid_alignments = LV_GRID_ALIGNMENTS.one_of +flex_alignments = LV_FLEX_ALIGNMENTS.one_of + +FLEX_LAYOUT_SCHEMA = { + cv.Required(CONF_TYPE): cv.one_of(TYPE_FLEX, lower=True), + cv.Optional(CONF_FLEX_FLOW, default="row_wrap"): FLEX_FLOWS.one_of, + cv.Optional(CONF_FLEX_ALIGN_MAIN, default="start"): flex_alignments, + cv.Optional( + CONF_FLEX_ALIGN_CROSS, default="start" + ): LV_FLEX_CROSS_ALIGNMENTS.one_of, + cv.Optional(CONF_FLEX_ALIGN_TRACK, default="start"): flex_alignments, + cv.Optional(CONF_PAD_ROW): padding, + cv.Optional(CONF_PAD_COLUMN): padding, + cv.Optional(CONF_FLEX_GROW): cv.int_, +} + +FLEX_HV_STYLE = { + CONF_FLEX_ALIGN_MAIN: "LV_FLEX_ALIGN_SPACE_EVENLY", + CONF_FLEX_ALIGN_TRACK: "LV_FLEX_ALIGN_CENTER", + CONF_FLEX_ALIGN_CROSS: "LV_FLEX_ALIGN_CENTER", + CONF_TYPE: TYPE_FLEX, +} + +FLEX_OBJ_SCHEMA = { + cv.Optional(CONF_FLEX_GROW): cv.int_, +} + + +def flex_hv_schema(dir): + dir = CONF_HEIGHT if dir == "horizontal" else CONF_WIDTH + return { + cv.Optional(CONF_FLEX_GROW, default=1): cv.int_, + cv.Optional(dir, default="100%"): size, + } + + +def grid_free_space(value): + value = cv.Upper(value) + if value.startswith("FR(") and value.endswith(")"): + value = value.removesuffix(")").removeprefix("FR(") + return f"LV_GRID_FR({cv.positive_int(value)})" + raise cv.Invalid("must be a size in pixels, CONTENT or FR(nn)") + + +grid_spec = cv.Any(size, LvConstant("LV_GRID_", "CONTENT").one_of, grid_free_space) + +GRID_CELL_SCHEMA = { + cv.Optional(CONF_GRID_CELL_ROW_POS): cv.positive_int, + cv.Optional(CONF_GRID_CELL_COLUMN_POS): cv.positive_int, + cv.Optional(CONF_GRID_CELL_ROW_SPAN, default=1): cv.positive_int, + cv.Optional(CONF_GRID_CELL_COLUMN_SPAN, default=1): cv.positive_int, + cv.Optional(CONF_GRID_CELL_X_ALIGN): grid_alignments, + cv.Optional(CONF_GRID_CELL_Y_ALIGN): grid_alignments, +} + + +class Layout: + """ + Define properties for a layout + The base class is layout "none" + """ + + def get_type(self): + return TYPE_NONE + + def get_layout_schemas(self, config: dict) -> tuple: + """ + Get the layout and child schema for a given widget based on its layout type. + """ + return None, {} + + def validate(self, config): + """ + Validate the layout configuration. This is called late in the schema validation + :param config: The input configuration + :return: The validated configuration + """ + return config + + +class FlexLayout(Layout): + def get_type(self): + return TYPE_FLEX + + def get_layout_schemas(self, config: dict) -> tuple: + layout = config.get(CONF_LAYOUT) + if not isinstance(layout, dict) or layout.get(CONF_TYPE) != TYPE_FLEX: + return None, {} + child_schema = FLEX_OBJ_SCHEMA + if grow := layout.get(CONF_FLEX_GROW): + child_schema = {cv.Optional(CONF_FLEX_GROW, default=grow): cv.int_} + # Polyfill to implement stretch alignment for flex containers + # LVGL does not support this natively, so we add a 100% size property to the children in the cross-axis + if layout.get(CONF_FLEX_ALIGN_CROSS) == "LV_FLEX_ALIGN_STRETCH": + dimension = ( + CONF_WIDTH + if "COLUMN" in layout[CONF_FLEX_FLOW].upper() + else CONF_HEIGHT + ) + child_schema[cv.Optional(dimension, default="100%")] = size + return FLEX_LAYOUT_SCHEMA, child_schema + + def validate(self, config): + """ + Perform validation on the container and its children for this layout + :param config: + :return: + """ + return config + + +class DirectionalLayout(FlexLayout): + def __init__(self, direction: str, flow): + """ + :param direction: "horizontal" or "vertical" + :param flow: "row" or "column" + """ + super().__init__() + self.direction = direction + self.flow = flow + + def get_type(self): + return self.direction + + def get_layout_schemas(self, config: dict) -> tuple: + if config.get(CONF_LAYOUT, "").lower() != self.direction: + return None, {} + return cv.one_of(self.direction, lower=True), flex_hv_schema(self.direction) + + def validate(self, config): + assert config[CONF_LAYOUT].lower() == self.direction + config[CONF_LAYOUT] = { + **FLEX_HV_STYLE, + CONF_FLEX_FLOW: "LV_FLEX_FLOW_" + self.flow.upper(), + } + return config + + +class GridLayout(Layout): + _GRID_LAYOUT_REGEX = re.compile(r"^\s*(\d+)\s*x\s*(\d+)\s*$") + + def get_type(self): + return TYPE_GRID + + def get_layout_schemas(self, config: dict) -> tuple: + layout = config.get(CONF_LAYOUT) + if isinstance(layout, str): + if GridLayout._GRID_LAYOUT_REGEX.match(layout): + return ( + cv.string, + { + cv.Optional(CONF_GRID_CELL_ROW_POS): cv.positive_int, + cv.Optional(CONF_GRID_CELL_COLUMN_POS): cv.positive_int, + cv.Optional( + CONF_GRID_CELL_ROW_SPAN, default=1 + ): cv.positive_int, + cv.Optional( + CONF_GRID_CELL_COLUMN_SPAN, default=1 + ): cv.positive_int, + cv.Optional( + CONF_GRID_CELL_X_ALIGN, default="center" + ): grid_alignments, + cv.Optional( + CONF_GRID_CELL_Y_ALIGN, default="center" + ): grid_alignments, + }, + ) + # Not a valid grid layout string + return None, {} + + if not isinstance(layout, dict) or layout.get(CONF_TYPE) != TYPE_GRID: + return None, {} + return ( + { + cv.Required(CONF_TYPE): cv.one_of(TYPE_GRID, lower=True), + cv.Required(CONF_GRID_ROWS): [grid_spec], + cv.Required(CONF_GRID_COLUMNS): [grid_spec], + cv.Optional(CONF_GRID_COLUMN_ALIGN): grid_alignments, + cv.Optional(CONF_GRID_ROW_ALIGN): grid_alignments, + cv.Optional(CONF_PAD_ROW): padding, + cv.Optional(CONF_PAD_COLUMN): padding, + }, + { + cv.Optional(CONF_GRID_CELL_ROW_POS): cv.positive_int, + cv.Optional(CONF_GRID_CELL_COLUMN_POS): cv.positive_int, + cv.Optional(CONF_GRID_CELL_ROW_SPAN, default=1): cv.positive_int, + cv.Optional(CONF_GRID_CELL_COLUMN_SPAN, default=1): cv.positive_int, + cv.Optional(CONF_GRID_CELL_X_ALIGN): grid_alignments, + cv.Optional(CONF_GRID_CELL_Y_ALIGN): grid_alignments, + }, + ) + + def validate(self, config: dict): + """ + Validate the grid layout. + The `layout:` key may be a dictionary with `rows` and `columns` keys, or a string in the format "rows x columns". + Either all cells must have a row and column, + or none, in which case the grid layout is auto-generated. + :param config: + :return: The config updated with auto-generated values + """ + layout = config.get(CONF_LAYOUT) + if isinstance(layout, str): + # If the layout is a string, assume it is in the format "rows x columns", implying + # a grid layout with the specified number of rows and columns each with CONTENT sizing. + layout = layout.strip() + match = GridLayout._GRID_LAYOUT_REGEX.match(layout) + if match: + rows = int(match.group(1)) + cols = int(match.group(2)) + layout = { + CONF_TYPE: TYPE_GRID, + CONF_GRID_ROWS: ["LV_GRID_FR(1)"] * rows, + CONF_GRID_COLUMNS: ["LV_GRID_FR(1)"] * cols, + } + config[CONF_LAYOUT] = layout + else: + raise cv.Invalid( + f"Invalid grid layout format: {config}, expected 'rows x columns'", + [CONF_LAYOUT], + ) + # should be guaranteed to be a dict at this point + assert isinstance(layout, dict) + assert layout.get(CONF_TYPE) == TYPE_GRID + rows = len(layout[CONF_GRID_ROWS]) + columns = len(layout[CONF_GRID_COLUMNS]) + used_cells = [[None] * columns for _ in range(rows)] + for index, widget in enumerate(config.get(CONF_WIDGETS, [])): + _, w = next(iter(widget.items())) + if (CONF_GRID_CELL_COLUMN_POS in w) != (CONF_GRID_CELL_ROW_POS in w): + raise cv.Invalid( + "Both row and column positions must be specified, or both omitted", + [CONF_WIDGETS, index], + ) + if CONF_GRID_CELL_ROW_POS in w: + row = w[CONF_GRID_CELL_ROW_POS] + column = w[CONF_GRID_CELL_COLUMN_POS] + else: + try: + row, column = next( + (r_idx, c_idx) + for r_idx, row in enumerate(used_cells) + for c_idx, value in enumerate(row) + if value is None + ) + except StopIteration: + raise cv.Invalid( + "No free cells available in grid layout", [CONF_WIDGETS, index] + ) from None + w[CONF_GRID_CELL_ROW_POS] = row + w[CONF_GRID_CELL_COLUMN_POS] = column + + for i in range(w[CONF_GRID_CELL_ROW_SPAN]): + for j in range(w[CONF_GRID_CELL_COLUMN_SPAN]): + if row + i >= rows or column + j >= columns: + raise cv.Invalid( + f"Cell at {row}/{column} span {w[CONF_GRID_CELL_ROW_SPAN]}x{w[CONF_GRID_CELL_COLUMN_SPAN]} " + f"exceeds grid size {rows}x{columns}", + [CONF_WIDGETS, index], + ) + if used_cells[row + i][column + j] is not None: + raise cv.Invalid( + f"Cell span {row + i}/{column + j} already occupied by widget at index {used_cells[row + i][column + j]}", + [CONF_WIDGETS, index], + ) + used_cells[row + i][column + j] = index + + return config + + +LAYOUT_CLASSES = ( + FlexLayout(), + GridLayout(), + DirectionalLayout("horizontal", "row"), + DirectionalLayout("vertical", "column"), +) +LAYOUT_CHOICES = [x.get_type() for x in LAYOUT_CLASSES] + + +def append_layout_schema(schema, config: dict): + """ + Get the child layout schema for a given widget based on its layout type. + :param config: The config to check + :return: A schema for the layout including a widgets key + """ + # Local import to avoid circular dependencies + if CONF_WIDGETS not in config: + if CONF_LAYOUT in config: + raise cv.Invalid( + f"Layout {config[CONF_LAYOUT]} requires a {CONF_WIDGETS} key", + [CONF_LAYOUT], + ) + return schema + + from .schemas import any_widget_schema + + if CONF_LAYOUT not in config: + # If no layout is specified, return the schema as is + return schema.extend({cv.Optional(CONF_WIDGETS): any_widget_schema()}) + + for layout_class in LAYOUT_CLASSES: + layout_schema, child_schema = layout_class.get_layout_schemas(config) + if layout_schema: + layout_schema = cv.Schema( + { + cv.Required(CONF_LAYOUT): layout_schema, + cv.Required(CONF_WIDGETS): any_widget_schema(child_schema), + } + ) + layout_schema.add_extra(layout_class.validate) + return layout_schema.extend(schema) + + # If no layout class matched, return a default schema + return cv.Schema( + { + cv.Optional(CONF_LAYOUT): cv.one_of(*LAYOUT_CHOICES, lower=True), + cv.Optional(CONF_WIDGETS): any_widget_schema(), + } + ) diff --git a/esphome/components/lvgl/lv_validation.py b/esphome/components/lvgl/lv_validation.py index 6f95a32a18..9fe72128ce 100644 --- a/esphome/components/lvgl/lv_validation.py +++ b/esphome/components/lvgl/lv_validation.py @@ -1,3 +1,4 @@ +import re from typing import TYPE_CHECKING, Any import esphome.codegen as cg @@ -246,6 +247,8 @@ def pixels_or_percent_validator(value): return ["pixels", "..%"] if isinstance(value, str) and value.lower().endswith("px"): value = cv.int_(value[:-2]) + if isinstance(value, str) and re.match(r"^lv_pct\((\d+)\)$", value): + return value value = cv.Any(cv.int_, cv.percentage)(value) if isinstance(value, int): return value diff --git a/esphome/components/lvgl/lvcode.py b/esphome/components/lvgl/lvcode.py index ea38845c07..c11597131f 100644 --- a/esphome/components/lvgl/lvcode.py +++ b/esphome/components/lvgl/lvcode.py @@ -299,6 +299,7 @@ class LvExpr(MockLv): # Top level mock for generic lv_ calls to be recorded lv = MockLv("lv_") +LV = MockLv("LV_") # Just generate an expression lv_expr = LvExpr("lv_") # Mock for lv_obj_ calls @@ -327,7 +328,7 @@ def lv_assign(target, expression): lv_add(AssignmentExpression("", "", target, expression)) -def lv_Pvariable(type, name): +def lv_Pvariable(type, name) -> MockObj: """ Create but do not initialise a pointer variable :param type: Type of the variable target @@ -343,7 +344,7 @@ def lv_Pvariable(type, name): return var -def lv_variable(type, name): +def lv_variable(type, name) -> MockObj: """ Create but do not initialise a variable :param type: Type of the variable target diff --git a/esphome/components/lvgl/schemas.py b/esphome/components/lvgl/schemas.py index 959d203c41..dd248d0b94 100644 --- a/esphome/components/lvgl/schemas.py +++ b/esphome/components/lvgl/schemas.py @@ -12,17 +12,21 @@ from esphome.const import ( CONF_TEXT, CONF_TIME, CONF_TRIGGER_ID, - CONF_TYPE, CONF_X, CONF_Y, ) from esphome.core import TimePeriod from esphome.core.config import StartupTrigger -from esphome.schema_extractors import SCHEMA_EXTRACT from . import defines as df, lv_validation as lvalid -from .defines import CONF_TIME_FORMAT, LV_GRAD_DIR, TYPE_GRID -from .helpers import add_lv_use, requires_component, validate_printf +from .defines import CONF_TIME_FORMAT, LV_GRAD_DIR +from .helpers import requires_component, validate_printf +from .layout import ( + FLEX_OBJ_SCHEMA, + GRID_CELL_SCHEMA, + append_layout_schema, + grid_alignments, +) from .lv_validation import lv_color, lv_font, lv_gradient, lv_image, opacity from .lvcode import LvglComponent, lv_event_t_ptr from .types import ( @@ -72,11 +76,9 @@ def _validate_text(value): # A schema for text properties -TEXT_SCHEMA = cv.Schema( - { - cv.Optional(CONF_TEXT): _validate_text, - } -) +TEXT_SCHEMA = { + cv.Optional(CONF_TEXT): _validate_text, +} LIST_ACTION_SCHEMA = cv.ensure_list( cv.maybe_simple_value( @@ -136,7 +138,7 @@ STYLE_PROPS = { "arc_opa": lvalid.opacity, "arc_color": lvalid.lv_color, "arc_rounded": lvalid.lv_bool, - "arc_width": lvalid.lv_positive_int, + "arc_width": lvalid.pixels, "anim_time": lvalid.lv_milliseconds, "bg_color": lvalid.lv_color, "bg_grad": lv_gradient, @@ -223,10 +225,6 @@ STYLE_REMAP = { "image_recolor_opa": "img_recolor_opa", } -cell_alignments = df.LV_CELL_ALIGNMENTS.one_of -grid_alignments = df.LV_GRID_ALIGNMENTS.one_of -flex_alignments = df.LV_FLEX_ALIGNMENTS.one_of - # Complete object style schema STYLE_SCHEMA = cv.Schema({cv.Optional(k): v for k, v in STYLE_PROPS.items()}).extend( { @@ -266,10 +264,8 @@ def part_schema(parts): :param parts: The parts to include :return: The schema """ - return ( - cv.Schema({cv.Optional(part): STATE_SCHEMA for part in parts}) - .extend(STATE_SCHEMA) - .extend(FLAG_SCHEMA) + return STATE_SCHEMA.extend(FLAG_SCHEMA).extend( + {cv.Optional(part): STATE_SCHEMA for part in parts} ) @@ -277,10 +273,10 @@ def automation_schema(typ: LvType): events = df.LV_EVENT_TRIGGERS + df.SWIPE_TRIGGERS if typ.has_on_value: events = events + (CONF_ON_VALUE,) - args = typ.get_arg_type() if isinstance(typ, LvType) else [] + args = typ.get_arg_type() args.append(lv_event_t_ptr) - return cv.Schema( - { + return { + **{ cv.Optional(event): validate_automation( { cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( @@ -289,14 +285,11 @@ def automation_schema(typ: LvType): } ) for event in events - } - ).extend( - { - cv.Optional(CONF_ON_BOOT): validate_automation( - {cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(StartupTrigger)} - ) - } - ) + }, + cv.Optional(CONF_ON_BOOT): validate_automation( + {cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(StartupTrigger)} + ), + } def base_update_schema(widget_type, parts): @@ -335,75 +328,17 @@ def obj_schema(widget_type: WidgetType): """ return ( part_schema(widget_type.parts) - .extend(LAYOUT_SCHEMA) .extend(ALIGN_TO_SCHEMA) .extend(automation_schema(widget_type.w_type)) .extend( - cv.Schema( - { - cv.Optional(CONF_STATE): SET_STATE_SCHEMA, - cv.Optional(CONF_GROUP): cv.use_id(lv_group_t), - } - ) + { + cv.Optional(CONF_STATE): SET_STATE_SCHEMA, + cv.Optional(CONF_GROUP): cv.use_id(lv_group_t), + } ) ) -def _validate_grid_layout(config): - layout = config[df.CONF_LAYOUT] - rows = len(layout[df.CONF_GRID_ROWS]) - columns = len(layout[df.CONF_GRID_COLUMNS]) - used_cells = [[None] * columns for _ in range(rows)] - for index, widget in enumerate(config[df.CONF_WIDGETS]): - _, w = next(iter(widget.items())) - if (df.CONF_GRID_CELL_COLUMN_POS in w) != (df.CONF_GRID_CELL_ROW_POS in w): - # pylint: disable=raise-missing-from - raise cv.Invalid( - "Both row and column positions must be specified, or both omitted", - [df.CONF_WIDGETS, index], - ) - if df.CONF_GRID_CELL_ROW_POS in w: - row = w[df.CONF_GRID_CELL_ROW_POS] - column = w[df.CONF_GRID_CELL_COLUMN_POS] - else: - try: - row, column = next( - (r_idx, c_idx) - for r_idx, row in enumerate(used_cells) - for c_idx, value in enumerate(row) - if value is None - ) - except StopIteration: - # pylint: disable=raise-missing-from - raise cv.Invalid( - "No free cells available in grid layout", [df.CONF_WIDGETS, index] - ) - w[df.CONF_GRID_CELL_ROW_POS] = row - w[df.CONF_GRID_CELL_COLUMN_POS] = column - - for i in range(w[df.CONF_GRID_CELL_ROW_SPAN]): - for j in range(w[df.CONF_GRID_CELL_COLUMN_SPAN]): - if row + i >= rows or column + j >= columns: - # pylint: disable=raise-missing-from - raise cv.Invalid( - f"Cell at {row}/{column} span {w[df.CONF_GRID_CELL_ROW_SPAN]}x{w[df.CONF_GRID_CELL_COLUMN_SPAN]} " - f"exceeds grid size {rows}x{columns}", - [df.CONF_WIDGETS, index], - ) - if used_cells[row + i][column + j] is not None: - # pylint: disable=raise-missing-from - raise cv.Invalid( - f"Cell span {row + i}/{column + j} already occupied by widget at index {used_cells[row + i][column + j]}", - [df.CONF_WIDGETS, index], - ) - used_cells[row + i][column + j] = index - - return config - - -LAYOUT_SCHEMAS = {} -LAYOUT_VALIDATORS = {TYPE_GRID: _validate_grid_layout} - ALIGN_TO_SCHEMA = { cv.Optional(df.CONF_ALIGN_TO): cv.Schema( { @@ -416,57 +351,6 @@ ALIGN_TO_SCHEMA = { } -def grid_free_space(value): - value = cv.Upper(value) - if value.startswith("FR(") and value.endswith(")"): - value = value.removesuffix(")").removeprefix("FR(") - return f"LV_GRID_FR({cv.positive_int(value)})" - raise cv.Invalid("must be a size in pixels, CONTENT or FR(nn)") - - -grid_spec = cv.Any( - lvalid.size, df.LvConstant("LV_GRID_", "CONTENT").one_of, grid_free_space -) - -LAYOUT_SCHEMA = { - cv.Optional(df.CONF_LAYOUT): cv.typed_schema( - { - df.TYPE_GRID: { - cv.Required(df.CONF_GRID_ROWS): [grid_spec], - cv.Required(df.CONF_GRID_COLUMNS): [grid_spec], - cv.Optional(df.CONF_GRID_COLUMN_ALIGN): grid_alignments, - cv.Optional(df.CONF_GRID_ROW_ALIGN): grid_alignments, - cv.Optional(df.CONF_PAD_ROW): lvalid.padding, - cv.Optional(df.CONF_PAD_COLUMN): lvalid.padding, - }, - df.TYPE_FLEX: { - cv.Optional( - df.CONF_FLEX_FLOW, default="row_wrap" - ): df.FLEX_FLOWS.one_of, - cv.Optional(df.CONF_FLEX_ALIGN_MAIN, default="start"): flex_alignments, - cv.Optional(df.CONF_FLEX_ALIGN_CROSS, default="start"): flex_alignments, - cv.Optional(df.CONF_FLEX_ALIGN_TRACK, default="start"): flex_alignments, - cv.Optional(df.CONF_PAD_ROW): lvalid.padding, - cv.Optional(df.CONF_PAD_COLUMN): lvalid.padding, - }, - }, - lower=True, - ) -} - -GRID_CELL_SCHEMA = { - cv.Optional(df.CONF_GRID_CELL_ROW_POS): cv.positive_int, - cv.Optional(df.CONF_GRID_CELL_COLUMN_POS): cv.positive_int, - cv.Optional(df.CONF_GRID_CELL_ROW_SPAN, default=1): cv.positive_int, - cv.Optional(df.CONF_GRID_CELL_COLUMN_SPAN, default=1): cv.positive_int, - cv.Optional(df.CONF_GRID_CELL_X_ALIGN): grid_alignments, - cv.Optional(df.CONF_GRID_CELL_Y_ALIGN): grid_alignments, -} - -FLEX_OBJ_SCHEMA = { - cv.Optional(df.CONF_FLEX_GROW): cv.int_, -} - DISP_BG_SCHEMA = cv.Schema( { cv.Optional(df.CONF_DISP_BG_IMAGE): cv.Any( @@ -498,48 +382,11 @@ ALL_STYLES = { } -def container_validator(schema, widget_type: WidgetType): - """ - Create a validator for a container given the widget type - :param schema: Base schema to extend - :param widget_type: - :return: - """ - - def validator(value): - if w_sch := widget_type.schema: - if isinstance(w_sch, dict): - w_sch = cv.Schema(w_sch) - # order is important here to preserve extras - result = w_sch.extend(schema) - else: - result = schema - ltype = df.TYPE_NONE - 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) - if not ltype: - raise (cv.Invalid("Layout schema requires type:")) - add_lv_use(ltype) - if value == SCHEMA_EXTRACT: - return result - result = result.extend( - LAYOUT_SCHEMAS.get(ltype.lower(), LAYOUT_SCHEMAS[df.TYPE_NONE]) - ) - value = result(value) - if layout_validator := LAYOUT_VALIDATORS.get(ltype): - value = layout_validator(value) - return value - - return validator - - 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. - :param widget_type: The widget type, e.g. "img" + :param widget_type: The widget type, e.g. "image" :param extras: Additional options to be made available, e.g. layout properties for children :return: The schema for this type of widget. """ @@ -549,31 +396,49 @@ def container_schema(widget_type: WidgetType, extras=None): if extras: schema = schema.extend(extras) # Delayed evaluation for recursion - return container_validator(schema, widget_type) + schema = schema.extend(widget_type.schema) -def widget_schema(widget_type: WidgetType, 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 := widget_type.required_component: - validator = cv.All(validator, requires_component(required)) - return cv.Exclusive(widget_type.name, df.CONF_WIDGETS), validator + def validator(value): + return append_layout_schema(schema, value)(value) - -# All widget schemas must be defined before this is called. + return validator 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 + Dynamically 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: A validator for the Widgets key """ - return cv.Any(dict(widget_schema(wt, extras) for wt in WIDGET_TYPES.values())) + + def validator(value): + if isinstance(value, dict): + # Convert to list + value = [{k: v} for k, v in value.items()] + if not isinstance(value, list): + raise cv.Invalid("Expected a list of widgets") + result = [] + for index, entry in enumerate(value): + if not isinstance(entry, dict) or len(entry) != 1: + raise cv.Invalid( + "Each widget must be a dictionary with a single key", path=[index] + ) + [(key, value)] = entry.items() + # Validate the widget against its schema + widget_type = WIDGET_TYPES.get(key) + if not widget_type: + raise cv.Invalid(f"Unknown widget type: {key}", path=[index]) + container_validator = container_schema(widget_type, extras=extras) + if required := widget_type.required_component: + container_validator = cv.All( + container_validator, requires_component(required) + ) + # Apply custom validation + value = widget_type.validate(value or {}) + result.append({key: container_validator(value)}) + return result + + return validator diff --git a/esphome/components/lvgl/types.py b/esphome/components/lvgl/types.py index 9955b530aa..8c33e13934 100644 --- a/esphome/components/lvgl/types.py +++ b/esphome/components/lvgl/types.py @@ -1,6 +1,7 @@ import sys from esphome import automation, codegen as cg +from esphome.config_validation import Schema from esphome.const import CONF_MAX_VALUE, CONF_MIN_VALUE, CONF_TEXT, CONF_VALUE from esphome.cpp_generator import MockObj, MockObjClass from esphome.cpp_types import esphome_ns @@ -135,14 +136,14 @@ class WidgetType: self.lv_name = lv_name or name self.w_type = w_type self.parts = parts - if schema is None: - self.schema = {} - else: - self.schema = schema + if not isinstance(schema, Schema): + schema = Schema(schema or {}) + self.schema = schema if modify_schema is None: - self.modify_schema = self.schema - else: - self.modify_schema = modify_schema + modify_schema = schema + if not isinstance(modify_schema, Schema): + modify_schema = Schema(modify_schema) + self.modify_schema = modify_schema self.mock_obj = MockObj(f"lv_{self.lv_name}", "_") @property @@ -163,7 +164,6 @@ class WidgetType: :param config: Its configuration :return: Generated code as a list of text lines """ - return [] async def obj_creator(self, parent: MockObjClass, config: dict): """ @@ -174,6 +174,13 @@ class WidgetType: """ return lv_expr.call(f"{self.lv_name}_create", parent) + def on_create(self, var: MockObj, config: dict): + """ + Called from to_code when the widget is created, to set up any initial properties + :param var: The variable representing the widget + :param config: Its configuration + """ + def get_uses(self): """ Get a list of other widgets used by this one @@ -193,6 +200,14 @@ class WidgetType: def get_scale(self, config: dict): return 1.0 + def validate(self, value): + """ + Provides an opportunity for custom validation for a given widget type + :param value: + :return: + """ + return value + class NumberType(WidgetType): def get_max(self, config: dict): diff --git a/esphome/components/lvgl/widgets/__init__.py b/esphome/components/lvgl/widgets/__init__.py index 1f9cdde0a0..7d9f9cb7de 100644 --- a/esphome/components/lvgl/widgets/__init__.py +++ b/esphome/components/lvgl/widgets/__init__.py @@ -339,7 +339,10 @@ async def set_obj_properties(w: Widget, config): if layout_type == TYPE_FLEX: lv_obj.set_flex_flow(w.obj, literal(layout[CONF_FLEX_FLOW])) main = literal(layout[CONF_FLEX_ALIGN_MAIN]) - cross = literal(layout[CONF_FLEX_ALIGN_CROSS]) + cross = layout[CONF_FLEX_ALIGN_CROSS] + if cross == "LV_FLEX_ALIGN_STRETCH": + cross = "LV_FLEX_ALIGN_CENTER" + cross = literal(cross) track = literal(layout[CONF_FLEX_ALIGN_TRACK]) lv_obj.set_flex_align(w.obj, main, cross, track) parts = collect_parts(config) @@ -446,9 +449,11 @@ async def widget_to_code(w_cnfig, w_type: WidgetType, parent): if spec.is_compound(): var = cg.new_Pvariable(wid) lv_add(var.set_obj(creator)) + spec.on_create(var.obj, w_cnfig) else: var = lv_Pvariable(lv_obj_t, wid) lv_assign(var, creator) + spec.on_create(var, w_cnfig) w = Widget.create(wid, var, spec, w_cnfig) if theme := theme_widget_map.get(w_type): diff --git a/esphome/components/lvgl/widgets/canvas.py b/esphome/components/lvgl/widgets/canvas.py index f0a9cd35ba..ead352aa77 100644 --- a/esphome/components/lvgl/widgets/canvas.py +++ b/esphome/components/lvgl/widgets/canvas.py @@ -159,18 +159,15 @@ async def canvas_set_pixel(config, action_id, template_arg, args): ) -DRAW_SCHEMA = cv.Schema( - { - cv.GenerateID(CONF_ID): cv.use_id(lv_canvas_t), - cv.Required(CONF_X): pixels, - cv.Required(CONF_Y): pixels, - } -) -DRAW_OPA_SCHEMA = DRAW_SCHEMA.extend( - { - cv.Optional(CONF_OPA): opacity, - } -) +DRAW_SCHEMA = { + cv.GenerateID(CONF_ID): cv.use_id(lv_canvas_t), + cv.Required(CONF_X): pixels, + cv.Required(CONF_Y): pixels, +} +DRAW_OPA_SCHEMA = { + **DRAW_SCHEMA, + cv.Optional(CONF_OPA): opacity, +} async def draw_to_code(config, dsc_type, props, do_draw, action_id, template_arg, args): @@ -224,12 +221,14 @@ RECT_PROPS = { @automation.register_action( "lvgl.canvas.draw_rectangle", ObjUpdateAction, - DRAW_SCHEMA.extend( + cv.Schema( { + **DRAW_OPA_SCHEMA, cv.Required(CONF_WIDTH): cv.templatable(cv.int_), cv.Required(CONF_HEIGHT): cv.templatable(cv.int_), - }, - ).extend({cv.Optional(prop): STYLE_PROPS[prop] for prop in RECT_PROPS}), + **{cv.Optional(prop): STYLE_PROPS[prop] for prop in RECT_PROPS}, + } + ), ) async def canvas_draw_rect(config, action_id, template_arg, args): width = await pixels.process(config[CONF_WIDTH]) @@ -261,13 +260,14 @@ TEXT_PROPS = { @automation.register_action( "lvgl.canvas.draw_text", ObjUpdateAction, - TEXT_SCHEMA.extend(DRAW_OPA_SCHEMA) - .extend( + cv.Schema( { + **TEXT_SCHEMA, + **DRAW_OPA_SCHEMA, cv.Required(CONF_MAX_WIDTH): cv.templatable(cv.int_), + **{cv.Optional(prop): STYLE_PROPS[f"text_{prop}"] for prop in TEXT_PROPS}, }, - ) - .extend({cv.Optional(prop): STYLE_PROPS[f"text_{prop}"] for prop in TEXT_PROPS}), + ), ) async def canvas_draw_text(config, action_id, template_arg, args): text = await lv_text.process(config[CONF_TEXT]) @@ -293,13 +293,15 @@ IMG_PROPS = { @automation.register_action( "lvgl.canvas.draw_image", ObjUpdateAction, - DRAW_OPA_SCHEMA.extend( + cv.Schema( { + **DRAW_OPA_SCHEMA, cv.Required(CONF_SRC): lv_image, cv.Optional(CONF_PIVOT_X, default=0): pixels, cv.Optional(CONF_PIVOT_Y, default=0): pixels, - }, - ).extend({cv.Optional(prop): validator for prop, validator in IMG_PROPS.items()}), + **{cv.Optional(prop): validator for prop, validator in IMG_PROPS.items()}, + } + ), ) async def canvas_draw_image(config, action_id, template_arg, args): src = await lv_image.process(config[CONF_SRC]) @@ -336,8 +338,9 @@ LINE_PROPS = { cv.GenerateID(CONF_ID): cv.use_id(lv_canvas_t), cv.Optional(CONF_OPA): opacity, cv.Required(CONF_POINTS): cv.ensure_list(point_schema), - }, - ).extend({cv.Optional(prop): validator for prop, validator in LINE_PROPS.items()}), + **{cv.Optional(prop): validator for prop, validator in LINE_PROPS.items()}, + } + ), ) async def canvas_draw_line(config, action_id, template_arg, args): points = [ @@ -363,8 +366,9 @@ async def canvas_draw_line(config, action_id, template_arg, args): { cv.GenerateID(CONF_ID): cv.use_id(lv_canvas_t), cv.Required(CONF_POINTS): cv.ensure_list(point_schema), + **{cv.Optional(prop): STYLE_PROPS[prop] for prop in RECT_PROPS}, }, - ).extend({cv.Optional(prop): STYLE_PROPS[prop] for prop in RECT_PROPS}), + ), ) async def canvas_draw_polygon(config, action_id, template_arg, args): points = [ @@ -395,13 +399,15 @@ ARC_PROPS = { @automation.register_action( "lvgl.canvas.draw_arc", ObjUpdateAction, - DRAW_OPA_SCHEMA.extend( + cv.Schema( { + **DRAW_OPA_SCHEMA, cv.Required(CONF_RADIUS): pixels, cv.Required(CONF_START_ANGLE): lv_angle_degrees, cv.Required(CONF_END_ANGLE): lv_angle_degrees, + **{cv.Optional(prop): validator for prop, validator in ARC_PROPS.items()}, } - ).extend({cv.Optional(prop): validator for prop, validator in ARC_PROPS.items()}), + ), ) async def canvas_draw_arc(config, action_id, template_arg, args): radius = await size.process(config[CONF_RADIUS]) diff --git a/esphome/components/lvgl/widgets/checkbox.py b/esphome/components/lvgl/widgets/checkbox.py index c344fbfe75..ca97e2d843 100644 --- a/esphome/components/lvgl/widgets/checkbox.py +++ b/esphome/components/lvgl/widgets/checkbox.py @@ -17,11 +17,10 @@ class CheckboxType(WidgetType): CONF_CHECKBOX, LvBoolean("lv_checkbox_t"), (CONF_MAIN, CONF_INDICATOR), - TEXT_SCHEMA.extend( - { - Optional(CONF_PAD_COLUMN): padding, - } - ), + { + **TEXT_SCHEMA, + Optional(CONF_PAD_COLUMN): padding, + }, ) async def to_code(self, w: Widget, config): diff --git a/esphome/components/lvgl/widgets/container.py b/esphome/components/lvgl/widgets/container.py new file mode 100644 index 0000000000..2ac1a3b244 --- /dev/null +++ b/esphome/components/lvgl/widgets/container.py @@ -0,0 +1,39 @@ +import esphome.config_validation as cv +from esphome.const import CONF_HEIGHT, CONF_WIDTH +from esphome.cpp_generator import MockObj + +from ..defines import CONF_CONTAINER, CONF_MAIN, CONF_OBJ, CONF_SCROLLBAR +from ..lv_validation import size +from ..lvcode import lv +from ..types import WidgetType, lv_obj_t + +CONTAINER_SCHEMA = cv.Schema( + { + cv.Optional(CONF_HEIGHT, default="100%"): size, + cv.Optional(CONF_WIDTH, default="100%"): size, + } +) + + +class ContainerType(WidgetType): + """ + A simple container widget that can hold other widgets and which defaults to a 100% size. + Made from an obj with all styles removed + """ + + def __init__(self): + super().__init__( + CONF_CONTAINER, + lv_obj_t, + (CONF_MAIN, CONF_SCROLLBAR), + schema=CONTAINER_SCHEMA, + modify_schema={}, + lv_name=CONF_OBJ, + ) + self.styles = {} + + def on_create(self, var: MockObj, config: dict): + lv.obj_remove_style_all(var) + + +container_spec = ContainerType() diff --git a/esphome/components/lvgl/widgets/label.py b/esphome/components/lvgl/widgets/label.py index 6b04235674..3a3a997737 100644 --- a/esphome/components/lvgl/widgets/label.py +++ b/esphome/components/lvgl/widgets/label.py @@ -23,12 +23,11 @@ class LabelType(WidgetType): CONF_LABEL, LvText("lv_label_t"), (CONF_MAIN, CONF_SCROLLBAR, CONF_SELECTED), - TEXT_SCHEMA.extend( - { - cv.Optional(CONF_RECOLOR): lv_bool, - cv.Optional(CONF_LONG_MODE): LV_LONG_MODES.one_of, - } - ), + { + **TEXT_SCHEMA, + cv.Optional(CONF_RECOLOR): lv_bool, + cv.Optional(CONF_LONG_MODE): LV_LONG_MODES.one_of, + }, ) async def to_code(self, w: Widget, config): diff --git a/esphome/components/lvgl/widgets/qrcode.py b/esphome/components/lvgl/widgets/qrcode.py index 028a81b449..ad46f67c6b 100644 --- a/esphome/components/lvgl/widgets/qrcode.py +++ b/esphome/components/lvgl/widgets/qrcode.py @@ -14,13 +14,12 @@ CONF_QRCODE = "qrcode" CONF_DARK_COLOR = "dark_color" CONF_LIGHT_COLOR = "light_color" -QRCODE_SCHEMA = TEXT_SCHEMA.extend( - { - cv.Optional(CONF_DARK_COLOR, default="black"): lv_color, - cv.Optional(CONF_LIGHT_COLOR, default="white"): lv_color, - cv.Required(CONF_SIZE): cv.int_, - } -) +QRCODE_SCHEMA = { + **TEXT_SCHEMA, + cv.Optional(CONF_DARK_COLOR, default="black"): lv_color, + cv.Optional(CONF_LIGHT_COLOR, default="white"): lv_color, + cv.Required(CONF_SIZE): cv.int_, +} class QrCodeType(WidgetType): diff --git a/esphome/components/lvgl/widgets/textarea.py b/esphome/components/lvgl/widgets/textarea.py index 23d50b3894..e5ab884685 100644 --- a/esphome/components/lvgl/widgets/textarea.py +++ b/esphome/components/lvgl/widgets/textarea.py @@ -21,15 +21,14 @@ CONF_TEXTAREA = "textarea" lv_textarea_t = LvText("lv_textarea_t") -TEXTAREA_SCHEMA = TEXT_SCHEMA.extend( - { - cv.Optional(CONF_PLACEHOLDER_TEXT): lv_text, - cv.Optional(CONF_ACCEPTED_CHARS): lv_text, - cv.Optional(CONF_ONE_LINE): lv_bool, - cv.Optional(CONF_PASSWORD_MODE): lv_bool, - cv.Optional(CONF_MAX_LENGTH): lv_int, - } -) +TEXTAREA_SCHEMA = { + **TEXT_SCHEMA, + cv.Optional(CONF_PLACEHOLDER_TEXT): lv_text, + cv.Optional(CONF_ACCEPTED_CHARS): lv_text, + cv.Optional(CONF_ONE_LINE): lv_bool, + cv.Optional(CONF_PASSWORD_MODE): lv_bool, + cv.Optional(CONF_MAX_LENGTH): lv_int, +} class TextareaType(WidgetType): diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index 14241a1669..ca669c16e4 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -113,9 +113,10 @@ lvgl: title: Messagebox bg_color: 0xffff widgets: - - label: - text: Hello Msgbox - id: msgbox_label + # Test single widget without list + label: + text: Hello Msgbox + id: msgbox_label body: text: This is a sample messagebox bg_color: 0x808080 @@ -281,7 +282,7 @@ lvgl: #endif return std::string(buf); align: top_left - - obj: + - container: align: center arc_opa: COVER arc_color: 0xFF0000 @@ -414,6 +415,7 @@ lvgl: - buttons: - id: button_e - button: + layout: 2x1 id: button_button width: 20% height: 10% @@ -430,8 +432,13 @@ lvgl: checked: bg_color: 0x000000 widgets: - - label: - text: Button + # Test parse a dict instead of list + label: + text: Button + align: bottom_right + image: + src: cat_image + align: top_left on_click: - lvgl.widget.focus: spin_up - lvgl.widget.focus: next @@ -539,6 +546,7 @@ lvgl: - logger.log: "tile 1 is now showing" tiles: - id: tile_1 + layout: vertical row: 0 column: 0 dir: ALL @@ -554,6 +562,7 @@ lvgl: bg_color: 0x000000 - id: page2 + layout: vertical widgets: - canvas: id: canvas_id @@ -1005,6 +1014,7 @@ lvgl: r_mod: -20 opa: 0% - id: page3 + layout: horizontal widgets: - keyboard: id: lv_keyboard