From 7e4f76977cc15578f9c586993abb32a71b427fd9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 3 May 2026 10:05:22 +0000 Subject: [PATCH] Add LVGL radial and conical gradient support Agent-Logs-Url: https://github.com/clydebarrow/esphome/sessions/442ec099-d163-4102-b27a-70af7755a583 Co-authored-by: clydebarrow <2366188+clydebarrow@users.noreply.github.com> --- esphome/components/lvgl/defines.py | 3 +- esphome/components/lvgl/gradient.py | 172 ++++++++++++++++++++---- tests/components/lvgl/lvgl-package.yaml | 57 ++++++++ 3 files changed, 207 insertions(+), 25 deletions(-) diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index ef29a99ddd4b..5c96244e412d 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -360,8 +360,9 @@ def __getattr__(self, item): "OUT_BOTTOM", ) -LV_GRAD_DIR = LvConstant("LV_GRAD_DIR_", "NONE", "HOR", "VER") +LV_GRAD_DIR = LvConstant("LV_GRAD_DIR_", "NONE", "HOR", "VER", "LINEAR", "RADIAL", "CONICAL") LV_DITHER = LvConstant("LV_DITHER_", "NONE", "ORDERED", "ERR_DIFF") +LV_GRAD_EXTEND = LvConstant("LV_GRAD_EXTEND_", "PAD", "REPEAT", "REFLECT") LV_LOG_LEVELS = { "VERBOSE": "TRACE", diff --git a/esphome/components/lvgl/gradient.py b/esphome/components/lvgl/gradient.py index e075433d03e2..ee9fc51f8dc8 100644 --- a/esphome/components/lvgl/gradient.py +++ b/esphome/components/lvgl/gradient.py @@ -12,13 +12,35 @@ from esphome.core import ID from esphome.cpp_generator import MockObj -from .defines import CONF_GRADIENTS, CONF_OPA, LV_DITHER, add_define, add_warning +from .defines import ( + CONF_END_ANGLE, + CONF_GRADIENTS, + CONF_OPA, + CONF_START_ANGLE, + LV_DITHER, + LV_GRAD_EXTEND, + add_define, + add_warning, +) from .helpers import add_lv_use -from .lv_validation import lv_color, lv_percentage, opacity +from .lv_validation import lv_color, lv_percentage, opacity, pixels_or_percent from .lvcode import lv from .types import lv_color_t, lv_gradient_t, lv_opa_t CONF_STOPS = "stops" +CONF_LINEAR = "linear" +CONF_RADIAL = "radial" +CONF_CONICAL = "conical" +CONF_EXTEND = "extend" +CONF_FROM_X = "from_x" +CONF_FROM_Y = "from_y" +CONF_TO_X = "to_x" +CONF_TO_Y = "to_y" +CONF_CENTER_X = "center_x" +CONF_CENTER_Y = "center_y" +CONF_FOCAL_X = "focal_x" +CONF_FOCAL_Y = "focal_y" +CONF_FOCAL_RADIUS = "focal_radius" def min_stops(value): @@ -27,27 +49,90 @@ def min_stops(value): return value +STOPS_SCHEMA = cv.All( + [ + cv.Schema( + { + cv.Required(CONF_COLOR): lv_color, + cv.Optional(CONF_OPA, default=1.0): opacity, + cv.Required(CONF_POSITION): lv_percentage, + } + ) + ], + min_stops, +) + +LINEAR_SCHEMA = cv.Schema( + { + cv.Required(CONF_FROM_X): pixels_or_percent, + cv.Required(CONF_FROM_Y): pixels_or_percent, + cv.Required(CONF_TO_X): pixels_or_percent, + cv.Required(CONF_TO_Y): pixels_or_percent, + cv.Optional(CONF_EXTEND, default="PAD"): LV_GRAD_EXTEND.one_of, + } +) + +RADIAL_SCHEMA = cv.Schema( + { + cv.Required(CONF_CENTER_X): pixels_or_percent, + cv.Required(CONF_CENTER_Y): pixels_or_percent, + cv.Required(CONF_TO_X): pixels_or_percent, + cv.Required(CONF_TO_Y): pixels_or_percent, + cv.Optional(CONF_FOCAL_X): pixels_or_percent, + cv.Optional(CONF_FOCAL_Y): pixels_or_percent, + cv.Optional(CONF_FOCAL_RADIUS, default=0): cv.positive_int, + cv.Optional(CONF_EXTEND, default="PAD"): LV_GRAD_EXTEND.one_of, + } +) + +CONICAL_SCHEMA = cv.Schema( + { + cv.Required(CONF_CENTER_X): pixels_or_percent, + cv.Required(CONF_CENTER_Y): pixels_or_percent, + cv.Optional(CONF_START_ANGLE, default=0): cv.int_range(0, 360), + cv.Optional(CONF_END_ANGLE, default=360): cv.int_range(0, 360), + cv.Optional(CONF_EXTEND, default="PAD"): LV_GRAD_EXTEND.one_of, + } +) + + +def gradient_validator(config): + direction = config[CONF_DIRECTION] + if direction == "LINEAR" and CONF_LINEAR not in config: + raise cv.Invalid("'linear' is required for LINEAR gradient direction") + if direction == "RADIAL" and CONF_RADIAL not in config: + raise cv.Invalid("'radial' is required for RADIAL gradient direction") + if direction == "CONICAL" and CONF_CONICAL not in config: + raise cv.Invalid("'conical' is required for CONICAL gradient direction") + if CONF_RADIAL in config: + radial = config[CONF_RADIAL] + has_focal_x = CONF_FOCAL_X in radial + has_focal_y = CONF_FOCAL_Y in radial + if has_focal_x != has_focal_y: + raise cv.Invalid( + "Both 'focal_x' and 'focal_y' must be specified together in 'radial'" + ) + return config + + GRADIENT_SCHEMA = cv.ensure_list( - cv.Schema( - { - cv.GenerateID(CONF_ID): cv.declare_id(lv_gradient_t), - cv.Required(CONF_DIRECTION): cv.one_of( - "HOR", "HORIZONTAL", "VER", "VERTICAL", upper=True - ), - cv.Optional(CONF_DITHER): LV_DITHER.one_of, - cv.Required(CONF_STOPS): cv.All( - [ - cv.Schema( - { - cv.Required(CONF_COLOR): lv_color, - cv.Optional(CONF_OPA, default=1.0): opacity, - cv.Required(CONF_POSITION): lv_percentage, - } - ) - ], - min_stops, - ), - } + cv.All( + cv.Schema( + { + cv.GenerateID(CONF_ID): cv.declare_id(lv_gradient_t), + cv.Required(CONF_DIRECTION): cv.one_of( + "HOR", "HORIZONTAL", "VER", "VERTICAL", + "LINEAR", "RADIAL", "CONICAL", + upper=True, + ), + cv.Optional(CONF_DITHER): LV_DITHER.one_of, + cv.Optional(CONF_LINEAR): LINEAR_SCHEMA, + cv.Optional(CONF_RADIAL): RADIAL_SCHEMA, + cv.Optional(CONF_CONICAL): CONICAL_SCHEMA, + cv.Required(CONF_STOPS): STOPS_SCHEMA, + } + ), + gradient_validator, ) ) @@ -64,10 +149,48 @@ async def gradients_to_code(config): idbase = gradient[CONF_ID].id stops = sorted(gradient[CONF_STOPS], key=itemgetter(CONF_POSITION)) max_stops = max(max_stops, len(stops)) - if gradient[CONF_DIRECTION].startswith("VER"): + direction = gradient[CONF_DIRECTION] + if direction.startswith("VER"): lv.grad_vertical_init(var) - else: + elif direction.startswith("HOR"): lv.grad_horizontal_init(var) + elif direction == "LINEAR": + linear = gradient[CONF_LINEAR] + lv.grad_linear_init( + var, + await pixels_or_percent.process(linear[CONF_FROM_X]), + await pixels_or_percent.process(linear[CONF_FROM_Y]), + await pixels_or_percent.process(linear[CONF_TO_X]), + await pixels_or_percent.process(linear[CONF_TO_Y]), + await LV_GRAD_EXTEND.process(linear[CONF_EXTEND]), + ) + elif direction == "RADIAL": + radial = gradient[CONF_RADIAL] + lv.grad_radial_init( + var, + await pixels_or_percent.process(radial[CONF_CENTER_X]), + await pixels_or_percent.process(radial[CONF_CENTER_Y]), + await pixels_or_percent.process(radial[CONF_TO_X]), + await pixels_or_percent.process(radial[CONF_TO_Y]), + await LV_GRAD_EXTEND.process(radial[CONF_EXTEND]), + ) + if CONF_FOCAL_X in radial: + lv.grad_radial_set_focal( + var, + await pixels_or_percent.process(radial[CONF_FOCAL_X]), + await pixels_or_percent.process(radial[CONF_FOCAL_Y]), + radial[CONF_FOCAL_RADIUS], + ) + elif direction == "CONICAL": + conical = gradient[CONF_CONICAL] + lv.grad_conical_init( + var, + await pixels_or_percent.process(conical[CONF_CENTER_X]), + await pixels_or_percent.process(conical[CONF_CENTER_Y]), + conical[CONF_START_ANGLE], + conical[CONF_END_ANGLE], + await LV_GRAD_EXTEND.process(conical[CONF_EXTEND]), + ) stop_colors = cg.static_const_array( ID(idbase + "_colors_", type=lv_color_t), [await lv_color.process(x[CONF_COLOR]) for x in stops], @@ -83,3 +206,4 @@ async def gradients_to_code(config): lv.grad_init_stops(var, stop_colors, stop_opacities, stop_positions, len(stops)) add_define("LV_GRADIENT_MAX_STOPS", max_stops) + diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index 9c4ad4bbf854..ace170a0326d 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -133,6 +133,63 @@ lvgl: position: 212 - color: 0xFF0000 position: 255 + - id: linear_grad + direction: LINEAR + linear: + from_x: 0% + from_y: 0% + to_x: 100% + to_y: 0% + extend: PAD + stops: + - color: 0xFF0000 + position: 0 + - color: 0x0000FF + position: 255 + - id: radial_grad + direction: RADIAL + radial: + center_x: 50% + center_y: 50% + to_x: 100% + to_y: 50% + extend: PAD + stops: + - color: 0xFFFFFF + position: 0 + - color: 0x000000 + position: 255 + - id: radial_focal_grad + direction: RADIAL + radial: + center_x: 50% + center_y: 50% + to_x: 100% + to_y: 50% + focal_x: 40% + focal_y: 40% + focal_radius: 10 + extend: REPEAT + stops: + - color: 0xFF0000 + position: 0 + - color: 0x0000FF + position: 255 + - id: conical_grad + direction: CONICAL + conical: + center_x: 50% + center_y: 50% + start_angle: 0 + end_angle: 360 + extend: PAD + stops: + - color: 0xFF0000 + position: 0 + - color: 0x00FF00 + position: 127 + - color: 0xFF0000 + position: 255 style_definitions: - id: style_test