mirror of
https://github.com/esphome/esphome.git
synced 2026-09-16 09:38:42 +00:00
[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:
co-authored by
clydeps
Claude Opus 4.8
Jonathan Swoboda
parent
2f5465c0e8
commit
bba3a9657b
@@ -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),
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user