[lvgl] Fix schema extraction (#16895)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Clyde Stubbs
2026-06-13 22:21:38 +10:00
committed by GitHub
parent bf6c8568d3
commit 10ce6024bf
4 changed files with 276 additions and 82 deletions

View File

@@ -47,6 +47,7 @@ from esphome.core import CORE, ID, Lambda
from esphome.cpp_generator import MockObj from esphome.cpp_generator import MockObj
from esphome.final_validate import full_config from esphome.final_validate import full_config
from esphome.helpers import write_file_if_changed from esphome.helpers import write_file_if_changed
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
from esphome.writer import clean_build from esphome.writer import clean_build
from esphome.yaml_util import load_yaml from esphome.yaml_util import load_yaml
@@ -75,10 +76,14 @@ from .schemas import (
BASE_PROPS, BASE_PROPS,
DISP_BG_SCHEMA, DISP_BG_SCHEMA,
FULL_STYLE_SCHEMA, FULL_STYLE_SCHEMA,
SET_STATE_SCHEMA,
STATE_SCHEMA,
STYLE_REMAP, STYLE_REMAP,
STYLE_SCHEMA,
WIDGET_TYPES, WIDGET_TYPES,
any_widget_schema, any_widget_schema,
container_schema, container_schema,
container_schema_value,
obj_dict, obj_dict,
) )
from .styles import styles_to_code, theme_to_code from .styles import styles_to_code, theme_to_code
@@ -113,6 +118,14 @@ from .widgets.page import ( # page_spec used in LVGL_SCHEMA
page_spec, page_spec,
) )
# These style schemas live in .schemas but are imported here so they land in
# this module's namespace, where script/build_language_schema.py registers them
# as *named* schemas and emits `extends` references — instead of inlining the
# ~80-property STYLE_SCHEMA at every widget x part x state, which bloated the
# dumped lvgl schema ~23x (17 MB vs ~750 KB). They are not otherwise used in
# this file; this tuple keeps the imports live (and self-documents why).
_SCHEMA_DUMPER_NAMED_SCHEMAS = (STYLE_SCHEMA, STATE_SCHEMA, SET_STATE_SCHEMA)
# Widget registration happens via WidgetType.__init__ in individual widget files # Widget registration happens via WidgetType.__init__ in individual widget files
# The imports below trigger creation of the widget types # The imports below trigger creation of the widget types
# Action registration (lvgl.{widget}.update) happens automatically # Action registration (lvgl.{widget}.update) happens automatically
@@ -559,94 +572,106 @@ def _theme_schema(value: dict) -> dict:
FINAL_VALIDATE_SCHEMA = final_validation FINAL_VALIDATE_SCHEMA = final_validation
LVGL_SCHEMA = cv.All( # The options accepted at the top level of an `lvgl:` block, on top of the base
container_schema( # object schema that `container_schema(obj_spec, ...)` supplies. Held in a
obj_spec, # module-level name (rather than inline) so the schema-extractor wrapper on
cv.polling_component_schema("1s") # CONFIG_SCHEMA below can hand the language-schema dumper the same composed
.extend( # schema the runtime validates against.
{ LVGL_TOP_LEVEL_SCHEMA = (
**{ cv.polling_component_schema("1s")
cv.Optional(event): validate_automation( .extend(
{ {
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( **{
Trigger.template(lv_obj_t_ptr, lv_event_t_ptr) cv.Optional(event): validate_automation(
),
}
)
for event in df.LV_SCREEN_EVENT_TRIGGERS
+ df.LV_DISPLAY_EVENT_TRIGGERS
},
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_ROTATION): validate_rotation,
cv.Optional(CONF_LOG_LEVEL, default="WARN"): cv.one_of(
*df.LV_LOG_LEVELS, upper=True
),
cv.Optional(CONF_BYTE_ORDER): 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.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
cv.Required(CONF_TIMEOUT): cv.templatable( Trigger.template(lv_obj_t_ptr, lv_event_t_ptr)
cv.positive_time_period_milliseconds
), ),
} }
), )
cv.Optional(CONF_PAGES): cv.ensure_list(container_schema(page_spec)), for event in df.LV_SCREEN_EVENT_TRIGGERS + df.LV_DISPLAY_EVENT_TRIGGERS
**{ },
cv.Optional(x): validate_automation( cv.GenerateID(CONF_ID): cv.declare_id(LvglComponent),
{ cv.GenerateID(CONF_ALIGN_TO_LAMBDA_ID): cv.declare_id(lv_lambda_t),
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(PlainTrigger), cv.GenerateID(df.CONF_DISPLAYS): display_schema,
}, cv.Optional(CONF_COLOR_DEPTH, default=16): cv.one_of(16),
single=True, cv.Optional(df.CONF_DEFAULT_FONT, default="montserrat_14"): lvalid.lv_font,
) cv.Optional(df.CONF_FULL_REFRESH, default=False): cv.boolean,
for x in SIMPLE_TRIGGERS cv.Optional(df.CONF_UPDATE_WHEN_DISPLAY_IDLE, default=False): cv.boolean,
}, cv.Optional(CONF_DRAW_ROUNDING, default=2): cv.positive_int,
cv.Optional(df.CONF_MSGBOXES): cv.ensure_list(MSGBOX_SCHEMA), cv.Optional(CONF_BUFFER_SIZE, default=0): cv.percentage,
cv.Optional(df.CONF_PAGE_WRAP, default=True): lv_bool, cv.Optional(CONF_ROTATION): validate_rotation,
cv.Optional(df.CONF_TOP_LAYER): container_schema(obj_spec), cv.Optional(CONF_LOG_LEVEL, default="WARN"): cv.one_of(
cv.Optional(df.CONF_BOTTOM_LAYER): container_schema(obj_spec), *df.LV_LOG_LEVELS, upper=True
cv.Optional( ),
df.CONF_TRANSPARENCY_KEY, default=0x000400 cv.Optional(CONF_BYTE_ORDER): cv.one_of(
): lvalid.lv_color, "big_endian", "little_endian", lower=True
cv.Optional(df.CONF_THEME): _theme_schema, ),
cv.Optional(df.CONF_GRADIENTS): GRADIENT_SCHEMA, cv.Optional(df.CONF_STYLE_DEFINITIONS): cv.ensure_list(
cv.Optional(df.CONF_TOUCHSCREENS, default=None): touchscreen_schema, cv.Schema({cv.Required(CONF_ID): cv.declare_id(lv_style_t)}).extend(
cv.Optional(df.CONF_ENCODERS, default=None): ENCODERS_CONFIG, FULL_STYLE_SCHEMA
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, cv.Optional(CONF_ON_IDLE): validate_automation(
} {
) cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(IdleTrigger),
.extend(DISP_BG_SCHEMA), 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)
)
LVGL_SCHEMA = cv.All(
container_schema(obj_spec, LVGL_TOP_LEVEL_SCHEMA),
cv.has_at_most_one_key(CONF_PAGES, df.CONF_LAYOUT), cv.has_at_most_one_key(CONF_PAGES, df.CONF_LAYOUT),
add_hello_world, add_hello_world,
) )
@schema_extractor("schema")
def lvgl_config_schema(config): def lvgl_config_schema(config):
""" """
Can't use cv.ensure_list here because it converts an empty config to an empty list, Can't use cv.ensure_list here because it converts an empty config to an empty list,
rather than a default config. rather than a default config.
""" """
if config is SCHEMA_EXTRACT:
# CONFIG_SCHEMA is this callable wrapping `cv.All` over a container_schema
# closure, so the language-schema dumper can't see the top-level `lvgl:`
# fields (it would emit an empty schema). Hand it the same composed
# obj + top-level schema the runtime validates against, plus the
# `widgets:` key (added per-value by append_layout_schema at runtime, so
# otherwise invisible to the dumper). Validation of real configs (the
# branches below) is unchanged.
return container_schema_value(obj_spec, LVGL_TOP_LEVEL_SCHEMA).extend(
{cv.Optional(df.CONF_WIDGETS): any_widget_schema()}
)
if not config or isinstance(config, dict): if not config or isinstance(config, dict):
return [LVGL_SCHEMA(config)] return [LVGL_SCHEMA(config)]
return cv.Schema([LVGL_SCHEMA])(config) return cv.Schema([LVGL_SCHEMA])(config)

View File

@@ -22,7 +22,11 @@ from esphome.const import (
) )
from esphome.core import TimePeriod from esphome.core import TimePeriod
from esphome.core.config import StartupTrigger from esphome.core.config import StartupTrigger
from esphome.schema_extractors import EnableSchemaExtraction from esphome.schema_extractors import (
SCHEMA_EXTRACT,
EnableSchemaExtraction,
schema_extractor,
)
from . import defines as df, lv_validation as lvalid from . import defines as df, lv_validation as lvalid
from .defines import ( from .defines import (
@@ -627,6 +631,25 @@ _CONTAINER_SCHEMA_CACHE: dict[
] = {} ] = {}
def container_schema_value(widget_type: WidgetType, extras: Any = None) -> cv.Schema:
"""
Build the static schema that :func:`container_schema` validates against, i.e.
everything except the value-dependent ``append_layout_schema`` applied at
validation time.
Factored out and exposed so the language-schema dumper can extract a
representative schema for a widget — and for the top-level ``lvgl:`` block,
whose ``CONFIG_SCHEMA`` is a callable that otherwise hides this behind the
:func:`container_schema` validator closure.
"""
schema = obj_schema(widget_type).extend(
{cv.GenerateID(): cv.declare_id(widget_type.w_type)}
)
if extras:
schema = schema.extend(extras)
return schema.extend(widget_type.schema)
def container_schema( def container_schema(
widget_type: WidgetType, extras: Any = None widget_type: WidgetType, extras: Any = None
) -> Callable[[Any], Any]: ) -> Callable[[Any], Any]:
@@ -649,12 +672,7 @@ def container_schema(
def get_schema() -> cv.Schema: def get_schema() -> cv.Schema:
nonlocal cached_schema nonlocal cached_schema
if cached_schema is None: if cached_schema is None:
schema = obj_schema(widget_type).extend( cached_schema = container_schema_value(widget_type, extras)
{cv.GenerateID(): cv.declare_id(widget_type.w_type)}
)
if extras:
schema = schema.extend(extras)
cached_schema = schema.extend(widget_type.schema)
return cached_schema return cached_schema
def validator(value: Any) -> Any: def validator(value: Any) -> Any:
@@ -678,7 +696,23 @@ def any_widget_schema(extras=None):
:return: A validator for the Widgets key :return: A validator for the Widgets key
""" """
@schema_extractor("schema")
def validator(value): def validator(value):
if value is SCHEMA_EXTRACT:
# The widgets: list is built per-value at validation time, so the
# language-schema dumper sees nothing. Enumerate every registered
# widget type as an optional key (a widget item is really a
# single-key mapping; over-listing them lets editors complete any
# widget — `esphome config` enforces exactly one). extras carries the
# layout child options where applicable.
return cv.ensure_list(
cv.Schema(
{
cv.Optional(name): container_schema_value(widget_type, extras)
for name, widget_type in WIDGET_TYPES.items()
}
)
)
if isinstance(value, dict): if isinstance(value, dict):
# Convert to list # Convert to list
is_dict = True is_dict = True

View File

@@ -428,6 +428,33 @@ def fix_menu():
menu[S_EXTENDS].append("display_menu_base.MENU_TYPES") menu[S_EXTENDS].append("display_menu_base.MENU_TYPES")
def fix_lvgl_widgets():
# lvgl's `widgets:` is a recursive tree (a widget can contain widgets). The
# dumper has no cycle detection, so — like fix_menu — hoist the inlined
# widget-type enumeration into a named schema and reference it for both the
# top-level list and each widget's own children, instead of expanding it.
if "lvgl" not in output:
return
schemas = output["lvgl"][S_SCHEMAS]
config_vars = schemas["CONFIG_SCHEMA"][S_SCHEMA][S_CONFIG_VARS]
widgets = config_vars.get("widgets")
if not widgets or S_SCHEMA not in widgets or S_CONFIG_VARS not in widgets[S_SCHEMA]:
return
# 1. Hoist the (one-level) widget enumeration into a named schema.
schemas["WIDGET_TYPES"] = {S_TYPE: S_SCHEMA, S_SCHEMA: widgets[S_SCHEMA]}
# 2. Reference it from the top-level widgets: list instead of inlining.
widgets[S_SCHEMA] = {S_EXTENDS: ["lvgl.WIDGET_TYPES"]}
# 3. Let every widget contain child widgets, via the same named ref.
for widget in schemas["WIDGET_TYPES"][S_SCHEMA][S_CONFIG_VARS].values():
if widget.get(S_TYPE) == S_SCHEMA and S_SCHEMA in widget:
widget[S_SCHEMA].setdefault(S_CONFIG_VARS, {})["widgets"] = {
S_TYPE: S_SCHEMA,
"is_list": True,
"key": "Optional",
S_SCHEMA: {S_EXTENDS: ["lvgl.WIDGET_TYPES"]},
}
def get_logger_tags(): def get_logger_tags():
pattern = re.compile(r'^static const char \*const TAG = "(\w.*)";', re.MULTILINE) pattern = re.compile(r'^static const char \*const TAG = "(\w.*)";', re.MULTILINE)
# tags not in components dir # tags not in components dir
@@ -740,6 +767,7 @@ def build_schema():
add_logger_tags() add_logger_tags()
shrink() shrink()
fix_menu() fix_menu()
fix_lvgl_widgets()
# aggregate components, so all component info is in same file, otherwise we have dallas.json, dallas.sensor.json, etc. # aggregate components, so all component info is in same file, otherwise we have dallas.json, dallas.sensor.json, etc.
data = {} data = {}

View File

@@ -4,7 +4,12 @@ from __future__ import annotations
import ast import ast
import importlib.util import importlib.util
import json
from pathlib import Path from pathlib import Path
import subprocess
import sys
import pytest
from esphome import config_validation as cv from esphome import config_validation as cv
@@ -176,3 +181,105 @@ def test_convert_keys_no_marker_for_non_sensitive_field() -> None:
entry = converted["schema"]["config_vars"]["hostname"] entry = converted["schema"]["config_vars"]["hostname"]
assert "sensitive" not in entry assert "sensitive" not in entry
assert "sensitive_source" not in entry assert "sensitive_source" not in entry
# ---------------------------------------------------------------------------
# Regression tests for the lvgl schema dump.
#
# lvgl's CONFIG_SCHEMA is a callable closure and its widget/style schemas are
# built lazily at validation time, so the static dumper used to emit an empty
# `lvgl:` schema, no widget completion, and an inlined ~80-property STYLE_SCHEMA
# duplicated at every widget x part x state (a 17 MB lvgl.json). These exercise
# the full `build_schema()` and assert the generated lvgl.json carries the data
# the schema_extractor hooks added.
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def lvgl_schema(tmp_path_factory: pytest.TempPathFactory) -> dict:
"""Run the full language-schema build once and return parsed lvgl.json.
The build must run in a fresh interpreter: ``build_language_schema.py``
enables schema extraction *before* importing any esphome component, and the
extraction hooks are no-ops if the components were already imported (as they
are inside the pytest session). Running it as a subprocess mirrors how CI
generates the schema and keeps this test isolated from import order.
"""
out_dir = tmp_path_factory.mktemp("language_schema")
subprocess.run(
[sys.executable, str(SCRIPT_PATH), "--output-path", str(out_dir)],
check=True,
capture_output=True,
text=True,
)
return json.loads((out_dir / "lvgl.json").read_text())
def _lvgl_config_vars(lvgl_schema: dict) -> dict:
config_schema = lvgl_schema["lvgl"]["schemas"]["CONFIG_SCHEMA"]
# Previously empty (`{}`); the schema_extractor on lvgl_config_schema now
# hands the dumper the composed top-level schema.
assert config_schema["type"] == "schema"
return config_schema["schema"]["config_vars"]
def test_lvgl_top_level_schema_is_exposed(lvgl_schema: dict) -> None:
config_vars = _lvgl_config_vars(lvgl_schema)
# Was 0 config_vars before LVGL_TOP_LEVEL_SCHEMA was exposed.
assert len(config_vars) > 100
# A representative spread of top-level options the runtime validates.
for key in ("displays", "pages", "default_font", "on_idle", "touchscreens"):
assert key in config_vars, f"missing top-level lvgl option: {key}"
def test_lvgl_widgets_key_enumerated(lvgl_schema: dict) -> None:
config_vars = _lvgl_config_vars(lvgl_schema)
# The widgets: list is assembled per-value at runtime; the extractor
# enumerates every registered widget type into a named WIDGET_TYPES schema
# which the widgets: list references (recursive, so widgets can nest).
assert "widgets" in config_vars
widgets = config_vars["widgets"]
assert widgets["is_list"] is True
assert widgets["schema"]["extends"] == ["lvgl.WIDGET_TYPES"]
widget_types = lvgl_schema["lvgl"]["schemas"]["WIDGET_TYPES"]["schema"][
"config_vars"
]
# Every registered widget type should appear as an optional key.
for name in ("obj", "label", "button", "slider", "switch", "arc"):
assert name in widget_types, f"widget type not enumerated: {name}"
# Each enumerated widget carries its own property schema, not an empty stub.
assert widget_types["label"]["type"] == "schema"
assert len(widget_types["label"]["schema"]["config_vars"]) > 0
# Each widget can contain child widgets, via the same named ref — so the
# tree is recursive and the dump stays finite.
nested = widget_types["obj"]["schema"]["config_vars"]["widgets"]
assert nested["is_list"] is True
assert nested["schema"]["extends"] == ["lvgl.WIDGET_TYPES"]
def test_lvgl_style_schemas_are_named_and_deduped(lvgl_schema: dict) -> None:
schemas = lvgl_schema["lvgl"]["schemas"]
# Importing these into the lvgl __init__ namespace lets the dumper register
# them as named schemas and emit `extends` refs instead of inlining them.
for name in ("STYLE_SCHEMA", "STATE_SCHEMA", "SET_STATE_SCHEMA"):
assert name in schemas, f"style schema not registered as named: {name}"
# STYLE_SCHEMA must be referenced via `extends`, not inlined at every use
# site. Count the references to prove the dedup actually happened.
refs = 0
def _count(node: object) -> None:
nonlocal refs
if isinstance(node, dict):
extends = node.get("extends")
if isinstance(extends, list) and "lvgl.STYLE_SCHEMA" in extends:
refs += 1
for value in node.values():
_count(value)
elif isinstance(node, list):
for value in node:
_count(value)
_count(lvgl_schema)
assert refs > 100, f"STYLE_SCHEMA should be referenced via extends, got {refs}"