diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index ac0363ca696..31131d253f9 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -47,9 +47,15 @@ from esphome.helpers import write_file_if_changed from esphome.writer import clean_build from esphome.yaml_util import load_yaml -from . import defines as df, helpers, lv_validation as lvalid, widgets -from .automation import focused_widgets, layers_to_code, lvgl_update, refreshed_widgets -from .defines import CONF_ALIGN_TO_LAMBDA_ID +from . import defines as df, lv_validation as lvalid, widgets +from .automation import layers_to_code, lvgl_update +from .defines import ( + CONF_ALIGN_TO_LAMBDA_ID, + get_focused_widgets, + get_lv_images_used, + get_refreshed_widgets, + set_widgets_completed, +) from .encoders import ( ENCODERS_CONFIG, encoders_to_code, @@ -58,7 +64,7 @@ from .encoders import ( ) from .gradient import GRADIENT_SCHEMA, gradients_to_code from .keypads import KEYPADS_CONFIG, keypads_to_code -from .lv_validation import lv_bool, lv_images_used +from .lv_validation import lv_bool from .lvcode import LvContext, LvglComponent, lv_event_t_ptr, lvgl_static from .schemas import ( DISP_BG_SCHEMA, @@ -89,7 +95,6 @@ from .widgets import ( add_widgets, get_screen_active, set_obj_properties, - styles_used, ) # Import only what we actually use directly in this file @@ -144,7 +149,7 @@ def generate_lv_conf_h(): df.LV_DEFINES + tuple(f"LV_USE_{w.upper()}" for w in WIDGET_TYPES) ) # Get the defines that are actually used based on the config - lv_defines = df.get_data(df.KEY_LV_DEFINES) + lv_defines = df.get_defines() unused_defines = all_defines - set(lv_defines) # Create the content of lv_conf.h with the used defines set to their value, and the unused defines disabled definitions = [as_macro(m, v) for m, v in lv_defines.items()] + [ @@ -211,7 +216,7 @@ def final_validation(config_list): buffer_frac = config[CONF_BUFFER_SIZE] if CORE.is_esp32 and buffer_frac > 0.5 and PSRAM_DOMAIN not in global_config: df.LOGGER.warning("buffer_size: may need to be reduced without PSRAM") - for w in focused_widgets: + for w in get_focused_widgets(): path = global_config.get_path_for_id(w) widget_conf = global_config.get_config_for_path(path[:-1]) if ( @@ -222,7 +227,7 @@ def final_validation(config_list): "A non adjustable arc may not be focused", path, ) - for w in refreshed_widgets: + for w in get_refreshed_widgets(): path = global_config.get_path_for_id(w) widget_conf = global_config.get_config_for_path(path[:-1]) if not any(isinstance(v, (Lambda, dict)) for v in widget_conf.values()): @@ -230,7 +235,7 @@ def final_validation(config_list): f"Widget '{w}' does not have any dynamic properties to refresh", ) # Do per-widget type final validation for update actions - for widget_type, update_configs in df.get_data(df.KEY_UPDATED_WIDGETS).items(): + for widget_type, update_configs in df.get_updated_widgets().items(): for conf in update_configs: for id_conf in conf.get(CONF_ID, ()): name = id_conf[CONF_ID] @@ -279,7 +284,7 @@ async def to_code(configs): cg.RawExpression(f"ESPHOME_LOG_LEVEL_{config_0[CONF_LOG_LEVEL]}"), ) df.add_define("LV_COLOR_DEPTH", config_0[CONF_COLOR_DEPTH]) - for font in helpers.lv_fonts_used: + for font in df.get_lv_fonts_used(): df.add_define(f"LV_FONT_{font.upper()}") if config_0[CONF_COLOR_DEPTH] == 16: @@ -294,7 +299,7 @@ async def to_code(configs): cg.add_build_flag("-Isrc") cg.add_global(lvgl_ns.using) - for font in helpers.esphome_fonts_used: + for font in df.get_esphome_fonts_used(): await cg.get_variable(font) default_font = config_0[df.CONF_DEFAULT_FONT] if not lvalid.is_lv_font(default_font): @@ -377,8 +382,8 @@ async def to_code(configs): await lvgl_update(lv_component, config) await msgboxes_to_code(lv_component, config) # await disp_update(lv_component.get_disp(), config) - # Set this directly since we are limited in how many methods can be added to the Widget class. - Widget.widgets_completed = True + # Mark all widgets as completed so awaiters of ``wait_for_widgets`` proceed. + set_widgets_completed(True) async with LvContext(): await generate_triggers() await generate_align_tos(configs[0]) @@ -404,9 +409,8 @@ async def to_code(configs): ) # This must be done after all widgets are created - for comp in helpers.lvgl_components_required: - cg.add_define(f"USE_LVGL_{comp.upper()}") - for use in helpers.lv_uses: + styles_used = df.get_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()}") @@ -433,7 +437,7 @@ async def to_code(configs): } & styles_used: lv_image_formats.add("A8") - for image_id in lv_images_used: + for image_id in get_lv_images_used(): await cg.get_variable(image_id) metadata = get_image_metadata(image_id.id) image_type = IMAGE_TYPE[metadata.image_type] diff --git a/esphome/components/lvgl/automation.py b/esphome/components/lvgl/automation.py index 977f1af9b49..bf9a3d74ad3 100644 --- a/esphome/components/lvgl/automation.py +++ b/esphome/components/lvgl/automation.py @@ -25,7 +25,9 @@ from .defines import ( PARTS, StaticCastExpression, add_warning, + get_focused_widgets, get_options, + get_refreshed_widgets, ) from .lv_validation import lv_bool, lv_milliseconds from .lvcode import ( @@ -70,9 +72,9 @@ from .widgets import ( wait_for_widgets, ) -# Record widgets that are used in a focused action here -focused_widgets = set() -refreshed_widgets = set() +# Widgets that are used in a focused/refreshed action are tracked in +# ``CORE.data`` (under the lvgl domain) so the state is cleared between +# successive compilations / unit tests via ``CORE.reset()``. async def layers_to_code(lv_component, config): @@ -316,7 +318,7 @@ async def resume_action_to_code(config, action_id, template_arg, args): ) async def obj_disable_to_code(config, action_id, template_arg, args): async def do_disable(widget: Widget): - widget.add_state(LV_STATE.DISABLED) + widget.set_state(LV_STATE.DISABLED, True) return await action_to_code( await get_widgets(config), do_disable, action_id, template_arg, args @@ -328,7 +330,7 @@ async def obj_disable_to_code(config, action_id, template_arg, args): ) async def obj_enable_to_code(config, action_id, template_arg, args): async def do_enable(widget: Widget): - widget.clear_state(LV_STATE.DISABLED) + widget.set_state(LV_STATE.DISABLED, False) return await action_to_code( await get_widgets(config), do_enable, action_id, template_arg, args @@ -361,7 +363,7 @@ async def obj_show_to_code(config, action_id, template_arg, args): def focused_id(value): value = cv.use_id(lv_pseudo_button_t)(value) - focused_widgets.add(value) + get_focused_widgets().add(value) return value @@ -446,8 +448,9 @@ async def obj_update_to_code(config, action_id, template_arg, args): def validate_refresh_config(config): + refreshed = get_refreshed_widgets() for w in config: - refreshed_widgets.add(w[CONF_ID]) + refreshed.add(w[CONF_ID]) return config diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index 03bbaf8ddb4..7bfd26bb6ec 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -19,46 +19,134 @@ from esphome.cpp_generator import ( from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor from esphome.types import Expression, SafeExpType -from .helpers import requires_component - LOGGER = logging.getLogger(__name__) lvgl_ns = cg.esphome_ns.namespace("lvgl") DOMAIN = "lvgl" KEY_COLOR_FORMATS = "color_formats" +KEY_ESPHOME_FONTS_USED = "esphome_fonts_used" +KEY_FOCUSED_WIDGETS = "focused_widgets" KEY_LV_DEFINES = "lv_defines" +KEY_LV_FONTS_USED = "lv_fonts_used" +KEY_LV_IMAGES_USED = "lv_images_used" +KEY_LV_USES = "lv_uses" +KEY_NAMED_STYLES = "named_styles" +KEY_REFRESHED_WIDGETS = "refreshed_widgets" KEY_REMAPPED_USES = "remapped_uses" +KEY_STYLES_USED = "styles_used" +KEY_THEME_WIDGET_MAP = "theme_widget_map" KEY_UPDATED_WIDGETS = "updated_widgets" +KEY_WIDGET_MAP = "widget_map" +KEY_WIDGETS_COMPLETED = "widgets_completed" KEY_OPTIONS = "options" KEY_WARNINGS = "warnings" +# Initial set of LVGL features that are always enabled. +_INITIAL_LV_USES = frozenset( + { + "USER_DATA", + "LOG", + "STYLE", + "FONT_PLACEHOLDER", + "THEME_DEFAULT", + } +) -def get_data(key, default=None): + +# These collections accumulate state across a single compilation run. They +# are stored under ``CORE.data`` (which ``CORE.reset()`` clears between runs) +# rather than as module-level globals, otherwise they would leak between +# successive compilations / unit tests. + + +def _get_data(key: str, default: Any) -> Any: """ Get a data structure from the global data store by key :param key: A key for the data - :param default: The default data - the default is an empty dict + :param default: The default data :return: """ - return CORE.data.setdefault(DOMAIN, {}).setdefault( - key, {} if default is None else default - ) + return CORE.data.setdefault(DOMAIN, {}).setdefault(key, default) -def get_warnings(): - return get_data(KEY_WARNINGS, set()) +def get_lv_images_used() -> set[ID]: + return _get_data(KEY_LV_IMAGES_USED, set()) -def get_remapped_uses(): - return get_data(KEY_REMAPPED_USES, set()) +def get_lv_uses() -> set[str]: + return _get_data(KEY_LV_USES, set(_INITIAL_LV_USES)) -def add_warning(msg: str): +def get_lv_fonts_used() -> set[str]: + return _get_data(KEY_LV_FONTS_USED, set()) + + +def get_esphome_fonts_used() -> set[ID]: + return _get_data(KEY_ESPHOME_FONTS_USED, set()) + + +def add_lv_use(*names: str) -> None: + uses = get_lv_uses() + for name in names: + uses.add(name) + + +def get_warnings() -> set[str]: + return _get_data(KEY_WARNINGS, set()) + + +def get_remapped_uses() -> set[str]: + return _get_data(KEY_REMAPPED_USES, set()) + + +def add_warning(msg: str) -> None: get_warnings().add(msg) -def get_options(): - return get_data(KEY_OPTIONS) +def get_options() -> dict[str, Any]: + return _get_data(KEY_OPTIONS, {}) + + +def get_defines() -> dict[str, str]: + return _get_data(KEY_LV_DEFINES, {}) + + +def get_updated_widgets() -> dict: + return _get_data(KEY_UPDATED_WIDGETS, {}) + + +def get_theme_widget_map() -> dict[str, Any]: + return _get_data(KEY_THEME_WIDGET_MAP, {}) + + +def get_styles_used() -> set[str]: + return _get_data(KEY_STYLES_USED, set()) + + +def get_widget_map() -> dict[str, Any]: + return _get_data(KEY_WIDGET_MAP, {}) + + +def get_widgets_completed() -> bool: + # ``[value]`` rather than the bare value so that we can mutate the + # entry in place; ``CORE.data`` is reset for us between runs. + return _get_data(KEY_WIDGETS_COMPLETED, [False])[0] + + +def set_widgets_completed(value: bool) -> None: + _get_data(KEY_WIDGETS_COMPLETED, [False])[0] = value + + +def is_widget_completed(name: ID) -> bool: + return name in get_widget_map() + + +def get_focused_widgets() -> set: + return _get_data(KEY_FOCUSED_WIDGETS, set()) + + +def get_refreshed_widgets() -> set: + return _get_data(KEY_REFRESHED_WIDGETS, set()) class StaticCastExpression(Expression): @@ -72,8 +160,8 @@ class StaticCastExpression(Expression): return f"static_cast<{self.type}>({self.exp})" -def add_define(macro, value="1"): - lv_defines = get_data(KEY_LV_DEFINES) +def add_define(macro: str, value="1"): + lv_defines = get_defines() value = str(value) if lv_defines.setdefault(macro, value) != value: LOGGER.error( @@ -82,8 +170,8 @@ def add_define(macro, value="1"): lv_defines[macro] = value -def is_defined(macro): - return macro in get_data(KEY_LV_DEFINES) +def is_defined(macro) -> bool: + return macro in get_defines() def literal(arg) -> MockObj: @@ -96,7 +184,7 @@ def addr(arg) -> MockObj: return MockObj(f"&{arg}") -def call_lambda(lamb: LambdaExpression): +def call_lambda(lamb: LambdaExpression) -> Expression: """ Given a lambda, either reduce to a simple expression or call it, possibly with parameters from the surrounding context @@ -135,7 +223,7 @@ class LValidator: def __call__(self, value): if self.requires: - value = requires_component(self.requires)(value) + value = cv.requires_component(self.requires)(value) if isinstance(value, cv.Lambda): return cv.returning_lambda(value) return self.validator(value) @@ -196,7 +284,7 @@ class LvConstant(LValidator): cv.ensure_list(self.one_of), cg.uint32, retmapper=self.mapper ) - def mapper(self, value): + def mapper(self, value) -> Any: if not isinstance(value, list): value = [value] value = [ diff --git a/esphome/components/lvgl/encoders.py b/esphome/components/lvgl/encoders.py index bafda8382ee..e6527bbc9b5 100644 --- a/esphome/components/lvgl/encoders.py +++ b/esphome/components/lvgl/encoders.py @@ -13,8 +13,8 @@ from .defines import ( CONF_LONG_PRESS_REPEAT_TIME, CONF_LONG_PRESS_TIME, CONF_RIGHT_BUTTON, + add_lv_use, ) -from .helpers import lvgl_components_required, requires_component from .lvcode import lv, lv_add, lv_assign, lv_expr, lv_Pvariable from .schemas import ENCODER_SCHEMA from .types import lv_group_t, lv_indev_type_t, lv_key_t @@ -26,7 +26,8 @@ ENCODERS_CONFIG = cv.ensure_list( cv.Required(CONF_ENTER_BUTTON): cv.use_id(BinarySensor), cv.Required(CONF_SENSOR): cv.Any( cv.All( - cv.use_id(RotaryEncoderSensor), requires_component("rotary_encoder") + cv.use_id(RotaryEncoderSensor), + cv.requires_component("rotary_encoder"), ), cv.Schema( { @@ -48,7 +49,7 @@ def get_default_group(config): async def encoders_to_code(var, config, default_group): for enc_conf in config[CONF_ENCODERS]: - lvgl_components_required.add("KEY_LISTENER") + add_lv_use("KEY_LISTENER", "ROTARY_ENCODER") lpt = enc_conf[CONF_LONG_PRESS_TIME].total_milliseconds lprt = enc_conf[CONF_LONG_PRESS_REPEAT_TIME].total_milliseconds listener = cg.new_Pvariable( diff --git a/esphome/components/lvgl/gradient.py b/esphome/components/lvgl/gradient.py index e075433d03e..2f1be207722 100644 --- a/esphome/components/lvgl/gradient.py +++ b/esphome/components/lvgl/gradient.py @@ -12,8 +12,14 @@ from esphome.const import ( from esphome.core import ID from esphome.cpp_generator import MockObj -from .defines import CONF_GRADIENTS, CONF_OPA, LV_DITHER, add_define, add_warning -from .helpers import add_lv_use +from .defines import ( + CONF_GRADIENTS, + CONF_OPA, + LV_DITHER, + add_define, + add_lv_use, + add_warning, +) from .lv_validation import lv_color, lv_percentage, opacity from .lvcode import lv from .types import lv_color_t, lv_gradient_t, lv_opa_t diff --git a/esphome/components/lvgl/helpers.py b/esphome/components/lvgl/helpers.py index c2bd58f71c3..baa618d472b 100644 --- a/esphome/components/lvgl/helpers.py +++ b/esphome/components/lvgl/helpers.py @@ -5,23 +5,6 @@ from esphome.const import CONF_ARGS, CONF_FORMAT CONF_IF_NAN = "if_nan" -lv_uses = { - "USER_DATA", - "LOG", - "STYLE", - "FONT_PLACEHOLDER", - "THEME_DEFAULT", -} - - -def add_lv_use(*names): - for name in names: - lv_uses.add(name) - - -lv_fonts_used = set() -esphome_fonts_used = set() -lvgl_components_required = set() # noqa f_regex = re.compile( @@ -66,11 +49,3 @@ def validate_printf(value): "Use of 'if_nan' requires a single valid printf-pattern of type %f" ) return value - - -def requires_component(comp): - def validator(value): - lvgl_components_required.add(comp) - return cv.requires_component(comp)(value) - - return validator diff --git a/esphome/components/lvgl/keypads.py b/esphome/components/lvgl/keypads.py index 7d8b3dd1282..6d4abbc63b7 100644 --- a/esphome/components/lvgl/keypads.py +++ b/esphome/components/lvgl/keypads.py @@ -9,9 +9,9 @@ from .defines import ( CONF_KEYPADS, CONF_LONG_PRESS_REPEAT_TIME, CONF_LONG_PRESS_TIME, + add_lv_use, literal, ) -from .helpers import lvgl_components_required from .lvcode import lv, lv_assign, lv_expr, lv_Pvariable from .schemas import ENCODER_SCHEMA from .types import lv_group_t, lv_indev_type_t @@ -52,7 +52,7 @@ KEYPADS_CONFIG = cv.ensure_list( async def keypads_to_code(var, config, default_group): for enc_conf in config[CONF_KEYPADS]: - lvgl_components_required.add("KEY_LISTENER") + add_lv_use("KEY_LISTENER") lpt = enc_conf[CONF_LONG_PRESS_TIME].total_milliseconds lprt = enc_conf[CONF_LONG_PRESS_REPEAT_TIME].total_milliseconds listener = cg.new_Pvariable( diff --git a/esphome/components/lvgl/light/lvgl_light.h b/esphome/components/lvgl/light/lvgl_light.h index 50da7af6021..bf019964c7b 100644 --- a/esphome/components/lvgl/light/lvgl_light.h +++ b/esphome/components/lvgl/light/lvgl_light.h @@ -37,7 +37,7 @@ class LVLight : public light::LightOutput { void set_value_(lv_color_t value) { lv_led_set_color(this->obj_, value); lv_led_on(this->obj_); - lv_obj_send_event(this->obj_, lv_api_event, nullptr); + lv_obj_send_event(this->obj_, lv_update_event, nullptr); } lv_obj_t *obj_{}; optional initial_value_{}; diff --git a/esphome/components/lvgl/lv_validation.py b/esphome/components/lvgl/lv_validation.py index 974eed9e817..a1b75182ebb 100644 --- a/esphome/components/lvgl/lv_validation.py +++ b/esphome/components/lvgl/lv_validation.py @@ -31,16 +31,14 @@ from .defines import ( LValidator, LvConstant, StaticCastExpression, + add_lv_use, call_lambda, + get_esphome_fonts_used, + get_lv_fonts_used, + get_lv_images_used, literal, ) -from .helpers import ( - CONF_IF_NAN, - add_lv_use, - esphome_fonts_used, - lv_fonts_used, - requires_component, -) +from .helpers import CONF_IF_NAN from .types import lv_coord_t, lv_gradient_t, lv_opa_t LV_OPA = LvConstant("LV_OPA_", "TRANSP", "COVER") @@ -370,14 +368,11 @@ def stop_value(value): return cv.int_range(0, 255)(value) -lv_images_used = set() - - def image_validator(value): - value = requires_component("image")(value) + value = cv.requires_component("image")(value) value = cv.use_id(Image_)(value) - lv_images_used.add(value) - add_lv_use("img", "label") + get_lv_images_used().add(value) + add_lv_use("label") return value @@ -496,7 +491,7 @@ class LvFont(LValidator): def __init__(self): def lv_builtin_font(value): fontval = cv.one_of(*LV_FONTS, lower=True)(value) - lv_fonts_used.add(fontval) + get_lv_fonts_used().add(fontval) return fontval def validator(value): @@ -506,8 +501,8 @@ class LvFont(LValidator): return lv_builtin_font(value) add_lv_use("font") fontval = cv.use_id(Font)(value) - esphome_fonts_used.add(fontval) - return requires_component("font")(fontval) + get_esphome_fonts_used().add(fontval) + return cv.requires_component("font")(fontval) # Use font::Font* as return type for lambdas returning ESPHome fonts # The inline overloads in lvgl_esphome.h handle conversion to lv_font_t* diff --git a/esphome/components/lvgl/lvcode.py b/esphome/components/lvgl/lvcode.py index eb8f7d4437c..32fb02e3d21 100644 --- a/esphome/components/lvgl/lvcode.py +++ b/esphome/components/lvgl/lvcode.py @@ -29,10 +29,9 @@ LvglComponent = lvgl_ns.class_("LvglComponent", cg.PollingComponent) LVGL_COMP_ARG = [(LvglComponent.operator("ptr"), LVGL_COMP)] lv_event_t_ptr = cg.global_ns.namespace("lv_event_t").operator("ptr") EVENT_ARG = [(lv_event_t_ptr, "event")] -# Two custom events; API_EVENT is fired when an entity is updated remotely by an API interaction; +# One custom event; # UPDATE_EVENT is fired when an entity is programmatically updated locally. # VALUE_CHANGED is the event generated by LVGL when an entity's value changes through user interaction. -API_EVENT = literal("lvgl::lv_api_event") UPDATE_EVENT = literal("lvgl::lv_update_event") diff --git a/esphome/components/lvgl/lvgl_esphome.cpp b/esphome/components/lvgl/lvgl_esphome.cpp index 3141c5f93c3..678ed9dbbfb 100644 --- a/esphome/components/lvgl/lvgl_esphome.cpp +++ b/esphome/components/lvgl/lvgl_esphome.cpp @@ -147,7 +147,6 @@ void LvglComponent::render_start_cb(lv_event_t *event) { comp->draw_start_(); } -lv_event_code_t lv_api_event; // NOLINT lv_event_code_t lv_update_event; // NOLINT void LvglComponent::dump_config() { ESP_LOGCONFIG(TAG, @@ -194,7 +193,6 @@ void LvglComponent::esphome_lvgl_init() { LV_GLOBAL_DEFAULT()->font_draw_buf_handlers.buf_free_cb = lv_free_core; lv_tick_set_cb([] { return millis(); }); lv_update_event = static_cast(lv_event_register_id()); - lv_api_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) { @@ -547,7 +545,7 @@ void LvSelectable::set_selected_text(const std::string &text, lv_anim_enable_t a auto index = std::find(this->options_.begin(), this->options_.end(), text); if (index != this->options_.end()) { this->set_selected_index(index - this->options_.begin(), anim); - lv_obj_send_event(this->obj, lv_api_event, nullptr); + lv_obj_send_event(this->obj, lv_update_event, nullptr); } } diff --git a/esphome/components/lvgl/lvgl_esphome.h b/esphome/components/lvgl/lvgl_esphome.h index 32bf3ccac6b..218f9a60ab9 100644 --- a/esphome/components/lvgl/lvgl_esphome.h +++ b/esphome/components/lvgl/lvgl_esphome.h @@ -50,7 +50,6 @@ using lv_color_data = uint16_t; using lv_color_data = uint32_t; #endif -extern lv_event_code_t lv_api_event; // NOLINT extern lv_event_code_t lv_update_event; // NOLINT extern std::string lv_event_code_name_for(lv_event_t *event); @@ -227,11 +226,43 @@ class LvglComponent : public PollingComponent { * Initialize the LVGL library and register custom events. */ 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 event1, lv_event_code_t event2, lv_event_code_t event3); + // change the state of a widget and fire an event if changed (only needed for CHECKED) + + static void lv_obj_set_state_value(lv_obj_t *obj, lv_state_t state, bool value) { + if (value != lv_obj_has_state(obj, state)) { + if (value) { + lv_obj_add_state(obj, state); + } else { + lv_obj_remove_state(obj, state); + } + if (state == LV_STATE_CHECKED) + lv_obj_send_event(obj, lv_update_event, nullptr); + } + } + + // change the state of a buttonmatrix button and fire an event if changed (only needed for CHECKED) +#ifdef USE_LVGL_BUTTONMATRIX + static void lv_buttonmatrix_set_button_ctrl_value(lv_obj_t *obj, uint32_t index, lv_buttonmatrix_ctrl_t ctrl, + bool value) { + if (value != lv_buttonmatrix_has_button_ctrl(obj, index, ctrl)) { + if (value) { + lv_buttonmatrix_set_button_ctrl(obj, index, ctrl); + } else { + lv_buttonmatrix_clear_button_ctrl(obj, index, ctrl); + } + if (ctrl == LV_BUTTONMATRIX_CTRL_CHECKED) + lv_obj_send_event(obj, lv_update_event, nullptr); + } + } +#endif + void add_page(LvPageType *page); void show_page(size_t index, lv_screen_load_anim_t anim, uint32_t time); void show_next_page(lv_screen_load_anim_t anim, uint32_t time); diff --git a/esphome/components/lvgl/number/__init__.py b/esphome/components/lvgl/number/__init__.py index d80e93708bf..0c7e4b9524a 100644 --- a/esphome/components/lvgl/number/__init__.py +++ b/esphome/components/lvgl/number/__init__.py @@ -7,7 +7,6 @@ from esphome.cpp_generator import MockObj from ..defines import CONF_ANIMATED, CONF_UPDATE_ON_RELEASE, CONF_WIDGET from ..lv_validation import animated from ..lvcode import ( - API_EVENT, EVENT_ARG, UPDATE_EVENT, LambdaContext, @@ -40,7 +39,7 @@ async def to_code(config): await widget.set_property( "value", MockObj("v") * MockObj(widget.get_scale()), config[CONF_ANIMATED] ) - lv_obj.send_event(widget.obj, API_EVENT, cg.nullptr) + lv_obj.send_event(widget.obj, UPDATE_EVENT, cg.nullptr) event_code = ( LV_EVENT.VALUE_CHANGED if not config[CONF_UPDATE_ON_RELEASE] diff --git a/esphome/components/lvgl/schemas.py b/esphome/components/lvgl/schemas.py index a9427a98520..6b71c2875e7 100644 --- a/esphome/components/lvgl/schemas.py +++ b/esphome/components/lvgl/schemas.py @@ -33,7 +33,7 @@ from .defines import ( get_remapped_uses, is_press_event, ) -from .helpers import CONF_IF_NAN, requires_component, validate_printf +from .helpers import CONF_IF_NAN, validate_printf from .layout import ( FLEX_OBJ_SCHEMA, GRID_CELL_SCHEMA, @@ -112,7 +112,7 @@ PRESS_TIME = cv.All( ENCODER_SCHEMA = cv.Schema( { cv.GenerateID(): cv.All( - cv.declare_id(LVEncoderListener), requires_component("binary_sensor") + cv.declare_id(LVEncoderListener), cv.requires_component("binary_sensor") ), cv.Optional(CONF_GROUP): cv.declare_id(lv_group_t), cv.Optional(df.CONF_INITIAL_FOCUS): cv.All( @@ -406,7 +406,7 @@ def _update_widget(widget_type: WidgetType) -> Callable[[dict], dict]: """ def validator(value: dict) -> dict: - df.get_data(df.KEY_UPDATED_WIDGETS).setdefault(widget_type, []).append(value) + df.get_updated_widgets().setdefault(widget_type, []).append(value) return value return validator @@ -573,7 +573,7 @@ def any_widget_schema(extras=None): container_validator = container_schema(widget_type, extras=extras) if required := widget_type.required_component: container_validator = cv.All( - container_validator, requires_component(required) + container_validator, cv.requires_component(required) ) # Apply custom validation path = [key] if is_dict else [index, key] diff --git a/esphome/components/lvgl/sensor/__init__.py b/esphome/components/lvgl/sensor/__init__.py index 167af9c6e19..682d84ede3e 100644 --- a/esphome/components/lvgl/sensor/__init__.py +++ b/esphome/components/lvgl/sensor/__init__.py @@ -3,7 +3,6 @@ import esphome.config_validation as cv from ..defines import CONF_WIDGET from ..lvcode import ( - API_EVENT, EVENT_ARG, UPDATE_EVENT, LambdaContext, @@ -35,7 +34,6 @@ async def to_code(config): widget.obj, await lamb.get_lambda(), LV_EVENT.VALUE_CHANGED, - API_EVENT, UPDATE_EVENT, ) ) diff --git a/esphome/components/lvgl/styles.py b/esphome/components/lvgl/styles.py index c17f30383bb..c1441526f95 100644 --- a/esphome/components/lvgl/styles.py +++ b/esphome/components/lvgl/styles.py @@ -4,12 +4,18 @@ import esphome.config_validation as cv from esphome.const import CONF_ID from esphome.core import ID -from .defines import CONF_STYLE_DEFINITIONS, CONF_THEME, LValidator, literal -from .helpers import add_lv_use +from .defines import ( + CONF_STYLE_DEFINITIONS, + CONF_THEME, + LValidator, + add_lv_use, + get_theme_widget_map, + literal, +) from .lvcode import LambdaContext, lv from .schemas import ALL_STYLES, FULL_STYLE_SCHEMA, WIDGET_TYPES, remap_property from .types import ObjUpdateAction, lv_style_t -from .widgets import collect_parts, theme_widget_map, wait_for_widgets +from .widgets import collect_parts, wait_for_widgets def has_style_props(config) -> bool: @@ -97,4 +103,4 @@ async def theme_to_code(config): ) for state, props in states.items() } - theme_widget_map[w_name] = styles + get_theme_widget_map()[w_name] = styles diff --git a/esphome/components/lvgl/switch/__init__.py b/esphome/components/lvgl/switch/__init__.py index a43851b4a39..509e4f42ad7 100644 --- a/esphome/components/lvgl/switch/__init__.py +++ b/esphome/components/lvgl/switch/__init__.py @@ -7,14 +7,11 @@ from esphome.cpp_types import Component from ..defines import CONF_WIDGET, literal from ..lvcode import ( - API_EVENT, EVENT_ARG, UPDATE_EVENT, LambdaContext, - LvConditional, LvContext, lv_add, - lv_obj, lvgl_static, ) from ..types import LV_EVENT, LV_STATE, lv_pseudo_button_t, lvgl_ns @@ -35,11 +32,7 @@ async def to_code(config): switch_id = MockObj(config[CONF_ID], "->") v = literal("v") async with LambdaContext([(cg.bool_, "v")]) as control: - with LvConditional(v) as cond: - widget.add_state(LV_STATE.CHECKED) - cond.else_() - widget.clear_state(LV_STATE.CHECKED) - lv_obj.send_event(widget.obj, API_EVENT, cg.nullptr) + widget.set_state(LV_STATE.CHECKED, literal("v")) control.add(switch_id.publish_state(v)) switch = cg.new_Pvariable(config[CONF_ID], await control.get_lambda()) await cg.register_component(switch, config) diff --git a/esphome/components/lvgl/text/__init__.py b/esphome/components/lvgl/text/__init__.py index 190ecacda52..61db5444e86 100644 --- a/esphome/components/lvgl/text/__init__.py +++ b/esphome/components/lvgl/text/__init__.py @@ -5,7 +5,6 @@ import esphome.config_validation as cv from ..defines import CONF_WIDGET from ..lvcode import ( - API_EVENT, EVENT_ARG, UPDATE_EVENT, LambdaContext, @@ -33,7 +32,7 @@ async def to_code(config): await wait_for_widgets() async with LambdaContext([(cg.std_string, "text_value")]) as control: await widget.set_property("text", "text_value.c_str()") - lv_obj.send_event(widget.obj, API_EVENT, cg.nullptr) + lv_obj.send_event(widget.obj, UPDATE_EVENT, cg.nullptr) control.add(textvar.publish_state(widget.get_value())) async with LambdaContext(EVENT_ARG) as lamb: lv_add(textvar.publish_state(widget.get_value())) diff --git a/esphome/components/lvgl/text_sensor/__init__.py b/esphome/components/lvgl/text_sensor/__init__.py index 4728fd137a5..c3306ad57a0 100644 --- a/esphome/components/lvgl/text_sensor/__init__.py +++ b/esphome/components/lvgl/text_sensor/__init__.py @@ -6,14 +6,7 @@ from esphome.components.text_sensor import ( import esphome.config_validation as cv from ..defines import CONF_WIDGET -from ..lvcode import ( - API_EVENT, - EVENT_ARG, - UPDATE_EVENT, - LambdaContext, - LvContext, - lvgl_static, -) +from ..lvcode import EVENT_ARG, UPDATE_EVENT, LambdaContext, LvContext, lvgl_static from ..types import LV_EVENT, LvText from ..widgets import get_widgets, wait_for_widgets @@ -37,7 +30,6 @@ async def to_code(config): widget.obj, await pressed_ctx.get_lambda(), LV_EVENT.VALUE_CHANGED, - API_EVENT, UPDATE_EVENT, ) ) diff --git a/esphome/components/lvgl/touchscreens.py b/esphome/components/lvgl/touchscreens.py index 0eb9f22f12e..0bb5715439d 100644 --- a/esphome/components/lvgl/touchscreens.py +++ b/esphome/components/lvgl/touchscreens.py @@ -8,15 +8,17 @@ from .defines import ( CONF_LONG_PRESS_REPEAT_TIME, CONF_LONG_PRESS_TIME, CONF_TOUCHSCREENS, + add_lv_use, ) -from .helpers import lvgl_components_required from .schemas import PRESS_TIME from .types import LVTouchListener CONF_TOUCHSCREEN = "touchscreen" TOUCHSCREENS_CONFIG = cv.maybe_simple_value( { - cv.Required(CONF_TOUCHSCREEN_ID): cv.use_id(Touchscreen), + cv.Required(CONF_TOUCHSCREEN_ID): cv.All( + cv.use_id(Touchscreen), cv.requires_component(CONF_TOUCHSCREEN) + ), cv.Optional(CONF_LONG_PRESS_TIME, default="400ms"): PRESS_TIME, cv.Optional(CONF_LONG_PRESS_REPEAT_TIME, default="100ms"): PRESS_TIME, cv.GenerateID(): cv.declare_id(LVTouchListener), @@ -34,7 +36,7 @@ def touchscreen_schema(config): async def touchscreens_to_code(lv_component, config): for tconf in config[CONF_TOUCHSCREENS]: - lvgl_components_required.add(CONF_TOUCHSCREEN) + add_lv_use(CONF_TOUCHSCREEN) touchscreen = await cg.get_variable(tconf[CONF_TOUCHSCREEN_ID]) lpt = tconf[CONF_LONG_PRESS_TIME].total_milliseconds lprt = tconf[CONF_LONG_PRESS_REPEAT_TIME].total_milliseconds diff --git a/esphome/components/lvgl/trigger.py b/esphome/components/lvgl/trigger.py index b3d12ed1837..64590d56f61 100644 --- a/esphome/components/lvgl/trigger.py +++ b/esphome/components/lvgl/trigger.py @@ -24,11 +24,11 @@ from .defines import ( LV_SCREEN_EVENT_MAP, LV_SCREEN_EVENT_TRIGGERS, SWIPE_TRIGGERS, + get_widget_map, is_press_event, literal, ) from .lvcode import ( - API_EVENT, EVENT_ARG, UPDATE_EVENT, LambdaContext, @@ -39,7 +39,7 @@ from .lvcode import ( lvgl_static, ) from .types import LV_EVENT, lv_point_t -from .widgets import LvScrActType, get_screen_active, widget_map +from .widgets import LvScrActType, get_screen_active async def add_on_boot_triggers(triggers): @@ -58,7 +58,7 @@ async def generate_triggers(): all_triggers = ( LV_EVENT_TRIGGERS + LV_DISPLAY_EVENT_TRIGGERS + LV_SCREEN_EVENT_TRIGGERS ) - for w in widget_map.values(): + for w in get_widget_map().values(): config = w.config if isinstance(w.type, LvScrActType): w = get_screen_active(w.var) @@ -89,7 +89,6 @@ async def generate_triggers(): conf, w, LV_EVENT.VALUE_CHANGED, - API_EVENT, UPDATE_EVENT, ) @@ -104,6 +103,7 @@ async def generate_align_tos(config: dict): :param config: :return: """ + widget_map = get_widget_map() align_tos = tuple( w for w in widget_map.values() if w.config and CONF_ALIGN_TO in w.config ) diff --git a/esphome/components/lvgl/widgets/__init__.py b/esphome/components/lvgl/widgets/__init__.py index d35f84c4f26..534ebe0b93d 100644 --- a/esphome/components/lvgl/widgets/__init__.py +++ b/esphome/components/lvgl/widgets/__init__.py @@ -15,6 +15,7 @@ from esphome.const import ( from esphome.core import ID, EsphomeError, TimePeriod from esphome.coroutine import FakeAwaitable from esphome.cpp_generator import MockObj +from esphome.types import Expression from ..defines import ( CONF_FLEX_ALIGN_CROSS, @@ -38,11 +39,15 @@ from ..defines import ( TYPE_FLEX, TYPE_GRID, LValidator, + add_lv_use, call_lambda, + get_styles_used, + get_theme_widget_map, + get_widget_map, + get_widgets_completed, join_enums, literal, ) -from ..helpers import add_lv_use from ..lvcode import ( LvConditional, add_line_marks, @@ -52,6 +57,7 @@ from ..lvcode import ( lv_expr, lv_obj, lv_Pvariable, + lvgl_static, ) from ..types import ( LV_STATE, @@ -65,9 +71,6 @@ from ..types import ( EVENT_LAMB = "event_lamb__" -theme_widget_map = {} -styles_used = set() - class WidgetType: """ @@ -157,7 +160,7 @@ class WidgetType: await self.on_create(var, config) w = Widget.create(wid, var, self, config) - if theme := theme_widget_map.get(self.name): + 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(): @@ -240,8 +243,6 @@ class Widget: This class has a lot of methods. Adding any more runs foul of lint checks ("too many public methods"). """ - widgets_completed = False - def __init__(self, var, wtype: WidgetType, config: dict = None): self.var = var self.type = wtype @@ -262,21 +263,14 @@ class Widget: @staticmethod def create(name, var, wtype: WidgetType, config: dict = None): w = Widget(var, wtype, config) - widget_map[name] = w + get_widget_map()[name] = w return w - def add_state(self, state): - if "|" in state: - state = f"(lv_state_t)({state})" - return lv_obj.add_state(self.obj, literal(state)) + def set_state(self, state: MockObj, value: bool | Expression): + lv_add(lvgl_static.lv_obj_set_state_value(self.obj, state, value)) - def clear_state(self, state): - if "|" in state: - state = f"(lv_state_t)({state})" - return lv_obj.remove_state(self.obj, literal(state)) - - def has_state(self, state): - return (lv_expr.obj_get_state(self.obj) & literal(state)) != 0 + def has_state(self, state: MockObj): + return lv_expr.obj_has_state(self.obj, state) def is_pressed(self): return self.has_state(LV_STATE.PRESSED) @@ -346,10 +340,10 @@ class Widget: ltype = ltype or self.__type_base() return cg.RawExpression(f"lv_{ltype}_get_{prop}({self.obj})") - def set_style(self, prop, value, state=LV_STATE.DEFAULT): + def set_style(self, prop: str, value, state=LV_STATE.DEFAULT): if value is None: return - styles_used.add(prop) + get_styles_used().add(prop) if isinstance(value, str): value = literal(value) lv.call(f"obj_set_style_{prop}", self.obj, value, state) @@ -403,14 +397,6 @@ class Widget: return self.type.get_scale(self.config) -# Map of widgets to their config, used for trigger generation -widget_map: dict[ID, Widget] = {} - - -def is_widget_completed(name: ID) -> bool: - return name in widget_map - - class LvScrActType(WidgetType): """ A "widget" representing the active screen. @@ -433,10 +419,11 @@ def get_widget_generator(wid): :param wid: :return: """ + widget_map = get_widget_map() while True: if obj := widget_map.get(wid): return obj - if Widget.widgets_completed: + if get_widgets_completed(): raise Invalid( f"Widget {wid} not found, yet all widgets should be defined by now" ) @@ -444,20 +431,20 @@ def get_widget_generator(wid): async def get_widget_(wid): - if obj := widget_map.get(wid): + if obj := get_widget_map().get(wid): return obj return await FakeAwaitable(get_widget_generator(wid)) def widgets_wait_generator(): while True: - if Widget.widgets_completed: + if get_widgets_completed(): return yield async def wait_for_widgets(): - if Widget.widgets_completed: + if get_widgets_completed(): return await FakeAwaitable(widgets_wait_generator()) @@ -608,30 +595,14 @@ async def set_obj_properties(w: Widget, config): cond.else_() w.clear_flag(flag) - if states := config.get(CONF_STATE): - adds = set() - clears = set() - lambs = {} - for key, value in states.items(): - if isinstance(value, cv.Lambda): - lambs[key] = value - elif value: - adds.add(key) - else: - clears.add(key) - if adds: - adds = join_enums(adds, "LV_STATE_") - w.add_state(adds) - if clears: - clears = join_enums(clears, "LV_STATE_") - w.clear_state(clears) - for key, value in lambs.items(): - lamb = await cg.process_lambda(value, [], capture="=", return_type=cg.bool_) - state = f"LV_STATE_{key.upper()}" - with LvConditional(call_lambda(lamb)) as cond: - w.add_state(state) - cond.else_() - w.clear_state(state) + for key, value in config.get(CONF_STATE, {}).items(): + if isinstance(value, cv.Lambda): + value = call_lambda( + await cg.process_lambda(value, [], capture="=", return_type=cg.bool_) + ) + state = getattr(LV_STATE, key.upper()) + w.set_state(state, value) + for property in OBJ_PROPERTIES: await w.set_property(property, config, lv_name="obj") diff --git a/esphome/components/lvgl/widgets/animimg.py b/esphome/components/lvgl/widgets/animimg.py index 8e2db5ff350..b6d59df7f25 100644 --- a/esphome/components/lvgl/widgets/animimg.py +++ b/esphome/components/lvgl/widgets/animimg.py @@ -4,7 +4,6 @@ from esphome.const import CONF_DURATION, CONF_ID from ..automation import action_to_code from ..defines import CONF_AUTO_START, CONF_MAIN, CONF_REPEAT_COUNT, CONF_SRC -from ..helpers import lvgl_components_required from ..lv_validation import lv_image_list, lv_milliseconds from ..lvcode import lv from ..types import LvType, ObjUpdateAction @@ -55,8 +54,6 @@ class AnimimgType(WidgetType): ) async def to_code(self, w: Widget, config): - lvgl_components_required.add(CONF_IMAGE) - lvgl_components_required.add(CONF_ANIMIMG) if srcs := config.get(CONF_SRC): srcs = await lv_image_list.process(srcs) lv.animimg_set_src(w.obj, srcs) @@ -68,7 +65,7 @@ class AnimimgType(WidgetType): lv.animimg_start(w.obj) def get_uses(self): - return "img", CONF_IMAGE, CONF_LABEL + return CONF_IMAGE, CONF_LABEL animimg_spec = AnimimgType() diff --git a/esphome/components/lvgl/widgets/button.py b/esphome/components/lvgl/widgets/button.py index b943a4d9aa6..0ad512cd8bb 100644 --- a/esphome/components/lvgl/widgets/button.py +++ b/esphome/components/lvgl/widgets/button.py @@ -2,8 +2,7 @@ from esphome import config_validation as cv from esphome.const import CONF_BUTTON, CONF_TEXT from esphome.cpp_generator import MockObj -from ..defines import CONF_MAIN, CONF_WIDGETS -from ..helpers import add_lv_use +from ..defines import CONF_MAIN, CONF_WIDGETS, add_lv_use from ..lv_validation import lv_text from ..lvcode import lv, lv_expr from ..schemas import TEXT_SCHEMA diff --git a/esphome/components/lvgl/widgets/buttonmatrix.py b/esphome/components/lvgl/widgets/buttonmatrix.py index f5ae0deba93..02dc9ed4ba8 100644 --- a/esphome/components/lvgl/widgets/buttonmatrix.py +++ b/esphome/components/lvgl/widgets/buttonmatrix.py @@ -5,6 +5,7 @@ from esphome.components.key_provider import KeyProvider import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_ITEMS, CONF_TEXT, CONF_WIDTH from esphome.cpp_generator import MockObj +from esphome.types import Expression from ..automation import action_to_code from ..defines import ( @@ -17,10 +18,10 @@ from ..defines import ( CONF_PAD_COLUMN, CONF_PAD_ROW, CONF_SELECTED, + get_widget_map, ) -from ..helpers import lvgl_components_required from ..lv_validation import key_code, lv_bool, padding -from ..lvcode import lv, lv_add, lv_expr +from ..lvcode import lv, lv_add, lv_expr, lvgl_static from ..schemas import automation_schema from ..types import ( LV_BTNMATRIX_CTRL, @@ -32,7 +33,7 @@ from ..types import ( char_ptr, lv_pseudo_button_t, ) -from . import Widget, WidgetType, get_widgets, widget_map +from . import Widget, WidgetType, get_widgets from .button import lv_button_t CONF_BUTTONMATRIX = "buttonmatrix" @@ -98,7 +99,7 @@ class MatrixButton(Widget): @staticmethod def create_button(id, parent, config: dict, index): w = MatrixButton(id, parent, config, index) - widget_map[id] = w + get_widget_map()[id] = w return w def __init__(self, id, parent: Widget, config, index): @@ -120,13 +121,13 @@ class MatrixButton(Widget): state = self.map_ctrls(state) return lv_expr.buttonmatrix_has_button_ctrl(self.obj, self.index, state) - def add_state(self, state): - state = self.map_ctrls(state) - return lv.buttonmatrix_set_button_ctrl(self.obj, self.index, state) - - def clear_state(self, state): - state = self.map_ctrls(state) - return lv.buttonmatrix_clear_button_ctrl(self.obj, self.index, state) + def set_state(self, state: MockObj, value: bool | Expression): + ctrl = self.map_ctrls(state) + lv_add( + lvgl_static.lv_buttonmatrix_set_button_ctrl_value( + self.obj, self.index, ctrl, value + ) + ) def is_pressed(self): return self.is_selected() & self.parent.has_state(LV_STATE.PRESSED) @@ -191,7 +192,6 @@ class ButtonMatrixType(WidgetType): ) async def to_code(self, w: Widget, config): - lvgl_components_required.add("BUTTONMATRIX") if CONF_ROWS not in config: return text_list, ctrl_list, width_list, key_list = await get_button_data( diff --git a/esphome/components/lvgl/widgets/dropdown.py b/esphome/components/lvgl/widgets/dropdown.py index ca89bb625be..34e6ef4e357 100644 --- a/esphome/components/lvgl/widgets/dropdown.py +++ b/esphome/components/lvgl/widgets/dropdown.py @@ -14,7 +14,6 @@ from ..defines import ( DIRECTIONS, literal, ) -from ..helpers import lvgl_components_required from ..lv_validation import lv_int, lv_text, option_string from ..lvcode import LocalVariable, lv, lv_add, lv_expr from ..schemas import part_schema @@ -95,7 +94,6 @@ class DropdownType(WidgetType): ) async def to_code(self, w: Widget, config): - lvgl_components_required.add(CONF_DROPDOWN) if options := config.get(CONF_OPTIONS): lv_add(w.var.set_options(options)) if symbol := config.get(CONF_SYMBOL): @@ -116,7 +114,7 @@ class DropdownType(WidgetType): await set_obj_properties(dwid, dlist) def get_uses(self): - return (CONF_LABEL,) + return CONF_LABEL, CONF_DROPDOWN dropdown_spec = DropdownType() diff --git a/esphome/components/lvgl/widgets/keyboard.py b/esphome/components/lvgl/widgets/keyboard.py index c5628cee3cd..bcd2d2ae597 100644 --- a/esphome/components/lvgl/widgets/keyboard.py +++ b/esphome/components/lvgl/widgets/keyboard.py @@ -5,10 +5,15 @@ from esphome.core import CORE from esphome.cpp_types import std_string from .. import LvContext -from ..defines import CONF_MAIN, KEYBOARD_MODES, literal -from ..helpers import lvgl_components_required +from ..defines import ( + CONF_MAIN, + KEYBOARD_MODES, + add_lv_use, + is_widget_completed, + literal, +) from ..types import LvCompound, LvType -from . import Widget, WidgetType, get_widgets, is_widget_completed +from . import Widget, WidgetType, get_widgets from .buttonmatrix import CONF_BUTTONMATRIX from .textarea import CONF_TEXTAREA, lv_textarea_t @@ -47,8 +52,7 @@ class KeyboardType(WidgetType): return CONF_KEYBOARD, CONF_TEXTAREA, CONF_BUTTONMATRIX async def to_code(self, w: Widget, config: dict): - lvgl_components_required.add("KEY_LISTENER") - lvgl_components_required.add(CONF_KEYBOARD) + add_lv_use("KEY_LISTENER") if mode := config.get(CONF_MODE): await w.set_property(CONF_MODE, await KEYBOARD_MODES.process(mode)) if textarea := config.get(CONF_TEXTAREA): diff --git a/esphome/components/lvgl/widgets/meter.py b/esphome/components/lvgl/widgets/meter.py index ab65a7c47d5..62ea14bddaf 100644 --- a/esphome/components/lvgl/widgets/meter.py +++ b/esphome/components/lvgl/widgets/meter.py @@ -41,10 +41,10 @@ from ..defines import ( LV_OBJ_FLAG, LV_PART, LV_SCALE_MODE, + add_lv_use, get_remapped_uses, get_warnings, ) -from ..helpers import add_lv_use from ..lv_validation import ( LV_OPA, LV_RADIUS, @@ -61,7 +61,6 @@ from ..lv_validation import ( padding, pixels, pixels_or_percent, - requires_component, size, ) from ..lvcode import LambdaContext, LocalVariable, lv, lv_add, lv_expr, lv_obj @@ -214,7 +213,7 @@ INDICATOR_SCHEMA = cv.Schema( cv.GenerateID(CONF_IMAGE_ID): cv.declare_id(lv_image_t), } ), - requires_component("image"), + cv.requires_component("image"), ), cv.Exclusive(CONF_ARC, CONF_INDICATORS): INDICATOR_ARC_SCHEMA.extend( { diff --git a/esphome/components/lvgl/widgets/msgbox.py b/esphome/components/lvgl/widgets/msgbox.py index d0e6bfa3a2b..29087009cb9 100644 --- a/esphome/components/lvgl/widgets/msgbox.py +++ b/esphome/components/lvgl/widgets/msgbox.py @@ -16,10 +16,10 @@ from ..defines import ( CONF_TITLE, LV_OBJ_FLAG, TYPE_FLEX, + add_lv_use, add_warning, literal, ) -from ..helpers import add_lv_use from ..lv_validation import lv_bool, lv_image, lv_text, pixels_or_percent from ..lvcode import EVENT_ARG, LambdaContext, LocalVariable, lv, lv_expr, lv_obj from ..schemas import ( diff --git a/esphome/components/lvgl/widgets/roller.py b/esphome/components/lvgl/widgets/roller.py index 6f9fee47d4b..f3caaa43499 100644 --- a/esphome/components/lvgl/widgets/roller.py +++ b/esphome/components/lvgl/widgets/roller.py @@ -11,7 +11,6 @@ from ..defines import ( ROLLER_MODES, literal, ) -from ..helpers import lvgl_components_required from ..lv_validation import animated, lv_int, lv_text, option_string from ..lvcode import lv_add from ..types import LvSelect @@ -55,7 +54,6 @@ class RollerType(WidgetType): ) async def to_code(self, w, config): - lvgl_components_required.add(CONF_ROLLER) if mode := config.get(CONF_MODE): mode = await ROLLER_MODES.process(mode) lv_add(w.var.set_mode(mode)) diff --git a/tests/component_tests/lvgl/config/widget_state_test.yaml b/tests/component_tests/lvgl/config/widget_state_test.yaml new file mode 100644 index 00000000000..644bf7dac73 --- /dev/null +++ b/tests/component_tests/lvgl/config/widget_state_test.yaml @@ -0,0 +1,83 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +spi: + - id: spi_bus + clk_pin: GPIO18 + mosi_pin: GPIO23 + +display: + - platform: mipi_spi + spi_id: spi_bus + model: st7789v + id: tft_display + dimensions: + width: 240 + height: 320 + cs_pin: GPIO22 + dc_pin: GPIO21 + auto_clear_enabled: false + invert_colors: false + update_interval: never + +lvgl: + id: lvgl_id + displays: tft_display + pages: + - id: main_page + widgets: + # Widget with multiple static states; one true, one false. + - button: + id: btn_static + state: + checked: true + disabled: false + + # Widget with a templated (lambda) state. + - button: + id: btn_lambda + state: + pressed: !lambda return true; + + # Button referenced by enable/disable actions; the on_click handler + # exercises both branches of the obj_disable/obj_enable code path. + - button: + id: btn_actions + on_click: + - lvgl.widget.disable: btn_actions + - lvgl.widget.enable: btn_actions + + # Button matrix with two buttons; matrix_btn_a is targeted by + # lvgl.widget.disable/enable actions to exercise the + # MatrixButton.set_state code path. + - buttonmatrix: + id: matrix + rows: + - buttons: + - id: matrix_btn_a + text: A + control: + checkable: true + - id: matrix_btn_b + text: B + control: + checkable: true + on_click: + - lvgl.widget.disable: matrix_btn_a + - lvgl.widget.enable: matrix_btn_a + + # Switch derived from an LVGL switch widget – exercises + # set_state(LV_STATE.CHECKED, v) inside the control lambda. + - switch: + id: switch_widget + +switch: + - platform: lvgl + id: lvgl_switch + name: LVGL Switch + widget: switch_widget diff --git a/tests/component_tests/lvgl/test_widget_state.py b/tests/component_tests/lvgl/test_widget_state.py new file mode 100644 index 00000000000..2d87bb382e2 --- /dev/null +++ b/tests/component_tests/lvgl/test_widget_state.py @@ -0,0 +1,167 @@ +"""Tests for LVGL widget state code generation. + +These tests cover the change from the old ``add_state``/``clear_state`` helpers +on :class:`Widget` (and on :class:`MatrixButton`) to a single ``set_state`` +method that delegates to the new C++ helpers +``LvglComponent::lv_obj_set_state_value`` and +``LvglComponent::lv_buttonmatrix_set_button_ctrl_value``. +""" + +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: + """Generate the C++ output for the shared widget-state YAML config once + per module. + + Module-scoped so the (relatively expensive) codegen runs a single time; + the function-scoped fixtures from ``conftest.py`` (e.g. ``generate_main``) + can't be requested from a higher-scoped fixture, so the small amount of + setup is inlined here. The captured string is independent of + ``CORE.reset()`` calls that the per-test autouse fixtures perform after + this fixture has produced its value. + """ + config_path = Path(request.fspath).parent / "config" / "widget_state_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_static_state_emits_set_state_value(main_cpp: str) -> None: + """A widget with ``state: { checked: true, disabled: false }`` should + generate one ``lv_obj_set_state_value`` call per entry, with the + appropriate boolean argument. + """ + assert ( + "LvglComponent::lv_obj_set_state_value(btn_static, LV_STATE_CHECKED, true)" + in main_cpp + ) + assert ( + "LvglComponent::lv_obj_set_state_value(btn_static, LV_STATE_DISABLED, false)" + in main_cpp + ) + + +def test_lambda_state_emits_set_state_value_with_lambda(main_cpp: str) -> None: + """A widget with ``state: { pressed: !lambda return true; }`` should + generate ``lv_obj_set_state_value(..., LV_STATE_PRESSED, )`` where + ```` is the lambda's return value (cast or inlined), not a static + bool. + """ + # The set_state call is emitted for the templated state. + assert ( + "LvglComponent::lv_obj_set_state_value(btn_lambda, LV_STATE_PRESSED," + in main_cpp + ) + # And it must NOT have collapsed the lambda to a literal true/false. + assert ( + "LvglComponent::lv_obj_set_state_value(btn_lambda, LV_STATE_PRESSED, true)" + not in main_cpp + ) + # The legacy if/else over add_state/remove_state is gone. + assert "lv_obj_add_state(btn_lambda, LV_STATE_PRESSED)" not in main_cpp + assert "lv_obj_remove_state(btn_lambda, LV_STATE_PRESSED)" not in main_cpp + + +def test_widget_disable_action_uses_set_state_value(main_cpp: str) -> None: + """``lvgl.widget.disable: btn_actions`` should emit a + ``set_state_value(..., LV_STATE_DISABLED, true)`` call rather than the + legacy ``lv_obj_add_state``. + """ + assert ( + "LvglComponent::lv_obj_set_state_value(btn_actions, LV_STATE_DISABLED, true)" + in main_cpp + ) + # No leftover legacy add_state for the DISABLED state of this widget. + assert "lv_obj_add_state(btn_actions, LV_STATE_DISABLED)" not in main_cpp + + +def test_widget_enable_action_uses_set_state_value(main_cpp: str) -> None: + """``lvgl.widget.enable: btn_actions`` should emit a + ``set_state_value(..., LV_STATE_DISABLED, false)`` call rather than the + legacy ``lv_obj_remove_state``. + """ + assert ( + "LvglComponent::lv_obj_set_state_value(btn_actions, LV_STATE_DISABLED, false)" + in main_cpp + ) + assert "lv_obj_remove_state(btn_actions, LV_STATE_DISABLED)" not in main_cpp + + +def test_buttonmatrix_disable_action_uses_helper(main_cpp: str) -> None: + """``lvgl.widget.disable: matrix_btn_a`` should route through the new + ``lv_buttonmatrix_set_button_ctrl_value`` helper for button index 0 + with the ``DISABLED`` control bit set to ``true``, instead of the + legacy ``lv_buttonmatrix_set_button_ctrl``. + + The button matrix obj is the compound's ``obj`` member and the index + is the position of the button in the row layout. + """ + assert ( + "LvglComponent::lv_buttonmatrix_set_button_ctrl_value(matrix->obj, 0, " + "LV_BUTTONMATRIX_CTRL_DISABLED, true)" + ) in main_cpp + + +def test_buttonmatrix_enable_action_uses_helper(main_cpp: str) -> None: + """``lvgl.widget.enable: matrix_btn_a`` should route through the new + ``lv_buttonmatrix_set_button_ctrl_value`` helper for button index 0 + with the ``DISABLED`` control bit set to ``false``, instead of the + legacy ``lv_buttonmatrix_clear_button_ctrl``. + """ + assert ( + "LvglComponent::lv_buttonmatrix_set_button_ctrl_value(matrix->obj, 0, " + "LV_BUTTONMATRIX_CTRL_DISABLED, false)" + ) in main_cpp + # The legacy clear_button_ctrl path is gone for the matrix button enable + # action. + assert ( + "lv_buttonmatrix_clear_button_ctrl(matrix->obj, 0, LV_BUTTONMATRIX_CTRL_DISABLED)" + not in main_cpp + ) + + +def test_lvgl_switch_control_calls_set_state_value(main_cpp: str) -> None: + """The LVGL switch platform installs a control lambda that mirrors the + switch's bool value into ``LV_STATE_CHECKED`` via + ``lv_obj_set_state_value`` (replacing the previous if/else over + ``add_state``/``clear_state`` plus an explicit ``send_event`` of + ``lv_api_event``). + """ + # The control lambda calls the new helper with the bool ``v`` parameter. + assert ( + "LvglComponent::lv_obj_set_state_value(switch_widget, LV_STATE_CHECKED, v)" + in main_cpp + ) + # The deprecated lv_api_event symbol must no longer appear anywhere. + assert "lv_api_event" not in main_cpp + + +def test_default_state_does_not_emit_set_state_value(main_cpp: str) -> None: + """A widget without a ``state:`` block must not generate any + ``lv_obj_set_state_value`` calls for it. (Sanity-check that the + new code path is opt-in driven by the YAML.) + """ + assert ( + "LvglComponent::lv_obj_set_state_value(switch_widget, LV_STATE_DISABLED" + not in main_cpp + ) + assert ( + "LvglComponent::lv_obj_set_state_value(btn_static, LV_STATE_PRESSED" + not in main_cpp + )