mirror of
https://github.com/esphome/esphome.git
synced 2026-06-24 12:33:10 +00:00
[lvgl] Fix schema extraction (#16895)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 = {}
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|||||||
Reference in New Issue
Block a user