[lvgl] Add lvgl.theme.update action (#17678)

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Clyde Stubbs
2026-07-29 13:08:52 -04:00
committed by GitHub
co-authored by Copilot Autofix powered by AI Claude Sonnet 5
parent ad3e2f83b8
commit 98ee7e0f82
7 changed files with 335 additions and 58 deletions
+14 -31
View File
@@ -1,4 +1,3 @@
import functools
import importlib
from pathlib import Path
import pkgutil
@@ -86,7 +85,7 @@ from .schemas import (
any_widget_schema,
container_schema,
container_schema_value,
obj_dict,
theme_schema,
)
from .styles import styles_to_code, theme_to_code
from .touchscreens import touchscreen_schema, touchscreens_to_code
@@ -215,6 +214,18 @@ def multi_conf_validate(configs: list[dict]):
raise cv.Invalid(
f"'{item}' must have an explicit group set when using multiple LVGL instances"
)
# The hidden styles a `theme:` block creates are tracked in a single map shared
# by all LVGL instances (keyed only by widget type, not by instance), so a
# second instance's `theme:` would silently lose to whichever instance is
# processed first instead of doing what its config implies.
themed_configs = sum(
1 for config in configs if config.get(df.CONF_THEME) is not None
)
if themed_configs > 1:
raise cv.Invalid(
"'theme' may only be set on one LVGL instance when using multiple LVGL "
"instances -- combine both themes into a single instance's 'theme:' block"
)
base_config = configs[0]
for config in configs[1:]:
for item in (
@@ -552,34 +563,6 @@ def add_hello_world(config):
return config
@functools.cache
def _build_theme_schema(
widget_types: tuple[tuple[str, widgets.WidgetType], ...],
) -> cv.Schema:
# The theme schema is value-independent: it depends only on the set of
# registered widget types. Key the cache on a snapshot of WIDGET_TYPES so
# that an external component registering a new widget after the first
# validation (legal per any_widget_schema's lazy-evaluation contract)
# produces a fresh tuple, a cache miss, and a rebuilt schema -- the cache
# self-heals instead of stale-rejecting valid themes. See obj_dict() in
# schemas.py for why chained .extend() is avoided here.
return cv.Schema(
{
cv.Optional(df.CONF_DARK_MODE, default=False): cv.boolean,
**{
cv.Optional(name): cv.Schema(
{**obj_dict(w), **FULL_STYLE_SCHEMA.schema}
)
for name, w in widget_types
},
}
)
def _theme_schema(value: dict) -> dict:
return _build_theme_schema(tuple(WIDGET_TYPES.items()))(value)
FINAL_VALIDATE_SCHEMA = final_validation
# The options accepted at the top level of an `lvgl:` block, on top of the base
@@ -647,7 +630,7 @@ LVGL_TOP_LEVEL_SCHEMA = (
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_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,
+9
View File
@@ -33,6 +33,7 @@ KEY_NAMED_STYLES = "named_styles"
KEY_REFRESHED_WIDGETS = "refreshed_widgets"
KEY_REMAPPED_USES = "remapped_uses"
KEY_STYLES_USED = "styles_used"
KEY_THEME_UPDATE_REQUESTS = "theme_update_requests"
KEY_THEME_WIDGET_MAP = "theme_widget_map"
KEY_UPDATED_WIDGETS = "updated_widgets"
KEY_WIDGET_MAP = "widget_map"
@@ -118,6 +119,14 @@ def get_theme_widget_map() -> dict[str, Any]:
return _get_data(KEY_THEME_WIDGET_MAP, {})
def get_theme_update_requests() -> dict[str, dict[tuple[str, str], None]]:
# Values are dicts used as ordered sets (insertion order is deterministic,
# unlike a plain `set` of strings/tuples, whose iteration order depends on
# per-process string hash randomization) so codegen output doesn't churn
# between builds of the same config.
return _get_data(KEY_THEME_UPDATE_REQUESTS, {})
def get_styles_used() -> set[str]:
return _get_data(KEY_STYLES_USED, set())
+93 -1
View File
@@ -1,4 +1,5 @@
from collections.abc import Callable
import functools
from typing import Any
from esphome import config_validation as cv
@@ -8,6 +9,7 @@ from esphome.components.time import RealTimeClock
from esphome.config_validation import prepend_path
from esphome.const import (
CONF_ARGS,
CONF_DEFAULT,
CONF_FORMAT,
CONF_GROUP,
CONF_ID,
@@ -69,7 +71,7 @@ from .types import (
lv_pseudo_button_t,
lv_style_t,
)
from .widgets import WidgetType
from .widgets import WidgetType, collect_parts
# this will be populated later, in __init__.py to avoid circular imports.
WIDGET_TYPES: dict = {}
@@ -591,6 +593,96 @@ def obj_schema(widget_type: WidgetType) -> cv.Schema:
return schema
@functools.cache
def _build_theme_schema(
widget_types: tuple[tuple[str, WidgetType], ...],
include_dark_mode: bool = True,
) -> cv.Schema:
# The theme schema is value-independent: it depends only on the set of
# registered widget types. Key the cache on a snapshot of WIDGET_TYPES so
# that an external component registering a new widget after the first
# validation (legal per any_widget_schema's lazy-evaluation contract)
# produces a fresh tuple, a cache miss, and a rebuilt schema -- the cache
# self-heals instead of stale-rejecting valid themes. See obj_dict() above
# for why chained .extend() is avoided here.
return cv.Schema(
{
**(
{cv.Optional(df.CONF_DARK_MODE, default=False): cv.boolean}
if include_dark_mode
else {}
),
**{
cv.Optional(name): cv.Schema(
{**obj_dict(w), **FULL_STYLE_SCHEMA.schema}
)
for name, w in widget_types
},
}
)
def _reject_theme_styles_key(validated: dict) -> dict:
"""
`styles:` (a list of already-declared named styles) is not allowed inside
`theme:` -- the hidden style objects theme: creates only carry direct
style properties, so a `styles:` reference there would be silently
dropped by `style_set`, which only walks `ALL_STYLES`.
"""
for w_name, style in validated.items():
if w_name not in WIDGET_TYPES:
continue
for part, states in collect_parts(style).items():
for state, props in states.items():
if df.CONF_STYLES not in props:
continue
path = [w_name]
if part != df.CONF_MAIN:
path.append(part)
if state != CONF_DEFAULT:
path.append(state)
path.append(df.CONF_STYLES)
raise cv.Invalid(
"'styles:' is not allowed in LVGL theme styles. "
"Set style properties directly instead.",
path,
)
return validated
def theme_schema(value: dict) -> dict:
return _reject_theme_styles_key(
_build_theme_schema(tuple(WIDGET_TYPES.items()))(value)
)
def theme_update_schema(value: dict) -> dict:
"""
Schema for `lvgl.theme.update`: same shape as `theme:` minus `dark_mode`.
As a validation side effect, records which (widget type, part, state)
combos are targeted so `theme_to_code` can make sure a hidden style
exists for each -- even ones never mentioned under `theme:` -- and gets
it attached to widgets at the same point real theme styles are.
"""
validated = _reject_theme_styles_key(
_build_theme_schema(tuple(WIDGET_TYPES.items()), include_dark_mode=False)(value)
)
for w_name, style in validated.items():
for part, states in collect_parts(style).items():
for state, props in states.items():
# collect_parts() unconditionally seeds a main/default entry
# even when nothing was set for it (e.g. `{pressed: {...}}`
# alone) -- skip combos with no properties so a request for
# one state doesn't also create an unused, empty main/default
# style that gets attached to every widget of this type.
if not props:
continue
df.get_theme_update_requests().setdefault(w_name, {})[(part, state)] = (
None
)
return validated
ALIGN_TO_SCHEMA = {
cv.Optional(df.CONF_ALIGN_TO): cv.Schema(
{
+89 -14
View File
@@ -10,11 +10,18 @@ from .defines import (
LValidator,
add_lv_use,
get_styles_used,
get_theme_update_requests,
get_theme_widget_map,
literal,
)
from .lvcode import LambdaContext, lv
from .schemas import ALL_STYLES, FULL_STYLE_SCHEMA, WIDGET_TYPES, remap_property
from .schemas import (
ALL_STYLES,
FULL_STYLE_SCHEMA,
WIDGET_TYPES,
remap_property,
theme_update_schema,
)
from .types import ObjUpdateAction, lv_style_t
from .widgets import collect_parts, wait_for_widgets
@@ -86,23 +93,91 @@ async def style_update_to_code(config, action_id, template_arg, args):
style = await cg.get_variable(config[CONF_ID])
async with LambdaContext(parameters=args, where=action_id) as context:
await style_set(style, config)
# Refresh and redraw every widget using this style -- otherwise the
# updated properties would sit unused until something else happens to
# invalidate the affected widgets.
lv.obj_report_style_change(style)
return cg.new_Pvariable(action_id, template_arg, await context.get_lambda())
async def theme_to_code(config):
if theme := config.get(CONF_THEME):
add_lv_use(CONF_THEME)
for w_name, style in ((k, v) for k, v in theme.items() if k in WIDGET_TYPES):
# Work around Python 3.10 bug with nested async comprehensions
# With Python 3.11 this could be simplified
# TODO: Now that we require Python 3.11+, this can be updated to use nested comprehensions
styles = {}
for part, states in collect_parts(style).items():
styles[part] = {
state: await create_style(
theme = config.get(CONF_THEME) or {}
requests = get_theme_update_requests()
# Iterate in WIDGET_TYPES' (deterministic, registration-order) sequence rather
# than a set -- a set of strings/tuples iterates in an order that depends on
# per-process hash randomization, which would otherwise churn the order hidden
# style variables are declared in main.cpp between builds of the same config.
widget_names = [
w_name for w_name in WIDGET_TYPES if w_name in theme or w_name in requests
]
if not widget_names:
return
add_lv_use(CONF_THEME)
theme_map = get_theme_widget_map()
for w_name in widget_names:
declared_parts = collect_parts(theme[w_name]) if w_name in theme else {}
parts = {part: dict(states) for part, states in declared_parts.items()}
for part, state in requests.get(w_name, {}):
parts.setdefault(part, {}).setdefault(state, {})
widget_styles = theme_map.setdefault(w_name, {})
for part, states in parts.items():
part_styles = widget_styles.setdefault(part, {})
declared_states = declared_parts.get(part, {})
for state, props in states.items():
if state not in part_styles:
part_styles[state] = await create_style(
"_lv_theme_style_" + w_name + "_" + part + "_" + state, props
)
for state, props in states.items()
}
get_theme_widget_map()[w_name] = styles
elif state in declared_states:
# A `theme.update` request for this combo (possibly from
# another LVGL instance) already created the style as an
# empty placeholder before this instance's real `theme:`
# declaration was reached -- apply the real values now
# instead of silently leaving it empty.
await style_set(part_styles[state], props)
@automation.register_action(
"lvgl.theme.update",
ObjUpdateAction,
theme_update_schema,
synchronous=True,
)
async def theme_update_to_code(config, action_id, template_arg, args):
await wait_for_widgets()
theme_map = get_theme_widget_map()
# Invariant this relies on: theme_update_schema() records every (widget
# type, part, state) combo this action targets as a request during config
# validation (which completes for the whole config tree before any
# to_code runs), and theme_to_code() -- which runs for every LVGL
# instance before any action's own to_code -- materialises a style for
# each recorded request. If that handshake is ever broken by a future
# change, fail with a diagnosable message rather than a bare KeyError.
to_update = []
for w_name, style in config.items():
for part, states in collect_parts(style).items():
for state, props in states.items():
# collect_parts() unconditionally seeds an (empty) main/default
# entry even when this action didn't target it -- skip it, both
# because there's nothing to update and because
# theme_update_schema no longer pre-creates a placeholder style
# for combos with no properties.
if not props:
continue
style_var = theme_map.get(w_name, {}).get(part, {}).get(state)
if style_var is None:
raise cv.Invalid(
f"No theme style exists for '{w_name}' {part}/{state}. "
"This is an internal error -- please report it."
)
to_update.append((style_var, props))
async with LambdaContext(parameters=args, where=action_id) as context:
for style_var, props in to_update:
await style_set(style_var, props)
# Refresh and redraw every widget using this style -- otherwise the
# updated properties would sit unused until something else happens
# to invalidate the affected widgets.
lv.obj_report_style_change(style_var)
return cg.new_Pvariable(action_id, template_arg, await context.get_lambda())
@@ -0,0 +1,55 @@
"""Tests for LVGL's multi-instance config cross-checks."""
from __future__ import annotations
import pytest
from esphome.components.lvgl import defines as df, multi_conf_validate
from esphome.components.lvgl.schemas import theme_schema
from esphome.config_validation import Invalid
def _config(displays: list[str], theme: dict | None = None) -> dict:
config = {
df.CONF_DISPLAYS: displays,
"log_level": "WARN",
"color_depth": 16,
"byte_order": "big_endian",
df.CONF_TRANSPARENCY_KEY: 0x000400,
}
if theme is not None:
config[df.CONF_THEME] = theme
return config
class TestThemeOnMultipleInstances:
def test_raises_when_two_instances_have_theme(self) -> None:
configs = [
_config(["disp_a"], theme={df.CONF_DARK_MODE: True}),
_config(["disp_b"], theme={df.CONF_DARK_MODE: False}),
]
with pytest.raises(Invalid, match="'theme' may only be set on one"):
multi_conf_validate(configs)
def test_raises_even_with_an_empty_theme_block(self) -> None:
# `theme: {}` still creates a CONF_THEME key (with dark_mode defaulted
# by the schema), so it should be treated the same as a populated one.
# Run it through the real schema rather than hand-building the dict,
# so this actually pins that defaulting behaviour.
configs = [
_config(["disp_a"], theme=theme_schema({})),
_config(["disp_b"], theme=theme_schema({})),
]
with pytest.raises(Invalid, match="'theme' may only be set on one"):
multi_conf_validate(configs)
def test_passes_when_only_one_instance_has_theme(self) -> None:
configs = [
_config(["disp_a"], theme={df.CONF_DARK_MODE: True}),
_config(["disp_b"]),
]
multi_conf_validate(configs)
def test_passes_when_no_instance_has_theme(self) -> None:
configs = [_config(["disp_a"]), _config(["disp_b"])]
multi_conf_validate(configs)
@@ -13,12 +13,7 @@ import pytest
import voluptuous as vol
from esphome import config_validation as cv
import esphome.components.lvgl
from esphome.components.lvgl import (
_theme_schema,
defines as df,
schemas as lvgl_schemas,
)
from esphome.components.lvgl import defines as df, schemas as lvgl_schemas
from esphome.components.lvgl.schemas import (
ALIGN_TO_SCHEMA,
FLAG_SCHEMA,
@@ -31,6 +26,8 @@ from esphome.components.lvgl.schemas import (
obj_schema,
part_dict,
part_schema,
theme_schema,
theme_update_schema,
)
from esphome.components.lvgl.types import LvType
from esphome.components.lvgl.widgets import WidgetType
@@ -43,7 +40,7 @@ def _clear_obj_dict_cache() -> Generator[None]:
cache.clear()
# The lazily-built theme schema is cached on _build_theme_schema; clear it
# too so each test starts from a clean slate.
build_theme = getattr(esphome.components.lvgl, "_build_theme_schema", None)
build_theme = getattr(lvgl_schemas, "_build_theme_schema", None)
if build_theme is not None and hasattr(build_theme, "cache_clear"):
build_theme.cache_clear()
yield
@@ -173,12 +170,12 @@ def test_spread_sources_carry_no_extra_schemas(schema: cv.Schema) -> None:
def test_theme_schema_merges_obj_dict_and_full_style_props() -> None:
# _theme_schema is the riskiest merge: obj_dict(w) and FULL_STYLE_SCHEMA.schema
# theme_schema is the riskiest merge: obj_dict(w) and FULL_STYLE_SCHEMA.schema
# share many STYLE_SCHEMA marker instances. Exercise the merged schema
# end-to-end with one key from each side (a STATE_SCHEMA part from obj_dict
# and a FULL_STYLE-only property) to lock the behaviour against future
# regressions in either source.
out = _theme_schema(
out = theme_schema(
{
df.CONF_DARK_MODE: True,
"obj": {
@@ -202,7 +199,7 @@ def test_theme_schema_self_heals_when_a_widget_type_is_registered_later() -> Non
# any_widget_schema explicitly supports external components registering
# widgets lazily, and the device builder revalidates in-process, so a
# widget registered after first use must invalidate the cached snapshot.
_theme_schema({df.CONF_DARK_MODE: True}) # populate the cache
theme_schema({df.CONF_DARK_MODE: True}) # populate the cache
name = "test_self_heal_widget"
assert name not in WIDGET_TYPES
@@ -210,18 +207,68 @@ def test_theme_schema_self_heals_when_a_widget_type_is_registered_later() -> Non
# manually so the next theme call sees the new entry.
WIDGET_TYPES[name] = WidgetType(name, LvType("test_fake_t"), (), is_mock=True)
try:
out = _theme_schema({df.CONF_DARK_MODE: False, name: {"bg_color": 0x010203}})
out = theme_schema({df.CONF_DARK_MODE: False, name: {"bg_color": 0x010203}})
assert out[name]["bg_color"] == 0x010203
finally:
WIDGET_TYPES.pop(name, None)
@pytest.mark.parametrize(
("config", "expected_path"),
[
({"button": {"styles": ["foo"]}}, ["button", "styles"]),
(
{"button": {"pressed": {"styles": ["foo"]}}},
["button", "pressed", "styles"],
),
(
{"arc": {"indicator": {"styles": ["foo"]}}},
["arc", "indicator", "styles"],
),
(
{"arc": {"indicator": {"pressed": {"styles": ["foo"]}}}},
["arc", "indicator", "pressed", "styles"],
),
],
)
def test_theme_schema_rejects_styles_key(
config: dict, expected_path: list[str]
) -> None:
# `styles:` (references to named styles) is accepted by FULL_STYLE_SCHEMA
# but silently dropped by style_set when building a theme's hidden style
# -- it only walks ALL_STYLES. Reject it instead of quietly doing nothing,
# at the top level and when nested under a part and/or state.
with pytest.raises(vol.Invalid, match="'styles:' is not allowed") as exc_info:
theme_schema(config)
assert exc_info.value.path == expected_path
def test_theme_update_schema_rejects_styles_key() -> None:
with pytest.raises(vol.Invalid, match="'styles:' is not allowed") as exc_info:
theme_update_schema({"label": {"styles": ["foo"]}})
assert exc_info.value.path == ["label", "styles"]
def test_theme_update_schema_does_not_request_untargeted_main_default() -> None:
# collect_parts() unconditionally seeds a main/default entry even when
# only a specific state (here "pressed") was targeted -- registering a
# request for that spurious entry would make theme_to_code create an
# unused, empty style and attach it to every widget of this type.
theme_update_schema({"label": {"pressed": {"text_color": 0x010203}}})
assert df.get_theme_update_requests()["label"] == {("main", "pressed"): None}
def test_theme_update_schema_requests_explicit_main_default() -> None:
theme_update_schema({"label": {"text_color": 0x010203}})
assert df.get_theme_update_requests()["label"] == {("main", "default"): None}
@pytest.mark.parametrize(
"schema",
[STATE_SCHEMA, FLAG_SCHEMA, STYLE_SCHEMA, FULL_STYLE_SCHEMA],
)
def test_spread_sources_have_no_top_level_marker_defaults(schema: cv.Schema) -> None:
# _theme_schema merges obj_dict(w) with FULL_STYLE_SCHEMA.schema; on a key
# theme_schema merges obj_dict(w) with FULL_STYLE_SCHEMA.schema; on a key
# collision, dict-spread keeps the first source's marker (and its default)
# but the last source's value, whereas .extend() would take both from the
# later source. The two are equivalent today because the overlapping
+16
View File
@@ -295,6 +295,22 @@ lvgl:
id: style_test
bg_color: blue
bg_opa: !lambda return 0.5;
# `obj` is already themed above -- exercises updating an existing hidden style.
- lvgl.theme.update:
obj:
border_width: 2
# `label` is never mentioned under `theme:` -- exercises lazily creating the
# hidden style and getting it attached to already-built label widgets.
- lvgl.theme.update:
label:
text_color: red
# `button` is never mentioned under `theme:`, and only a non-default state is
# targeted here -- exercises that no spurious, empty main/default style is
# created (and attached to every button) alongside the requested one.
- lvgl.theme.update:
button:
pressed:
bg_color: red
- lvgl.image.update:
id: lv_image
src: