[lvgl] Add animations (#16796)

Co-authored-by: clydeps <U5yx99dok9>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
This commit is contained in:
Clyde Stubbs
2026-07-08 12:17:57 -04:00
committed by GitHub
co-authored by clydeps Claude Opus 4.8 Jonathan Swoboda
parent 2f5465c0e8
commit bba3a9657b
10 changed files with 854 additions and 29 deletions
+6 -1
View File
@@ -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),
+197
View File
@@ -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<size_t DATA_SIZE, bool AUTO_START = false> class LvAnimation : public Component {
public:
LvAnimation(void (*update_callback)(const lv_coord_t *data), std::vector<TemplatableValue<lv_coord_t>> from,
std::vector<TemplatableValue<lv_coord_t>> 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<float>(elapsed) / static_cast<float>(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<lv_coord_t>(
roundf(this->data_from_[i] + static_cast<lv_coord_t>(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<typename F> void add_on_start_callback(F &&callback) {
this->start_callback_.add(std::forward<F>(callback));
}
template<typename F> void add_on_stop_callback(F &&callback) { this->stop_callback_.add(std::forward<F>(callback)); }
protected:
void (*const update_callback_)(const lv_coord_t *data);
LazyCallbackManager<void()> start_callback_{};
LazyCallbackManager<void()> stop_callback_{};
TemplatableValue<lv_coord_t> from_[DATA_SIZE]{};
TemplatableValue<lv_coord_t> 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<LvAnimationTiming *> timings_{};
bool loop_{false};
};
} // namespace esphome::lvgl
#endif // USE_LVGL_ANIMATION
+295
View File
@@ -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<lv_coord_t>({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
+17 -6
View File
@@ -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"
+41 -21
View File
@@ -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()
+1
View File
@@ -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")
+1
View File
@@ -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
@@ -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"]
+57
View File
@@ -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
+38 -1
View File
@@ -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"