From bba3a9657bae2ac8edf3e1cc63c0b58154f9ac28 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 9 Jul 2026 02:17:57 +1000 Subject: [PATCH] [lvgl] Add animations (#16796) Co-authored-by: clydeps Co-authored-by: Claude Opus 4.8 Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/lvgl/__init__.py | 7 +- esphome/components/lvgl/animation.h | 197 +++++++++++++ esphome/components/lvgl/animation.py | 295 +++++++++++++++++++ esphome/components/lvgl/defines.py | 23 +- esphome/components/lvgl/lv_validation.py | 62 ++-- esphome/components/lvgl/types.py | 1 + esphome/core/defines.h | 1 + tests/component_tests/lvgl/test_animation.py | 201 +++++++++++++ tests/components/lvgl/lvgl-package.yaml | 57 ++++ tests/components/lvgl/test.host.yaml | 39 ++- 10 files changed, 854 insertions(+), 29 deletions(-) create mode 100644 esphome/components/lvgl/animation.h create mode 100644 esphome/components/lvgl/animation.py create mode 100644 tests/component_tests/lvgl/test_animation.py diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index ecc4b0a7779..b758390f0d0 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -52,9 +52,11 @@ from esphome.writer import clean_build from esphome.yaml_util import load_yaml from . import defines as df, lv_validation as lvalid, widgets +from .animation import ANIMATION_SCHEMA, add_animation_triggers, animations_to_code from .automation import layers_to_code, lvgl_update from .defines import ( CONF_ALIGN_TO_LAMBDA_ID, + CONF_ANIMATIONS, LOGGER, add_lv_use, get_focused_widgets, @@ -435,7 +437,8 @@ async def to_code(configs): await layers_to_code(lv_component, config) await lvgl_update(lv_component, config) await msgboxes_to_code(lv_component, config) - # await disp_update(lv_component.get_disp(), config) + await animations_to_code(config.get(CONF_ANIMATIONS, [])) + # Mark all widgets as completed so awaiters of ``wait_for_widgets`` proceed. set_widgets_completed(True) async with LvContext(): @@ -443,6 +446,7 @@ async def to_code(configs): await generate_align_tos(configs[0]) for config in configs: lv_component = await cg.get_variable(config[CONF_ID]) + await add_animation_triggers(config.get(CONF_ANIMATIONS, [])) await generate_page_triggers(config) await initial_focus_to_code(config) for conf in config.get(CONF_ON_IDLE, ()): @@ -636,6 +640,7 @@ LVGL_TOP_LEVEL_SCHEMA = ( for x in SIMPLE_TRIGGERS }, cv.Optional(df.CONF_MSGBOXES): cv.ensure_list(MSGBOX_SCHEMA), + cv.Optional(df.CONF_ANIMATIONS): cv.ensure_list(ANIMATION_SCHEMA), cv.Optional(df.CONF_PAGE_WRAP, default=True): lv_bool, cv.Optional(df.CONF_TOP_LAYER): container_schema(obj_spec), cv.Optional(df.CONF_BOTTOM_LAYER): container_schema(obj_spec), diff --git a/esphome/components/lvgl/animation.h b/esphome/components/lvgl/animation.h new file mode 100644 index 00000000000..1e0abce3583 --- /dev/null +++ b/esphome/components/lvgl/animation.h @@ -0,0 +1,197 @@ +#pragma once +#include "esphome/core/defines.h" + +#ifdef USE_LVGL_ANIMATION +#include "lvgl_esphome.h" +#include "esphome/core/hal.h" + +namespace esphome::lvgl { + +enum class AnimationState { + STOPPED, + STARTED, + RUNNING, +}; + +class LvAnimationTiming { + public: + // Map progress in the range [0, 1] + virtual float map_progress(float value) = 0; +}; + +class LvAnimationTimingRoundTrip : public LvAnimationTiming { + public: + float map_progress(float value) override { + value *= 2.0f; + if (value > 1.0f) + return 2.0f - value; + return value; + } +}; + +class LvAnimationTimingGravity : public LvAnimationTiming { + public: + LvAnimationTimingGravity(float acceleration, float bounce) : acceleration_(acceleration), bounce_(bounce) {} + float map_progress(float value) override { + if (value == 0.0f) { + this->initial_position_ = 0.0f; + this->initial_speed_ = 0.0f; + this->initial_time_ = 0.0f; + } + auto position = this->calc_pos_(value); + if (position > 1.0f) { + auto initial_time = this->calc_end_time_(); + this->initial_speed_ = -this->calc_speed_(initial_time) * this->bounce_; + this->initial_position_ = 1.0f; + this->initial_time_ = initial_time; + position = calc_pos_(value); + if (position > 1.0f) { + position = 1.0f; + } + } + return position; + } + + protected: + float calc_pos_(float value) const { + value -= this->initial_time_; + return (0.5 * value * this->acceleration_ + this->initial_speed_) * value + this->initial_position_; + } + + float calc_speed_(float value) const { + value -= this->initial_time_; + return this->acceleration_ * value + this->initial_speed_; + } + + float calc_end_time_() const { + return (-this->initial_speed_ + std::sqrt(this->initial_speed_ * this->initial_speed_ - + 4.0f * this->acceleration_ / 2.0 * (this->initial_position_ - 1.0f))) / + this->acceleration_ + + this->initial_time_; + } + + float acceleration_; + float bounce_; + float initial_position_{0.0f}; + float initial_time_{0.0f}; + float initial_speed_{0.0f}; +}; + +class LvAnimationTimingEaseInOut : public LvAnimationTiming { + public: + LvAnimationTimingEaseInOut(float slope) : slope_(slope) {} + float map_progress(float value) override { + float sqr = value * value; + sqr = sqr / (2.0f * (sqr - value) + 1.0f); + return this->slope_ * sqr + (1.0 - this->slope_) * value; + } + + protected: + float slope_; +}; + +template class LvAnimation : public Component { + public: + LvAnimation(void (*update_callback)(const lv_coord_t *data), std::vector> from, + std::vector> to) + : update_callback_(update_callback) { + std::copy(from.begin(), from.end(), this->from_); + std::copy(to.begin(), to.end(), this->to_); + } + + void start() { + if (this->state_ > AnimationState::STOPPED) + this->stop(); + if (this->duration_ == 0) + return; + // evaluate any lambdas + for (size_t i = 0; i != DATA_SIZE; i++) { + this->data_from_[i] = this->from_[i].value(); + this->data_to_[i] = this->to_[i].value(); + } + this->start_time_ = millis(); + this->state_ = AnimationState::STARTED; + this->loop(); + this->start_callback_.call(); + } + + void stop() { + // Only fire the stop callback on a genuine running -> stopped transition, so that + // repeated stop() calls (e.g. start() pre-clearing a stopped animation) don't re-fire it. + if (this->state_ == AnimationState::STOPPED) + return; + this->state_ = AnimationState::STOPPED; + this->stop_callback_.call(); + } + + void setup() override { + if constexpr (AUTO_START) + this->start(); + } + + void loop() override { + if (this->state_ == AnimationState::STOPPED) + return; + uint32_t elapsed = millis() - this->start_time_; + float progress = static_cast(elapsed) / static_cast(this->duration_); + switch (this->state_) { + case AnimationState::STARTED: + if (elapsed < this->start_delay_) + return; + this->state_ = AnimationState::RUNNING; + this->start_time_ = millis(); + progress = 0.0f; + break; + case AnimationState::RUNNING: + if (progress >= 1.0f) { + progress = 1.0f; + this->stop(); + if (this->loop_) + this->start(); + } + break; + default: + return; + } + + for (auto *timing : this->timings_) { + progress = timing->map_progress(progress); + } + lv_coord_t data[DATA_SIZE]; + for (size_t i = 0; i != DATA_SIZE; i++) { + data[i] = static_cast( + roundf(this->data_from_[i] + static_cast(this->data_to_[i] - this->data_from_[i]) * progress)); + } + this->update_callback_(data); + } + + float get_setup_priority() const override { return setup_priority::PROCESSOR - 20.0; } + void set_duration(uint32_t duration) { this->duration_ = duration; } + void set_start_delay(uint32_t start_delay) { this->start_delay_ = start_delay; } + void add_timing(LvAnimationTiming *timing) { this->timings_.push_back(timing); } + void set_loop(bool loop) { this->loop_ = loop; } + + template void add_on_start_callback(F &&callback) { + this->start_callback_.add(std::forward(callback)); + } + template void add_on_stop_callback(F &&callback) { this->stop_callback_.add(std::forward(callback)); } + + protected: + void (*const update_callback_)(const lv_coord_t *data); + LazyCallbackManager start_callback_{}; + LazyCallbackManager stop_callback_{}; + TemplatableValue from_[DATA_SIZE]{}; + TemplatableValue to_[DATA_SIZE]{}; + uint32_t duration_{0}; + uint32_t start_delay_{0}; + uint32_t start_time_{0}; + lv_coord_t data_from_[DATA_SIZE]{0}; + lv_coord_t data_to_[DATA_SIZE]{0}; + AnimationState state_{AnimationState::STOPPED}; + std::vector timings_{}; + bool loop_{false}; +}; + +} // namespace esphome::lvgl + +#endif // USE_LVGL_ANIMATION diff --git a/esphome/components/lvgl/animation.py b/esphome/components/lvgl/animation.py new file mode 100644 index 00000000000..2b1500f2c47 --- /dev/null +++ b/esphome/components/lvgl/animation.py @@ -0,0 +1,295 @@ +from esphome import automation, codegen as cg, config_validation as cv +from esphome.automation import Trigger, build_automation +from esphome.config_validation import COMPONENT_SCHEMA +from esphome.const import ( + CONF_ACCELERATION, + CONF_DURATION, + CONF_FROM, + CONF_ID, + CONF_ON_START, + CONF_TIMING, + CONF_TO, + CONF_TRIGGER_ID, + CONF_TYPE, + CONF_WEIGHT, +) +from esphome.cpp_generator import MockObj, TemplateArguments + +from ..const import CONF_LOOP +from .defines import ( + CONF_AUTO_START, + CONF_LVGL_ID, + CONF_ON_STOP, + CONF_WIDGETS, + LValidator, + add_define, + literal, +) +from .lv_validation import ( + color, + get_component_colors, + lv_color, + lv_milliseconds, + lv_positive_float, + lv_zero_to_one_float, +) +from .lvcode import LVGL_COMP_ARG, LambdaContext, LvglComponent, lv_add +from .schemas import STYLE_PROPS +from .types import LvAnimation, LvglAction, lv_color_t, lv_coord_t, lv_obj_t, lvgl_ns +from .widgets import get_widgets + +LvAnimationTimingRoundTrip = lvgl_ns.class_("LvAnimationTimingRoundTrip") +LvAnimationTimingEaseInOut = lvgl_ns.class_("LvAnimationTimingEaseInOut") + +CONF_BOUNCE = "bounce" + + +def timing_class(name, extras=None): + # Convert config option to camel case + cls_name = "LvAnimationTiming" + "".join([w.capitalize() for w in name.split("_")]) + cls = lvgl_ns.class_(cls_name) + schema = cv.Schema({cv.GenerateID(): cv.declare_id(cls)}) + if extras: + schema = schema.extend(extras) + return name, schema + + +# TODO - currently the order of arguments to timing classes is expected to be alphabetical, but this is not enforced. +# It would be better to have a more robust way of passing arguments to the timing classes. +TIMING_SCHEMA = cv.maybe_simple_value( + cv.typed_schema( + dict( + [ + timing_class("round_trip"), + timing_class( + "ease_in_out", + {cv.Optional(CONF_WEIGHT, default=2.0): lv_positive_float}, + ), + timing_class( + "gravity", + { + cv.Optional(CONF_ACCELERATION, default=0.5): lv_positive_float, + cv.Optional(CONF_BOUNCE, default=0.5): lv_zero_to_one_float, + }, + ), + ] + ), + default_type="ease_in_out", + ), + key=CONF_TYPE, +) + +CONF_START_DELAY = "start_delay" + + +class LiteralColorValidator(LValidator): + def __init__(self): + super().__init__( + color, lv_color_t, retmapper=get_component_colors, animatable=True + ) + + def __call__(self, value): + if isinstance(value, cv.Lambda): + raise cv.Invalid( + "An animated color may not be set with a lambda, only a literal color value." + ) + return super().__call__(value) + + +literal_color = LiteralColorValidator() + + +def from_to(validator): + return cv.Schema( + { + cv.Required(CONF_FROM): validator, + cv.Required(CONF_TO): validator, + } + ) + + +# Colors can only be animated between constants, not lambdas. +def map_v(validator): + if validator == lv_color: + return literal_color + return validator + + +ANIMABLE_STYLES = { + k: map_v(v) + for k, v in STYLE_PROPS.items() + if isinstance(v, LValidator) and v.animatable +} + +ANIMATION_SCHEMA = cv.Schema( + { + cv.Optional(CONF_AUTO_START, default=False): cv.boolean, + cv.Optional(CONF_LOOP, default=False): cv.boolean, + cv.Optional(CONF_DURATION, default="5s"): lv_milliseconds, + cv.Optional(CONF_START_DELAY, default="0s"): lv_milliseconds, + cv.Optional(CONF_TIMING, default=[]): cv.ensure_list(TIMING_SCHEMA), + cv.Required(CONF_ID): cv.declare_id(LvAnimation), + cv.Optional(CONF_ON_START): automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(Trigger.template()), + } + ), + cv.Optional(CONF_ON_STOP): automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(Trigger.template()), + } + ), + cv.Required(CONF_WIDGETS): cv.ensure_list( + cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(lv_obj_t), + } + ).extend({cv.Optional(k): from_to(v) for k, v in ANIMABLE_STYLES.items()}) + ), + } +).extend(COMPONENT_SCHEMA) + + +async def _process_arg(validator, arg) -> list: + # from/to values are evaluated at animation start with no arguments, so the + # generated lambda must be parameterless rather than inheriting the enclosing + # update-callback's `values` parameter. + value = await validator.process(arg, args=[], raw_lambda=True) + value = list(value) if isinstance(value, tuple) else [value] + return [literal(f"TemplatableValue({v})") for v in value] + + +async def animations_to_code(config): + for animation in config: + add_define("USE_LVGL_ANIMATION") + widgets = animation[CONF_WIDGETS] + async with LambdaContext( + [(lv_coord_t.operator("const").operator("ptr"), "values")] + ) as ctx: + froms = [] + tos = [] + for widget in widgets: + w = (await get_widgets(widget))[0] + props = [(k, v) for k, v in widget.items() if k in ANIMABLE_STYLES] + for prop, value_range in props: + # prop is the style property, value_range is a dict with from: and to: values + validator = ANIMABLE_STYLES[prop] + from_value = await _process_arg(validator, value_range[CONF_FROM]) + to_value = await _process_arg(validator, value_range[CONF_TO]) + index = len(froms) + if len(from_value) == 1: + value = f"values[{index}]" + else: + value = f"lv_color_make(values[{index}+0], values[{index}+1], values[{index}+2])" + w.set_style(prop, literal(value), 0) + # The value arrays are extended by 1 item for scalar properties, 3 for colors + froms.extend(from_value) + tos.extend(to_value) + + data_size = len(froms) + loop = animation[CONF_LOOP] + start_delay = await lv_milliseconds.process(animation.get(CONF_START_DELAY)) + var = cg.new_Pvariable( + animation[CONF_ID], + TemplateArguments(data_size, animation[CONF_AUTO_START]), + await ctx.get_lambda(), + froms, + tos, + ) + for timing in animation[CONF_TIMING]: + timing_id = timing[CONF_ID] + args = sorted( + [(k, v) for k, v in timing.items() if k not in [CONF_ID, CONF_TYPE]] + ) + args = [v for k, v in args] + timing_var = cg.new_Pvariable(timing_id, *args) + cg.add(var.add_timing(timing_var)) + + if start_delay: + cg.add(var.set_start_delay(start_delay)) + if loop: + cg.add(var.set_loop(loop)) + cg.add( + var.set_duration(await lv_milliseconds.process(animation[CONF_DURATION])) + ) + await cg.register_component(var, animation) + + +async def add_animation_triggers(config): + async def add_triggers(animation: MockObj, event: str, config: dict) -> None: + for conf in config: + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID]) + await build_automation(trigger, [], conf) + async with LambdaContext([]) as context: + lv_add(trigger.trigger()) + lv_add( + getattr( + animation, + f"add_{event}_callback", + )(await context.get_lambda()) + ) + + for animation in config: + var = await cg.get_variable(animation[CONF_ID]) + await add_triggers(var, CONF_ON_START, animation.get(CONF_ON_START, [])) + await add_triggers(var, CONF_ON_STOP, animation.get(CONF_ON_STOP, [])) + + +@automation.register_action( + "lvgl.animation.start", + LvglAction, + cv.maybe_simple_value( + { + cv.Required(CONF_ID): cv.ensure_list(cv.use_id(LvAnimation)), + cv.GenerateID(CONF_LVGL_ID): cv.use_id(LvglComponent), + cv.Optional(CONF_DURATION): lv_milliseconds, + cv.Optional(CONF_START_DELAY): lv_milliseconds, + cv.Optional(CONF_LOOP): cv.boolean, + }, + key=CONF_ID, + ), + synchronous=True, +) +async def start_animation(config, action_id, template_arg, args): + animations = config[CONF_ID] + loop = config.get(CONF_LOOP) + async with LambdaContext(LVGL_COMP_ARG, where=action_id) as context: + for animation in animations: + anim_var = await cg.get_variable(animation) + if loop is not None: + context.add(anim_var.set_loop(loop)) + if (duration := config.get(CONF_DURATION)) is not None: + context.add( + anim_var.set_duration(await lv_milliseconds.process(duration)) + ) + if (start_delay := config.get(CONF_START_DELAY)) is not None: + context.add( + anim_var.set_start_delay(await lv_milliseconds.process(start_delay)) + ) + context.add(anim_var.start()) + var = cg.new_Pvariable(action_id, template_arg, await context.get_lambda()) + await cg.register_parented(var, config[CONF_LVGL_ID]) + return var + + +@automation.register_action( + "lvgl.animation.stop", + LvglAction, + cv.maybe_simple_value( + { + cv.Required(CONF_ID): cv.ensure_list(cv.use_id(LvAnimation)), + cv.GenerateID(CONF_LVGL_ID): cv.use_id(LvglComponent), + }, + key=CONF_ID, + ), + synchronous=True, +) +async def stop_animation(config, action_id, template_arg, args): + animations = config[CONF_ID] + async with LambdaContext(LVGL_COMP_ARG, where=action_id) as context: + for animation in animations: + anim_var = await cg.get_variable(animation) + context.add(anim_var.stop()) + var = cg.new_Pvariable(action_id, template_arg, await context.get_lambda()) + await cg.register_parented(var, config[CONF_LVGL_ID]) + return var diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index 15e593b3f64..5c75269c648 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -214,11 +214,14 @@ class LValidator: has `process()` to convert a value during code generation """ - def __init__(self, validator, rtype: MockObj, retmapper=None, requires=None): + def __init__( + self, validator, rtype: MockObj, retmapper=None, requires=None, animatable=False + ): self.validator = validator self.rtype = rtype self.retmapper = retmapper self.requires = requires + self.animatable = animatable def __call__(self, value): if self.requires: @@ -228,7 +231,10 @@ class LValidator: return self.validator(value) async def process( - self, value: Any, args: list[tuple[SafeExpType, str]] | None = None + self, + value: Any, + args: list[tuple[SafeExpType, str]] | None = None, + raw_lambda: bool = False, ) -> Expression: if value is None: return None @@ -236,11 +242,15 @@ class LValidator: # Local import to avoid circular import from .lvcode import get_lambda_context_args - args = args or get_lambda_context_args() + # `args is None` means "inherit the enclosing lambda context"; an explicit + # empty list means "no parameters" and must be preserved as-is. + if args is None: + args = get_lambda_context_args() - return call_lambda( - await cg.process_lambda(value, args, return_type=self.rtype) - ) + lamb = await cg.process_lambda(value, args, return_type=self.rtype) + if raw_lambda: + return lamb + return call_lambda(lamb) if self.retmapper is not None: return self.retmapper(value) if isinstance(value, ID): @@ -751,6 +761,7 @@ CONF_ON_DRAW_END = "on_draw_end" CONF_ON_PAUSE = "on_pause" CONF_ON_RESUME = "on_resume" CONF_ON_SELECT = "on_select" +CONF_ON_STOP = "on_stop" CONF_OPA = "opa" CONF_NEXT = "next" CONF_PAD_ROW = "pad_row" diff --git a/esphome/components/lvgl/lv_validation.py b/esphome/components/lvgl/lv_validation.py index 27cbfff6942..d31c8324dba 100644 --- a/esphome/components/lvgl/lv_validation.py +++ b/esphome/components/lvgl/lv_validation.py @@ -60,6 +60,7 @@ opacity = LValidator( opacity_validator, lv_opa_t, retmapper=lambda opa: StaticCastExpression(cg.uint8, opa * 255.0), + animatable=True, ) COLOR_NAMES = { @@ -223,35 +224,33 @@ def color(value): ) -def color_retmapper(value): - if isinstance(value, cv.Lambda): - return cv.returning_lambda(value) +def get_component_colors(value): if isinstance(value, str) and value in COLOR_NAMES: value = COLOR_NAMES[value] if isinstance(value, int): - return literal( - f"lv_color_make({(value >> 16) & 0xFF}, {(value >> 8) & 0xFF}, {value & 0xFF})" - ) + return value >> 16, value >> 8 & 0xFF, value & 0xFF if isinstance(value, ID): cval = [x for x in CORE.config[CONF_COLOR] if x[CONF_ID] == value][0] if CONF_HEX in cval: r, g, b = cval[CONF_HEX] else: r, g, b, _ = from_rgbw(cval) - return literal(f"lv_color_make({r}, {g}, {b})") + return r, g, b raise AssertionError(f"Unhandled lv_color value: {value!r}") -def option_string(value): - value = cv.string(value).strip() - if value.find("\n") != -1: - raise cv.Invalid("Options strings must not contain newlines") - return value +def color_retmapper(value): + if isinstance(value, cv.Lambda): + return cv.returning_lambda(value) + r, g, b = get_component_colors(value) + return literal(f"lv_color_make({r}, {g}, {b})") class LvColor(LValidator): def __init__(self): - super().__init__(color, ty.lv_color_t, retmapper=color_retmapper) + super().__init__( + color, ty.lv_color_t, retmapper=color_retmapper, animatable=True + ) def __getattr__(self, item): if item in COLOR_NAMES: @@ -262,6 +261,13 @@ class LvColor(LValidator): lv_color = LvColor() +def option_string(value): + value = cv.string(value).strip() + if value.find("\n") != -1: + raise cv.Invalid("Options strings must not contain newlines") + return value + + def pixels_or_percent_validator(value): """A length in one axis - either a number (pixels) or a percentage""" if value == SCHEMA_EXTRACT: @@ -277,6 +283,7 @@ pixels_or_percent = LValidator( pixels_or_percent_validator, lv_coord_t, retmapper=lambda x: x if isinstance(x, int) else literal(f"lv_pct({int(x * 100)})"), + animatable=True, ) @@ -315,10 +322,10 @@ def angle(value): # Validator for angles in LVGL expressed in 1/10 degree units. -lv_angle = LValidator(angle, uint32, retmapper=lambda x: int(x * 10)) +lv_angle = LValidator(angle, uint32, retmapper=lambda x: int(x * 10), animatable=True) # Validator for angles in LVGL expressed in whole degrees -lv_angle_degrees = LValidator(angle, uint32, retmapper=int) +lv_angle_degrees = LValidator(angle, uint32, retmapper=int, animatable=True) @schema_extractor("one_of") @@ -410,7 +417,10 @@ class TextValidator(LValidator): return super().__call__(value) async def process( - self, value: Any, args: list[tuple[SafeExpType, str]] | None = None + self, + value: Any, + args: list[tuple[SafeExpType, str]] | None = None, + raw_lambda: bool = False, ) -> Expression: # Local import to avoid circular import at module level from .lvcode import get_lambda_context_args @@ -455,13 +465,18 @@ class TextValidator(LValidator): return value # Either a std::string or a lambda call returning that. We need const char* return MockObj(f"({value}).c_str()") - return await super().process(value, args) + return await super().process(value, args, raw_lambda) lv_text = TextValidator() lv_float = LValidator(cv.float_, cg.float_) -lv_int = LValidator(cv.int_, cg.int_) -lv_positive_int = LValidator(cv.positive_int, cg.int_) +lv_positive_float = LValidator(cv.positive_float, cg.float_) +lv_zero_to_one_float = LValidator(cv.zero_to_one_float, cg.float_) +lv_int = LValidator(cv.int_, cg.int_, animatable=True) +lv_positive_int = LValidator(cv.positive_int, cg.int_, animatable=True) +lv_brightness = LValidator( + cv.percentage, cg.float_, retmapper=lambda x: int(x * 255), animatable=True +) def _percentage_validator(value): @@ -508,12 +523,17 @@ class LvFont(LValidator): # The inline overloads in lvgl_esphome.h handle conversion to lv_font_t* super().__init__(validator, Font.operator("ptr")) - async def process(self, value, args=()): + async def process( + self, + value: Any, + args: list[tuple[SafeExpType, str]] | None = None, + raw_lambda: bool = False, + ): if is_lv_font(value): return literal(f"&lv_font_{value}") if isinstance(value, str): return literal(f"{value}") - return await super().process(value, args) + return await super().process(value, args, raw_lambda) lv_font = LvFont() diff --git a/esphome/components/lvgl/types.py b/esphome/components/lvgl/types.py index 509d5cc7824..61efe385e69 100644 --- a/esphome/components/lvgl/types.py +++ b/esphome/components/lvgl/types.py @@ -67,6 +67,7 @@ lv_obj_t = LvType("lv_obj_t") lv_page_t = LvType("LvPageType", parents=(LvCompound,)) lv_image_t = LvType("lv_image_t") lv_gradient_t = LvType("lv_grad_dsc_t") +LvAnimation = lvgl_ns.class_("LvAnimation", cg.Component) lv_event_t = LvType("lv_event_t") RotationType = lvgl_ns.enum("RotationType") lv_point_t = cg.global_ns.struct("lv_point_t") diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 639508a7b2e..bdb0f27f453 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -89,6 +89,7 @@ #define USE_LOGGER_LEVEL_LISTENERS #define USE_LOGGER_RUNTIME_TAG_LEVELS #define USE_LVGL +#define USE_LVGL_ANIMATION #define USE_LVGL_ANIMIMG #define USE_LVGL_ARC #define USE_LVGL_BINARY_SENSOR diff --git a/tests/component_tests/lvgl/test_animation.py b/tests/component_tests/lvgl/test_animation.py new file mode 100644 index 00000000000..1a2cde632c5 --- /dev/null +++ b/tests/component_tests/lvgl/test_animation.py @@ -0,0 +1,201 @@ +"""Tests for the LVGL animation schema and configuration validation.""" + +from __future__ import annotations + +import pytest +from voluptuous import Invalid, MultipleInvalid + +from esphome.components.lvgl.animation import ( + ANIMABLE_STYLES, + ANIMATION_SCHEMA, + TIMING_SCHEMA, + from_to, + literal_color, +) +from esphome.components.lvgl.defines import LValidator +from esphome.core import Lambda + + +def _animation(**overrides) -> dict: + """A minimal valid animation config, with optional overrides applied.""" + config = { + "id": "anim_id", + "widgets": [{"id": "widget_id", "x": {"from": 0, "to": 100}}], + } + config.update(overrides) + return config + + +# --------------------------------------------------------------------------- +# Animatable property set +# --------------------------------------------------------------------------- + + +class TestAnimableStyles: + def test_all_entries_are_animatable_validators(self) -> None: + """Every animatable style must be an LValidator marked animatable.""" + assert ANIMABLE_STYLES + assert all( + isinstance(v, LValidator) and v.animatable for v in ANIMABLE_STYLES.values() + ) + + def test_known_animatable_present(self) -> None: + for prop in ("x", "y", "opa", "bg_color", "transform_rotation"): + assert prop in ANIMABLE_STYLES + + def test_non_animatable_absent(self) -> None: + # width/height set size but are not animatable; layout/padding never are. + for prop in ("width", "height", "radius", "pad_all", "align"): + assert prop not in ANIMABLE_STYLES + + +# --------------------------------------------------------------------------- +# Animation schema +# --------------------------------------------------------------------------- + + +class TestAnimationSchema: + def test_defaults(self) -> None: + config = ANIMATION_SCHEMA(_animation()) + assert config["duration"].total_milliseconds == 5000 + assert config["start_delay"].total_milliseconds == 0 + assert config["auto_start"] is False + assert config["loop"] is False + assert config["timing"] == [] + + def test_values_preserved(self) -> None: + config = ANIMATION_SCHEMA( + _animation(duration="2s", start_delay="250ms", auto_start=True, loop=True) + ) + assert config["duration"].total_milliseconds == 2000 + assert config["start_delay"].total_milliseconds == 250 + assert config["auto_start"] is True + assert config["loop"] is True + + def test_id_required(self) -> None: + with pytest.raises((Invalid, MultipleInvalid)): + ANIMATION_SCHEMA({"widgets": [{"id": "widget_id"}]}) + + def test_widgets_required(self) -> None: + with pytest.raises((Invalid, MultipleInvalid)): + ANIMATION_SCHEMA({"id": "anim_id"}) + + def test_multiple_properties_and_widgets(self) -> None: + config = ANIMATION_SCHEMA( + _animation( + widgets=[ + { + "id": "w1", + "x": {"from": 0, "to": 100}, + "opa": {"from": "0%", "to": "100%"}, + }, + {"id": "w2", "y": {"from": 10, "to": 50}}, + ] + ) + ) + assert len(config["widgets"]) == 2 + + def test_unknown_property_rejected(self) -> None: + with pytest.raises((Invalid, MultipleInvalid)): + ANIMATION_SCHEMA( + _animation(widgets=[{"id": "w1", "not_a_style": {"from": 0, "to": 1}}]) + ) + + +class TestAnimatedColorLiteral: + """A color animated via from/to must be a literal, not a lambda.""" + + def test_color_lambda_rejected_directly(self) -> None: + with pytest.raises(Invalid, match="lambda"): + literal_color(Lambda("return lv_color_hex(0xFF0000);")) + + def test_color_literal_accepted_directly(self) -> None: + # A literal color value validates without error. + literal_color(0xFF0000) + + def test_color_lambda_rejected_in_animation(self) -> None: + with pytest.raises((Invalid, MultipleInvalid), match="lambda"): + ANIMATION_SCHEMA( + _animation( + widgets=[ + { + "id": "w1", + "text_color": { + "from": Lambda("return lv_color_hex(0xFF0000);"), + "to": 0x00FF00, + }, + } + ] + ) + ) + + def test_color_literals_accepted_in_animation(self) -> None: + config = ANIMATION_SCHEMA( + _animation( + widgets=[{"id": "w1", "text_color": {"from": 0xFF0000, "to": 0x00FF00}}] + ) + ) + assert config["widgets"][0]["id"].id == "w1" + + def test_non_color_property_allows_lambda(self) -> None: + # Only colors are restricted; numeric properties may use lambdas. + config = ANIMATION_SCHEMA( + _animation( + widgets=[{"id": "w1", "x": {"from": Lambda("return 5;"), "to": 100}}] + ) + ) + assert config["widgets"][0]["id"].id == "w1" + + +class TestFromTo: + def test_requires_both(self) -> None: + validator = from_to(lambda value: value) + with pytest.raises((Invalid, MultipleInvalid)): + validator({"from": 1}) + with pytest.raises((Invalid, MultipleInvalid)): + validator({"to": 1}) + + def test_accepts_both(self) -> None: + validator = from_to(lambda value: value) + assert validator({"from": 1, "to": 2}) == {"from": 1, "to": 2} + + +# --------------------------------------------------------------------------- +# Timing schema +# --------------------------------------------------------------------------- + + +class TestTimingSchema: + def test_round_trip_string(self) -> None: + assert TIMING_SCHEMA("round_trip")["type"] == "round_trip" + + def test_ease_in_out_default_weight(self) -> None: + result = TIMING_SCHEMA("ease_in_out") + assert result["type"] == "ease_in_out" + assert result["weight"] == pytest.approx(2.0) + + def test_ease_in_out_custom_weight(self) -> None: + result = TIMING_SCHEMA({"type": "ease_in_out", "weight": 3}) + assert result["weight"] == pytest.approx(3.0) + + def test_gravity_defaults(self) -> None: + result = TIMING_SCHEMA("gravity") + assert result["type"] == "gravity" + assert result["bounce"] == pytest.approx(0.5) + assert result["acceleration"] == pytest.approx(0.5) + + def test_gravity_custom(self) -> None: + result = TIMING_SCHEMA({"type": "gravity", "bounce": 0.3, "acceleration": 0.8}) + assert result["bounce"] == pytest.approx(0.3) + assert result["acceleration"] == pytest.approx(0.8) + + def test_unknown_type_rejected(self) -> None: + with pytest.raises((Invalid, MultipleInvalid)): + TIMING_SCHEMA({"type": "not_a_timing"}) + + def test_timing_list_in_animation(self) -> None: + config = ANIMATION_SCHEMA( + _animation(timing=["round_trip", {"type": "gravity", "bounce": 0.3}]) + ) + types = [t["type"] for t in config["timing"]] + assert types == ["round_trip", "gravity"] diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index 4ec4eb3bd62..4b18b998484 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -53,6 +53,12 @@ lvgl: id: meter_arc_indicator start_value: 0 end_value: 180 + - lvgl.animation.start: + id: + - anim_slide + - anim_color + duration: 3s + loop: true on_invalidate_area: logger.log: Invalidate area on_resolution_change: @@ -97,6 +103,52 @@ lvgl: - obj: bg_color: 0x000000 bg_opa: cover + top_layer: + widgets: + - obj: + id: anim_box + x: 0 + y: 0 + width: 50 + height: 50 + bg_color: 0xFF0000 + - label: + id: anim_label + text: anim + animations: + - id: anim_slide + duration: 1s + start_delay: 100ms + auto_start: true + loop: true + timing: ease_in_out + on_start: + - logger.log: anim started + on_stop: + - logger.log: anim stopped + widgets: + - id: anim_box + x: + from: 0 + to: 100 + y: + from: 0 + to: !lambda "return 80;" + opa: + from: 50% + to: 100% + - id: anim_color + duration: 2s + timing: + - round_trip + - type: gravity + bounce: 0.3 + acceleration: 0.8 + widgets: + - id: anim_label + text_color: + from: 0xFF0000 + to: color_id theme: dark_mode: true obj: @@ -199,6 +251,11 @@ lvgl: on_click: then: - lvgl.display.set_rotation: 0 + - lvgl.animation.stop: anim_slide + - lvgl.animation.stop: + id: + - anim_slide + - anim_color - lvgl.widget.hide: message_box - lvgl.style.update: id: style_test diff --git a/tests/components/lvgl/test.host.yaml b/tests/components/lvgl/test.host.yaml index 6328648fe34..90cbb3c0a59 100644 --- a/tests/components/lvgl/test.host.yaml +++ b/tests/components/lvgl/test.host.yaml @@ -22,6 +22,36 @@ lvgl: displays: sdl0 rotation: 180 top_layer: + widgets: + - obj: + id: anim_box + x: 0 + y: 0 + width: 40 + height: 40 + bg_color: 0xFF0000 + animations: + - id: anim_slide + duration: 1s + start_delay: 100ms + auto_start: true + loop: true + timing: + - round_trip + - type: ease_in_out + weight: 3 + on_start: + - logger.log: anim started + on_stop: + - logger.log: anim stopped + widgets: + - id: anim_box + x: + from: 0 + to: !lambda "return 100;" + opa: + from: 50% + to: 100% - id: lvgl_1 displays: sdl1 @@ -42,7 +72,14 @@ lvgl: - label: text: Click ME on_click: - logger.log: Clicked + then: + - logger.log: Clicked + - lvgl.animation.stop: + id: anim_slide + lvgl_id: lvgl_0 + - lvgl.animation.start: + id: anim_slide + lvgl_id: lvgl_0 font: - file: "gfonts://Roboto"