Files
esphome-lsm6ds/esphome/components/lvgl/__init__.py
T
2026-04-02 10:39:51 +13:00

552 lines
20 KiB
Python

import importlib
from pathlib import Path
import pkgutil
from esphome.automation import build_automation, validate_automation
import esphome.codegen as cg
from esphome.components.const import (
CONF_BYTE_ORDER,
CONF_COLOR_DEPTH,
CONF_DRAW_ROUNDING,
)
from esphome.components.display import Display
from esphome.components.esp32 import (
VARIANT_ESP32P4,
add_idf_component,
add_idf_sdkconfig_option,
get_esp32_variant,
)
from esphome.components.image import (
CONF_OPAQUE,
IMAGE_TYPE,
ImageBinary,
ImageGrayscale,
ImageRGB,
ImageRGB565,
get_image_metadata,
)
from esphome.components.psram import DOMAIN as PSRAM_DOMAIN
import esphome.config_validation as cv
from esphome.const import (
CONF_AUTO_CLEAR_ENABLED,
CONF_BUFFER_SIZE,
CONF_GROUP,
CONF_ID,
CONF_LAMBDA,
CONF_LOG_LEVEL,
CONF_ON_BOOT,
CONF_ON_IDLE,
CONF_PAGES,
CONF_TIMEOUT,
CONF_TRIGGER_ID,
)
from esphome.core import CORE, ID, Lambda
from esphome.cpp_generator import MockObj
from esphome.final_validate import full_config
from esphome.helpers import write_file_if_changed
from esphome.yaml_util import load_yaml
from . import defines as df, helpers, lv_validation as lvalid, widgets
from .automation import focused_widgets, layers_to_code, lvgl_update, refreshed_widgets
from .defines import CONF_ALIGN_TO_LAMBDA_ID
from .encoders import (
ENCODERS_CONFIG,
encoders_to_code,
get_default_group,
initial_focus_to_code,
)
from .gradient import GRADIENT_SCHEMA, gradients_to_code
from .keypads import KEYPADS_CONFIG, keypads_to_code
from .lv_validation import lv_bool, lv_images_used
from .lvcode import LvContext, LvglComponent, lvgl_static
from .schemas import (
DISP_BG_SCHEMA,
FULL_STYLE_SCHEMA,
STYLE_REMAP,
WIDGET_TYPES,
any_widget_schema,
container_schema,
obj_schema,
)
from .styles import styles_to_code, theme_to_code
from .touchscreens import touchscreen_schema, touchscreens_to_code
from .trigger import add_on_boot_triggers, generate_align_tos, generate_triggers
from .types import (
IdleTrigger,
PlainTrigger,
lv_font_t,
lv_group_t,
lv_lambda_t,
lv_style_t,
lvgl_ns,
)
from .widgets import (
LvScrActType,
Widget,
add_widgets,
get_screen_active,
set_obj_properties,
styles_used,
)
# Import only what we actually use directly in this file
from .widgets.msgbox import MSGBOX_SCHEMA, msgboxes_to_code
from .widgets.obj import obj_spec # Used in LVGL_SCHEMA
from .widgets.page import ( # page_spec used in LVGL_SCHEMA
add_pages,
generate_page_triggers,
page_spec,
)
# Widget registration happens via WidgetType.__init__ in individual widget files
# The imports below trigger creation of the widget types
# Action registration (lvgl.{widget}.update) happens automatically
# in the WidgetType.__init__ method
for module_info in pkgutil.iter_modules(widgets.__path__):
importlib.import_module(f".widgets.{module_info.name}", package=__package__)
DOMAIN = "lvgl"
DEPENDENCIES = ["display"]
AUTO_LOAD = ["key_provider"]
CODEOWNERS = ["@clydebarrow"]
HELLO_WORLD_FILE = "hello_world.yaml"
SIMPLE_TRIGGERS = (
df.CONF_ON_PAUSE,
df.CONF_ON_RESUME,
df.CONF_ON_DRAW_START,
df.CONF_ON_DRAW_END,
)
def as_macro(macro, value):
if value is None:
return f"#define {macro}"
return f"#define {macro} {value}"
LVGL_VERSION = "9.5.0"
LV_CONF_FILENAME = "lv_conf.h"
LV_CONF_H_FORMAT = """\
#pragma once
{}
"""
def generate_lv_conf_h():
# Get all possible LV_ config defines based on the widgets used in the config, and the standard LVGL options
all_defines = set(
df.LV_DEFINES + tuple(f"LV_USE_{w.upper()}" for w in WIDGET_TYPES)
)
# Get the defines that are actually used based on the config
lv_defines = df.get_data(df.KEY_LV_DEFINES)
unused_defines = all_defines - set(lv_defines)
# Create the content of lv_conf.h with the used defines set to their value, and the unused defines disabled
definitions = [as_macro(m, v) for m, v in lv_defines.items()] + [
as_macro(m, "0") for m in unused_defines
]
definitions.sort()
return LV_CONF_H_FORMAT.format("\n".join(definitions))
def multi_conf_validate(configs: list[dict]):
displays = [config[df.CONF_DISPLAYS] for config in configs]
# flatten the display list
display_list = [disp for disps in displays for disp in disps]
if len(display_list) != len(set(display_list)):
raise cv.Invalid("A display ID may be used in only one LVGL instance")
for config in configs:
for item in (df.CONF_ENCODERS, df.CONF_KEYPADS):
for enc in config.get(item, ()):
if CONF_GROUP not in enc:
raise cv.Invalid(
f"'{item}' must have an explicit group set when using multiple LVGL instances"
)
base_config = configs[0]
for config in configs[1:]:
for item in (
CONF_LOG_LEVEL,
CONF_COLOR_DEPTH,
CONF_BYTE_ORDER,
df.CONF_TRANSPARENCY_KEY,
):
if base_config[item] != config[item]:
raise cv.Invalid(
f"Config item '{item}' must be the same for all LVGL instances"
)
def final_validation(config_list):
if len(config_list) != 1:
multi_conf_validate(config_list)
global_config = full_config.get()
for config in config_list:
if (pages := config.get(CONF_PAGES)) and all(p[df.CONF_SKIP] for p in pages):
raise cv.Invalid("At least one page must not be skipped")
for display_id in config[df.CONF_DISPLAYS]:
path = global_config.get_path_for_id(display_id)[:-1]
display = global_config.get_config_for_path(path)
if CONF_LAMBDA in display or CONF_PAGES in display:
raise cv.Invalid(
"Using lambda: or pages: in display config is not compatible with LVGL"
)
if display.get(CONF_AUTO_CLEAR_ENABLED) is True:
raise cv.Invalid(
"Using auto_clear_enabled: true in display config not compatible with LVGL"
)
if draw_rounding := display.get(CONF_DRAW_ROUNDING):
config[CONF_DRAW_ROUNDING] = max(
draw_rounding, config[CONF_DRAW_ROUNDING]
)
buffer_frac = config[CONF_BUFFER_SIZE]
if CORE.is_esp32 and buffer_frac > 0.5 and PSRAM_DOMAIN not in global_config:
df.LOGGER.warning("buffer_size: may need to be reduced without PSRAM")
for w in focused_widgets:
path = global_config.get_path_for_id(w)
widget_conf = global_config.get_config_for_path(path[:-1])
if (
df.CONF_ADJUSTABLE in widget_conf
and not widget_conf[df.CONF_ADJUSTABLE]
):
raise cv.Invalid(
"A non adjustable arc may not be focused",
path,
)
for w in refreshed_widgets:
path = global_config.get_path_for_id(w)
widget_conf = global_config.get_config_for_path(path[:-1])
if not any(isinstance(v, (Lambda, dict)) for v in widget_conf.values()):
raise cv.Invalid(
f"Widget '{w}' does not have any dynamic properties to refresh",
)
# Do per-widget type final validation for update actions
for widget_type, update_configs in df.get_data(df.KEY_UPDATED_WIDGETS).items():
for conf in update_configs:
for id_conf in conf.get(CONF_ID, ()):
name = id_conf[CONF_ID]
path = global_config.get_path_for_id(name)
widget_conf = global_config.get_config_for_path(path[:-1])
widget_type.final_validate(name, conf, widget_conf, path[1:])
async def to_code(configs):
config_0 = configs[0]
# Global configuration
if CORE.is_esp32:
# Skip compiling lvgl examples
add_idf_sdkconfig_option("CONFIG_LV_BUILD_EXAMPLES", False)
add_idf_sdkconfig_option("CONFIG_LV_BUILD_DEMOS", False)
if get_esp32_variant() == VARIANT_ESP32P4:
add_idf_sdkconfig_option("CONFIG_LV_DRAW_BUF_ALIGN", 64)
# disable use of PPA for fills until upstream bugs fixed
df.add_define("LV_USE_PPA", "0")
df.add_define("LV_DRAW_BUF_ALIGN", "64")
else:
df.add_define("LV_DRAW_BUF_ALIGN", "32")
add_idf_component(name="lvgl/lvgl", ref=LVGL_VERSION)
else:
df.add_define("LV_DRAW_BUF_ALIGN", "1")
cg.add_library("lvgl/lvgl", LVGL_VERSION)
df.add_define("LV_DRAW_BUF_STRIDE_ALIGN", "1")
df.add_define("LV_USE_DRAW_SW", "1")
df.add_define("LV_USE_STDLIB_SPRINTF", "LV_STDLIB_CLIB")
df.add_define("LV_USE_STDLIB_STRING", "LV_STDLIB_CLIB")
df.add_define("LV_USE_STDLIB_MALLOC", "LV_STDLIB_CUSTOM")
cg.add_define("USE_LVGL")
# suppress default enabling of extra widgets
# cg.add_define("LV_KCONFIG_PRESENT")
# Always enable - lots of things use it.
df.add_define("LV_DRAW_SW_COMPLEX", "1")
df.add_define(
"LV_LOG_LEVEL",
f"LV_LOG_LEVEL_{df.LV_LOG_LEVELS[config_0[CONF_LOG_LEVEL]]}",
)
df.add_define("LV_USE_LOG", "1")
cg.add_define(
"LVGL_LOG_LEVEL",
cg.RawExpression(f"ESPHOME_LOG_LEVEL_{config_0[CONF_LOG_LEVEL]}"),
)
df.add_define("LV_COLOR_DEPTH", config_0[CONF_COLOR_DEPTH])
for font in helpers.lv_fonts_used:
df.add_define(f"LV_FONT_{font.upper()}")
if config_0[CONF_COLOR_DEPTH] == 16:
df.add_define(
"LV_COLOR_16_SWAP",
"1" if config_0[CONF_BYTE_ORDER] == "big_endian" else "0",
)
df.add_define(
"LV_COLOR_CHROMA_KEY",
await lvalid.lv_color.process(config_0[df.CONF_TRANSPARENCY_KEY]),
)
cg.add_build_flag("-Isrc")
cg.add_global(lvgl_ns.using)
for font in helpers.esphome_fonts_used:
await cg.get_variable(font)
default_font = config_0[df.CONF_DEFAULT_FONT]
if not lvalid.is_lv_font(default_font):
df.add_define(
"LV_FONT_CUSTOM_DECLARE", f"LV_FONT_DECLARE(*{df.DEFAULT_ESPHOME_FONT})"
)
globfont_id = ID(
df.DEFAULT_ESPHOME_FONT,
True,
type=lv_font_t.operator("ptr").operator("const"),
)
# static=False because LV_FONT_CUSTOM_DECLARE creates an extern declaration
cg.new_variable(
globfont_id,
MockObj(await lvalid.lv_font.process(default_font), "->").get_lv_font(),
static=False,
)
df.add_define("LV_FONT_DEFAULT", df.DEFAULT_ESPHOME_FONT)
else:
df.add_define("LV_FONT_DEFAULT", await lvalid.lv_font.process(default_font))
cg.add(lvgl_static.esphome_lvgl_init())
default_group = get_default_group(config_0)
for config in configs:
frac = config[CONF_BUFFER_SIZE]
if frac >= 0.75:
frac = 1
elif frac >= 0.375:
frac = 2
elif frac > 0.19:
frac = 4
elif frac != 0:
frac = 8
displays = [
await cg.get_variable(display) for display in config[df.CONF_DISPLAYS]
]
lv_component = cg.new_Pvariable(
config[CONF_ID],
displays,
frac,
config[df.CONF_FULL_REFRESH],
config[CONF_DRAW_ROUNDING],
config[df.CONF_RESUME_ON_INPUT],
config[df.CONF_UPDATE_WHEN_DISPLAY_IDLE],
)
await cg.register_component(lv_component, config)
Widget.create(config[CONF_ID], lv_component, LvScrActType(), config)
lv_scr_act = get_screen_active(lv_component)
async with LvContext():
cg.add(lv_component.set_big_endian(config[CONF_BYTE_ORDER] == "big_endian"))
await touchscreens_to_code(lv_component, config)
await encoders_to_code(lv_component, config, default_group)
await keypads_to_code(lv_component, config, default_group)
await theme_to_code(config)
await gradients_to_code(config)
await styles_to_code(config)
await set_obj_properties(lv_scr_act, config)
await add_widgets(lv_scr_act, config)
await add_pages(lv_component, config)
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)
# Set this directly since we are limited in how many methods can be added to the Widget class.
Widget.widgets_completed = True
async with LvContext():
await generate_triggers()
await generate_align_tos(configs[0])
for config in configs:
lv_component = await cg.get_variable(config[CONF_ID])
await generate_page_triggers(config)
await initial_focus_to_code(config)
for conf in config.get(CONF_ON_IDLE, ()):
templ = await cg.templatable(conf[CONF_TIMEOUT], [], cg.uint32)
idle_trigger = cg.new_Pvariable(
conf[CONF_TRIGGER_ID], lv_component, templ
)
await build_automation(idle_trigger, [], conf)
for trigger_name in SIMPLE_TRIGGERS:
if conf := config.get(trigger_name):
trigger_var = cg.new_Pvariable(conf[CONF_TRIGGER_ID])
await build_automation(trigger_var, [], conf)
cg.add(
getattr(
lv_component,
f"set_{trigger_name.removeprefix('on_')}_trigger",
)(trigger_var)
)
await add_on_boot_triggers(config.get(CONF_ON_BOOT, ()))
# This must be done after all widgets are created
for comp in helpers.lvgl_components_required:
cg.add_define(f"USE_LVGL_{comp.upper()}")
for use in helpers.lv_uses:
df.add_define(f"LV_USE_{use.upper()}")
cg.add_define(f"USE_LVGL_{use.upper()}")
if {
"transform_rotation",
"transform_scale",
"transform_scale_x",
"transform_scale_y",
} & styles_used:
df.add_define("LV_COLOR_SCREEN_TRANSP", "1")
# Currently always need RGB565 for the display buffer, and ARGB8888 is used for layer blending
lv_image_formats = {"RGB565", "ARGB8888"}
if {
"drop_shadow_color",
"drop_shadow_offset_x",
"drop_shadow_offset_y",
"drop_shadow_opa",
"drop_shadow_quality",
"drop_shadow_radius",
} & styles_used:
lv_image_formats.add("A8")
for image_id in lv_images_used:
await cg.get_variable(image_id)
metadata = get_image_metadata(image_id.id)
image_type = IMAGE_TYPE[metadata.image_type]
transparent = metadata.transparency != CONF_OPAQUE
if image_type == ImageBinary:
lv_image_formats.add("I1")
if image_type == ImageGrayscale:
lv_image_formats.add("A8")
if image_type == ImageRGB565:
lv_image_formats.add("RGB565A8" if transparent else "RGB565")
if image_type == ImageRGB:
lv_image_formats.add("ARGB8888" if transparent else "RGB8888")
if df.is_defined("LV_GRADIENT_MAX_STOPS"):
lv_image_formats.add("RGB888")
for fmt in lv_image_formats:
df.add_define(f"LV_DRAW_SW_SUPPORT_{fmt}", "1")
lv_conf_h_file = CORE.relative_src_path(LV_CONF_FILENAME)
write_file_if_changed(lv_conf_h_file, generate_lv_conf_h())
cg.add_build_flag("-DLV_CONF_H=1")
# handle windows paths in a way that doesn't break the generated C++
lv_conf_h_path = Path(lv_conf_h_file).as_posix()
cg.add_build_flag(f'-DLV_CONF_PATH=\\"{lv_conf_h_path}\\"')
cg.add_build_flag("-DLV_KCONFIG_IGNORE")
for prop in df.get_remapped_uses():
df.LOGGER.warning(
"Property '%s' is deprecated, use '%s' instead", prop, STYLE_REMAP[prop]
)
for warning in df.get_warnings():
df.LOGGER.warning(warning)
def display_schema(config):
value = cv.ensure_list(cv.use_id(Display))(config)
value = value or [cv.use_id(Display)(config)]
if len(set(value)) != len(value):
raise cv.Invalid("Display IDs must be unique")
return value
def add_hello_world(config):
if df.CONF_WIDGETS not in config and CONF_PAGES not in config:
df.LOGGER.info(
"No pages or widgets configured, creating default hello_world page"
)
hello_world_path = Path(__file__).parent / HELLO_WORLD_FILE
config[df.CONF_WIDGETS] = any_widget_schema()(load_yaml(hello_world_path))
return config
def _theme_schema(value):
return cv.Schema(
{
cv.Optional(name): obj_schema(w).extend(FULL_STYLE_SCHEMA)
for name, w in WIDGET_TYPES.items()
}
)(value)
FINAL_VALIDATE_SCHEMA = final_validation
LVGL_SCHEMA = cv.All(
container_schema(
obj_spec,
cv.polling_component_schema("1s")
.extend(
{
cv.GenerateID(CONF_ID): cv.declare_id(LvglComponent),
cv.GenerateID(CONF_ALIGN_TO_LAMBDA_ID): cv.declare_id(lv_lambda_t),
cv.GenerateID(df.CONF_DISPLAYS): display_schema,
cv.Optional(CONF_COLOR_DEPTH, default=16): cv.one_of(16),
cv.Optional(
df.CONF_DEFAULT_FONT, default="montserrat_14"
): lvalid.lv_font,
cv.Optional(df.CONF_FULL_REFRESH, default=False): cv.boolean,
cv.Optional(
df.CONF_UPDATE_WHEN_DISPLAY_IDLE, default=False
): cv.boolean,
cv.Optional(CONF_DRAW_ROUNDING, default=2): cv.positive_int,
cv.Optional(CONF_BUFFER_SIZE, default=0): cv.percentage,
cv.Optional(CONF_LOG_LEVEL, default="WARN"): cv.one_of(
*df.LV_LOG_LEVELS, upper=True
),
cv.Optional(CONF_BYTE_ORDER, default="big_endian"): cv.one_of(
"big_endian", "little_endian", lower=True
),
cv.Optional(df.CONF_STYLE_DEFINITIONS): cv.ensure_list(
cv.Schema({cv.Required(CONF_ID): cv.declare_id(lv_style_t)}).extend(
FULL_STYLE_SCHEMA
)
),
cv.Optional(CONF_ON_IDLE): validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(IdleTrigger),
cv.Required(CONF_TIMEOUT): cv.templatable(
cv.positive_time_period_milliseconds
),
}
),
cv.Optional(CONF_PAGES): cv.ensure_list(container_schema(page_spec)),
**{
cv.Optional(x): validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(PlainTrigger),
},
single=True,
)
for x in SIMPLE_TRIGGERS
},
cv.Optional(df.CONF_MSGBOXES): cv.ensure_list(MSGBOX_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),
cv.Optional(
df.CONF_TRANSPARENCY_KEY, default=0x000400
): lvalid.lv_color,
cv.Optional(df.CONF_THEME): _theme_schema,
cv.Optional(df.CONF_GRADIENTS): GRADIENT_SCHEMA,
cv.Optional(df.CONF_TOUCHSCREENS, default=None): touchscreen_schema,
cv.Optional(df.CONF_ENCODERS, default=None): ENCODERS_CONFIG,
cv.Optional(df.CONF_KEYPADS, default=None): KEYPADS_CONFIG,
cv.GenerateID(df.CONF_DEFAULT_GROUP): cv.declare_id(lv_group_t),
cv.Optional(df.CONF_RESUME_ON_INPUT, default=True): cv.boolean,
}
)
.extend(DISP_BG_SCHEMA),
),
cv.has_at_most_one_key(CONF_PAGES, df.CONF_LAYOUT),
add_hello_world,
)
def lvgl_config_schema(config):
"""
Can't use cv.ensure_list here because it converts an empty config to an empty list,
rather than a default config.
"""
if not config or isinstance(config, dict):
return [LVGL_SCHEMA(config)]
return cv.Schema([LVGL_SCHEMA])(config)
CONFIG_SCHEMA = lvgl_config_schema