mirror of
https://github.com/esphome/esphome.git
synced 2026-07-10 08:55:36 +00:00
[lvgl] Dynamic rotation features (#16773)
This commit is contained in:
@@ -148,6 +148,8 @@ SIMPLE_TRIGGERS = (
|
||||
df.CONF_ON_RESUME,
|
||||
df.CONF_ON_DRAW_START,
|
||||
df.CONF_ON_DRAW_END,
|
||||
df.CONF_ON_LANDSCAPE,
|
||||
df.CONF_ON_PORTRAIT,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ from typing import Any
|
||||
from esphome import automation
|
||||
from esphome.automation import StatelessLambdaAction
|
||||
import esphome.codegen as cg
|
||||
from esphome.components.display import validate_rotation
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ACTION, CONF_GROUP, CONF_ID, CONF_ROTATION, CONF_TIMEOUT
|
||||
from esphome.core import Lambda
|
||||
@@ -16,6 +15,7 @@ from .defines import (
|
||||
CONF_BOTTOM_LAYER,
|
||||
CONF_EDITING,
|
||||
CONF_FREEZE,
|
||||
CONF_LAYOUT,
|
||||
CONF_LVGL_ID,
|
||||
CONF_MAIN,
|
||||
CONF_OBJ,
|
||||
@@ -29,7 +29,8 @@ from .defines import (
|
||||
get_options,
|
||||
get_refreshed_widgets,
|
||||
)
|
||||
from .lv_validation import lv_bool, lv_milliseconds
|
||||
from .layout import layout_validator
|
||||
from .lv_validation import lv_bool, lv_milliseconds, lv_rotation
|
||||
from .lvcode import (
|
||||
LVGL_COMP_ARG,
|
||||
UPDATE_EVENT,
|
||||
@@ -199,7 +200,7 @@ async def lvgl_is_idle(config, condition_id, template_arg, args):
|
||||
def _validate_rotation(value):
|
||||
# Note that we need rotation
|
||||
get_options()[CONF_ROTATION] = True
|
||||
return validate_rotation(value)
|
||||
return lv_rotation(value)
|
||||
|
||||
|
||||
@automation.register_action(
|
||||
@@ -218,7 +219,8 @@ def _validate_rotation(value):
|
||||
async def lvgl_set_rotation(config, action_id, template_arg, args):
|
||||
lv_comp = await cg.get_variable(config[CONF_LVGL_ID])
|
||||
async with LambdaContext(args, where=action_id) as context:
|
||||
lv_add(lv_comp.set_rotation(config[CONF_ROTATION]))
|
||||
rotation = await lv_rotation.process(config[CONF_ROTATION])
|
||||
lv_add(lv_comp.set_rotation(rotation))
|
||||
return cg.new_Pvariable(action_id, template_arg, await context.get_lambda())
|
||||
|
||||
|
||||
@@ -254,6 +256,13 @@ layer_spec = WidgetType(CONF_OBJ, lv_obj_t, (CONF_MAIN, CONF_SCROLLBAR), is_mock
|
||||
DISP_PROPS = {str(x) for x in DISP_BG_SCHEMA.schema}
|
||||
|
||||
|
||||
def _layer_update_schema() -> cv.Schema:
|
||||
"""Schema for updating a display layer's styling and layout options."""
|
||||
return part_schema(layer_spec.parts).extend(
|
||||
{cv.Optional(CONF_LAYOUT): layout_validator}
|
||||
)
|
||||
|
||||
|
||||
@automation.register_action(
|
||||
"lvgl.update",
|
||||
LvglAction,
|
||||
@@ -262,8 +271,9 @@ DISP_PROPS = {str(x) for x in DISP_BG_SCHEMA.schema}
|
||||
.extend(DISP_BG_SCHEMA)
|
||||
.extend(
|
||||
{
|
||||
cv.Optional(CONF_TOP_LAYER): part_schema(layer_spec.parts),
|
||||
cv.Optional(CONF_BOTTOM_LAYER): part_schema(layer_spec.parts),
|
||||
cv.Optional(CONF_LAYOUT): layout_validator,
|
||||
cv.Optional(CONF_TOP_LAYER): _layer_update_schema(),
|
||||
cv.Optional(CONF_BOTTOM_LAYER): _layer_update_schema(),
|
||||
}
|
||||
),
|
||||
synchronous=True,
|
||||
@@ -272,7 +282,12 @@ async def lvgl_update_to_code(config, action_id, template_arg, args):
|
||||
widgets = await get_widgets(config, CONF_LVGL_ID)
|
||||
w = widgets[0]
|
||||
async with LambdaContext(LVGL_COMP_ARG, where=action_id) as context:
|
||||
# Apply the top-level properties (styles and layout) to the active screen...
|
||||
await set_obj_properties(get_screen_active(w.var), config)
|
||||
# ...the deprecated flat `disp_*` background properties...
|
||||
await lvgl_update(w.var, config)
|
||||
# ...and the `top_layer`/`bottom_layer` keys (styling and layout updates).
|
||||
await layers_to_code(w.var, config)
|
||||
var = cg.new_Pvariable(action_id, template_arg, await context.get_lambda())
|
||||
await cg.register_parented(var, w.var)
|
||||
return var
|
||||
|
||||
@@ -760,7 +760,9 @@ CONF_ONE_CHECKED = "one_checked"
|
||||
CONF_ONE_LINE = "one_line"
|
||||
CONF_ON_DRAW_START = "on_draw_start"
|
||||
CONF_ON_DRAW_END = "on_draw_end"
|
||||
CONF_ON_LANDSCAPE = "on_landscape"
|
||||
CONF_ON_PAUSE = "on_pause"
|
||||
CONF_ON_PORTRAIT = "on_portrait"
|
||||
CONF_ON_RESUME = "on_resume"
|
||||
CONF_ON_SELECT = "on_select"
|
||||
CONF_ON_STOP = "on_stop"
|
||||
|
||||
@@ -34,6 +34,7 @@ from .defines import (
|
||||
TYPE_GRID,
|
||||
TYPE_NONE,
|
||||
LvConstant,
|
||||
add_lv_use,
|
||||
)
|
||||
from .lv_validation import padding, size
|
||||
|
||||
@@ -401,6 +402,61 @@ LAYOUT_CLASSES = (
|
||||
LAYOUT_CHOICES = [x.get_type() for x in LAYOUT_CLASSES]
|
||||
|
||||
|
||||
# Layout properties that may be changed at runtime via an update action. These
|
||||
# are limited to simple style properties (set via ``lv_obj_set_style_...``).
|
||||
# Structural properties are deliberately excluded:
|
||||
# - the layout ``type``, which determines which options are available to child
|
||||
# widgets, and
|
||||
# - the grid ``grid_rows``/``grid_columns`` descriptors, which define the cells
|
||||
# that child widgets are placed into.
|
||||
# Both are fixed at widget creation.
|
||||
_GRID_LAYOUT_KEYS = (
|
||||
CONF_GRID_COLUMN_ALIGN,
|
||||
CONF_GRID_ROW_ALIGN,
|
||||
)
|
||||
_FLEX_LAYOUT_KEYS = (
|
||||
CONF_FLEX_FLOW,
|
||||
CONF_FLEX_ALIGN_MAIN,
|
||||
CONF_FLEX_ALIGN_CROSS,
|
||||
CONF_FLEX_ALIGN_TRACK,
|
||||
)
|
||||
|
||||
LAYOUT_UPDATE_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_FLEX_FLOW): FLEX_FLOWS.one_of,
|
||||
cv.Optional(CONF_FLEX_ALIGN_MAIN): flex_alignments,
|
||||
cv.Optional(CONF_FLEX_ALIGN_CROSS): LV_FLEX_CROSS_ALIGNMENTS.one_of,
|
||||
cv.Optional(CONF_FLEX_ALIGN_TRACK): flex_alignments,
|
||||
cv.Optional(CONF_GRID_COLUMN_ALIGN): grid_alignments,
|
||||
cv.Optional(CONF_GRID_ROW_ALIGN): grid_alignments,
|
||||
cv.Optional(CONF_PAD_ROW): padding,
|
||||
cv.Optional(CONF_PAD_COLUMN): padding,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def layout_validator(value):
|
||||
"""
|
||||
Validate a ``layout:`` value for an update action. Only the layout options
|
||||
may be changed (not the layout ``type``, which is fixed at widget creation).
|
||||
:param value: The value of the ``layout:`` key
|
||||
:return: The validated layout options dict
|
||||
"""
|
||||
result = LAYOUT_UPDATE_SCHEMA(value)
|
||||
if not result:
|
||||
raise cv.Invalid(
|
||||
"A layout update must specify at least one layout option", [CONF_LAYOUT]
|
||||
)
|
||||
# Register the relevant layout feature so its LV_USE_* define is emitted even
|
||||
# when the option is set solely via an update action (whose code generation
|
||||
# may run after LVGL has finished collecting its used features).
|
||||
if any(key in result for key in _GRID_LAYOUT_KEYS):
|
||||
add_lv_use(TYPE_GRID)
|
||||
if any(key in result for key in _FLEX_LAYOUT_KEYS):
|
||||
add_lv_use(TYPE_FLEX)
|
||||
return result
|
||||
|
||||
|
||||
def append_layout_schema(schema, config: dict):
|
||||
"""
|
||||
Get the child layout schema for a given widget based on its layout type.
|
||||
|
||||
@@ -331,6 +331,19 @@ lv_angle = LValidator(angle, uint32, retmapper=lambda x: int(x * 10), animatable
|
||||
lv_angle_degrees = LValidator(angle, uint32, retmapper=int, animatable=True)
|
||||
|
||||
|
||||
def rotation_degrees(value):
|
||||
"""Validate a display rotation, returning the angle in whole degrees.
|
||||
|
||||
Accepts the four supported rotations, optionally suffixed with "°".
|
||||
"""
|
||||
value = cv.string(value).removesuffix("°")
|
||||
return cv.one_of(0, 90, 180, 270, int=True)(value)
|
||||
|
||||
|
||||
# Validator for a display rotation expressed in whole degrees (templatable)
|
||||
lv_rotation = LValidator(rotation_degrees, cg.int_)
|
||||
|
||||
|
||||
@schema_extractor("one_of")
|
||||
def size_validator(value):
|
||||
"""A size in one axis - one of "size_content", a number (pixels) or a percentage"""
|
||||
|
||||
@@ -91,11 +91,24 @@ void LvglComponent::set_rotation(display::DisplayRotation rotation) {
|
||||
this->rotation_ = rotation;
|
||||
if (this->is_ready()) {
|
||||
this->set_resolution_();
|
||||
this->update_orientation_();
|
||||
lv_obj_update_layout(this->get_screen_active());
|
||||
lv_obj_invalidate(this->get_screen_active());
|
||||
}
|
||||
}
|
||||
|
||||
void LvglComponent::set_rotation(int angle) {
|
||||
// Normalize to [0, 360). The DisplayRotation enum values are the angles in degrees.
|
||||
angle %= 360;
|
||||
if (angle < 0)
|
||||
angle += 360;
|
||||
if (angle % 90 != 0) {
|
||||
ESP_LOGW(TAG, "Invalid rotation angle %d; must be a multiple of 90 degrees.", angle);
|
||||
return;
|
||||
}
|
||||
this->set_rotation(static_cast<display::DisplayRotation>(angle));
|
||||
}
|
||||
|
||||
void LvglComponent::rotate_coordinates(int32_t &x, int32_t &y) const {
|
||||
switch (this->rotation_) {
|
||||
default:
|
||||
@@ -719,6 +732,18 @@ void LvglComponent::set_resolution_() const {
|
||||
}
|
||||
lv_display_set_resolution(this->disp_, width, height);
|
||||
}
|
||||
|
||||
void LvglComponent::update_orientation_() {
|
||||
// A square display is treated as landscape.
|
||||
auto orientation = this->get_width() >= this->get_height() ? Orientation::LANDSCAPE : Orientation::PORTRAIT;
|
||||
if (orientation == this->orientation_)
|
||||
return;
|
||||
this->orientation_ = orientation;
|
||||
auto *trigger = orientation == Orientation::LANDSCAPE ? this->landscape_callback_ : this->portrait_callback_;
|
||||
if (trigger != nullptr)
|
||||
trigger->trigger();
|
||||
}
|
||||
|
||||
void LvglComponent::setup() {
|
||||
auto *display = this->displays_[0];
|
||||
auto rounding = this->draw_rounding;
|
||||
@@ -757,7 +782,7 @@ void LvglComponent::setup() {
|
||||
lv_display_add_event_cb(this->disp_, rounder_cb, LV_EVENT_INVALIDATE_AREA, this);
|
||||
lv_display_set_buffers(this->disp_, this->draw_buf_, nullptr, buf_bytes,
|
||||
this->full_refresh_ ? LV_DISPLAY_RENDER_MODE_FULL : LV_DISPLAY_RENDER_MODE_PARTIAL);
|
||||
if (this->rotation_type_ == RotationType::ROTATION_SOFTWARE) {
|
||||
if (this->rotation_type_ == ROTATION_SOFTWARE) {
|
||||
this->rotate_buf_ = static_cast<lv_color_t *>(lv_alloc_draw_buf(buf_bytes, false)); // NOLINT
|
||||
if (this->rotate_buf_ == nullptr) {
|
||||
this->status_set_error(LOG_STR("Memory allocation failure"));
|
||||
@@ -796,6 +821,7 @@ void LvglComponent::setup() {
|
||||
#endif
|
||||
this->show_page(0, LV_SCREEN_LOAD_ANIM_NONE, 0);
|
||||
lv_display_trigger_activity(this->disp_);
|
||||
this->update_orientation_();
|
||||
}
|
||||
|
||||
void LvglComponent::update() {
|
||||
|
||||
@@ -185,6 +185,12 @@ enum RotationType : uint8_t {
|
||||
ROTATION_HARDWARE,
|
||||
};
|
||||
|
||||
enum class Orientation : uint8_t {
|
||||
UNKNOWN,
|
||||
LANDSCAPE,
|
||||
PORTRAIT,
|
||||
};
|
||||
|
||||
class LvglComponent final : public PollingComponent {
|
||||
constexpr static const char *const TAG = "lvgl";
|
||||
|
||||
@@ -291,7 +297,11 @@ class LvglComponent final : public PollingComponent {
|
||||
void set_resume_trigger(Trigger<> *trigger) { this->resume_callback_ = trigger; }
|
||||
void set_draw_start_trigger(Trigger<> *trigger) { this->draw_start_callback_ = trigger; }
|
||||
void set_draw_end_trigger(Trigger<> *trigger) { this->draw_end_callback_ = trigger; }
|
||||
void set_landscape_trigger(Trigger<> *trigger) { this->landscape_callback_ = trigger; }
|
||||
void set_portrait_trigger(Trigger<> *trigger) { this->portrait_callback_ = trigger; }
|
||||
void set_rotation(display::DisplayRotation rotation);
|
||||
/// Set the rotation from an angle in degrees. Must be a multiple of 90.
|
||||
void set_rotation(int angle);
|
||||
display::DisplayRotation get_rotation() const { return this->rotation_; }
|
||||
void rotate_coordinates(int32_t &x, int32_t &y) const;
|
||||
|
||||
@@ -300,6 +310,9 @@ class LvglComponent final : public PollingComponent {
|
||||
|
||||
protected:
|
||||
void set_resolution_() const;
|
||||
// Determine the current orientation from the effective resolution and fire the
|
||||
// landscape/portrait trigger if it has changed since the last check.
|
||||
void update_orientation_();
|
||||
void draw_end_();
|
||||
// Not checking for non-null callback since the
|
||||
// LVGL callback that calls it is not set in that case
|
||||
@@ -347,6 +360,9 @@ class LvglComponent final : public PollingComponent {
|
||||
Trigger<> *resume_callback_{};
|
||||
Trigger<> *draw_start_callback_{};
|
||||
Trigger<> *draw_end_callback_{};
|
||||
Trigger<> *landscape_callback_{};
|
||||
Trigger<> *portrait_callback_{};
|
||||
Orientation orientation_{Orientation::UNKNOWN};
|
||||
void *rotate_buf_{};
|
||||
display::DisplayRotation rotation_{display::DISPLAY_ROTATION_0_DEGREES};
|
||||
RotationType rotation_type_;
|
||||
|
||||
@@ -55,6 +55,7 @@ from .layout import (
|
||||
GRID_CELL_SCHEMA,
|
||||
append_layout_schema,
|
||||
grid_alignments,
|
||||
layout_validator,
|
||||
)
|
||||
from .lv_validation import lv_color, lv_font, lv_gradient, lv_image, opacity
|
||||
from .lvcode import UPDATE_EVENT, LvglComponent, lv_event_t_ptr
|
||||
@@ -523,6 +524,7 @@ def base_update_schema(widget_type: WidgetType | LvType, parts):
|
||||
)
|
||||
),
|
||||
cv.Optional(CONF_STATE): SET_STATE_SCHEMA,
|
||||
cv.Optional(df.CONF_LAYOUT): layout_validator,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -36,11 +36,10 @@ from ..defines import (
|
||||
CONF_SCALE,
|
||||
CONF_STYLES,
|
||||
CONF_WIDGETS,
|
||||
LOGGER,
|
||||
OBJ_FLAGS,
|
||||
PARTS,
|
||||
STATES,
|
||||
TYPE_FLEX,
|
||||
TYPE_GRID,
|
||||
LValidator,
|
||||
add_lv_use,
|
||||
call_lambda,
|
||||
@@ -541,44 +540,76 @@ def _size_to_str(value):
|
||||
return str(value)
|
||||
|
||||
|
||||
def _grid_descriptor_array(name: str, specs) -> MockObj:
|
||||
"""Generate a file-scope ``static const`` grid row/column descriptor array
|
||||
and return a reference to it."""
|
||||
values = ",".join(_size_to_str(x) for x in specs)
|
||||
initializer = "{" + values + ", LV_GRID_TEMPLATE_LAST}"
|
||||
arr_id = ID(name, is_declaration=True, type=lv_coord_t)
|
||||
return cg.static_const_array(arr_id, cg.RawExpression(initializer))
|
||||
|
||||
|
||||
def _set_layout_options(w: Widget, layout: dict, base_name: str | None) -> None:
|
||||
"""Apply the layout options present in ``layout`` to ``w``.
|
||||
|
||||
Only options actually present are applied, so this works both for widget
|
||||
creation (where every option is supplied) and for update actions (where the
|
||||
layout ``type`` and grid structure are fixed and only the style options are
|
||||
changed). ``base_name`` names the generated grid descriptor arrays and is
|
||||
only required at creation, when ``grid_rows``/``grid_columns`` are present.
|
||||
"""
|
||||
if (pad_row := layout.get(CONF_PAD_ROW)) is not None:
|
||||
w.set_style(CONF_PAD_ROW, pad_row)
|
||||
if (pad_column := layout.get(CONF_PAD_COLUMN)) is not None:
|
||||
w.set_style(CONF_PAD_COLUMN, pad_column)
|
||||
if (rows := layout.get(CONF_GRID_ROWS)) is not None:
|
||||
w.set_style(
|
||||
"grid_row_dsc_array", _grid_descriptor_array(f"{base_name}_row_dsc", rows)
|
||||
)
|
||||
if (columns := layout.get(CONF_GRID_COLUMNS)) is not None:
|
||||
w.set_style(
|
||||
"grid_column_dsc_array",
|
||||
_grid_descriptor_array(f"{base_name}_column_dsc", columns),
|
||||
)
|
||||
if (align := layout.get(CONF_GRID_COLUMN_ALIGN)) is not None:
|
||||
w.set_style(CONF_GRID_COLUMN_ALIGN, literal(align))
|
||||
if (align := layout.get(CONF_GRID_ROW_ALIGN)) is not None:
|
||||
w.set_style(CONF_GRID_ROW_ALIGN, literal(align))
|
||||
if (flow := layout.get(CONF_FLEX_FLOW)) is not None:
|
||||
lv_obj.set_flex_flow(w.obj, literal(flow))
|
||||
if (main := layout.get(CONF_FLEX_ALIGN_MAIN)) is not None:
|
||||
w.set_style("flex_main_place", literal(main))
|
||||
if (cross := layout.get(CONF_FLEX_ALIGN_CROSS)) is not None:
|
||||
# Stretch is implemented at creation time by sizing the children; at
|
||||
# runtime we can only fall back to centering.
|
||||
if cross == "LV_FLEX_ALIGN_STRETCH":
|
||||
LOGGER.warning(
|
||||
"Flex cross alignment 'stretch' is not supported at runtime; using 'center' instead"
|
||||
)
|
||||
cross = "LV_FLEX_ALIGN_CENTER"
|
||||
w.set_style("flex_cross_place", literal(cross))
|
||||
if (track := layout.get(CONF_FLEX_ALIGN_TRACK)) is not None:
|
||||
w.set_style("flex_track_place", literal(track))
|
||||
|
||||
|
||||
async def set_obj_properties(w: Widget, config):
|
||||
"""Generate a list of C++ statements to apply properties to an lv_obj_t"""
|
||||
|
||||
from ..schemas import ALL_STYLES, OBJ_PROPERTIES, remap_property
|
||||
|
||||
if layout := config.get(CONF_LAYOUT):
|
||||
layout_type: str = layout[CONF_TYPE]
|
||||
add_lv_use(layout_type)
|
||||
lv_obj.set_layout(w.obj, literal(f"LV_LAYOUT_{layout_type.upper()}"))
|
||||
if (pad_row := layout.get(CONF_PAD_ROW)) is not None:
|
||||
w.set_style(CONF_PAD_ROW, pad_row)
|
||||
if (pad_column := layout.get(CONF_PAD_COLUMN)) is not None:
|
||||
w.set_style(CONF_PAD_COLUMN, pad_column)
|
||||
if layout_type == TYPE_GRID:
|
||||
wid = config[CONF_ID]
|
||||
rows = [_size_to_str(x) for x in layout[CONF_GRID_ROWS]]
|
||||
rows = "{" + ",".join(rows) + ", LV_GRID_TEMPLATE_LAST}"
|
||||
row_id = ID(f"{wid}_row_dsc", is_declaration=True, type=lv_coord_t)
|
||||
row_array = cg.static_const_array(row_id, cg.RawExpression(rows))
|
||||
w.set_style("grid_row_dsc_array", row_array)
|
||||
columns = [_size_to_str(x) for x in layout[CONF_GRID_COLUMNS]]
|
||||
columns = "{" + ",".join(columns) + ", LV_GRID_TEMPLATE_LAST}"
|
||||
column_id = ID(f"{wid}_column_dsc", is_declaration=True, type=lv_coord_t)
|
||||
column_array = cg.static_const_array(column_id, cg.RawExpression(columns))
|
||||
w.set_style("grid_column_dsc_array", column_array)
|
||||
w.set_style(
|
||||
CONF_GRID_COLUMN_ALIGN, literal(layout.get(CONF_GRID_COLUMN_ALIGN))
|
||||
)
|
||||
w.set_style(CONF_GRID_ROW_ALIGN, literal(layout.get(CONF_GRID_ROW_ALIGN)))
|
||||
if layout_type == TYPE_FLEX:
|
||||
lv_obj.set_flex_flow(w.obj, literal(layout[CONF_FLEX_FLOW]))
|
||||
main = literal(layout[CONF_FLEX_ALIGN_MAIN])
|
||||
cross = layout[CONF_FLEX_ALIGN_CROSS]
|
||||
if cross == "LV_FLEX_ALIGN_STRETCH":
|
||||
cross = "LV_FLEX_ALIGN_CENTER"
|
||||
cross = literal(cross)
|
||||
track = literal(layout[CONF_FLEX_ALIGN_TRACK])
|
||||
lv_obj.set_flex_align(w.obj, main, cross, track)
|
||||
# The layout `type` (and the grid row/column structure) is only present
|
||||
# when a widget is created; update actions only change the layout style
|
||||
# options, leaving the type and grid structure unchanged.
|
||||
layout_type = layout.get(CONF_TYPE)
|
||||
if layout_type is not None:
|
||||
add_lv_use(layout_type)
|
||||
lv_obj.set_layout(w.obj, literal(f"LV_LAYOUT_{layout_type.upper()}"))
|
||||
# The widget's own id gives the grid descriptor arrays stable names.
|
||||
base_name = str(config[CONF_ID])
|
||||
else:
|
||||
base_name = None
|
||||
_set_layout_options(w, layout, base_name)
|
||||
parts = collect_parts(config)
|
||||
for part, states in parts.items():
|
||||
part = "LV_PART_" + part.upper()
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
spi:
|
||||
- id: spi_bus
|
||||
clk_pin: GPIO18
|
||||
mosi_pin: GPIO23
|
||||
|
||||
display:
|
||||
- platform: mipi_spi
|
||||
spi_id: spi_bus
|
||||
model: st7789v
|
||||
id: tft_display
|
||||
dimensions:
|
||||
width: 240
|
||||
height: 320
|
||||
cs_pin: GPIO22
|
||||
dc_pin: GPIO21
|
||||
auto_clear_enabled: false
|
||||
invert_colors: false
|
||||
update_interval: never
|
||||
|
||||
lvgl:
|
||||
id: lvgl_id
|
||||
displays: tft_display
|
||||
pages:
|
||||
- id: main_page
|
||||
widgets:
|
||||
# A flex container whose layout options are changed at runtime.
|
||||
- obj:
|
||||
id: flex_box
|
||||
layout:
|
||||
type: flex
|
||||
flex_flow: row
|
||||
widgets:
|
||||
- label:
|
||||
text: a
|
||||
- label:
|
||||
text: b
|
||||
|
||||
# A grid container whose alignment options are changed at runtime.
|
||||
# The grid structure (rows/columns) is fixed here at creation.
|
||||
- obj:
|
||||
id: grid_box
|
||||
layout:
|
||||
type: grid
|
||||
grid_rows: [content, content]
|
||||
grid_columns: [fr(1), fr(1)]
|
||||
widgets:
|
||||
- label:
|
||||
text: c
|
||||
- label:
|
||||
text: d
|
||||
|
||||
# Button hosting all of the update actions under test.
|
||||
- button:
|
||||
id: btn_actions
|
||||
on_click:
|
||||
# Update flex container options (type unchanged).
|
||||
- lvgl.widget.update:
|
||||
id: flex_box
|
||||
layout:
|
||||
flex_flow: column
|
||||
flex_align_main: center
|
||||
flex_align_cross: end
|
||||
pad_row: 7px
|
||||
# Update grid container alignment options (structure unchanged).
|
||||
- lvgl.widget.update:
|
||||
id: grid_box
|
||||
layout:
|
||||
grid_column_align: space_between
|
||||
grid_row_align: center
|
||||
# Top-level layout applies to the active screen.
|
||||
- lvgl.update:
|
||||
layout:
|
||||
flex_flow: column
|
||||
pad_column: 5px
|
||||
# Layout applied to the top display layer.
|
||||
- lvgl.update:
|
||||
top_layer:
|
||||
layout:
|
||||
flex_flow: row
|
||||
# Styling applied to the bottom display layer (exercises the
|
||||
# layers code path that previously generated no code).
|
||||
- lvgl.update:
|
||||
bottom_layer:
|
||||
bg_color: 0x123456
|
||||
@@ -0,0 +1,208 @@
|
||||
"""Tests for updating LVGL layout options via the update actions.
|
||||
|
||||
The ``lvgl.update`` and ``lvgl.widget.update`` (and per-widget
|
||||
``lvgl.<widget>.update``) actions can change a container's layout *options* at
|
||||
runtime. The layout ``type`` and the grid ``grid_rows``/``grid_columns``
|
||||
structure are fixed at widget creation (they determine the cells/options
|
||||
available to child widgets), so only the simple style options - those applied
|
||||
via ``lv_obj_set_style_...`` calls - may be changed.
|
||||
|
||||
These tests cover both the ``layout_validator`` (schema/normalisation) and the
|
||||
generated C++ for each target: a widget, the active screen (top-level
|
||||
``lvgl.update``) and the display layers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from voluptuous import Invalid
|
||||
|
||||
from esphome.__main__ import generate_cpp_contents
|
||||
from esphome.components.lvgl.defines import TYPE_FLEX, TYPE_GRID, get_lv_uses
|
||||
from esphome.components.lvgl.layout import layout_validator
|
||||
from esphome.config import read_config
|
||||
from esphome.core import CORE
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# layout_validator - schema and normalisation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value,expected",
|
||||
[
|
||||
({"flex_flow": "row"}, {"flex_flow": "LV_FLEX_FLOW_ROW"}),
|
||||
({"flex_align_main": "center"}, {"flex_align_main": "LV_FLEX_ALIGN_CENTER"}),
|
||||
({"flex_align_cross": "end"}, {"flex_align_cross": "LV_FLEX_ALIGN_END"}),
|
||||
(
|
||||
{"grid_column_align": "space_between"},
|
||||
{"grid_column_align": "LV_GRID_ALIGN_SPACE_BETWEEN"},
|
||||
),
|
||||
({"grid_row_align": "center"}, {"grid_row_align": "LV_GRID_ALIGN_CENTER"}),
|
||||
({"pad_row": "7px"}, {"pad_row": 7}),
|
||||
({"pad_column": "5px"}, {"pad_column": 5}),
|
||||
],
|
||||
)
|
||||
def test_layout_validator_normalises_options(value: dict, expected: dict) -> None:
|
||||
"""Each supported option is accepted and normalised to its LVGL form."""
|
||||
assert layout_validator(value) == expected
|
||||
|
||||
|
||||
def test_layout_validator_accepts_multiple_options() -> None:
|
||||
"""Several options may be combined in one update."""
|
||||
result = layout_validator(
|
||||
{"flex_flow": "column", "flex_align_main": "center", "pad_row": "4px"}
|
||||
)
|
||||
assert result == {
|
||||
"flex_flow": "LV_FLEX_FLOW_COLUMN",
|
||||
"flex_align_main": "LV_FLEX_ALIGN_CENTER",
|
||||
"pad_row": 4,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value",
|
||||
[
|
||||
{"type": "flex"},
|
||||
{"type": "grid", "grid_column_align": "center"},
|
||||
{"grid_rows": 3},
|
||||
{"grid_columns": ["fr(1)"]},
|
||||
{"grid_rows": [1, 2], "flex_flow": "row"},
|
||||
],
|
||||
)
|
||||
def test_layout_validator_rejects_structural_keys(value: dict) -> None:
|
||||
"""The layout type and grid structure are fixed at creation and must not
|
||||
be changeable via an update action."""
|
||||
with pytest.raises(Invalid, match="extra keys not allowed"):
|
||||
layout_validator(value)
|
||||
|
||||
|
||||
def test_layout_validator_rejects_empty() -> None:
|
||||
"""An update must specify at least one layout option."""
|
||||
with pytest.raises(Invalid, match="at least one layout option"):
|
||||
layout_validator({})
|
||||
|
||||
|
||||
def test_layout_validator_registers_flex_use() -> None:
|
||||
"""Validating a flex option registers the flex feature so LV_USE_FLEX is
|
||||
emitted even when the option is set solely via an update action."""
|
||||
layout_validator({"flex_flow": "row"})
|
||||
assert TYPE_FLEX in get_lv_uses()
|
||||
|
||||
|
||||
def test_layout_validator_registers_grid_use() -> None:
|
||||
"""Validating a grid option registers the grid feature."""
|
||||
layout_validator({"grid_column_align": "center"})
|
||||
assert TYPE_GRID in get_lv_uses()
|
||||
|
||||
|
||||
def test_pad_only_update_registers_no_layout_use() -> None:
|
||||
"""Padding options belong to both layout types, so they alone do not force
|
||||
either feature on."""
|
||||
layout_validator({"pad_row": "4px"})
|
||||
uses = get_lv_uses()
|
||||
assert TYPE_FLEX not in uses
|
||||
assert TYPE_GRID not in uses
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Generated C++ for the update actions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def main_cpp(request: pytest.FixtureRequest) -> str:
|
||||
"""Generate the C++ output for the shared layout-update YAML config once
|
||||
per module (codegen is relatively expensive)."""
|
||||
config_path = Path(request.fspath).parent / "config" / "layout_update_test.yaml"
|
||||
original_path = CORE.config_path
|
||||
try:
|
||||
CORE.config_path = config_path
|
||||
CORE.config = read_config({})
|
||||
generate_cpp_contents(CORE.config)
|
||||
return CORE.cpp_global_section + CORE.cpp_main_section
|
||||
finally:
|
||||
CORE.config_path = original_path
|
||||
CORE.reset()
|
||||
|
||||
|
||||
def test_widget_flex_update_applies_partial_options(main_cpp: str) -> None:
|
||||
"""``lvgl.widget.update`` changes only the flex options that are specified,
|
||||
via the appropriate ``lv_obj_set_style_...``/``lv_obj_set_flex_flow``
|
||||
calls on the target widget."""
|
||||
assert "lv_obj_set_flex_flow(flex_box, LV_FLEX_FLOW_COLUMN)" in main_cpp
|
||||
assert (
|
||||
"lv_obj_set_style_flex_main_place(flex_box, LV_FLEX_ALIGN_CENTER, LV_STATE_DEFAULT)"
|
||||
in main_cpp
|
||||
)
|
||||
assert (
|
||||
"lv_obj_set_style_flex_cross_place(flex_box, LV_FLEX_ALIGN_END, LV_STATE_DEFAULT)"
|
||||
in main_cpp
|
||||
)
|
||||
assert "lv_obj_set_style_pad_row(flex_box, 7, LV_STATE_DEFAULT)" in main_cpp
|
||||
|
||||
|
||||
def test_widget_flex_update_does_not_change_type(main_cpp: str) -> None:
|
||||
"""The update must not re-establish the layout type: ``lv_obj_set_layout``
|
||||
is emitted once (at creation) and never from the update action."""
|
||||
assert main_cpp.count("lv_obj_set_layout(flex_box,") == 1
|
||||
|
||||
|
||||
def test_widget_flex_update_is_partial(main_cpp: str) -> None:
|
||||
"""An option that was not specified in the update (the track placement) is
|
||||
only set at creation, not by the partial update."""
|
||||
assert main_cpp.count("lv_obj_set_style_flex_track_place(flex_box,") == 1
|
||||
|
||||
|
||||
def test_widget_grid_update_applies_alignments(main_cpp: str) -> None:
|
||||
"""``lvgl.widget.update`` on a grid container changes its alignment
|
||||
options without touching the grid structure."""
|
||||
assert (
|
||||
"lv_obj_set_style_grid_column_align(grid_box, LV_GRID_ALIGN_SPACE_BETWEEN, "
|
||||
"LV_STATE_DEFAULT)" in main_cpp
|
||||
)
|
||||
assert (
|
||||
"lv_obj_set_style_grid_row_align(grid_box, LV_GRID_ALIGN_CENTER, LV_STATE_DEFAULT)"
|
||||
in main_cpp
|
||||
)
|
||||
|
||||
|
||||
def test_grid_update_does_not_regenerate_descriptor_arrays(main_cpp: str) -> None:
|
||||
"""The grid row/column descriptor arrays are structural and generated once
|
||||
at creation; an update must not regenerate them."""
|
||||
assert main_cpp.count("grid_box_row_dsc") != 0
|
||||
# The descriptor array is declared once and referenced once at creation.
|
||||
assert main_cpp.count("grid_box_row_dsc") == main_cpp.count("grid_box_column_dsc")
|
||||
assert "lv_obj_set_layout(grid_box," in main_cpp
|
||||
assert main_cpp.count("lv_obj_set_layout(grid_box,") == 1
|
||||
|
||||
|
||||
def test_top_level_layout_targets_active_screen(main_cpp: str) -> None:
|
||||
"""A top-level ``lvgl.update: { layout: ... }`` applies to the active
|
||||
screen, not to the LVGL component object."""
|
||||
assert (
|
||||
"lv_obj_set_flex_flow(lvgl_id->get_screen_active(), LV_FLEX_FLOW_COLUMN)"
|
||||
in main_cpp
|
||||
)
|
||||
assert (
|
||||
"lv_obj_set_style_pad_column(lvgl_id->get_screen_active(), 5, LV_STATE_DEFAULT)"
|
||||
in main_cpp
|
||||
)
|
||||
|
||||
|
||||
def test_top_layer_layout_applied(main_cpp: str) -> None:
|
||||
"""A layout under ``top_layer`` is applied to the display's top layer."""
|
||||
assert "lv_display_get_layer_top(lvgl_id->get_disp())" in main_cpp
|
||||
assert "lv_obj_set_flex_flow(top_layer_VAR_, LV_FLEX_FLOW_ROW)" in main_cpp
|
||||
|
||||
|
||||
def test_bottom_layer_styling_applied(main_cpp: str) -> None:
|
||||
"""A ``bottom_layer`` style update generates code (previously the layer
|
||||
keys of ``lvgl.update`` were silently ignored)."""
|
||||
assert "lv_display_get_layer_bottom(lvgl_id->get_disp())" in main_cpp
|
||||
assert (
|
||||
"lv_obj_set_style_bg_color(bottom_layer_VAR_, lv_color_make(18, 52, 86), "
|
||||
"LV_PART_MAIN)" in main_cpp
|
||||
)
|
||||
@@ -46,6 +46,40 @@ lvgl:
|
||||
- lvgl.display.set_rotation:
|
||||
rotation: 0
|
||||
lvgl_id: lvgl_id
|
||||
- lvgl.display.set_rotation:
|
||||
rotation: !lambda "return 180;"
|
||||
lvgl_id: lvgl_id
|
||||
on_landscape:
|
||||
- logger.log: LVGL display is now landscape
|
||||
# Re-layout a container in response to orientation changes. The layout type
|
||||
# and grid structure are fixed at creation; only the style options change.
|
||||
- lvgl.widget.update:
|
||||
id: grid_rows_only_shorthand
|
||||
layout:
|
||||
grid_column_align: center
|
||||
grid_row_align: space_between
|
||||
pad_row: 4px
|
||||
- lvgl.update:
|
||||
top_layer:
|
||||
layout:
|
||||
flex_flow: row
|
||||
on_portrait:
|
||||
- logger.log: LVGL display is now portrait
|
||||
- lvgl.widget.update:
|
||||
id: grid_rows_only_shorthand
|
||||
layout:
|
||||
grid_column_align: start
|
||||
pad_row: 2px
|
||||
# Top-level layout applies to the active screen
|
||||
- lvgl.update:
|
||||
layout:
|
||||
flex_flow: column
|
||||
pad_row: 8px
|
||||
- lvgl.update:
|
||||
top_layer:
|
||||
layout:
|
||||
flex_flow: column
|
||||
flex_align_main: center
|
||||
|
||||
on_boot:
|
||||
- logger.log: LVGL has started
|
||||
|
||||
Reference in New Issue
Block a user