Compare commits

...

4 Commits

Author SHA1 Message Date
J. Nick Koston
2159c53c9f [lvgl] Add return-type annotations to lazy schema helpers
_lazy_update_schema, container_schema, and their inner build() /
validator() closures all returned untyped functions. Annotate them so
callers see Callable[[Any], Any] for the validator factories and Schema
for the inner builds.
2026-05-16 17:03:19 -07:00
J. Nick Koston
c062c1ddbd [lvgl] Hoist lazy_once import, document thread-safety
Addresses two stylistic notes on the PR:

- Move `from ..helpers import lazy_once` to module scope in
  widgets/__init__.py. helpers.py only depends on esphome and
  esphome.const, so there's no circular-import risk that would justify
  the function-local import. Keep the `from ..schemas import
  base_update_schema` deferred because schemas.py imports WidgetType
  from this module.
- Document that lazy_once is single-threaded and retries build() on
  exception (no negative-cache), so future callers know the contract
  if the helper migrates to esphome/core/helpers.py.
2026-05-16 17:00:22 -07:00
J. Nick Koston
fabc1b584f [lvgl] Extract lazy_once helper to share the build-once pattern
Both container_schema and _lazy_update_schema implement the same
"build the voluptuous schema on first call and cache the result"
closure. Factor it out into lazy_once() in helpers.py so the pattern
only lives in one place.

No behavioral change; lvgl test configs still pass and import time is
unchanged from the previous commit (~215ms cold on M-series Mac).
2026-05-16 15:28:12 -07:00
J. Nick Koston
3cee06e930 [lvgl] Build container_schema and widget update schemas lazily
Voluptuous schema construction inside container_schema() and the
register_action() chain in WidgetType.__init__ dominates cold-validate
time for any YAML that imports the lvgl component. cProfile of a cold
`import esphome.components.lvgl` shows ~225ms cumulative inside
container_schema (9 callers) and ~196ms cumulative inside
base_update_schema (30 callers, one per widget type), out of ~400ms
total.

Defer both: container_schema now returns a validator that builds its
underlying schema on first call, and WidgetType registers a lazy
validator with register_action so base_update_schema is only evaluated
when an `lvgl.<widget>.update` action is actually validated.

Measured on M-series Mac, fresh subprocess, 5 cold imports:

  Before: 389, 385, 389, 384, 380 ms (avg 385ms)
  After:  229, 217, 218, 215, 215 ms (avg 219ms)

A 43% reduction in `import esphome.components.lvgl`. Real
`esphome -q config` on tests/components/lvgl/test.host.yaml drops from
~770ms to ~550ms (warm-cache subprocess). Extrapolated to slower
hardware (Celeron N5105 dashboard hosts) this is ~1.5s saved per cold
LVGL save.

Schema output is unchanged: lvgl test configs validate identically and
`script/build_language_schema.py` produces a byte-identical lvgl
sub-tree before vs after.
2026-05-16 15:03:33 -07:00
3 changed files with 84 additions and 14 deletions

View File

