diff --git a/esphome/components/gpio/one_wire/gpio_one_wire.cpp b/esphome/components/gpio/one_wire/gpio_one_wire.cpp index 1fecfbf0dd..f445efeca3 100644 --- a/esphome/components/gpio/one_wire/gpio_one_wire.cpp +++ b/esphome/components/gpio/one_wire/gpio_one_wire.cpp @@ -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; diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index bfe91eedd5..2d2f1d6288 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -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) diff --git a/esphome/components/lvgl/automation.py b/esphome/components/lvgl/automation.py index cad065adee..a62f466413 100644 --- a/esphome/components/lvgl/automation.py +++ b/esphome/components/lvgl/automation.py @@ -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") ) diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index 65e975ad6d..81a4d2b4ab 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -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", diff --git a/esphome/components/lvgl/lvcode.py b/esphome/components/lvgl/lvcode.py index de00593773..850b63a26f 100644 --- a/esphome/components/lvgl/lvcode.py +++ b/esphome/components/lvgl/lvcode.py @@ -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};" diff --git a/esphome/components/lvgl/lvgl_esphome.cpp b/esphome/components/lvgl/lvgl_esphome.cpp index acd5a9bdef..22fccdd92a 100644 --- a/esphome/components/lvgl/lvgl_esphome.cpp +++ b/esphome/components/lvgl/lvgl_esphome.cpp @@ -208,21 +208,21 @@ void LvglComponent::esphome_lvgl_init() { lv_update_event = static_cast(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; } diff --git a/esphome/components/lvgl/lvgl_esphome.h b/esphome/components/lvgl/lvgl_esphome.h index 9221ab9542..98b97e26d7 100644 --- a/esphome/components/lvgl/lvgl_esphome.h +++ b/esphome/components/lvgl/lvgl_esphome.h @@ -116,6 +116,18 @@ inline void lv_animimg_set_src(lv_obj_t *img, std::vector 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 void delete_lv_compound_on_delete(lv_event_t *e) { + delete static_cast(lv_event_get_user_data(e)); +} + class LvglComponent; class LvPageType : public Parented { @@ -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) diff --git a/esphome/components/lvgl/schemas.py b/esphome/components/lvgl/schemas.py index e400dae50f..bbc977dca5 100644 --- a/esphome/components/lvgl/schemas.py +++ b/esphome/components/lvgl/schemas.py @@ -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. diff --git a/esphome/components/lvgl/trigger.py b/esphome/components/lvgl/trigger.py index 5f524969e2..56dcf81a79 100644 --- a/esphome/components/lvgl/trigger.py +++ b/esphome/components/lvgl/trigger.py @@ -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) ) diff --git a/esphome/components/lvgl/widgets/__init__.py b/esphome/components/lvgl/widgets/__init__.py index 968db46adc..c9099e3c3a 100644 --- a/esphome/components/lvgl/widgets/__init__.py +++ b/esphome/components/lvgl/widgets/__init__.py @@ -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. diff --git a/esphome/components/lvgl/widgets/lv_list.py b/esphome/components/lvgl/widgets/lv_list.py new file mode 100644 index 0000000000..83cbfb5ef9 --- /dev/null +++ b/esphome/components/lvgl/widgets/lv_list.py @@ -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_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 + ) diff --git a/esphome/components/runtime_image/image_format.cpp b/esphome/components/runtime_image/image_format.cpp index 3ba8871862..9575b30887 100644 --- a/esphome/components/runtime_image/image_format.cpp +++ b/esphome/components/runtime_image/image_format.cpp @@ -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 { diff --git a/esphome/components/zigbee/const_esp32.py b/esphome/components/zigbee/const_esp32.py index bfc4d93d5b..2e1b09fb22 100644 --- a/esphome/components/zigbee/const_esp32.py +++ b/esphome/components/zigbee/const_esp32.py @@ -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 = { diff --git a/esphome/components/zigbee/time/__init__.py b/esphome/components/zigbee/time/__init__.py index 3acab0076f..74f81df2e9 100644 --- a/esphome/components/zigbee/time/__init__.py +++ b/esphome/components/zigbee/time/__init__.py @@ -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 diff --git a/esphome/components/zigbee/time/zigbee_time_esp32.cpp b/esphome/components/zigbee/time/zigbee_time_esp32.cpp new file mode 100644 index 0000000000..5567295782 --- /dev/null +++ b/esphome/components/zigbee/time/zigbee_time_esp32.cpp @@ -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::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(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 diff --git a/esphome/components/zigbee/time/zigbee_time_esp32.h b/esphome/components/zigbee/time/zigbee_time_esp32.h new file mode 100644 index 0000000000..4a47137745 --- /dev/null +++ b/esphome/components/zigbee/time/zigbee_time_esp32.h @@ -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 diff --git a/esphome/components/zigbee/zigbee_ep_esp32.py b/esphome/components/zigbee/zigbee_ep_esp32.py index c2001c66d6..700267ef50 100644 --- a/esphome/components/zigbee/zigbee_ep_esp32.py +++ b/esphome/components/zigbee/zigbee_ep_esp32.py @@ -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 diff --git a/esphome/components/zigbee/zigbee_helpers_esp32.c b/esphome/components/zigbee/zigbee_helpers_esp32.c index 150be612f6..0793669955 100644 --- a/esphome/components/zigbee/zigbee_helpers_esp32.c +++ b/esphome/components/zigbee/zigbee_helpers_esp32.c @@ -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: diff --git a/requirements.txt b/requirements.txt index 2c61cfa7a0..3d4439bf10 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/tests/component_tests/lvgl/config/list_on_add_lvgl_action_test.yaml b/tests/component_tests/lvgl/config/list_on_add_lvgl_action_test.yaml new file mode 100644 index 0000000000..875974a227 --- /dev/null +++ b/tests/component_tests/lvgl/config/list_on_add_lvgl_action_test.yaml @@ -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" diff --git a/tests/component_tests/lvgl/config/list_outside_block_test.yaml b/tests/component_tests/lvgl/config/list_outside_block_test.yaml new file mode 100644 index 0000000000..4440734899 --- /dev/null +++ b/tests/component_tests/lvgl/config/list_outside_block_test.yaml @@ -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 diff --git a/tests/component_tests/lvgl/config/list_test.yaml b/tests/component_tests/lvgl/config/list_test.yaml new file mode 100644 index 0000000000..2b5b73478d --- /dev/null +++ b/tests/component_tests/lvgl/config/list_test.yaml @@ -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 diff --git a/tests/component_tests/lvgl/test_list.py b/tests/component_tests/lvgl/test_list.py new file mode 100644 index 0000000000..93ee162f1a --- /dev/null +++ b/tests/component_tests/lvgl/test_list.py @@ -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, 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 diff --git a/tests/component_tests/lvgl/test_list_on_add_lvgl_action.py b/tests/component_tests/lvgl/test_list_on_add_lvgl_action.py new file mode 100644 index 0000000000..2383adc251 --- /dev/null +++ b/tests/component_tests/lvgl/test_list_on_add_lvgl_action.py @@ -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 diff --git a/tests/component_tests/lvgl/test_list_outside_block.py b/tests/component_tests/lvgl/test_list_outside_block.py new file mode 100644 index 0000000000..34c93a18e2 --- /dev/null +++ b/tests/component_tests/lvgl/test_list_outside_block.py @@ -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 diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index 46c1fd362a..c78e910bc8 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -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_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_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 diff --git a/tests/components/lvgl/validate.host.yaml b/tests/components/lvgl/validate.host.yaml new file mode 100644 index 0000000000..77db404a6e --- /dev/null +++ b/tests/components/lvgl/validate.host.yaml @@ -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] diff --git a/tests/components/zigbee/common.yaml b/tests/components/zigbee/common.yaml index cc0d28ea61..4518e39060 100644 --- a/tests/components/zigbee/common.yaml +++ b/tests/components/zigbee/common.yaml @@ -41,3 +41,6 @@ number: min_value: 2 max_value: 100 step: 1 + +time: + - platform: zigbee diff --git a/tests/components/zigbee/common_nrf52.yaml b/tests/components/zigbee/common_nrf52.yaml index c05c4053a5..2da6570618 100644 --- a/tests/components/zigbee/common_nrf52.yaml +++ b/tests/components/zigbee/common_nrf52.yaml @@ -10,6 +10,3 @@ zigbee: on_start: then: - logger.log: "Started zigbee stack" - -time: - - platform: zigbee diff --git a/tests/components/zigbee/test-router.esp32-c6-idf.yaml b/tests/components/zigbee/test-router.esp32-c6-idf.yaml index 228fe331e5..557cc08f04 100644 --- a/tests/components/zigbee/test-router.esp32-c6-idf.yaml +++ b/tests/components/zigbee/test-router.esp32-c6-idf.yaml @@ -5,3 +5,6 @@ zigbee: on_join: then: - logger.log: "Joined network" + +time: + - platform: zigbee