Merge branch 'esp8266-native-toolchain-plumbing' into esp8266-native-build-infra

This commit is contained in:
J. Nick Koston
2026-08-24 18:47:06 -05:00
30 changed files with 1804 additions and 78 deletions
@@ -55,8 +55,11 @@ int HOT IRAM_ATTR GPIOOneWireBus::reset_int() {
delayMicroseconds(1);
}
// delay J
delayMicroseconds(start + 480 - micros());
// delay J: finish the 480us slot, but never spin if it already elapsed
// (unsigned wrap here would busy-wait for minutes with interrupts off)
uint32_t elapsed = micros() - start;
if (elapsed < 480)
delayMicroseconds(480 - elapsed);
this->pin_.digital_write(true);
this->pin_.pin_mode(gpio::FLAG_OUTPUT);
return r ? 1 : 0;
+11 -22
View File
@@ -57,7 +57,6 @@ from .defines import (
CONF_ALIGN_TO_LAMBDA_ID,
CONF_ANIMATIONS,
LOGGER,
add_lv_use,
get_focused_widgets,
get_lv_images_used,
get_refreshed_widgets,
@@ -74,7 +73,6 @@ from .keypads import KEYPADS_CONFIG, keypads_to_code
from .lv_validation import lv_bool
from .lvcode import LvContext, LvglComponent, lv_event_t_ptr, lvgl_static
from .schemas import (
BASE_PROPS,
DISP_BG_SCHEMA,
FULL_STYLE_SCHEMA,
SET_STATE_SCHEMA,
@@ -83,6 +81,7 @@ from .schemas import (
STYLE_SCHEMA,
WIDGET_TYPES,
any_widget_schema,
apply_style_driven_defines,
container_schema,
container_schema_value,
theme_schema,
@@ -108,7 +107,6 @@ from .widgets import (
get_screen_active,
set_obj_properties,
)
from .widgets.img import CONF_IMAGE
# Import only what we actually use directly in this file
from .widgets.msgbox import MSGBOX_SCHEMA, msgboxes_to_code
@@ -455,6 +453,15 @@ async def to_code(configs):
# Mark all widgets as completed so awaiters of ``wait_for_widgets`` proceed.
set_widgets_completed(True)
async with LvContext():
# Local import: lv_list imports meter, which imports obj_spec/set_obj_properties
# from this module's own namespace - a top-level import here would be circular.
from .widgets.lv_list import finish_list_triggers
# Must run before generate_triggers(): that's what actually processes other
# widgets' on_click etc. automations, which can include lvgl.list.add/remove/
# clear actions that fire a list's on_add/on_remove triggers - those need to
# already exist by then, not still be pending.
await finish_list_triggers()
await generate_triggers()
await generate_align_tos(configs[0])
for config in configs:
@@ -481,34 +488,16 @@ async def to_code(configs):
# This must be done after all widgets are created
styles_used = df.get_styles_used()
if any(BASE_PROPS.get(x) is lvalid.lv_image for x in styles_used):
add_lv_use(CONF_IMAGE)
apply_style_driven_defines(styles_used)
for use in df.get_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")
if configs[0].get(df.CONF_THEME, {}).get(df.CONF_DARK_MODE):
df.add_define("LV_THEME_DEFAULT_DARK", "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 get_lv_images_used():
await cg.get_variable(image_id)
+1 -1
View File
@@ -416,7 +416,7 @@ async def obj_set_z_index_to_code(config, action_id, template_arg, args):
widget.obj, literal(f"{lv_expr.obj_get_index(widget.obj)} + 1")
)
elif position == "DOWN":
with LvConditional(f"{lv_expr.obj_get_index(widget.obj)} > 0"):
with LvConditional(literal(f"{lv_expr.obj_get_index(widget.obj)} > 0")):
lv_obj.move_to_index(
widget.obj, literal(f"{lv_expr.obj_get_index(widget.obj)} - 1")
)
+15
View File
@@ -585,6 +585,21 @@ FLEX_FLOWS = LvConstant(
"COLUMN_WRAP_REVERSE",
)
TRANSFORM_STYLE_PROPS = frozenset(
{"transform_rotation", "transform_scale", "transform_scale_x", "transform_scale_y"}
)
DROP_SHADOW_STYLE_PROPS = frozenset(
{
"drop_shadow_color",
"drop_shadow_offset_x",
"drop_shadow_offset_y",
"drop_shadow_opa",
"drop_shadow_quality",
"drop_shadow_radius",
}
)
OBJ_FLAGS = (
"hidden",
"clickable",
+39 -2
View File
@@ -242,7 +242,7 @@ class LocalVariable(MockObj):
self.base.type, self.modifier, self.base.id
)
)
return MockObj(self.base)
return MockObj(self.base, "->" if self.modifier == "*" else ".")
def __exit__(self, *args):
CodeContext.end_block()
@@ -283,7 +283,15 @@ class MockLv:
class LvConditional:
def __init__(self, condition):
self.condition = condition
# Condition is embedded directly into a raw `if (...)` statement below, rather than
# going through the argument-list machinery (ExpressionList) that would otherwise
# convert a native Python value (e.g. a plain bool) to a proper Expression.
if isinstance(condition, str):
raise ValueError(
"LvConditional condition must not be a raw str; wrap it in literal() "
"if a string literal condition is really intended"
)
self.condition = cg.safe_exp(condition) if condition is not None else None
def __enter__(self):
if self.condition is not None:
@@ -303,6 +311,35 @@ class LvConditional:
CodeContext.code_context.indent()
class LvCountdown:
"""
Emits a C++ `for` loop that counts an int variable down from `count - 1` to `0` inclusive.
Used to iterate over a widget's children in reverse, e.g. to fire a trigger once per child
before they're all removed.
"""
def __init__(self, var_name: str, count):
self.var_name = var_name
self.count = count
def __enter__(self):
# Cast explicitly rather than relying on `count`'s (typically unsigned) type to wrap
# and then narrow back to a negative int when count is 0 -- true in practice on every
# toolchain ESPHome targets, but not worth leaning on.
CodeContext.append(
RawStatement(
f"for (int {self.var_name} = (int) ({self.count}) - 1; {self.var_name} >= 0; "
f"{self.var_name}--) {{"
)
)
CodeContext.code_context.indent()
return literal(self.var_name)
def __exit__(self, *args):
CodeContext.code_context.detent()
CodeContext.append(RawStatement("}"))
class ReturnStatement(ExpressionStatement):
def __str__(self):
return f"return {self.expression};"
+28 -9
View File
@@ -208,21 +208,21 @@ void LvglComponent::esphome_lvgl_init() {
lv_update_event = static_cast<lv_event_code_t>(lv_event_register_id());
}
void LvglComponent::add_event_cb(lv_obj_t *obj, event_callback_t callback, lv_event_code_t event) {
lv_obj_add_event_cb(obj, callback, event, nullptr);
void LvglComponent::add_event_cb(lv_obj_t *obj, event_callback_t callback, lv_event_code_t event, void *user_data) {
lv_obj_add_event_cb(obj, callback, event, user_data);
}
void LvglComponent::add_event_cb(lv_obj_t *obj, event_callback_t callback, lv_event_code_t event1,
lv_event_code_t event2) {
add_event_cb(obj, callback, event1);
add_event_cb(obj, callback, event2);
lv_event_code_t event2, void *user_data) {
add_event_cb(obj, callback, event1, user_data);
add_event_cb(obj, callback, event2, user_data);
}
void LvglComponent::add_event_cb(lv_obj_t *obj, event_callback_t callback, lv_event_code_t event1,
lv_event_code_t event2, lv_event_code_t event3) {
add_event_cb(obj, callback, event1);
add_event_cb(obj, callback, event2);
add_event_cb(obj, callback, event3);
lv_event_code_t event2, lv_event_code_t event3, void *user_data) {
add_event_cb(obj, callback, event1, user_data);
add_event_cb(obj, callback, event2, user_data);
add_event_cb(obj, callback, event3, user_data);
}
void LvglComponent::add_page(LvPageType *page) {
@@ -963,6 +963,25 @@ lv_obj_t *lv_container_create(lv_obj_t *parent) {
lv_obj_class_init_obj(obj);
return obj;
}
#ifdef USE_LVGL_LIST
int lv_list_get_row_index(lv_obj_t *list, lv_obj_t *child) {
for (lv_obj_t *obj = child; obj != nullptr; obj = lv_obj_get_parent(obj)) {
if (lv_obj_get_parent(obj) == list)
return lv_obj_get_index(obj);
}
ESP_LOGW(TAG, "lvgl.list: entry is not inside the list it was added to");
return -1;
}
lv_obj_t *lv_list_get_row_for_remove(lv_obj_t *list, int index) {
lv_obj_t *child = index < 0 ? nullptr : lv_obj_get_child(list, index);
if (child == nullptr) {
ESP_LOGW(TAG, "lvgl.list.remove: index %d is out of range, ignoring", index);
}
return child;
}
#endif // USE_LVGL_LIST
} // namespace esphome::lvgl
lv_result_t lv_mem_test_core() { return LV_RESULT_OK; }
+22 -3
View File
@@ -116,6 +116,18 @@ inline void lv_animimg_set_src(lv_obj_t *img, std::vector<image::Image *> images
int16_t lv_get_needle_angle_for_value(lv_obj_t *obj, int32_t value);
#endif
#ifdef USE_LVGL_LIST
// Returns the index, within `list`, of the entry that contains `child`: `child` itself if it's a
// direct child of `list`, or the ancestor of `child` that is, when `child` is nested inside a
// widget hierarchy added via `lvgl.list.add`. Returns -1 if `child` isn't inside `list` at all.
int lv_list_get_row_index(lv_obj_t *list, lv_obj_t *child);
// Returns the entry at `index` within `list`, or nullptr (logging why) if `index` is out of
// range -- shared by every `lvgl.list.remove` call site, since a templatable index can go out of
// range at runtime in ways config validation can't catch (e.g. driven by a sensor value).
lv_obj_t *lv_list_get_row_for_remove(lv_obj_t *list, int index);
#endif
#ifdef USE_LVGL_GRADIENT
/**
*
@@ -135,6 +147,12 @@ class LvCompound {
lv_obj_t *obj{};
};
// Frees a heap-allocated LvCompound wrapper on LV_EVENT_DELETE, since lv_obj_del() only knows how to destroy LVGL's own
// object tree, not a separate C++ object paired with one of its nodes.
template<typename T> void delete_lv_compound_on_delete(lv_event_t *e) {
delete static_cast<T *>(lv_event_get_user_data(e));
}
class LvglComponent;
class LvPageType : public Parented<LvglComponent> {
@@ -241,10 +259,11 @@ class LvglComponent final : public PollingComponent {
static void esphome_lvgl_init();
// Convenience overloads for adding a callback for one or more events
static void add_event_cb(lv_obj_t *obj, event_callback_t callback, lv_event_code_t event);
static void add_event_cb(lv_obj_t *obj, event_callback_t callback, lv_event_code_t event1, lv_event_code_t event2);
static void add_event_cb(lv_obj_t *obj, event_callback_t callback, lv_event_code_t event, void *user_data = nullptr);
static void add_event_cb(lv_obj_t *obj, event_callback_t callback, lv_event_code_t event1, lv_event_code_t event2,
lv_event_code_t event3);
void *user_data = nullptr);
static void add_event_cb(lv_obj_t *obj, event_callback_t callback, lv_event_code_t event1, lv_event_code_t event2,
lv_event_code_t event3, void *user_data = nullptr);
// change the state of a widget and fire an event if changed (only needed for CHECKED)
+20
View File
@@ -726,6 +726,26 @@ ALL_STYLES = {
}
def apply_style_driven_defines(props: set[str]) -> None:
"""Given a set of style-property names in use, registers everything their use
drives: add_lv_use(image) if any of them is image-typed (per BASE_PROPS), and
the LV_COLOR_SCREEN_TRANSP / LV_DRAW_SW_SUPPORT_A8 defines. Shared between
__init__.py (driven by df.get_styles_used(), for statically-declared widgets)
and lv_list.py's _register_dynamic_widget_style_uses (driven by scanning a
dynamically-added widget's own config), so a future style-driven define added
to one can't be missed in the other.
"""
# Local import: avoids a module-load-time cycle (widgets.img -> ... -> schemas).
from .widgets.img import CONF_IMAGE
if any(BASE_PROPS.get(prop) is lvalid.lv_image for prop in props):
df.add_lv_use(CONF_IMAGE)
if df.TRANSFORM_STYLE_PROPS & props:
df.add_define("LV_COLOR_SCREEN_TRANSP", "1")
if df.DROP_SHADOW_STYLE_PROPS & props:
df.add_define("LV_DRAW_SW_SUPPORT_A8", "1")
def strip_defaults(schema: cv.Schema):
"""
Take a schema and remove any default values, also convert Required to Optional.
+23 -4
View File
@@ -59,7 +59,10 @@ async def generate_triggers():
all_triggers = (
LV_EVENT_TRIGGERS + LV_DISPLAY_EVENT_TRIGGERS + LV_SCREEN_EVENT_TRIGGERS
)
for w in get_widget_map().values():
# Snapshot: building a trigger below can recurse into widget creation (e.g. a
# buttonmatrix's or tabview's to_code registers its own child widgets), which
# would otherwise mutate this dict mid-iteration.
for w in list(get_widget_map().values()):
config = w.config
if isinstance(w.type, LvScrActType):
w = get_screen_active(w.var)
@@ -141,7 +144,21 @@ def _get_event_literal(trigger: str | MockObj) -> MockObj:
return literal("LV_EVENT_" + TRIGGER_MAP[trigger.upper()])
async def add_trigger(conf, w, *events: str | MockObj, is_selected=None):
async def add_trigger(
conf, w, *events: str | MockObj, is_selected=None, attach_obj=None, user_data=None
):
"""
:param attach_obj: The object to actually register the callback on, if different
from `w.obj` - used when `w.obj` isn't valid at the point the callback gets
registered (e.g. a local variable that's only in scope inside the very
block this is called from, not from within the callback body itself; see
widgets/lv_list.py's dynamic widget creation). Defaults to `w.obj`.
:param user_data: Opaque pointer passed through to the registered event callback,
retrievable inside it via `lv_event_get_user_data(event)` - used to recover a
compound widget's C++ wrapper, which a captureless callback has no other way
to reach when it isn't a global variable (see widgets/lv_list.py). Defaults to
`nullptr`.
"""
is_selected = is_selected or w.is_selected()
tid = conf[CONF_TRIGGER_ID]
trigger = cg.new_Pvariable(tid)
@@ -158,12 +175,14 @@ async def add_trigger(conf, w, *events: str | MockObj, is_selected=None):
lv_add(trigger.trigger(*value, literal("event")))
callback = await context.get_lambda()
event_literals = [_get_event_literal(event) for event in events]
attach_obj = w.obj if attach_obj is None else attach_obj
user_data = nullptr if user_data is None else user_data
if str(events[0]) in DISPLAY_TRIGGERS:
assert len(events) == 1
lv.display_add_event_cb(
lv_expr.obj_get_display(w.obj), callback, event_literals[0], nullptr
lv_expr.obj_get_display(attach_obj), callback, event_literals[0], user_data
)
else:
lv_add(
lvgl_static.add_event_cb(w.obj, await context.get_lambda(), *event_literals)
lvgl_static.add_event_cb(attach_obj, callback, *event_literals, user_data)
)
+17 -13
View File
@@ -190,18 +190,7 @@ class WidgetType:
await self.on_create(var, config)
w = Widget.create(wid, var, self, config)
if theme := get_theme_widget_map().get(self.name):
for part, states in theme.items():
part = "LV_PART_" + part.upper()
for state, style in states.items():
state = "LV_STATE_" + state.upper()
if state == "LV_STATE_DEFAULT":
lv_state = literal(part)
elif part == "LV_PART_MAIN":
lv_state = literal(state)
else:
lv_state = join_enums((state, part))
w.add_style(style, lv_state)
apply_theme_styles(w)
await set_obj_properties(w, config)
await add_widgets(w, config)
await self.to_code(w, config)
@@ -230,7 +219,7 @@ class WidgetType:
:param config: Its configuration
"""
def get_uses(self):
def get_uses(self) -> tuple:
"""
Get a list of other widgets used by this one
:return:
@@ -267,6 +256,21 @@ class WidgetType:
"""
def apply_theme_styles(w: "Widget") -> None:
"""Apply the current theme's styles for this widget's type"""
for part, states in get_theme_widget_map().get(w.type.name, {}).items():
part = "LV_PART_" + part.upper()
for state, style in states.items():
state = "LV_STATE_" + state.upper()
if state == "LV_STATE_DEFAULT":
lv_state = literal(part)
elif part == "LV_PART_MAIN":
lv_state = literal(state)
else:
lv_state = join_enums((state, part))
w.add_style(style, lv_state)
class Widget:
"""
Represents a Widget.
+553
View File
@@ -0,0 +1,553 @@
from collections.abc import Generator
from dataclasses import dataclass, field
from typing import Any
from esphome import automation
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import (
CONF_BUTTON,
CONF_ID,
CONF_INDEX,
CONF_ON_BOOT,
CONF_ON_UPDATE,
CONF_ON_VALUE,
CONF_TEXT,
CONF_TRIGGER_ID,
)
from esphome.core import CORE
from esphome.coroutine import FakeAwaitable
from esphome.cpp_generator import MockObj
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
from ..automation import action_to_code
from ..defines import (
CONF_ALIGN_TO,
CONF_MAIN,
CONF_PAD_ROW,
CONF_SCROLLBAR,
CONF_WIDGETS,
LV_EVENT_TRIGGERS,
SWIPE_TRIGGERS,
TYPE_FLEX,
add_lv_use,
literal,
)
from ..lv_validation import lv_int, lv_text, padding
from ..lvcode import (
UPDATE_EVENT,
LocalVariable,
LvConditional,
LvCountdown,
lv,
lv_add,
lv_expr,
lv_obj,
)
from ..schemas import (
ALL_STYLES,
WIDGET_TYPES,
any_widget_schema,
apply_style_driven_defines,
container_schema_value,
remap_property,
)
from ..trigger import add_trigger
from ..types import LV_EVENT, LvType, ObjUpdateAction, lv_obj_t
from . import (
Widget,
WidgetType,
apply_theme_styles,
collect_parts,
get_widgets,
set_obj_properties,
)
from .buttonmatrix import CONF_BUTTONMATRIX
from .canvas import CONF_CANVAS
from .label import CONF_LABEL
from .meter import CONF_METER
from .tabview import CONF_TABVIEW
from .tileview import CONF_TILEVIEW
CONF_LIST = "list"
CONF_WIDGET = "widget"
CONF_ON_ADD = "on_add"
CONF_ON_REMOVE = "on_remove"
DOMAIN = "lvgl_list"
lv_list_t = LvType("lv_list_t")
@dataclass
class ListTriggers:
on_add: list = field(default_factory=list)
on_remove: list = field(default_factory=list)
def _get_list_triggers(list_id) -> ListTriggers:
"""
Trigger Pvariables built for a given list's `on_add`/`on_remove` config, indexed by the
list's own ID.
"""
triggers_by_list = CORE.data.setdefault(DOMAIN, {})
return triggers_by_list.setdefault(list_id, ListTriggers())
def _get_pending_list_triggers(list_id) -> ListTriggers:
"""
Same shape as _get_list_triggers(), but holding raw on_add/on_remove automation
configs, not yet built.
"""
pending_by_list = CORE.data.setdefault(DOMAIN + "_pending", {})
return pending_by_list.setdefault(list_id, ListTriggers())
def _list_triggers_completed_flag() -> list[bool]:
return CORE.data.setdefault(DOMAIN + "_completed", [False])
def _list_triggers_completed_generator() -> Generator[None, None, None]:
while True:
if _list_triggers_completed_flag()[0]:
return
yield
async def _wait_list_triggers_completed() -> None:
"""Waits until finish_list_triggers() has built every list's on_add/on_remove automations."""
if _list_triggers_completed_flag()[0]:
return
await FakeAwaitable(_list_triggers_completed_generator())
async def finish_list_triggers() -> None:
"""
Builds every list's on_add/on_remove automations, collected by ListType.to_code()
instead of being built there directly. Must run after set_widgets_completed(True).
"""
for list_id, pending in CORE.data.get(DOMAIN + "_pending", {}).items():
triggers = _get_list_triggers(list_id)
for conf in pending.on_add:
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID])
await automation.build_automation(trigger, [(cg.int_, "list_index")], conf)
triggers.on_add.append(trigger)
for conf in pending.on_remove:
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID])
await automation.build_automation(trigger, [(cg.int_, "list_index")], conf)
triggers.on_remove.append(trigger)
_list_triggers_completed_flag()[0] = True
def _fire_index_triggers(triggers: list, index) -> None:
for trigger in triggers:
lv_add(trigger.trigger(index))
async def _fire_on_add(list_id, list_obj, entry_obj) -> None:
await _wait_list_triggers_completed()
triggers = _get_list_triggers(list_id).on_add
if not triggers:
return
index = cg.RawExpression(f"lvgl::lv_list_get_row_index({list_obj}, {entry_obj})")
_fire_index_triggers(triggers, index)
async def _fire_on_remove(list_id, index) -> None:
await _wait_list_triggers_completed()
_fire_index_triggers(_get_list_triggers(list_id).on_remove, index)
LIST_SCHEMA = cv.Schema(
{
cv.Optional(CONF_PAD_ROW): padding,
}
)
LIST_CREATE_SCHEMA = LIST_SCHEMA.extend(
{
cv.Optional(CONF_ON_ADD): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
automation.Trigger.template(cg.int_)
),
}
),
cv.Optional(CONF_ON_REMOVE): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
automation.Trigger.template(cg.int_)
),
}
),
}
)
class ListType(WidgetType):
"""A plain wrapper around LVGL's native `lv_list`"""
def __init__(self):
super().__init__(
CONF_LIST,
lv_list_t,
(CONF_MAIN, CONF_SCROLLBAR),
LIST_CREATE_SCHEMA,
modify_schema=LIST_SCHEMA,
)
def get_uses(self):
return TYPE_FLEX, CONF_LABEL, CONF_BUTTON
async def to_code(self, w: Widget, config: dict):
on_add = config.get(CONF_ON_ADD, ())
on_remove = config.get(CONF_ON_REMOVE, ())
if not on_add and not on_remove:
return
pending = _get_pending_list_triggers(w.config[CONF_ID])
pending.on_add.extend(on_add)
pending.on_remove.extend(on_remove)
list_spec = ListType()
LIST_ID_SCHEMA = cv.Schema({cv.Required(CONF_ID): cv.use_id(lv_list_t)})
@automation.register_action(
"lvgl.list.add_text",
ObjUpdateAction,
LIST_ID_SCHEMA.extend(
{
cv.Required(CONF_TEXT): lv_text,
cv.Optional(CONF_INDEX): cv.templatable(cv.int_),
}
),
synchronous=True,
)
async def list_add_text_to_code(config, action_id, template_arg, args):
widgets = await get_widgets(config)
async def do_add_text(w: Widget):
text = await lv_text.process(config[CONF_TEXT])
with LocalVariable(
"list_entry", lv_obj_t, lv_expr.list_add_text(w.obj, text)
) as entry:
if (idx := config.get(CONF_INDEX)) is not None:
lv.obj_move_to_index(entry, await lv_int.process(idx))
await _fire_on_add(config[CONF_ID], w.obj, entry)
return await action_to_code(
widgets, do_add_text, action_id, template_arg, args, config
)
_DYNAMIC_WIDGET_UNSUPPORTED = (
CONF_BUTTONMATRIX,
CONF_TABVIEW,
CONF_TILEVIEW,
CONF_METER,
CONF_CANVAS,
)
def _check_dynamic_widget_supported(w_type_name: str, w_conf: dict) -> None:
# Each of these allocates a Pvariable, or registers children into the global widget
# map, once at boot - rebuilding them on every lvgl.list.add call would break that.
if w_type_name in _DYNAMIC_WIDGET_UNSUPPORTED:
raise cv.Invalid(
f"'{w_type_name}' cannot be used with lvgl.list.add - it manages its own "
"child widgets in a way that isn't compatible with widgets created at runtime"
)
for child in w_conf.get(CONF_WIDGETS, ()):
[(child_type, child_conf)] = child.items()
_check_dynamic_widget_supported(child_type, child_conf)
_UNSUPPORTED_DYNAMIC_KEYS = SWIPE_TRIGGERS + (CONF_ON_BOOT, CONF_ALIGN_TO)
def _check_no_unsupported_triggers(w_type_name: str, w_conf: dict) -> None:
# These triggers currently aren't supporte for dynamic widgets
for key in _UNSUPPORTED_DYNAMIC_KEYS:
if key in w_conf:
raise cv.Invalid(
f"'{key}' is not supported on a widget added via lvgl.list.add - it "
"would validate but generate nothing, since it's only wired for "
"widgets that exist at boot",
path=[w_type_name, key],
)
for child in w_conf.get(CONF_WIDGETS, ()):
[(child_type, child_conf)] = child.items()
_check_no_unsupported_triggers(child_type, child_conf)
def _check_no_explicit_widget_id(raw_value: dict) -> None:
for w_type_name, w_conf in raw_value.items():
if not isinstance(w_conf, dict):
continue
if CONF_ID in w_conf:
raise cv.Invalid(
"'id' is not allowed on a widget added via lvgl.list.add - it is "
"rebuilt fresh on every call and never registered anywhere it "
"could be looked up by",
path=[w_type_name, CONF_ID],
)
for child in w_conf.get(CONF_WIDGETS, ()):
if isinstance(child, dict):
_check_no_explicit_widget_id(child)
@schema_extractor("schema")
def list_add_schema(value: Any) -> Any:
# A plain cv.Schema can't express "id, an optional index, plus exactly one arbitrary
# widget-type key", since the set of widget types isn't fixed until validation time.
if value is SCHEMA_EXTRACT:
return LIST_ID_SCHEMA.extend(
{
cv.Optional(CONF_INDEX): cv.templatable(cv.int_),
**{
cv.Optional(name): container_schema_value(widget_type)
for name, widget_type in WIDGET_TYPES.items()
},
}
)
if not isinstance(value, dict):
raise cv.Invalid("Expected a mapping")
value = value.copy()
if CONF_ID not in value:
raise cv.Invalid(f"required key '{CONF_ID}' not provided")
with cv.prepend_path([CONF_ID]):
list_id = cv.use_id(lv_list_t)(value.pop(CONF_ID))
result = {CONF_ID: list_id}
if CONF_INDEX in value:
with cv.prepend_path([CONF_INDEX]):
result[CONF_INDEX] = cv.templatable(cv.int_)(value.pop(CONF_INDEX))
if len(value) != 1:
raise cv.Invalid(
"lvgl.list.add takes exactly one widget definition, e.g. 'label:' or 'button:', alongside 'id' and optional 'index'"
)
_check_no_explicit_widget_id(value)
result[CONF_WIDGET] = any_widget_schema()(value)
[(w_type_name, w_conf)] = result[CONF_WIDGET][0].items()
_check_dynamic_widget_supported(w_type_name, w_conf)
_check_no_unsupported_triggers(w_type_name, w_conf)
return result
def _register_lv_uses(w_type_name: str, w_conf: dict) -> None:
# Must run before this coroutine's first await.
widget_type = WIDGET_TYPES[w_type_name]
add_lv_use(w_type_name)
add_lv_use(*widget_type.get_uses())
for child in w_conf.get(CONF_WIDGETS, ()):
[(child_type, child_conf)] = child.items()
_register_lv_uses(child_type, child_conf)
def _register_dynamic_widget_style_uses(w_conf: dict) -> None:
props = {
remap_property(prop)
for part_states in collect_parts(w_conf).values()
for state_props in part_states.values()
for prop in state_props
if prop in ALL_STYLES
}
apply_style_driven_defines(props)
for child in w_conf.get(CONF_WIDGETS, ()):
[(_, child_conf)] = child.items()
_register_dynamic_widget_style_uses(child_conf)
@automation.register_action(
"lvgl.list.add",
ObjUpdateAction,
list_add_schema,
synchronous=True,
)
async def list_add_to_code(config, action_id, template_arg, args):
[(w_type_name, w_conf)] = config[CONF_WIDGET][0].items()
_register_lv_uses(w_type_name, w_conf)
_register_dynamic_widget_style_uses(w_conf)
widgets = await get_widgets(config)
async def do_add(w: Widget):
index = None
if (idx := config.get(CONF_INDEX)) is not None:
index = await lv_int.process(idx)
await _build_dynamic_widget(
w_type_name,
w_conf,
w.obj,
config[CONF_ID],
w.obj,
top_level=True,
index=index,
)
return await action_to_code(widgets, do_add, action_id, template_arg, args, config)
async def _build_dynamic_widget(
w_type_name: str,
w_conf: dict,
parent,
list_id,
list_obj,
top_level: bool = False,
index=None,
depth: int = 0,
) -> None:
# Builds one widget (recursively, with children and triggers) as a LocalVariable
# instead of a global Pvariable. Compound
# widgets are heap-allocated and freed via LV_EVENT_DELETE.
# `depth` suffixes the local variable's name below the row's top level.
widget_type = WIDGET_TYPES[w_type_name]
var_name = f"dyn_{w_type_name}" if depth == 0 else f"dyn_{w_type_name}_{depth}"
add_lv_use(w_type_name)
add_lv_use(*widget_type.get_uses())
async def finish_and_fire(w: Widget) -> None:
# Shared tail for both branches below - must run while var's LocalVariable
# block (opened by whichever branch calls this) is still open
await _finish_dynamic_widget(w, w_conf, list_id, list_obj, depth)
if top_level:
if index is not None:
lv.obj_move_to_index(w.obj, index)
await _fire_on_add(list_id, list_obj, w.obj)
if widget_type.is_compound():
with LocalVariable(
var_name, widget_type.w_type, widget_type.w_type.new()
) as var:
creator = await widget_type.obj_creator(parent, w_conf)
lv_add(var.set_obj(creator))
w = Widget(var, widget_type, w_conf)
lv_obj.add_event_cb(
w.obj,
literal(f"lvgl::delete_lv_compound_on_delete<{widget_type.w_type}>"),
literal("LV_EVENT_DELETE"),
var,
)
await finish_and_fire(w)
else:
creator = await widget_type.obj_creator(parent, w_conf)
with LocalVariable(var_name, lv_obj_t, creator) as var:
w = Widget(var, widget_type, w_conf)
await finish_and_fire(w)
async def _finish_dynamic_widget(
w: Widget, w_conf: dict, list_id, list_obj, depth: int = 0
) -> None:
await w.type.on_create(w.obj, w_conf)
apply_theme_styles(w)
await set_obj_properties(w, w_conf)
await w.type.to_code(w, w_conf)
await _wire_dynamic_triggers(w, w_conf)
for child in w_conf.get(CONF_WIDGETS, ()):
[(child_type, child_conf)] = child.items()
await _build_dynamic_widget(
child_type, child_conf, w.obj, list_id, list_obj, depth=depth + 1
)
async def _wire_dynamic_triggers(w: Widget, config: dict) -> None:
# Mirrors generate_triggers(), but runs immediately
if w.type.is_compound():
event_var = MockObj(
f"static_cast<{w.type.w_type} *>(lv_event_get_user_data(event))", "->"
)
user_data = w.var
else:
event_var = literal("static_cast<lv_obj_t *>(lv_event_get_target(event))")
user_data = None
event_target = Widget(event_var, w.type, config)
for event, conf in {
event: conf for event, conf in config.items() if event in LV_EVENT_TRIGGERS
}.items():
w.add_flag("LV_OBJ_FLAG_CLICKABLE")
await add_trigger(
conf[0], event_target, event, attach_obj=w.obj, user_data=user_data
)
for conf in config.get(CONF_ON_VALUE, ()):
await add_trigger(
conf,
event_target,
LV_EVENT.VALUE_CHANGED,
UPDATE_EVENT,
attach_obj=w.obj,
user_data=user_data,
)
for conf in config.get(CONF_ON_UPDATE, ()):
await add_trigger(
conf, event_target, UPDATE_EVENT, attach_obj=w.obj, user_data=user_data
)
LIST_REMOVE_SCHEMA = LIST_ID_SCHEMA.extend(
{
# positive_int, not int_: a negative index would silently delete the *last*
# row (lv_obj_get_child() counts back from the end) while reporting that
# same bogus value to on_remove's list_index.
cv.Required(CONF_INDEX): cv.templatable(cv.positive_int),
}
)
@automation.register_action(
"lvgl.list.remove",
ObjUpdateAction,
LIST_REMOVE_SCHEMA,
synchronous=True,
)
async def list_remove_to_code(config, action_id, template_arg, args):
widgets = await get_widgets(config)
async def do_remove(w: Widget):
index = await lv_int.process(config[CONF_INDEX])
# Materialised into a local since index is needed at two call sites below, and
# a lambda's body gets re-emitted (and re-run) at every point it's used.
with (
LocalVariable("list_index", cg.int_, index, modifier="") as idx,
# Out-of-range lookup/log lives in a shared C++ helper, not inline here:
# a config can have many lvgl.list.remove call sites.
LocalVariable(
"list_child",
lv_obj_t,
cg.RawExpression(f"lvgl::lv_list_get_row_for_remove({w.obj}, {idx})"),
) as child,
LvConditional(child),
):
await _fire_on_remove(config[CONF_ID], idx)
# Recursively destroys the whole subtree
lv.obj_del(child)
return await action_to_code(
widgets, do_remove, action_id, template_arg, args, config
)
@automation.register_action(
"lvgl.list.clear",
ObjUpdateAction,
LIST_ID_SCHEMA,
synchronous=True,
)
async def list_clear_to_code(config, action_id, template_arg, args):
widgets = await get_widgets(config)
async def do_clear(w: Widget):
await _wait_list_triggers_completed()
triggers = _get_list_triggers(config[CONF_ID]).on_remove
if triggers:
# Fire on_remove for every entry, newest to oldest, before wiping them all out,
# so on_remove's semantics ("an entry left the list") hold
with LvCountdown("list_index", lv_expr.obj_get_child_count(w.obj)) as index:
_fire_index_triggers(triggers, index)
# lv_obj_clean recursively destroys every child's whole subtree
lv.obj_clean(w.obj)
return await action_to_code(
widgets, do_clear, action_id, template_arg, args, config
)
@@ -1,6 +1,6 @@
#include "esphome/core/defines.h"
#include "esphome/core/helpers.h"
#include "image_format.h"
#include "image_decoder.h"
namespace esphome::runtime_image {
+3
View File
@@ -9,6 +9,7 @@ SCALE = "scale"
CONF_ATTRIBUTE_ID = "attribute_id"
KEY_ZIGBEE_EP = "zigbee_ep"
KEY_ZIGBEE_EP_NO_NUM = "zigbee_ep_no_num"
KEY_ZIGBEE_FIRST_EP_CL = "zigbee_first_ep_cl"
DEVICE_ID = {
"RANGE_EXTENDER": cg.RawExpression("EZB_ZHA_RANGE_EXTENDER_DEVICE_ID"),
@@ -18,11 +19,13 @@ DEVICE_ID = {
cluster_id = cg.esphome_ns.enum("ezb_zcl_cluster_id_e")
CLUSTER_ID = {
"BASIC": cluster_id.EZB_ZCL_CLUSTER_ID_BASIC,
"TIME": cluster_id.EZB_ZCL_CLUSTER_ID_TIME,
"BINARY_INPUT": cluster_id.EZB_ZCL_CLUSTER_ID_BINARY_INPUT,
"ANALOG_INPUT": cluster_id.EZB_ZCL_CLUSTER_ID_ANALOG_INPUT,
}
CLUSTER_ROLE = {
"SERVER": cg.RawExpression("EZB_ZCL_CLUSTER_SERVER"),
"CLIENT": cg.RawExpression("EZB_ZCL_CLUSTER_CLIENT"),
}
attr_type = cg.esphome_ns.enum("ezb_zcl_attr_type_e")
ATTR_TYPE = {
+38 -10
View File
@@ -1,13 +1,15 @@
import esphome.codegen as cg
from esphome.components import time as time_
import esphome.config_validation as cv
from esphome.const import CONF_ID
from esphome.const import CONF_ID, CONF_UPDATE_INTERVAL
from esphome.core import CORE
from esphome.types import ConfigType
from .. import consume_endpoint
from ..const import zigbee_ns
from ..const_esp32 import ROLE
from ..const_zephyr import CONF_ZIGBEE_ID
from ..zigbee_ep_esp32 import add_clusters_to_first_ep, get_first_ep_num
from ..zigbee_zephyr import (
ZigbeeClusterDesc,
ZigbeeComponent,
@@ -22,26 +24,52 @@ DEPENDENCIES = ["zigbee"]
ZigbeeTime = zigbee_ns.class_("ZigbeeTime", time_.RealTimeClock)
def _validate_zigbee_time(config: ConfigType) -> ConfigType:
if CORE.is_nrf52:
return consume_endpoint(config)
if CORE.is_esp32:
cl = [
{
CONF_ID: "TIME",
ROLE: "CLIENT",
},
{
CONF_ID: "TIME",
ROLE: "SERVER",
},
]
add_clusters_to_first_ep(cl)
return config
CONFIG_SCHEMA = cv.All(
time_.TIME_SCHEMA.extend(
{
cv.GenerateID(): cv.declare_id(ZigbeeTime),
cv.OnlyWith(CONF_ZIGBEE_ID, ["nrf52", "zigbee"]): cv.use_id(
ZigbeeComponent
),
cv.GenerateID(CONF_ZIGBEE_ID): cv.use_id(ZigbeeComponent),
cv.SplitDefault(
CONF_UPDATE_INTERVAL,
nrf52="1s",
esp32="15min",
): cv.update_interval, # override default from TIME_SCHEMA. Remove once nrf52 implementation is aligned.
}
)
.extend(cv.COMPONENT_SCHEMA)
.extend(cv.polling_component_schema("1s")),
consume_endpoint,
).extend(cv.COMPONENT_SCHEMA),
_validate_zigbee_time,
)
async def to_code(config: ConfigType) -> None:
CORE.add_job(_add_time, config)
if CORE.using_zephyr:
CORE.add_job(_add_time_zephyr, config)
if CORE.is_esp32:
zb = await cg.get_variable(config[CONF_ZIGBEE_ID])
var = cg.new_Pvariable(config[CONF_ID], zb, get_first_ep_num())
await cg.register_component(var, config)
await time_.register_time(var, config)
async def _add_time(config: ConfigType) -> None:
async def _add_time_zephyr(config: ConfigType) -> None:
slot_index = get_slot_index()
# Create unique names for this sensor's variables based on slot index
@@ -0,0 +1,118 @@
#include "zigbee_time_esp32.h"
#if defined(USE_ZIGBEE) && defined(USE_ESP32) && defined(USE_TIME)
#include "esphome/core/log.h"
#include "esphome/core/application.h"
namespace esphome::zigbee {
static const char *const TAG = "zigbee.time";
// This time standard is the number of
// seconds since 0 hrs 0 mins 0 sec on 1st January 2000 UTC (Universal Coordinated Time).
constexpr time_t EPOCH_2000 = 946684800;
static ZigbeeTime *global_time = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
void ZigbeeTime::setup() {
global_time = this;
if (this->parent_->is_started()) {
this->register_zb_time_();
} else {
this->parent_->add_on_start_callback([this]() { this->register_zb_time_(); });
}
}
void ZigbeeTime::register_zb_time_() {
ezb_zcl_time_interface_t time_interface = {
.get_utc_time = esphome::zigbee::ZigbeeTime::get_utc_time,
.set_utc_time = esphome::zigbee::ZigbeeTime::set_utc_time,
};
ezb_err_t ret;
if (!esp_zigbee_lock_acquire(10 / portTICK_PERIOD_MS)) {
this->set_timeout("zb_time_register", 100, [this]() { this->register_zb_time_(); });
return;
}
ret = ezb_zcl_time_server_interface_register(this->endpoint_, time_interface);
esp_zigbee_lock_release();
if (ret != EZB_ERR_NONE) {
ESP_LOGW(TAG, "Setup failed: %d", ret);
this->mark_failed();
return;
}
this->registered_ = true;
this->parent_->add_on_join_callback([this](bool x) { this->update(); });
if (this->parent_->is_joined()) {
this->update();
}
}
void ZigbeeTime::status_cb(ezb_err_t status) {
if (status == EZB_ERR_NONE) {
ESP_LOGV(TAG, "Time synchronization successful");
} else if (status == EZB_ERR_TIMEOUT) {
ESP_LOGW(TAG, "Time synchronization timed out");
} else {
ESP_LOGW(TAG, "Time synchronization failed with error: %d", status);
}
}
void ZigbeeTime::update() {
if (this->parent_->is_joined() && this->registered_) {
if (esp_zigbee_lock_acquire(10 / portTICK_PERIOD_MS)) {
ESP_LOGV(TAG, "Updating time sync from Zigbee network...");
ezb_zcl_time_server_synchronize_time(this->endpoint_, 10, esphome::zigbee::ZigbeeTime::status_cb,
EZB_ZCL_TIME_SERVER_RANK_MASTER);
esp_zigbee_lock_release();
this->retry_count_ = 0;
} else {
if (this->retry_count_ == 0) {
ESP_LOGW(TAG, "Could not acquire Zigbee lock to synchronize time, will retry maximum 3 times");
}
if (this->retry_count_ < 3) {
this->set_timeout("zb_time_sync", 100, [this]() { this->update(); });
this->retry_count_++;
} else {
ESP_LOGW(TAG, "Could not acquire Zigbee lock to synchronize time");
this->retry_count_ = 0;
}
}
} else {
ESP_LOGD(TAG, "Not connected to Zigbee network, cannot synchronize time");
}
}
uint32_t ZigbeeTime::get_utc_time() {
const time_t now = global_time->timestamp_now();
if (now < EPOCH_2000) {
return 0xFFFFFFFF; // ZCL invalid UTCTime
}
return (uint32_t) (now - EPOCH_2000);
}
void ZigbeeTime::set_utc_time(uint32_t utc) {
// prevent overflow
if (utc <= (std::numeric_limits<uint32_t>::max() - EPOCH_2000)) {
global_time->set_epoch_time(utc + EPOCH_2000);
}
}
void ZigbeeTime::set_epoch_time(uint32_t utc) {
// called from zigbee task, defer to main loop
this->defer([this, utc]() {
ESP_LOGV(TAG, "Setting device time to UTC: %u", static_cast<unsigned>(utc));
this->synchronize_epoch_(utc);
});
App.wake_loop_threadsafe();
}
void ZigbeeTime::dump_config() {
ESP_LOGCONFIG(TAG,
"Zigbee Time\n"
" Endpoint: %u",
this->endpoint_);
RealTimeClock::dump_config();
}
} // namespace esphome::zigbee
#endif
@@ -0,0 +1,34 @@
#pragma once
#include "esphome/core/defines.h"
#if defined(USE_ZIGBEE) && defined(USE_ESP32) && defined(USE_TIME)
#include "esphome/core/component.h"
#include "esphome/components/time/real_time_clock.h"
#include "../zigbee_esp32.h"
namespace esphome::zigbee {
class ZigbeeComponent;
class ZigbeeTime final : public time::RealTimeClock {
public:
ZigbeeTime(ZigbeeComponent *parent, uint8_t ep) : parent_(parent), endpoint_(ep) {}
void setup() override;
void update() override;
void dump_config() override;
void set_epoch_time(uint32_t utc);
protected:
void register_zb_time_();
static void set_utc_time(uint32_t utc);
static uint32_t get_utc_time();
static void status_cb(ezb_err_t status);
ZigbeeComponent *parent_;
uint8_t endpoint_;
uint8_t retry_count_{0};
bool registered_{false};
};
} // namespace esphome::zigbee
#endif
+46 -7
View File
@@ -18,6 +18,7 @@ from .const_esp32 import (
DEVICE_TYPE,
KEY_ZIGBEE_EP,
KEY_ZIGBEE_EP_NO_NUM,
KEY_ZIGBEE_FIRST_EP_CL,
ROLE,
)
@@ -95,11 +96,11 @@ def _get_next_ep_num(eps: list[int]) -> int:
def _compare_clusters(
existing_ep: dict[str, Any],
ep: dict[str, Any],
existing_cl_list: list[dict[str, Any]],
cl_list: list[dict[str, Any]],
) -> tuple[str | int, str] | None:
existing_clusters = [(cl[CONF_ID], cl[ROLE]) for cl in existing_ep[CONF_CLUSTERS]]
for cl in [(cl[CONF_ID], cl[ROLE]) for cl in ep[CONF_CLUSTERS]]:
existing_clusters = [(cl[CONF_ID], cl[ROLE]) for cl in existing_cl_list]
for cl in [(cl[CONF_ID], cl[ROLE]) for cl in cl_list]:
if cl in existing_clusters:
return cl
return None
@@ -110,7 +111,7 @@ def _merge_endpoints(
ep: dict[str, Any],
use_type: bool | None,
) -> bool:
if _compare_clusters(existing_ep, ep):
if _compare_clusters(existing_ep.get(CONF_CLUSTERS, []), ep.get(CONF_CLUSTERS, [])):
return False
if (
ep.get(DEVICE_TYPE)
@@ -200,6 +201,17 @@ def create_ep(router: bool) -> None:
# clear list so that it is not processed again
del zb_data[KEY_ZIGBEE_EP_NO_NUM]
# Add clusters to first ep
cl_list: list[dict] = zb_data.setdefault(KEY_ZIGBEE_FIRST_EP_CL, [])
if cl_list:
first_ep = ep_dict[get_first_ep_num()]
first_ep.setdefault(CONF_CLUSTERS, [])
if cl := _compare_clusters(first_ep[CONF_CLUSTERS], cl_list):
raise cv.Invalid(
f"Endpoint {get_first_ep_num()} has more than one cluster with cluster id {cl[0]} and role {cl[1]}."
)
first_ep[CONF_CLUSTERS] += cl_list
del zb_data[KEY_ZIGBEE_FIRST_EP_CL]
# Add default device type to endpoints that have none
for ep in ep_dict.values():
@@ -207,6 +219,15 @@ def create_ep(router: bool) -> None:
ep[DEVICE_TYPE] = "CUSTOM_ATTR"
def get_first_ep_num() -> int | None:
"""Return the number of the first endpoint."""
zb_data = CORE.data.setdefault(KEY_ZIGBEE, {})
ep_dict: dict[int, dict] = zb_data.setdefault(KEY_ZIGBEE_EP, {})
if ep_dict:
return min(ep_dict.keys())
return None
def add_ep(ep: dict[str, Any], ep_num: int | None, use_type: bool | None) -> None:
"""Add a Zigbee endpoint configuration to CORE.data.
@@ -230,8 +251,8 @@ def add_ep(ep: dict[str, Any], ep_num: int | None, use_type: bool | None) -> Non
# check if the existing endpoint has same clusters
existing_ep = ep_dict[ep_num]
if cl := _compare_clusters(
existing_ep,
ep,
existing_ep.get(CONF_CLUSTERS, []),
ep.get(CONF_CLUSTERS, []),
):
raise cv.Invalid(
f"Endpoint {ep_num} has more than one cluster with cluster id {cl[0]} and role {cl[1]}."
@@ -245,3 +266,21 @@ def add_ep(ep: dict[str, Any], ep_num: int | None, use_type: bool | None) -> Non
if use_type or ep.get(DEVICE_TYPE):
ep[CONF_USE_DEVICE_TYPE] = {ep.get(DEVICE_TYPE): use_type}
ep_dict[ep_num] = ep
def add_clusters_to_first_ep(cl: list[dict[str, Any]]) -> None:
"""Add a list of Zigbee clusters to CORE.data.
Args:
cl: list of cluster dictonaries.
"""
zb_data = CORE.data.setdefault(KEY_ZIGBEE, {})
cl_list: list[dict] = zb_data.setdefault(KEY_ZIGBEE_FIRST_EP_CL, [])
if cluster := _compare_clusters(
cl_list,
cl,
):
raise cv.Invalid(
f"Only one cluster with cluster id {cluster[0]} and role {cluster[1]} can be added to first endpoint."
)
cl_list += cl
@@ -30,6 +30,8 @@ ezb_zcl_cluster_desc_t esphome_zb_default_cluster_dscr_create(uint16_t cluster_i
return ezb_zcl_basic_create_cluster_desc(NULL, role_mask);
case EZB_ZCL_CLUSTER_ID_IDENTIFY:
return ezb_zcl_identify_create_cluster_desc(NULL, role_mask);
case EZB_ZCL_CLUSTER_ID_TIME:
return ezb_zcl_time_create_cluster_desc(NULL, role_mask);
case EZB_ZCL_CLUSTER_ID_ANALOG_INPUT:
return ezb_zcl_analog_input_create_cluster_desc(NULL, role_mask);
case EZB_ZCL_CLUSTER_ID_BINARY_INPUT:
@@ -49,6 +51,8 @@ ezb_err_t esphome_zb_cluster_add_attr(uint16_t cluster_id, ezb_zcl_cluster_desc_
return ezb_zcl_basic_cluster_desc_add_attr(cluster_desc, attr_id, value_p);
case EZB_ZCL_CLUSTER_ID_IDENTIFY:
return ezb_zcl_identify_cluster_desc_add_attr(cluster_desc, attr_id, value_p);
case EZB_ZCL_CLUSTER_ID_TIME:
return ezb_zcl_time_cluster_desc_add_attr(cluster_desc, attr_id, value_p);
case EZB_ZCL_CLUSTER_ID_ANALOG_INPUT:
return ezb_zcl_analog_input_cluster_desc_add_attr(cluster_desc, attr_id, value_p);
case EZB_ZCL_CLUSTER_ID_BINARY_INPUT:
+1 -1
View File
@@ -12,7 +12,7 @@ pyserial==3.5
platformio==6.1.19
esptool==5.3.1
click==8.3.3
aioesphomeapi==46.1.0
aioesphomeapi==46.2.0
aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi
zeroconf==0.150.0
puremagic==2.2.0
@@ -0,0 +1,36 @@
esphome:
name: test-list-on-add-lvgl-action
esp32:
board: lolin_c3_mini
spi:
mosi_pin:
number: GPIO2
ignore_strapping_warning: true
clk_pin: GPIO1
display:
- platform: mipi_spi
data_rate: 20MHz
model: st7735
cs_pin:
number: GPIO8
ignore_strapping_warning: true
dc_pin: GPIO3
lvgl:
widgets:
- label:
id: later_label
text: orig
- list:
id: test_list
on_add:
- lvgl.label.update:
id: later_label
text: "changed"
on_remove:
- lvgl.label.update:
id: later_label
text: "removed"
@@ -0,0 +1,41 @@
esphome:
name: test-list-outside-block
on_boot:
priority: -100
then:
- lvgl.list.add:
id: test_list
switch:
transform_rotation: 100
drop_shadow_color: 0x000000
bg_image_src: my_image
esp32:
board: lolin_c3_mini
spi:
mosi_pin:
number: GPIO2
ignore_strapping_warning: true
clk_pin: GPIO1
display:
- platform: mipi_spi
data_rate: 20MHz
model: st7735
cs_pin:
number: GPIO8
ignore_strapping_warning: true
dc_pin: GPIO3
image:
- platform: file
file: mdi:battery
id: my_image
resize: 8x8
type: binary
lvgl:
widgets:
- list:
id: test_list
@@ -0,0 +1,77 @@
esphome:
name: test-list
esp32:
board: lolin_c3_mini
spi:
mosi_pin:
number: GPIO2
ignore_strapping_warning: true
clk_pin: GPIO1
display:
- platform: mipi_spi
data_rate: 20MHz
model: st7735
cs_pin:
number: GPIO8
ignore_strapping_warning: true
dc_pin: GPIO3
lvgl:
theme:
label:
bg_color: 0xFF0000
widgets:
- list:
id: test_list
pad_row: 4
on_add:
- delay: 10ms
- delay: 20ms
on_remove:
- delay: 10ms
- button:
id: trigger_button
text: "Trigger"
on_click:
- lvgl.list.add_text:
id: test_list
text: "Header"
- lvgl.list.add_text:
id: test_list
text: "Pinned"
index: 0
- lvgl.list.add:
id: test_list
button:
text: "Entry"
checkable: true
- lvgl.list.add:
id: test_list
index: 1
obj:
widgets:
- label:
text: "Nested"
- dropdown:
options:
- "One"
- "Two"
- lvgl.list.add:
id: test_list
obj:
widgets:
- obj:
widgets:
- label:
text: "Grandchild"
- lvgl.list.remove:
id: test_list
index: 0
- lvgl.list.clear:
id: test_list
- lvgl.list.update:
id: test_list
pad_row: 8
+403
View File
@@ -0,0 +1,403 @@
"""Tests for the LVGL ``list`` widget: schema validation for its actions
(``lvgl.list.add_text``/``add``/``remove``/``clear``) and the code they generate.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from esphome.__main__ import generate_cpp_contents
from esphome.components.lvgl.widgets.lv_list import (
LIST_CREATE_SCHEMA,
LIST_REMOVE_SCHEMA,
LIST_SCHEMA,
list_add_schema,
)
from esphome.config import read_config
import esphome.config_validation as cv
from esphome.core import CORE
# ---------------------------------------------------------------------------
# lvgl.list.add schema: id + optional index + exactly one widget-type key
# ---------------------------------------------------------------------------
class TestListAddSchema:
def test_valid_single_widget(self) -> None:
result = list_add_schema({"id": "my_list", "label": {"text": "hi"}})
assert result["id"].id == "my_list"
assert "widget" in result
def test_index_optional_and_templatable(self) -> None:
result = list_add_schema({"id": "my_list", "index": 2, "label": {"text": "hi"}})
assert result["index"] == 2
def test_index_omitted_when_not_given(self) -> None:
result = list_add_schema({"id": "my_list", "label": {"text": "hi"}})
assert "index" not in result
def test_missing_id_rejected(self) -> None:
with pytest.raises(cv.Invalid, match="required key 'id' not provided"):
list_add_schema({"label": {"text": "hi"}})
def test_no_widget_key_rejected(self) -> None:
with pytest.raises(cv.Invalid, match="exactly one widget definition"):
list_add_schema({"id": "my_list"})
def test_two_widget_keys_rejected(self) -> None:
with pytest.raises(cv.Invalid, match="exactly one widget definition"):
list_add_schema(
{
"id": "my_list",
"label": {"text": "a"},
"button": {"text": "b"},
}
)
def test_non_mapping_rejected(self) -> None:
with pytest.raises(cv.Invalid, match="Expected a mapping"):
list_add_schema("not_a_mapping")
def test_any_registered_widget_type_accepted(self) -> None:
for widget_key, widget_conf in (
("checkbox", {"text": "Option"}),
("switch", {}),
("spinner", {}),
("obj", {}),
("dropdown", {"options": ["a", "b"]}),
):
result = list_add_schema({"id": "my_list", widget_key: widget_conf})
assert widget_key in result["widget"][0]
@pytest.mark.parametrize(
("widget_key", "widget_conf"),
[
("buttonmatrix", {"rows": [{"buttons": [{"text": "A"}]}]}),
("tabview", {"tabs": [{"name": "Tab1"}]}),
("tileview", {"tiles": [{"row": 0, "column": 0}]}),
("meter", {"scales": [{"range_from": 0, "range_to": 100}]}),
("canvas", {"width": 20, "height": 20}),
],
)
def test_dynamic_widget_unsupported_rejected(
self, widget_key: str, widget_conf: dict
) -> None:
"""buttonmatrix/tabview/tileview all register their own child widgets into
the global widget map from inside their to_code - fine for a widget built
once at boot, but broken if lvgl.list.add re-enters that on every call.
meter/canvas are rejected for a related but distinct reason: they declare a
Pvariable (meter's scale/indicator objects; canvas's draw buffer) with
cg.Pvariable()/cg.new_Pvariable(), which emits its assignment wherever code
is currently being generated -- fine at the top level of a boot-time
to_code, but lvgl.list.add's do_add runs inside a lambda. meter's assignment
would then end up outside the very lambda that declares the local object it
refers to (doesn't compile); canvas's Pvariable is declared once per config
site rather than per call, so every call overwrites its one draw buffer
(compiles, but leaks the old buffer and shares one buffer across every row).
"""
with pytest.raises(cv.Invalid, match="cannot be used with lvgl.list.add"):
list_add_schema({"id": "my_list", widget_key: widget_conf})
def test_dynamic_widget_unsupported_rejected_when_nested(self) -> None:
"""The check must recurse into `widgets:` so a tabview hidden a few levels
deep inside another widget is caught too, not just at the top level.
"""
with pytest.raises(cv.Invalid, match="cannot be used with lvgl.list.add"):
list_add_schema(
{
"id": "my_list",
"obj": {"widgets": [{"tabview": {"tabs": [{"name": "Tab1"}]}}]},
}
)
def test_explicit_id_rejected(self) -> None:
"""A dynamically-added widget is LocalVariable-scoped and rebuilt fresh
on every call, never registered anywhere an id could be looked up by --
an explicit id: would otherwise validate fine and then fail confusingly
(an uncaught traceback, not a clean config error) the moment anything
tries to reference it.
"""
with pytest.raises(cv.Invalid, match="'id' is not allowed"):
list_add_schema(
{"id": "my_list", "label": {"id": "dyn_label", "text": "hi"}}
)
def test_explicit_id_rejected_when_nested(self) -> None:
with pytest.raises(cv.Invalid, match="'id' is not allowed"):
list_add_schema(
{
"id": "my_list",
"obj": {"widgets": [{"label": {"id": "dyn_label", "text": "hi"}}]},
}
)
def test_no_explicit_id_still_valid(self) -> None:
"""An id is auto-generated (and simply unused) when none is given --
only an explicit one is rejected."""
result = list_add_schema({"id": "my_list", "label": {"text": "hi"}})
assert "id" in result["widget"][0]["label"]
@pytest.mark.parametrize(
("key", "conf"),
[
("on_swipe_left", [{"logger.log": "swiped"}]),
("on_swipe_right", [{"logger.log": "swiped"}]),
("on_swipe_up", [{"logger.log": "swiped"}]),
("on_swipe_down", [{"logger.log": "swiped"}]),
("on_boot", [{"logger.log": "booted"}]),
("align_to", {"id": "some_other_widget", "align": "OUT_LEFT_TOP"}),
],
)
def test_unsupported_trigger_rejected(self, key: str, conf: list) -> None:
"""_wire_dynamic_triggers only wires LV_EVENT_TRIGGERS/on_value/on_update --
on_swipe_*/on_boot would otherwise validate fine and then silently generate
nothing at all for a widget added via lvgl.list.add. align_to is in the same
bucket: it's only ever consumed by generate_triggers() reading
get_widget_map(), which a widget built via lvgl.list.add never enters.
"""
with pytest.raises(cv.Invalid, match="is not supported"):
list_add_schema({"id": "my_list", "obj": {key: conf}})
def test_unsupported_trigger_rejected_when_nested(self) -> None:
with pytest.raises(cv.Invalid, match="is not supported"):
list_add_schema(
{
"id": "my_list",
"obj": {
"widgets": [
{
"label": {
"text": "hi",
"on_swipe_left": [{"logger.log": "swiped"}],
}
}
]
},
}
)
# ---------------------------------------------------------------------------
# lvgl.list.remove: index must be non-negative -- LVGL treats a negative index as
# counting back from the end, which would silently delete the wrong row while
# reporting a list_index that matches nothing real to on_remove.
# ---------------------------------------------------------------------------
class TestListRemoveSchema:
def test_negative_index_rejected(self) -> None:
with pytest.raises(cv.Invalid, match="at least 0"):
LIST_REMOVE_SCHEMA({"id": "my_list", "index": -1})
def test_zero_index_accepted(self) -> None:
result = LIST_REMOVE_SCHEMA({"id": "my_list", "index": 0})
assert result["index"] == 0
# ---------------------------------------------------------------------------
# The list widget's own schema: pad_row is shared between create/update, but
# on_add/on_remove only make sense at creation time.
# ---------------------------------------------------------------------------
class TestListCreateVsModifySchema:
def test_create_schema_has_pad_row_and_triggers(self) -> None:
keys = {str(k) for k in LIST_CREATE_SCHEMA.schema}
assert "pad_row" in keys
assert "on_add" in keys
assert "on_remove" in keys
def test_modify_schema_has_pad_row_but_not_triggers(self) -> None:
"""``lvgl.list.update`` can change pad_row but can't (re-)declare triggers."""
keys = {str(k) for k in LIST_SCHEMA.schema}
assert "pad_row" in keys
assert "on_add" not in keys
assert "on_remove" not in keys
def test_on_add_single_automation_with_multiple_actions(self) -> None:
"""A bare action list under on_add: is one automation with a multi-step
`then:`, not multiple independent automations.
"""
config = LIST_CREATE_SCHEMA({"on_add": [{"delay": "10ms"}, {"delay": "20ms"}]})
assert len(config["on_add"]) == 1
assert len(config["on_add"][0]["then"]) == 2
def test_on_add_accepts_multiple_independent_automations(self) -> None:
"""Each explicit `then:` entry gets its own Trigger, so on_add can fire
more than one independent automation.
"""
config = LIST_CREATE_SCHEMA(
{
"on_add": [
{"then": [{"delay": "10ms"}]},
{"then": [{"delay": "20ms"}]},
]
}
)
assert len(config["on_add"]) == 2
# ---------------------------------------------------------------------------
# Code generation
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def main_cpp(request: pytest.FixtureRequest) -> str:
"""Generate the C++ output for the shared list-widget YAML config once per
module -- see test_widget_state.py for why this is module-scoped and
inlines the generate_main fixture logic rather than depending on it.
"""
config_path = Path(request.fspath).parent / "config" / "list_test.yaml"
original_path = CORE.config_path
try:
CORE.config_path = config_path
CORE.config = read_config({})
generate_cpp_contents(CORE.config)
return CORE.cpp_global_section + CORE.cpp_main_section
finally:
CORE.config_path = original_path
CORE.reset()
def test_pad_row_set_at_creation(main_cpp: str) -> None:
assert "lv_obj_set_style_pad_row(test_list, 4, LV_PART_MAIN);" in main_cpp
def test_pad_row_updated_via_update_action(main_cpp: str) -> None:
assert "lv_obj_set_style_pad_row(test_list, 8, LV_PART_MAIN);" in main_cpp
def test_add_text_appends(main_cpp: str) -> None:
assert 'lv_list_add_text(test_list, "Header");' in main_cpp
def test_add_text_with_index_moves_before_firing_on_add(main_cpp: str) -> None:
"""The index move must happen before on_add fires, so the reported
list_index reflects the entry's final position, not where it was appended.
"""
assert (
'lv_obj_t *list_entry_VAR_ = lv_list_add_text(test_list, "Pinned");\n'
" lv_obj_move_to_index(list_entry_VAR_, 0);\n"
" triggerint_id->trigger(lvgl::lv_list_get_row_index(test_list, list_entry_VAR_));"
) in main_cpp
def test_add_button_with_checkable_flag(main_cpp: str) -> None:
assert "lv_obj_t *dyn_button_VAR_ = lv_btn_create(test_list);" in main_cpp
assert (
"lv_obj_add_flag(dyn_button_VAR_, (lv_obj_flag_t)(LV_OBJ_FLAG_CHECKABLE));"
in main_cpp
)
assert (
'lv_label_set_text(lv_obj_get_child(dyn_button_VAR_, 0), "Entry");' in main_cpp
)
def test_add_nested_hierarchy_with_compound_child(main_cpp: str) -> None:
"""`obj: {widgets: [label, dropdown]}` builds a plain label child and a
heap-allocated (compound) dropdown child, both parented to the new row.
The child variable names carry a `_1` (depth) suffix, distinguishing them
from the row's own top-level variable -- necessary so that a child of the
*same* widget type as its parent (e.g. `obj: {widgets: [{obj: {...}}]}`)
doesn't declare a C++ variable that shadows its own not-yet-initialized
self, silently parenting the child to garbage.
"""
assert "lv_obj_t *dyn_obj_VAR_ = lv_obj_create(test_list);" in main_cpp
assert (
"lv_obj_t *dyn_label_1_VAR_ = lv_label_create(dyn_obj_VAR_);\n"
" lv_obj_add_style(dyn_label_1_VAR_, _lv_theme_style_label_main_default, "
"(lv_state_t)(LV_PART_MAIN));\n"
' lv_label_set_text(dyn_label_1_VAR_, "Nested");'
) in main_cpp
def test_add_applies_theme_styles_to_dynamic_widget(main_cpp: str) -> None:
"""A widget added via lvgl.list.add must pick up the same `theme:` styling a
statically-declared widget of the same type gets, not render unthemed.
"""
assert (
"lv_obj_add_style(dyn_label_1_VAR_, _lv_theme_style_label_main_default, "
"(lv_state_t)(LV_PART_MAIN));"
) in main_cpp
assert "LvDropdownType *dyn_dropdown_1_VAR_ = new LvDropdownType();" in main_cpp
assert "lv_dropdown_create(dyn_obj_VAR_)" in main_cpp
assert (
"lvgl::delete_lv_compound_on_delete<LvDropdownType>, LV_EVENT_DELETE, "
"dyn_dropdown_1_VAR_);"
) in main_cpp
def test_add_nested_same_type_child_does_not_shadow_parent(main_cpp: str) -> None:
"""A child of the same widget type as its parent (`obj: {widgets: [{obj:
...}]}`) must get a distinct C++ variable name (or the child's declaration
would shadow its own not-yet-initialized self, parenting it to garbage --
compiling clean but for a -Wuninitialized warning). A grandchild of a third
type proves depth, not just type, drives the disambiguating suffix.
"""
assert "lv_obj_t *dyn_obj_VAR_ = lv_obj_create(test_list);" in main_cpp
assert "lv_obj_t *dyn_obj_1_VAR_ = lv_obj_create(dyn_obj_VAR_);" in main_cpp
assert (
"lv_obj_t *dyn_label_2_VAR_ = lv_label_create(dyn_obj_1_VAR_);\n"
" lv_obj_add_style(dyn_label_2_VAR_, _lv_theme_style_label_main_default, "
"(lv_state_t)(LV_PART_MAIN));\n"
' lv_label_set_text(dyn_label_2_VAR_, "Grandchild");'
) in main_cpp
def test_add_moves_row_to_given_index_before_firing_on_add(main_cpp: str) -> None:
assert (
"lv_obj_move_to_index(dyn_obj_VAR_, 1);\n"
" triggerint_id->trigger(lvgl::lv_list_get_row_index(test_list, dyn_obj_VAR_));"
) in main_cpp
def test_on_add_fires_once_per_entry_via_shared_trigger(main_cpp: str) -> None:
"""A single on_add: automation means a single Trigger instance, reused by
every lvgl.list.add_text/add call site.
"""
assert main_cpp.count("triggerint_id->trigger(lvgl::lv_list_get_row_index(") == 5
def test_remove_guards_against_missing_child_and_fires_before_delete(
main_cpp: str,
) -> None:
"""The index is materialised into a local once (list_index_VAR_) and reused for
both the child lookup and the on_remove trigger, so a templatable index isn't
evaluated twice.
"""
assert (
"int list_index_VAR_ = 0;\n"
" {\n"
" lv_obj_t *list_child_VAR_ = lvgl::lv_list_get_row_for_remove(test_list, list_index_VAR_);\n"
" if (list_child_VAR_) {\n"
" triggerint_id_2->trigger(list_index_VAR_);\n"
" lv_obj_del(list_child_VAR_);"
) in main_cpp
def test_remove_out_of_range_lookup_uses_shared_cpp_helper(main_cpp: str) -> None:
"""The out-of-range lookup (and its log line) live in a single C++ helper --
lvgl::lv_list_get_row_for_remove() in lvgl_esphome.cpp -- rather than being
generated inline at every lvgl.list.remove call site, since a config can
have many of them and duplicating that logic (and its log string) at each
one would waste flash for no benefit.
"""
assert (
"lv_obj_t *list_child_VAR_ = lvgl::lv_list_get_row_for_remove(test_list, list_index_VAR_);"
in main_cpp
)
assert "ESP_LOGV" not in main_cpp
def test_clear_fires_on_remove_for_every_entry_then_cleans(main_cpp: str) -> None:
assert (
"for (int list_index = (int) (lv_obj_get_child_count(test_list)) - 1; "
"list_index >= 0; list_index--) {\n"
" triggerint_id_2->trigger(list_index);\n"
" }\n"
" lv_obj_clean(test_list);"
) in main_cpp
@@ -0,0 +1,47 @@
"""Regression test: on_add:/on_remove: containing an lvgl action must not deadlock.
ListType.to_code() used to build the on_add/on_remove automations directly, during
widget creation. Every lvgl action's to_code awaits wait_for_widgets(), which only
resolves once *all* widgets - including the list itself - have finished being
created. Building an automation containing an lvgl action from inside that same
widget-creation walk therefore could never complete: codegen deadlocked with
"Circular dependency detected!". Fixed by deferring the actual build_automation()
call to finish_list_triggers(), run after set_widgets_completed(True) - and,
critically, before generate_triggers(), which is what processes other widgets'
on_click etc. automations that might reference this list (e.g. via lvgl.list.add),
and which therefore need the list's own on_add/on_remove triggers to already exist.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from esphome.__main__ import generate_cpp_contents
from esphome.config import read_config
from esphome.core import CORE
@pytest.fixture(scope="module")
def main_cpp(request: pytest.FixtureRequest) -> str:
config_path = (
Path(request.fspath).parent / "config" / "list_on_add_lvgl_action_test.yaml"
)
original_path = CORE.config_path
try:
CORE.config_path = config_path
CORE.config = read_config({})
generate_cpp_contents(CORE.config)
return CORE.cpp_main_section
finally:
CORE.config_path = original_path
CORE.reset()
def test_on_add_with_lvgl_action_does_not_deadlock(main_cpp: str) -> None:
assert 'lv_label_set_text(later_label, "changed");' in main_cpp
def test_on_remove_with_lvgl_action_does_not_deadlock(main_cpp: str) -> None:
assert 'lv_label_set_text(later_label, "removed");' in main_cpp
@@ -0,0 +1,84 @@
"""Regression test for lvgl.list.add called from outside the lvgl: block.
lv_list.py's list_add_to_code() must call _register_lv_uses() and
_register_dynamic_widget_style_uses() before its first await (get_widgets(),
which can block until the target list is defined) -- for an action referenced
outside the lvgl: block, that wait can outlast lvgl's own to_code, which reads
get_lv_uses()/get_styles_used() and flushes everything they drive (USE_LVGL_*
defines, plus add_lv_use(image)/screen-transparency/A8-draw-support triggered
by style properties) just once, near the end of its run. Every existing
list_test.yaml call site lives inside lvgl: widgets:, so neither ordering
requirement had any coverage.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
import pytest
from esphome.__main__ import generate_cpp_contents
from esphome.config import read_config
from esphome.core import CORE
@dataclass
class GeneratedOutput:
main_cpp: str
define_names: set[str]
lv_define_names: set[str]
@pytest.fixture(scope="module")
def generated(request: pytest.FixtureRequest) -> GeneratedOutput:
config_path = (
Path(request.fspath).parent / "config" / "list_outside_block_test.yaml"
)
original_path = CORE.config_path
try:
CORE.config_path = config_path
CORE.config = read_config({})
generate_cpp_contents(CORE.config)
# Copy out before CORE.reset() below clears these out from under us.
from esphome.components.lvgl import defines as df
return GeneratedOutput(
main_cpp=CORE.cpp_global_section + CORE.cpp_main_section,
define_names={d.name for d in CORE.defines},
lv_define_names=set(df.get_defines()),
)
finally:
CORE.config_path = original_path
CORE.reset()
def test_dynamic_widget_creates_correctly(generated: GeneratedOutput) -> None:
assert (
"lv_obj_t *dyn_switch_VAR_ = lv_switch_create(test_list);" in generated.main_cpp
)
def test_dynamic_widget_type_use_define_is_registered(
generated: GeneratedOutput,
) -> None:
"""The switch type is only ever referenced via the on_boot lvgl.list.add call
(never declared as a static widget), so USE_LVGL_SWITCH can only be present
if _register_lv_uses() ran in time for lvgl's own to_code to flush it.
"""
assert "USE_LVGL_SWITCH" in generated.define_names
assert "USE_LVGL_LIST" in generated.define_names
def test_dynamic_widget_style_use_defines_are_registered(
generated: GeneratedOutput,
) -> None:
"""bg_image_src/transform_rotation/drop_shadow_color are only ever set on
the dynamically-added switch (never on a static widget), so
USE_LVGL_IMAGE/LV_COLOR_SCREEN_TRANSP/LV_DRAW_SW_SUPPORT_A8 can only be
present if _register_dynamic_widget_style_uses() ran in time for lvgl's own
to_code to flush them.
"""
assert "USE_LVGL_IMAGE" in generated.define_names
assert "LV_COLOR_SCREEN_TRANSP" in generated.lv_define_names
assert "LV_DRAW_SW_SUPPORT_A8" in generated.lv_define_names
+58
View File
@@ -1214,6 +1214,64 @@ lvgl:
id: checkbox_id
text: Checkbox
align: bottom_right
- list:
id: test_list_id
align: top_right
width: 150px
height: 120px
pad_row: 4
on_add:
- logger.log:
format: "list entry added at %d"
args: [list_index]
on_remove:
- logger.log:
format: "list entry removed at %d"
args: [list_index]
on_click:
- lvgl.list.add_text:
id: test_list_id
text: !lambda return "Section";
- lvgl.list.add_text:
id: test_list_id
text: "Pinned section"
index: 0
- lvgl.list.add:
id: test_list_id
button:
text: "Entry"
checkable: true
- lvgl.list.add:
id: test_list_id
index: 1
obj:
widgets:
- label:
text: !lambda return "Dynamic row " + std::to_string(millis());
- button:
widgets:
- label:
text: "Tap"
on_click:
- lambda: |-
ESP_LOGD("lvgl", "dynamic row button clicked, row %d",
lvgl::lv_list_get_row_index(id(test_list_id), static_cast<lv_obj_t *>(lv_event_get_target(event))));
- dropdown:
options:
- "One"
- "Two"
on_value:
- lambda: |-
ESP_LOGD("lvgl", "dynamic row dropdown changed, row %d",
lvgl::lv_list_get_row_index(id(test_list_id), static_cast<lv_obj_t *>(lv_event_get_target(event))));
- lvgl.list.remove:
id: test_list_id
index: 0
- lvgl.list.clear:
id: test_list_id
- lvgl.list.update:
id: test_list_id
pad_row: 8
- slider:
id: slider_id
align: top_mid
+73
View File
@@ -0,0 +1,73 @@
esphome:
name: lvgl-list-validate
host:
logger:
display:
- platform: sdl
id: sdl0
dimensions:
width: 320
height: 240
lvgl:
displays: sdl0
widgets:
# Two independent lists, each with their own on_add/on_remove and, for list_a,
# more than one automation under the same trigger key -- checks that the
# per-list trigger bookkeeping is keyed correctly and doesn't require exactly
# one automation.
- list:
id: validate_list_a
align: center
pad_row: 6
on_add:
- logger.log:
format: "a: added %d"
args: [list_index]
- logger.log:
format: "a: also added %d"
args: [list_index]
on_remove:
- logger.log:
format: "a: removed %d"
args: [list_index]
on_boot:
# lvgl.list.add_text and lvgl.list.add both take an optional, templatable index.
- lvgl.list.add_text:
id: validate_list_a
text: "Header"
index: !lambda return 0;
# any registered widget type is valid as the single lvgl.list.add key.
- lvgl.list.add:
id: validate_list_a
checkbox:
align: center
text: "Option"
- lvgl.list.add:
id: validate_list_a
index: !lambda return 0;
switch:
align: center
- lvgl.list.add:
id: validate_list_a
spinner:
align: center
- lvgl.list.add:
id: validate_list_a
obj:
align: center
- lvgl.list.remove:
id: validate_list_a
index: !lambda return 0;
- lvgl.list.clear:
id: validate_list_a
- list:
id: validate_list_b
align: center
on_remove:
- logger.log:
format: "b: removed %d"
args: [list_index]
+3
View File
@@ -41,3 +41,6 @@ number:
min_value: 2
max_value: 100
step: 1
time:
- platform: zigbee
@@ -10,6 +10,3 @@ zigbee:
on_start:
then:
- logger.log: "Started zigbee stack"
time:
- platform: zigbee
@@ -5,3 +5,6 @@ zigbee:
on_join:
then:
- logger.log: "Joined network"
time:
- platform: zigbee