@@ -1,10 +1,36 @@
from collections.abc import Callable
import re
from typing import TypeVar
from esphome import config_validation as cv
from esphome.const import CONF_ARGS, CONF_FORMAT
CONF_IF_NAN = "if_nan"
T = TypeVar("T")
def lazy_once(build: Callable[[], T]) -> Callable[[], T]:
"""Return a no-arg callable that runs ``build`` at most once and caches it.
Used to defer voluptuous schema construction until first validation. Many
of the lvgl schemas would otherwise be built at module-import time even for
YAMLs that never reach them.
Not thread-safe — concurrent first-callers would each run ``build``. esphome
config validation is single-threaded so this is fine in practice. If
``build`` raises, the cache stays empty and the next call retries; there is
no negative-cache.
"""
cached: list[T] = []
def get() -> T:
if not cached:
cached.append(build())
return cached[0]
return get
# noqa
f_regex = re.compile(

View File

@@ -1,4 +1,5 @@
from collections.abc import Callable
from typing import Any
from esphome import config_validation as cv
from esphome.automation import Trigger, validate_automation
@@ -40,7 +41,7 @@ from .defines import (
get_remapped_uses,
is_press_event,
)
from .helpers import CONF_IF_NAN, validate_printf
from .helpers import CONF_IF_NAN, lazy_once, validate_printf
from .layout import (
FLEX_OBJ_SCHEMA,
GRID_CELL_SCHEMA,
@@ -534,7 +535,7 @@ def strip_defaults(schema: cv.Schema):
return cv.Schema({cv.Optional(k): v for k, v in schema.schema.items()})
def container_schema(widget_type: WidgetType, extras=None):
def container_schema(widget_type: WidgetType, extras=None) -> Callable[[Any], Any]:
"""
Create a schema for a container widget of a given type. All obj properties are available, plus
the extras passed in, plus any defined for the specific widget being specified.
@@ -542,18 +543,25 @@ def container_schema(widget_type: WidgetType, extras=None):
:param extras: Additional options to be made available, e.g. layout properties for children
:return: The schema for this type of widget.
"""
schema = obj_schema(widget_type).extend(
{cv.GenerateID(): cv.declare_id(widget_type.w_type)}
)
if extras:
schema = schema.extend(extras)
# Delayed evaluation for recursion
schema = schema.extend(widget_type.schema)
# Schema construction is deferred until first validation. obj_schema and the
# part_schema -> base_update_schema chain account for ~225ms of cumulative
# work at lvgl module import time across ~9 callers; building lazily keeps
# `esphome config` fast for YAMLs that never reach this widget type.
def build() -> cv.Schema:
built = obj_schema(widget_type).extend(
{cv.GenerateID(): cv.declare_id(widget_type.w_type)}
)
if extras:
built = built.extend(extras)
# Delayed evaluation for recursion
return built.extend(widget_type.schema)
def validator(value):
get_schema = lazy_once(build)
def validator(value: Any) -> Any:
value = value or {}
return append_layout_schema(schema, value)(value)
return append_layout_schema(get_schema(), value)(value)
return validator

View File

@@ -1,4 +1,6 @@
from collections.abc import Callable
import sys
from typing import Any
from esphome import codegen as cg, config_validation as cv
from esphome.automation import register_action
@@ -48,6 +50,7 @@ from ..defines import (
join_enums,
literal,
)
from ..helpers import lazy_once
from ..lv_validation import lv_int
from ..lvcode import (
LvConditional,
@@ -73,6 +76,34 @@ from ..types import (
EVENT_LAMB = "event_lamb__"
def _lazy_update_schema(widget_type: "WidgetType") -> Callable[[Any], Any]:
"""Defer construction of a widget's update-action schema until first use.
base_update_schema(...).extend(widget_type.modify_schema) compiles several
voluptuous schemas and is invoked once per WidgetType at import time. The
caller (register_action) only needs a validator, so wrap the build in a
closure that materialises on the first validation and caches the result.
The ``from ..schemas import base_update_schema`` import stays inside the
closure because ``schemas`` imports ``WidgetType`` from this module — top-
level would deadlock the import.
"""
def build() -> Schema:
from ..schemas import base_update_schema
return base_update_schema(widget_type, widget_type.parts).extend(
widget_type.modify_schema
)
get_schema = lazy_once(build)
def validator(value: Any) -> Any:
return get_schema()(value)
return validator
class WidgetType:
"""
Describes a type of Widget, e.g. "bar" or "line"
@@ -113,18 +144,23 @@ class WidgetType:
# Local import to avoid circular import
from ..automation import update_to_code
from ..schemas import WIDGET_TYPES, base_update_schema
from ..schemas import WIDGET_TYPES
if not is_mock:
if self.name in WIDGET_TYPES:
raise EsphomeError(f"Duplicate definition of widget type '{self.name}'")
WIDGET_TYPES[self.name] = self
# Register the update action automatically, adding widget-specific properties
# Register the update action automatically, adding widget-specific
# properties. The update schema is built lazily on first validation:
# base_update_schema involves part_schema / Schema.extend chains that
# cost ~7ms per widget at import time (~200ms total across ~25
# widgets) and is unused for any YAML that never triggers a
# `lvgl.<widget>.update` action.
register_action(
f"lvgl.{self.name}.update",
ObjUpdateAction,
base_update_schema(self, self.parts).extend(self.modify_schema),
_lazy_update_schema(self),
synchronous=True,
)(update_to_code)