From 5a5894eaa3049a174f44c9af185bcd3c080cae5d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 1 Nov 2025 01:05:26 -0500 Subject: [PATCH 01/19] [ruff] Remove deprecated UP038 rule from ignore list (#11646) --- pyproject.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 49598d434d..d6aa584237 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -133,7 +133,6 @@ ignore = [ "PLW1641", # Object does not implement `__hash__` method "PLR2004", # Magic value used in comparison, consider replacing {value} with a constant variable "PLW2901", # Outer {outer_kind} variable {name} overwritten by inner {inner_kind} target - "UP038", # https://github.com/astral-sh/ruff/issues/7871 https://github.com/astral-sh/ruff/pull/16681 ] [tool.ruff.lint.isort] From 8df5a3a6308a1b01c0a4137a7b68f0769d55e2a5 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 1 Nov 2025 16:27:28 +1000 Subject: [PATCH 02/19] [lvgl] Trigger improvements and additions (#11628) --- esphome/components/lvgl/__init__.py | 52 +++++++++++++---------- esphome/components/lvgl/automation.py | 3 +- esphome/components/lvgl/defines.py | 2 + esphome/components/lvgl/lvgl_esphome.cpp | 33 ++++++++++---- esphome/components/lvgl/lvgl_esphome.h | 27 +++++++----- esphome/components/lvgl/types.py | 6 ++- tests/components/lvgl/test.esp32-idf.yaml | 8 ++++ 7 files changed, 86 insertions(+), 45 deletions(-) diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 5af61300da..aa6935c5fc 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -58,7 +58,7 @@ from .types import ( FontEngine, IdleTrigger, ObjUpdateAction, - PauseTrigger, + PlainTrigger, lv_font_t, lv_group_t, lv_style_t, @@ -151,6 +151,13 @@ for w_type in WIDGET_TYPES.values(): create_modify_schema(w_type), )(update_to_code) +SIMPLE_TRIGGERS = ( + df.CONF_ON_PAUSE, + df.CONF_ON_RESUME, + df.CONF_ON_DRAW_START, + df.CONF_ON_DRAW_END, +) + def as_macro(macro, value): if value is None: @@ -244,9 +251,9 @@ def final_validation(configs): for w in 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) for v in widget_conf.values()): + if not any(isinstance(v, (Lambda, dict)) for v in widget_conf.values()): raise cv.Invalid( - f"Widget '{w}' does not have any templated properties to refresh", + f"Widget '{w}' does not have any dynamic properties to refresh", ) @@ -366,16 +373,16 @@ async def to_code(configs): conf[CONF_TRIGGER_ID], lv_component, templ ) await build_automation(idle_trigger, [], conf) - for conf in config.get(df.CONF_ON_PAUSE, ()): - pause_trigger = cg.new_Pvariable( - conf[CONF_TRIGGER_ID], lv_component, True - ) - await build_automation(pause_trigger, [], conf) - for conf in config.get(df.CONF_ON_RESUME, ()): - resume_trigger = cg.new_Pvariable( - conf[CONF_TRIGGER_ID], lv_component, False - ) - await build_automation(resume_trigger, [], conf) + for trigger_name in SIMPLE_TRIGGERS: + if conf := config.get(trigger_name): + trigger_var = cg.new_Pvariable(conf[CONF_TRIGGER_ID]) + await build_automation(trigger_var, [], conf) + cg.add( + getattr( + lv_component, + f"set_{trigger_name.removeprefix('on_')}_trigger", + )(trigger_var) + ) await add_on_boot_triggers(config.get(CONF_ON_BOOT, ())) # This must be done after all widgets are created @@ -443,16 +450,15 @@ LVGL_SCHEMA = cv.All( ), } ), - cv.Optional(df.CONF_ON_PAUSE): validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(PauseTrigger), - } - ), - cv.Optional(df.CONF_ON_RESUME): validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(PauseTrigger), - } - ), + **{ + cv.Optional(x): validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(PlainTrigger), + }, + single=True, + ) + for x in SIMPLE_TRIGGERS + }, cv.Exclusive(df.CONF_WIDGETS, CONF_PAGES): cv.ensure_list( WIDGET_SCHEMA ), diff --git a/esphome/components/lvgl/automation.py b/esphome/components/lvgl/automation.py index fc70b0f682..593c8c67bb 100644 --- a/esphome/components/lvgl/automation.py +++ b/esphome/components/lvgl/automation.py @@ -400,7 +400,8 @@ async def obj_refresh_to_code(config, action_id, template_arg, args): # must pass all widget-specific options here, even if not templated, but only do so if at least one is # templated. First filter out common style properties. config = {k: v for k, v in widget.config.items() if k not in ALL_STYLES} - if any(isinstance(v, Lambda) for v in config.values()): + # Check if v is a Lambda or a dict, implying it is dynamic + if any(isinstance(v, (Lambda, dict)) for v in config.values()): await widget.type.to_code(widget, config) if ( widget.type.w_type.value_property is not None diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index 7fbb6de071..3241ba9c3f 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -483,6 +483,8 @@ CONF_MSGBOXES = "msgboxes" CONF_OBJ = "obj" CONF_ONE_CHECKED = "one_checked" CONF_ONE_LINE = "one_line" +CONF_ON_DRAW_START = "on_draw_start" +CONF_ON_DRAW_END = "on_draw_end" CONF_ON_PAUSE = "on_pause" CONF_ON_RESUME = "on_resume" CONF_ON_SELECT = "on_select" diff --git a/esphome/components/lvgl/lvgl_esphome.cpp b/esphome/components/lvgl/lvgl_esphome.cpp index 7a32691b53..947342089c 100644 --- a/esphome/components/lvgl/lvgl_esphome.cpp +++ b/esphome/components/lvgl/lvgl_esphome.cpp @@ -82,6 +82,18 @@ static void rounder_cb(lv_disp_drv_t *disp_drv, lv_area_t *area) { area->y2 = (area->y2 + draw_rounding) / draw_rounding * draw_rounding - 1; } +void LvglComponent::monitor_cb(lv_disp_drv_t *disp_drv, uint32_t time, uint32_t px) { + ESP_LOGVV(TAG, "Draw end: %" PRIu32 " pixels in %" PRIu32 " ms", px, time); + auto *comp = static_cast(disp_drv->user_data); + comp->draw_end_(); +} + +void LvglComponent::render_start_cb(lv_disp_drv_t *disp_drv) { + ESP_LOGVV(TAG, "Draw start"); + auto *comp = static_cast(disp_drv->user_data); + comp->draw_start_(); +} + lv_event_code_t lv_api_event; // NOLINT lv_event_code_t lv_update_event; // NOLINT void LvglComponent::dump_config() { @@ -101,7 +113,10 @@ void LvglComponent::set_paused(bool paused, bool show_snow) { lv_disp_trig_activity(this->disp_); // resets the inactivity time lv_obj_invalidate(lv_scr_act()); } - this->pause_callbacks_.call(paused); + if (paused && this->pause_callback_ != nullptr) + this->pause_callback_->trigger(); + if (!paused && this->resume_callback_ != nullptr) + this->resume_callback_->trigger(); } void LvglComponent::esphome_lvgl_init() { @@ -225,13 +240,6 @@ IdleTrigger::IdleTrigger(LvglComponent *parent, TemplatableValue timeo }); } -PauseTrigger::PauseTrigger(LvglComponent *parent, TemplatableValue paused) : paused_(std::move(paused)) { - parent->add_on_pause_callback([this](bool pausing) { - if (this->paused_.value() == pausing) - this->trigger(); - }); -} - #ifdef USE_LVGL_TOUCHSCREEN LVTouchListener::LVTouchListener(uint16_t long_press_time, uint16_t long_press_repeat_time, LvglComponent *parent) { this->set_parent(parent); @@ -474,6 +482,12 @@ void LvglComponent::setup() { return; } } + if (this->draw_start_callback_ != nullptr) { + this->disp_drv_.render_start_cb = render_start_cb; + } + if (this->draw_end_callback_ != nullptr) { + this->disp_drv_.monitor_cb = monitor_cb; + } #if LV_USE_LOG lv_log_register_print_cb([](const char *buf) { auto next = strchr(buf, ')'); @@ -502,8 +516,9 @@ void LvglComponent::loop() { if (this->paused_) { if (this->show_snow_) this->write_random_(); + } else { + lv_timer_handler_run_in_period(5); } - lv_timer_handler_run_in_period(5); } #ifdef USE_LVGL_ANIMIMG diff --git a/esphome/components/lvgl/lvgl_esphome.h b/esphome/components/lvgl/lvgl_esphome.h index d3dc8fac5a..ea58fdb85b 100644 --- a/esphome/components/lvgl/lvgl_esphome.h +++ b/esphome/components/lvgl/lvgl_esphome.h @@ -171,7 +171,9 @@ class LvglComponent : public PollingComponent { void add_on_idle_callback(std::function &&callback) { this->idle_callbacks_.add(std::move(callback)); } - void add_on_pause_callback(std::function &&callback) { this->pause_callbacks_.add(std::move(callback)); } + + static void monitor_cb(lv_disp_drv_t *disp_drv, uint32_t time, uint32_t px); + static void render_start_cb(lv_disp_drv_t *disp_drv); void dump_config() override; bool is_idle(uint32_t idle_ms) { return lv_disp_get_inactive_time(this->disp_) > idle_ms; } lv_disp_t *get_disp() { return this->disp_; } @@ -213,12 +215,20 @@ class LvglComponent : public PollingComponent { size_t draw_rounding{2}; display::DisplayRotation rotation{display::DISPLAY_ROTATION_0_DEGREES}; + void set_pause_trigger(Trigger<> *trigger) { this->pause_callback_ = trigger; } + void set_resume_trigger(Trigger<> *trigger) { this->resume_callback_ = trigger; } + void set_draw_start_trigger(Trigger<> *trigger) { this->draw_start_callback_ = trigger; } + void set_draw_end_trigger(Trigger<> *trigger) { this->draw_end_callback_ = trigger; } protected: + // these functions are never called unless the callbacks are non-null since the + // LVGL callbacks that call them are not set unless the start/end callbacks are non-null + void draw_start_() const { this->draw_start_callback_->trigger(); } + void draw_end_() const { this->draw_end_callback_->trigger(); } + void write_random_(); void draw_buffer_(const lv_area_t *area, lv_color_t *ptr); void flush_cb_(lv_disp_drv_t *disp_drv, const lv_area_t *area, lv_color_t *color_p); - std::vector displays_{}; size_t buffer_frac_{1}; bool full_refresh_{}; @@ -235,7 +245,10 @@ class LvglComponent : public PollingComponent { std::map focus_marks_{}; CallbackManager idle_callbacks_{}; - CallbackManager pause_callbacks_{}; + Trigger<> *pause_callback_{}; + Trigger<> *resume_callback_{}; + Trigger<> *draw_start_callback_{}; + Trigger<> *draw_end_callback_{}; lv_color_t *rotate_buf_{}; }; @@ -248,14 +261,6 @@ class IdleTrigger : public Trigger<> { bool is_idle_{}; }; -class PauseTrigger : public Trigger<> { - public: - explicit PauseTrigger(LvglComponent *parent, TemplatableValue paused); - - protected: - TemplatableValue paused_; -}; - template class LvglAction : public Action, public Parented { public: explicit LvglAction(std::function &&lamb) : action_(std::move(lamb)) {} diff --git a/esphome/components/lvgl/types.py b/esphome/components/lvgl/types.py index c19c89401a..9955b530aa 100644 --- a/esphome/components/lvgl/types.py +++ b/esphome/components/lvgl/types.py @@ -3,6 +3,7 @@ import sys from esphome import automation, codegen as cg from esphome.const import CONF_MAX_VALUE, CONF_MIN_VALUE, CONF_TEXT, CONF_VALUE from esphome.cpp_generator import MockObj, MockObjClass +from esphome.cpp_types import esphome_ns from .defines import lvgl_ns from .lvcode import lv_expr @@ -42,8 +43,11 @@ lv_event_code_t = cg.global_ns.enum("lv_event_code_t") lv_indev_type_t = cg.global_ns.enum("lv_indev_type_t") lv_key_t = cg.global_ns.enum("lv_key_t") FontEngine = lvgl_ns.class_("FontEngine") +PlainTrigger = esphome_ns.class_("Trigger<>", automation.Trigger.template()) +DrawEndTrigger = esphome_ns.class_( + "Trigger", automation.Trigger.template(cg.uint32, cg.uint32) +) IdleTrigger = lvgl_ns.class_("IdleTrigger", automation.Trigger.template()) -PauseTrigger = lvgl_ns.class_("PauseTrigger", automation.Trigger.template()) ObjUpdateAction = lvgl_ns.class_("ObjUpdateAction", automation.Action) LvglCondition = lvgl_ns.class_("LvglCondition", automation.Condition) LvglAction = lvgl_ns.class_("LvglAction", automation.Action) diff --git a/tests/components/lvgl/test.esp32-idf.yaml b/tests/components/lvgl/test.esp32-idf.yaml index 6170b0f4fb..2450d28eb8 100644 --- a/tests/components/lvgl/test.esp32-idf.yaml +++ b/tests/components/lvgl/test.esp32-idf.yaml @@ -68,5 +68,13 @@ lvgl: enter_button: pushbutton group: general initial_focus: lv_roller + on_draw_start: + - logger.log: draw started + on_draw_end: + - logger.log: draw ended + - lvgl.pause: + - component.update: tft_display + - delay: 60s + - lvgl.resume: <<: !include common.yaml From 4d1d37a9112a6b54467722531438349d672d0f5a Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 1 Nov 2025 16:37:07 +1000 Subject: [PATCH 03/19] [lvgl] Fix event for binary sensor (#11636) --- esphome/components/lvgl/binary_sensor/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/lvgl/binary_sensor/__init__.py b/esphome/components/lvgl/binary_sensor/__init__.py index ffbdc977b2..f9df7d23fa 100644 --- a/esphome/components/lvgl/binary_sensor/__init__.py +++ b/esphome/components/lvgl/binary_sensor/__init__.py @@ -31,7 +31,7 @@ async def to_code(config): lvgl_static.add_event_cb( widget.obj, await pressed_ctx.get_lambda(), - LV_EVENT.PRESSING, + LV_EVENT.PRESSED, LV_EVENT.RELEASED, ) ) From 0b4d445794f1d804b072d73d9ae0952139a48524 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 1 Nov 2025 16:45:42 +1000 Subject: [PATCH 04/19] [sdl] Fix keymappings (#11635) --- esphome/components/sdl/binary_sensor.py | 485 ++++++++++++------------ tests/components/sdl/common.yaml | 6 +- 2 files changed, 253 insertions(+), 238 deletions(-) diff --git a/esphome/components/sdl/binary_sensor.py b/esphome/components/sdl/binary_sensor.py index 3ea6c2d218..e19a488800 100644 --- a/esphome/components/sdl/binary_sensor.py +++ b/esphome/components/sdl/binary_sensor.py @@ -12,241 +12,256 @@ CODEOWNERS = ["@bdm310"] STATE_ARG = "state" -SDL_KEYMAP = { - "SDLK_UNKNOWN": 0, - "SDLK_FIRST": 0, - "SDLK_BACKSPACE": 8, - "SDLK_TAB": 9, - "SDLK_CLEAR": 12, - "SDLK_RETURN": 13, - "SDLK_PAUSE": 19, - "SDLK_ESCAPE": 27, - "SDLK_SPACE": 32, - "SDLK_EXCLAIM": 33, - "SDLK_QUOTEDBL": 34, - "SDLK_HASH": 35, - "SDLK_DOLLAR": 36, - "SDLK_AMPERSAND": 38, - "SDLK_QUOTE": 39, - "SDLK_LEFTPAREN": 40, - "SDLK_RIGHTPAREN": 41, - "SDLK_ASTERISK": 42, - "SDLK_PLUS": 43, - "SDLK_COMMA": 44, - "SDLK_MINUS": 45, - "SDLK_PERIOD": 46, - "SDLK_SLASH": 47, - "SDLK_0": 48, - "SDLK_1": 49, - "SDLK_2": 50, - "SDLK_3": 51, - "SDLK_4": 52, - "SDLK_5": 53, - "SDLK_6": 54, - "SDLK_7": 55, - "SDLK_8": 56, - "SDLK_9": 57, - "SDLK_COLON": 58, - "SDLK_SEMICOLON": 59, - "SDLK_LESS": 60, - "SDLK_EQUALS": 61, - "SDLK_GREATER": 62, - "SDLK_QUESTION": 63, - "SDLK_AT": 64, - "SDLK_LEFTBRACKET": 91, - "SDLK_BACKSLASH": 92, - "SDLK_RIGHTBRACKET": 93, - "SDLK_CARET": 94, - "SDLK_UNDERSCORE": 95, - "SDLK_BACKQUOTE": 96, - "SDLK_a": 97, - "SDLK_b": 98, - "SDLK_c": 99, - "SDLK_d": 100, - "SDLK_e": 101, - "SDLK_f": 102, - "SDLK_g": 103, - "SDLK_h": 104, - "SDLK_i": 105, - "SDLK_j": 106, - "SDLK_k": 107, - "SDLK_l": 108, - "SDLK_m": 109, - "SDLK_n": 110, - "SDLK_o": 111, - "SDLK_p": 112, - "SDLK_q": 113, - "SDLK_r": 114, - "SDLK_s": 115, - "SDLK_t": 116, - "SDLK_u": 117, - "SDLK_v": 118, - "SDLK_w": 119, - "SDLK_x": 120, - "SDLK_y": 121, - "SDLK_z": 122, - "SDLK_DELETE": 127, - "SDLK_WORLD_0": 160, - "SDLK_WORLD_1": 161, - "SDLK_WORLD_2": 162, - "SDLK_WORLD_3": 163, - "SDLK_WORLD_4": 164, - "SDLK_WORLD_5": 165, - "SDLK_WORLD_6": 166, - "SDLK_WORLD_7": 167, - "SDLK_WORLD_8": 168, - "SDLK_WORLD_9": 169, - "SDLK_WORLD_10": 170, - "SDLK_WORLD_11": 171, - "SDLK_WORLD_12": 172, - "SDLK_WORLD_13": 173, - "SDLK_WORLD_14": 174, - "SDLK_WORLD_15": 175, - "SDLK_WORLD_16": 176, - "SDLK_WORLD_17": 177, - "SDLK_WORLD_18": 178, - "SDLK_WORLD_19": 179, - "SDLK_WORLD_20": 180, - "SDLK_WORLD_21": 181, - "SDLK_WORLD_22": 182, - "SDLK_WORLD_23": 183, - "SDLK_WORLD_24": 184, - "SDLK_WORLD_25": 185, - "SDLK_WORLD_26": 186, - "SDLK_WORLD_27": 187, - "SDLK_WORLD_28": 188, - "SDLK_WORLD_29": 189, - "SDLK_WORLD_30": 190, - "SDLK_WORLD_31": 191, - "SDLK_WORLD_32": 192, - "SDLK_WORLD_33": 193, - "SDLK_WORLD_34": 194, - "SDLK_WORLD_35": 195, - "SDLK_WORLD_36": 196, - "SDLK_WORLD_37": 197, - "SDLK_WORLD_38": 198, - "SDLK_WORLD_39": 199, - "SDLK_WORLD_40": 200, - "SDLK_WORLD_41": 201, - "SDLK_WORLD_42": 202, - "SDLK_WORLD_43": 203, - "SDLK_WORLD_44": 204, - "SDLK_WORLD_45": 205, - "SDLK_WORLD_46": 206, - "SDLK_WORLD_47": 207, - "SDLK_WORLD_48": 208, - "SDLK_WORLD_49": 209, - "SDLK_WORLD_50": 210, - "SDLK_WORLD_51": 211, - "SDLK_WORLD_52": 212, - "SDLK_WORLD_53": 213, - "SDLK_WORLD_54": 214, - "SDLK_WORLD_55": 215, - "SDLK_WORLD_56": 216, - "SDLK_WORLD_57": 217, - "SDLK_WORLD_58": 218, - "SDLK_WORLD_59": 219, - "SDLK_WORLD_60": 220, - "SDLK_WORLD_61": 221, - "SDLK_WORLD_62": 222, - "SDLK_WORLD_63": 223, - "SDLK_WORLD_64": 224, - "SDLK_WORLD_65": 225, - "SDLK_WORLD_66": 226, - "SDLK_WORLD_67": 227, - "SDLK_WORLD_68": 228, - "SDLK_WORLD_69": 229, - "SDLK_WORLD_70": 230, - "SDLK_WORLD_71": 231, - "SDLK_WORLD_72": 232, - "SDLK_WORLD_73": 233, - "SDLK_WORLD_74": 234, - "SDLK_WORLD_75": 235, - "SDLK_WORLD_76": 236, - "SDLK_WORLD_77": 237, - "SDLK_WORLD_78": 238, - "SDLK_WORLD_79": 239, - "SDLK_WORLD_80": 240, - "SDLK_WORLD_81": 241, - "SDLK_WORLD_82": 242, - "SDLK_WORLD_83": 243, - "SDLK_WORLD_84": 244, - "SDLK_WORLD_85": 245, - "SDLK_WORLD_86": 246, - "SDLK_WORLD_87": 247, - "SDLK_WORLD_88": 248, - "SDLK_WORLD_89": 249, - "SDLK_WORLD_90": 250, - "SDLK_WORLD_91": 251, - "SDLK_WORLD_92": 252, - "SDLK_WORLD_93": 253, - "SDLK_WORLD_94": 254, - "SDLK_WORLD_95": 255, - "SDLK_KP0": 256, - "SDLK_KP1": 257, - "SDLK_KP2": 258, - "SDLK_KP3": 259, - "SDLK_KP4": 260, - "SDLK_KP5": 261, - "SDLK_KP6": 262, - "SDLK_KP7": 263, - "SDLK_KP8": 264, - "SDLK_KP9": 265, - "SDLK_KP_PERIOD": 266, - "SDLK_KP_DIVIDE": 267, - "SDLK_KP_MULTIPLY": 268, - "SDLK_KP_MINUS": 269, - "SDLK_KP_PLUS": 270, - "SDLK_KP_ENTER": 271, - "SDLK_KP_EQUALS": 272, - "SDLK_UP": 273, - "SDLK_DOWN": 274, - "SDLK_RIGHT": 275, - "SDLK_LEFT": 276, - "SDLK_INSERT": 277, - "SDLK_HOME": 278, - "SDLK_END": 279, - "SDLK_PAGEUP": 280, - "SDLK_PAGEDOWN": 281, - "SDLK_F1": 282, - "SDLK_F2": 283, - "SDLK_F3": 284, - "SDLK_F4": 285, - "SDLK_F5": 286, - "SDLK_F6": 287, - "SDLK_F7": 288, - "SDLK_F8": 289, - "SDLK_F9": 290, - "SDLK_F10": 291, - "SDLK_F11": 292, - "SDLK_F12": 293, - "SDLK_F13": 294, - "SDLK_F14": 295, - "SDLK_F15": 296, - "SDLK_NUMLOCK": 300, - "SDLK_CAPSLOCK": 301, - "SDLK_SCROLLOCK": 302, - "SDLK_RSHIFT": 303, - "SDLK_LSHIFT": 304, - "SDLK_RCTRL": 305, - "SDLK_LCTRL": 306, - "SDLK_RALT": 307, - "SDLK_LALT": 308, - "SDLK_RMETA": 309, - "SDLK_LMETA": 310, - "SDLK_LSUPER": 311, - "SDLK_RSUPER": 312, - "SDLK_MODE": 313, - "SDLK_COMPOSE": 314, - "SDLK_HELP": 315, - "SDLK_PRINT": 316, - "SDLK_SYSREQ": 317, - "SDLK_BREAK": 318, - "SDLK_MENU": 319, - "SDLK_POWER": 320, - "SDLK_EURO": 321, - "SDLK_UNDO": 322, -} +SDL_KeyCode = cg.global_ns.enum("SDL_KeyCode") + +SDL_KEYS = ( + "SDLK_UNKNOWN", + "SDLK_RETURN", + "SDLK_ESCAPE", + "SDLK_BACKSPACE", + "SDLK_TAB", + "SDLK_SPACE", + "SDLK_EXCLAIM", + "SDLK_QUOTEDBL", + "SDLK_HASH", + "SDLK_PERCENT", + "SDLK_DOLLAR", + "SDLK_AMPERSAND", + "SDLK_QUOTE", + "SDLK_LEFTPAREN", + "SDLK_RIGHTPAREN", + "SDLK_ASTERISK", + "SDLK_PLUS", + "SDLK_COMMA", + "SDLK_MINUS", + "SDLK_PERIOD", + "SDLK_SLASH", + "SDLK_0", + "SDLK_1", + "SDLK_2", + "SDLK_3", + "SDLK_4", + "SDLK_5", + "SDLK_6", + "SDLK_7", + "SDLK_8", + "SDLK_9", + "SDLK_COLON", + "SDLK_SEMICOLON", + "SDLK_LESS", + "SDLK_EQUALS", + "SDLK_GREATER", + "SDLK_QUESTION", + "SDLK_AT", + "SDLK_LEFTBRACKET", + "SDLK_BACKSLASH", + "SDLK_RIGHTBRACKET", + "SDLK_CARET", + "SDLK_UNDERSCORE", + "SDLK_BACKQUOTE", + "SDLK_a", + "SDLK_b", + "SDLK_c", + "SDLK_d", + "SDLK_e", + "SDLK_f", + "SDLK_g", + "SDLK_h", + "SDLK_i", + "SDLK_j", + "SDLK_k", + "SDLK_l", + "SDLK_m", + "SDLK_n", + "SDLK_o", + "SDLK_p", + "SDLK_q", + "SDLK_r", + "SDLK_s", + "SDLK_t", + "SDLK_u", + "SDLK_v", + "SDLK_w", + "SDLK_x", + "SDLK_y", + "SDLK_z", + "SDLK_CAPSLOCK", + "SDLK_F1", + "SDLK_F2", + "SDLK_F3", + "SDLK_F4", + "SDLK_F5", + "SDLK_F6", + "SDLK_F7", + "SDLK_F8", + "SDLK_F9", + "SDLK_F10", + "SDLK_F11", + "SDLK_F12", + "SDLK_PRINTSCREEN", + "SDLK_SCROLLLOCK", + "SDLK_PAUSE", + "SDLK_INSERT", + "SDLK_HOME", + "SDLK_PAGEUP", + "SDLK_DELETE", + "SDLK_END", + "SDLK_PAGEDOWN", + "SDLK_RIGHT", + "SDLK_LEFT", + "SDLK_DOWN", + "SDLK_UP", + "SDLK_NUMLOCKCLEAR", + "SDLK_KP_DIVIDE", + "SDLK_KP_MULTIPLY", + "SDLK_KP_MINUS", + "SDLK_KP_PLUS", + "SDLK_KP_ENTER", + "SDLK_KP_1", + "SDLK_KP_2", + "SDLK_KP_3", + "SDLK_KP_4", + "SDLK_KP_5", + "SDLK_KP_6", + "SDLK_KP_7", + "SDLK_KP_8", + "SDLK_KP_9", + "SDLK_KP_0", + "SDLK_KP_PERIOD", + "SDLK_APPLICATION", + "SDLK_POWER", + "SDLK_KP_EQUALS", + "SDLK_F13", + "SDLK_F14", + "SDLK_F15", + "SDLK_F16", + "SDLK_F17", + "SDLK_F18", + "SDLK_F19", + "SDLK_F20", + "SDLK_F21", + "SDLK_F22", + "SDLK_F23", + "SDLK_F24", + "SDLK_EXECUTE", + "SDLK_HELP", + "SDLK_MENU", + "SDLK_SELECT", + "SDLK_STOP", + "SDLK_AGAIN", + "SDLK_UNDO", + "SDLK_CUT", + "SDLK_COPY", + "SDLK_PASTE", + "SDLK_FIND", + "SDLK_MUTE", + "SDLK_VOLUMEUP", + "SDLK_VOLUMEDOWN", + "SDLK_KP_COMMA", + "SDLK_KP_EQUALSAS400", + "SDLK_ALTERASE", + "SDLK_SYSREQ", + "SDLK_CANCEL", + "SDLK_CLEAR", + "SDLK_PRIOR", + "SDLK_RETURN2", + "SDLK_SEPARATOR", + "SDLK_OUT", + "SDLK_OPER", + "SDLK_CLEARAGAIN", + "SDLK_CRSEL", + "SDLK_EXSEL", + "SDLK_KP_00", + "SDLK_KP_000", + "SDLK_THOUSANDSSEPARATOR", + "SDLK_DECIMALSEPARATOR", + "SDLK_CURRENCYUNIT", + "SDLK_CURRENCYSUBUNIT", + "SDLK_KP_LEFTPAREN", + "SDLK_KP_RIGHTPAREN", + "SDLK_KP_LEFTBRACE", + "SDLK_KP_RIGHTBRACE", + "SDLK_KP_TAB", + "SDLK_KP_BACKSPACE", + "SDLK_KP_A", + "SDLK_KP_B", + "SDLK_KP_C", + "SDLK_KP_D", + "SDLK_KP_E", + "SDLK_KP_F", + "SDLK_KP_XOR", + "SDLK_KP_POWER", + "SDLK_KP_PERCENT", + "SDLK_KP_LESS", + "SDLK_KP_GREATER", + "SDLK_KP_AMPERSAND", + "SDLK_KP_DBLAMPERSAND", + "SDLK_KP_VERTICALBAR", + "SDLK_KP_DBLVERTICALBAR", + "SDLK_KP_COLON", + "SDLK_KP_HASH", + "SDLK_KP_SPACE", + "SDLK_KP_AT", + "SDLK_KP_EXCLAM", + "SDLK_KP_MEMSTORE", + "SDLK_KP_MEMRECALL", + "SDLK_KP_MEMCLEAR", + "SDLK_KP_MEMADD", + "SDLK_KP_MEMSUBTRACT", + "SDLK_KP_MEMMULTIPLY", + "SDLK_KP_MEMDIVIDE", + "SDLK_KP_PLUSMINUS", + "SDLK_KP_CLEAR", + "SDLK_KP_CLEARENTRY", + "SDLK_KP_BINARY", + "SDLK_KP_OCTAL", + "SDLK_KP_DECIMAL", + "SDLK_KP_HEXADECIMAL", + "SDLK_LCTRL", + "SDLK_LSHIFT", + "SDLK_LALT", + "SDLK_LGUI", + "SDLK_RCTRL", + "SDLK_RSHIFT", + "SDLK_RALT", + "SDLK_RGUI", + "SDLK_MODE", + "SDLK_AUDIONEXT", + "SDLK_AUDIOPREV", + "SDLK_AUDIOSTOP", + "SDLK_AUDIOPLAY", + "SDLK_AUDIOMUTE", + "SDLK_MEDIASELECT", + "SDLK_WWW", + "SDLK_MAIL", + "SDLK_CALCULATOR", + "SDLK_COMPUTER", + "SDLK_AC_SEARCH", + "SDLK_AC_HOME", + "SDLK_AC_BACK", + "SDLK_AC_FORWARD", + "SDLK_AC_STOP", + "SDLK_AC_REFRESH", + "SDLK_AC_BOOKMARKS", + "SDLK_BRIGHTNESSDOWN", + "SDLK_BRIGHTNESSUP", + "SDLK_DISPLAYSWITCH", + "SDLK_KBDILLUMTOGGLE", + "SDLK_KBDILLUMDOWN", + "SDLK_KBDILLUMUP", + "SDLK_EJECT", + "SDLK_SLEEP", + "SDLK_APP1", + "SDLK_APP2", + "SDLK_AUDIOREWIND", + "SDLK_AUDIOFASTFORWARD", + "SDLK_SOFTLEFT", + "SDLK_SOFTRIGHT", + "SDLK_CALL", + "SDLK_ENDCALL", +) + +SDL_KEYMAP = {key: getattr(SDL_KeyCode, key) for key in SDL_KEYS} CONFIG_SCHEMA = ( binary_sensor.binary_sensor_schema(BinarySensor) diff --git a/tests/components/sdl/common.yaml b/tests/components/sdl/common.yaml index 50fa4a5990..52991d595c 100644 --- a/tests/components/sdl/common.yaml +++ b/tests/components/sdl/common.yaml @@ -14,10 +14,10 @@ display: binary_sensor: - platform: sdl id: key_up - key: SDLK_a + key: SDLK_UP - platform: sdl id: key_down - key: SDLK_d + key: SDLK_DOWN - platform: sdl id: key_enter - key: SDLK_s + key: SDLK_RETURN From e28c15229811ed6731ab446cdf880f0929555ac9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 1 Nov 2025 04:48:58 -0500 Subject: [PATCH 05/19] [cpp_generator] Align isinstance() with codebase style (tuple vs PEP 604) (#11645) --- esphome/cpp_generator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index a2da424e5a..6f1af01a5b 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -350,7 +350,7 @@ def safe_exp(obj: SafeExpType) -> Expression: return IntLiteral(int(obj.total_seconds)) if isinstance(obj, TimePeriodMinutes): return IntLiteral(int(obj.total_minutes)) - if isinstance(obj, tuple | list): + if isinstance(obj, (tuple, list)): return ArrayInitializer(*[safe_exp(o) for o in obj]) if obj is bool: return bool_ From c662697ca77cca55e6b63a4515476f5404d5810f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 1 Nov 2025 11:18:10 -0500 Subject: [PATCH 06/19] [json] Fix component test compilation errors (#11647) --- tests/components/json/common.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/components/json/common.yaml b/tests/components/json/common.yaml index f4074e1172..c36c7f2a5a 100644 --- a/tests/components/json/common.yaml +++ b/tests/components/json/common.yaml @@ -14,12 +14,14 @@ interval: // Test parse_json bool parse_ok = esphome::json::parse_json(json_str, [](JsonObject root) { - if (root.containsKey("sensor") && root.containsKey("value")) { + if (root["sensor"].is() && root["value"].is()) { const char* sensor = root["sensor"]; float value = root["value"]; ESP_LOGD("test", "Parsed: sensor=%s, value=%.1f", sensor, value); + return true; } else { ESP_LOGD("test", "Parsed JSON missing required keys"); + return false; } }); ESP_LOGD("test", "Parse result (JSON syntax only): %s", parse_ok ? "success" : "failed"); From 55af8186294b900d52cd31eabd20407f30563286 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Sat, 1 Nov 2025 17:18:38 +0100 Subject: [PATCH 07/19] [nrf52] fix compilation warning (#11656) --- esphome/components/zephyr/core.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/zephyr/core.cpp b/esphome/components/zephyr/core.cpp index ad7a148cdb..365b6b8ed2 100644 --- a/esphome/components/zephyr/core.cpp +++ b/esphome/components/zephyr/core.cpp @@ -3,7 +3,7 @@ #include #include #include -#include +#include #include "esphome/core/hal.h" #include "esphome/core/helpers.h" From d25121a55c100da3aa3ddce112beac9e43f5b2e3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 1 Nov 2025 22:43:08 -0500 Subject: [PATCH 08/19] [core] Remove redundant fd bounds check in yield_with_select_() (#11666) --- esphome/core/application.cpp | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index c745aa0ae5..61cfcc7585 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -576,10 +576,11 @@ void Application::yield_with_select_(uint32_t delay_ms) { // Update fd_set if socket list has changed if (this->socket_fds_changed_) { FD_ZERO(&this->base_read_fds_); + // fd bounds are already validated in register_socket_fd() or guaranteed by platform design: + // - ESP32: LwIP guarantees fd < FD_SETSIZE by design (LWIP_SOCKET_OFFSET = FD_SETSIZE - CONFIG_LWIP_MAX_SOCKETS) + // - Other platforms: register_socket_fd() validates fd < FD_SETSIZE for (int fd : this->socket_fds_) { - if (fd >= 0 && fd < FD_SETSIZE) { - FD_SET(fd, &this->base_read_fds_); - } + FD_SET(fd, &this->base_read_fds_); } this->socket_fds_changed_ = false; } From 1fc3165b582a290f65b5be68a52ee45d75721ca5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 1 Nov 2025 22:43:39 -0500 Subject: [PATCH 09/19] [api] Remove unnecessary intermediate variable in frame helpers (#11668) --- esphome/components/api/api_frame_helper_noise.cpp | 3 +-- esphome/components/api/api_frame_helper_plaintext.cpp | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index e952ea670b..633b07a7fa 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -434,8 +434,7 @@ APIError APINoiseFrameHelper::write_protobuf_packets(ProtoWriteBuffer buffer, st return APIError::OK; } - std::vector *raw_buffer = buffer.get_buffer(); - uint8_t *buffer_data = raw_buffer->data(); // Cache buffer pointer + uint8_t *buffer_data = buffer.get_buffer()->data(); this->reusable_iovs_.clear(); this->reusable_iovs_.reserve(packets.size()); diff --git a/esphome/components/api/api_frame_helper_plaintext.cpp b/esphome/components/api/api_frame_helper_plaintext.cpp index 471e6c5404..dcbd35aa32 100644 --- a/esphome/components/api/api_frame_helper_plaintext.cpp +++ b/esphome/components/api/api_frame_helper_plaintext.cpp @@ -230,8 +230,7 @@ APIError APIPlaintextFrameHelper::write_protobuf_packets(ProtoWriteBuffer buffer return APIError::OK; } - std::vector *raw_buffer = buffer.get_buffer(); - uint8_t *buffer_data = raw_buffer->data(); // Cache buffer pointer + uint8_t *buffer_data = buffer.get_buffer()->data(); this->reusable_iovs_.clear(); this->reusable_iovs_.reserve(packets.size()); From edde2fc94c584cb07365c16c6fe7a9d0b20488a1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 2 Nov 2025 08:18:17 -0600 Subject: [PATCH 10/19] Add basic tests for web_server_idf (#11659) --- tests/components/web_server_idf/common.yaml | 29 +++++++++++++++++++ .../web_server_idf/test.esp32-idf.yaml | 3 ++ 2 files changed, 32 insertions(+) create mode 100644 tests/components/web_server_idf/common.yaml create mode 100644 tests/components/web_server_idf/test.esp32-idf.yaml diff --git a/tests/components/web_server_idf/common.yaml b/tests/components/web_server_idf/common.yaml new file mode 100644 index 0000000000..b1885af266 --- /dev/null +++ b/tests/components/web_server_idf/common.yaml @@ -0,0 +1,29 @@ +esphome: + name: test-web-server-idf + +esp32: + board: esp32dev + framework: + type: esp-idf + +network: + +# Add some entities to test SSE event formatting +sensor: + - platform: template + name: "Test Sensor" + id: test_sensor + update_interval: 60s + lambda: "return 42.5;" + +binary_sensor: + - platform: template + name: "Test Binary Sensor" + id: test_binary_sensor + lambda: "return true;" + +switch: + - platform: template + name: "Test Switch" + id: test_switch + optimistic: true diff --git a/tests/components/web_server_idf/test.esp32-idf.yaml b/tests/components/web_server_idf/test.esp32-idf.yaml new file mode 100644 index 0000000000..c3b85178ef --- /dev/null +++ b/tests/components/web_server_idf/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +<<: !include common.yaml + +web_server: From f6946c0b9aed3b15986079c16c98c0e51438311a Mon Sep 17 00:00:00 2001 From: Kjell Braden Date: Sun, 2 Nov 2025 22:08:45 +0100 Subject: [PATCH 11/19] add integration test for script re-entry argument issue (#11652) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- .../fixtures/action_concurrent_reentry.yaml | 105 ++++++++++++++++++ .../test_action_concurrent_reentry.py | 93 ++++++++++++++++ 2 files changed, 198 insertions(+) create mode 100644 tests/integration/fixtures/action_concurrent_reentry.yaml create mode 100644 tests/integration/test_action_concurrent_reentry.py diff --git a/tests/integration/fixtures/action_concurrent_reentry.yaml b/tests/integration/fixtures/action_concurrent_reentry.yaml new file mode 100644 index 0000000000..68d36d1510 --- /dev/null +++ b/tests/integration/fixtures/action_concurrent_reentry.yaml @@ -0,0 +1,105 @@ +esphome: + name: action-concurrent-reentry + on_boot: + - priority: -100 + then: + - repeat: + count: 5 + then: + - lambda: id(handler_wait_until)->execute(id(global_counter)); + - lambda: id(handler_repeat)->execute(id(global_counter)); + - lambda: id(handler_while)->execute(id(global_counter)); + - lambda: id(handler_script_wait)->execute(id(global_counter)); + - delay: 50ms + - lambda: id(global_counter)++; + - delay: 50ms + +host: + +api: + +globals: + - id: global_counter + type: int + +script: + - id: handler_wait_until + + mode: parallel + + parameters: + arg: int + + then: + - wait_until: + condition: + lambda: return id(global_counter) == 5; + + - logger.log: + format: "AFTER wait_until ARG %d" + args: + - arg + + - id: handler_script_wait + + mode: parallel + + parameters: + arg: int + + then: + - script.wait: handler_wait_until + + - logger.log: + format: "AFTER script.wait ARG %d" + args: + - arg + + - id: handler_repeat + + mode: parallel + + parameters: + arg: int + + then: + - repeat: + count: 3 + then: + - logger.log: + format: "IN repeat %d ARG %d" + args: + - iteration + - arg + - delay: 100ms + + - logger.log: + format: "AFTER repeat ARG %d" + args: + - arg + + - id: handler_while + + mode: parallel + + parameters: + arg: int + + then: + - while: + condition: + lambda: return id(global_counter) != 5; + then: + - logger.log: + format: "IN while ARG %d" + args: + - arg + - delay: 100ms + + - logger.log: + format: "AFTER while ARG %d" + args: + - arg + +logger: + level: DEBUG diff --git a/tests/integration/test_action_concurrent_reentry.py b/tests/integration/test_action_concurrent_reentry.py new file mode 100644 index 0000000000..ba67e4c798 --- /dev/null +++ b/tests/integration/test_action_concurrent_reentry.py @@ -0,0 +1,93 @@ +"""Integration test for API conditional memory optimization with triggers and services.""" + +from __future__ import annotations + +import asyncio +import collections +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.xfail(reason="https://github.com/esphome/issues/issues/6534") +@pytest.mark.asyncio +async def test_action_concurrent_reentry( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """ + This test runs a script in parallel with varying arguments and verifies if + each script keeps its original argument throughout its execution + """ + test_complete = asyncio.Event() + expected = {0, 1, 2, 3, 4} + + # Patterns to match in logs + after_wait_until_pattern = re.compile(r"AFTER wait_until ARG (\d+)") + after_script_wait_pattern = re.compile(r"AFTER script\.wait ARG (\d+)") + after_repeat_pattern = re.compile(r"AFTER repeat ARG (\d+)") + in_repeat_pattern = re.compile(r"IN repeat (\d+) ARG (\d+)") + after_while_pattern = re.compile(r"AFTER while ARG (\d+)") + in_while_pattern = re.compile(r"IN while ARG (\d+)") + + after_wait_until_args = [] + after_script_wait_args = [] + after_while_args = [] + in_while_args = [] + after_repeat_args = [] + in_repeat_args = collections.defaultdict(list) + + def check_output(line: str) -> None: + """Check log output for expected messages.""" + if test_complete.is_set(): + return + + if mo := after_wait_until_pattern.search(line): + after_wait_until_args.append(int(mo.group(1))) + elif mo := after_script_wait_pattern.search(line): + after_script_wait_args.append(int(mo.group(1))) + elif mo := in_while_pattern.search(line): + in_while_args.append(int(mo.group(1))) + elif mo := after_while_pattern.search(line): + after_while_args.append(int(mo.group(1))) + elif mo := in_repeat_pattern.search(line): + in_repeat_args[int(mo.group(1))].append(int(mo.group(2))) + elif mo := after_repeat_pattern.search(line): + after_repeat_args.append(int(mo.group(1))) + if len(after_repeat_args) == len(expected): + test_complete.set() + + # Run with log monitoring + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + # Verify device info + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "action-concurrent-reentry" + + # Wait for tests to complete with timeout + try: + await asyncio.wait_for(test_complete.wait(), timeout=8.0) + except TimeoutError: + pytest.fail("test timed out") + + # order may change, but all args must be present + for args in in_repeat_args.values(): + assert set(args) == expected + assert set(in_repeat_args.keys()) == {0, 1, 2} + assert set(after_wait_until_args) == expected, after_wait_until_args + assert set(after_script_wait_args) == expected, after_script_wait_args + assert set(after_repeat_args) == expected, after_repeat_args + assert set(after_while_args) == expected, after_while_args + assert dict(collections.Counter(in_while_args)) == { + 0: 5, + 1: 4, + 2: 3, + 3: 2, + 4: 1, + }, in_while_args From 425c88ee9434f35fcb093488f08a5651c4df57cf Mon Sep 17 00:00:00 2001 From: Edward Firmo <94725493+edwardtfn@users.noreply.github.com> Date: Sun, 2 Nov 2025 23:06:13 +0100 Subject: [PATCH 12/19] [nextion] Send `auto_wake_on_touch` as part of startup commands on loop (#11670) --- esphome/components/nextion/nextion.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/esphome/components/nextion/nextion.cpp b/esphome/components/nextion/nextion.cpp index fc152ece1e..d77af510d7 100644 --- a/esphome/components/nextion/nextion.cpp +++ b/esphome/components/nextion/nextion.cpp @@ -323,6 +323,8 @@ void Nextion::loop() { this->set_touch_sleep_timeout(this->touch_sleep_timeout_); } + this->set_auto_wake_on_touch(this->connection_state_.auto_wake_on_touch_); + this->connection_state_.ignore_is_setup_ = false; } From 338190abeca6cd7cd29ca6065065d549c490857e Mon Sep 17 00:00:00 2001 From: Guillermo Ruffino Date: Sun, 2 Nov 2025 19:11:02 -0300 Subject: [PATCH 13/19] ESP32 Pin loopTask to CORE 1 (#11669) --- esphome/components/esp32/core.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/esphome/components/esp32/core.cpp b/esphome/components/esp32/core.cpp index 3427c96e70..1c8f29fa95 100644 --- a/esphome/components/esp32/core.cpp +++ b/esphome/components/esp32/core.cpp @@ -96,7 +96,11 @@ void loop_task(void *pv_params) { extern "C" void app_main() { esp32::setup_preferences(); +#if CONFIG_FREERTOS_UNICORE xTaskCreate(loop_task, "loopTask", 8192, nullptr, 1, &loop_task_handle); +#else + xTaskCreatePinnedToCore(loop_task, "loopTask", 8192, nullptr, 1, &loop_task_handle, 1); +#endif } #endif // USE_ESP_IDF From 70ea3af578e1440fc49751dff420bc41f195af20 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Sun, 2 Nov 2025 23:19:28 +0100 Subject: [PATCH 14/19] [nrf52,gpio] switch input gpio to polling mode (#11664) --- esphome/components/gpio/binary_sensor/__init__.py | 3 ++- esphome/components/zephyr/gpio.cpp | 9 ++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/esphome/components/gpio/binary_sensor/__init__.py b/esphome/components/gpio/binary_sensor/__init__.py index ca4dc43e9c..3c2021d40e 100644 --- a/esphome/components/gpio/binary_sensor/__init__.py +++ b/esphome/components/gpio/binary_sensor/__init__.py @@ -39,6 +39,7 @@ CONFIG_SCHEMA = ( # due to hardware limitations or lack of reliable interrupt support. This ensures # stable operation on these platforms. Future maintainers should verify platform # capabilities before changing this default behavior. + # nrf52 has no gpio interrupts implemented yet cv.SplitDefault( CONF_USE_INTERRUPT, bk72xx=False, @@ -46,7 +47,7 @@ CONFIG_SCHEMA = ( esp8266=True, host=True, ln882x=False, - nrf52=True, + nrf52=False, rp2040=True, rtl87xx=False, ): cv.boolean, diff --git a/esphome/components/zephyr/gpio.cpp b/esphome/components/zephyr/gpio.cpp index 4b84910368..41b983535c 100644 --- a/esphome/components/zephyr/gpio.cpp +++ b/esphome/components/zephyr/gpio.cpp @@ -8,8 +8,8 @@ namespace zephyr { static const char *const TAG = "zephyr"; -static int flags_to_mode(gpio::Flags flags, bool inverted, bool value) { - int ret = 0; +static gpio_flags_t flags_to_mode(gpio::Flags flags, bool inverted, bool value) { + gpio_flags_t ret = 0; if (flags & gpio::FLAG_INPUT) { ret |= GPIO_INPUT; } @@ -79,7 +79,10 @@ void ZephyrGPIOPin::pin_mode(gpio::Flags flags) { if (nullptr == this->gpio_) { return; } - gpio_pin_configure(this->gpio_, this->pin_ % 32, flags_to_mode(flags, this->inverted_, this->value_)); + auto ret = gpio_pin_configure(this->gpio_, this->pin_ % 32, flags_to_mode(flags, this->inverted_, this->value_)); + if (ret != 0) { + ESP_LOGE(TAG, "gpio %u cannot be configured %d.", this->pin_, ret); + } } std::string ZephyrGPIOPin::dump_summary() const { From 50e7ce55e7370f4f015488e0e61e2746134d7075 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Sun, 2 Nov 2025 23:20:30 +0100 Subject: [PATCH 15/19] [nrf52] enable nrf52 test (#11379) --- esphome/components/nrf52/__init__.py | 1 + tests/components/nrf52/test-bootloader.nrf52-xiao-ble.yaml | 3 +++ tests/components/nrf52/test.nrf52-mcumgr.yaml | 0 3 files changed, 4 insertions(+) create mode 100644 tests/components/nrf52/test-bootloader.nrf52-xiao-ble.yaml create mode 100644 tests/components/nrf52/test.nrf52-mcumgr.yaml diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 27e1246744..ace324c1f5 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -290,6 +290,7 @@ def show_logs(config: ConfigType, args, devices: list[str]) -> bool: address = ble_device.address else: return True + if is_mac_address(address): asyncio.run(logger_connect(address)) return True diff --git a/tests/components/nrf52/test-bootloader.nrf52-xiao-ble.yaml b/tests/components/nrf52/test-bootloader.nrf52-xiao-ble.yaml new file mode 100644 index 0000000000..022ab9c753 --- /dev/null +++ b/tests/components/nrf52/test-bootloader.nrf52-xiao-ble.yaml @@ -0,0 +1,3 @@ +nrf52: + # it is not correct bootloader for the board + bootloader: adafruit_nrf52_sd140_v6 diff --git a/tests/components/nrf52/test.nrf52-mcumgr.yaml b/tests/components/nrf52/test.nrf52-mcumgr.yaml new file mode 100644 index 0000000000..e69de29bb2 From c822ec152f45528949dc9b220165774286cf7456 Mon Sep 17 00:00:00 2001 From: Jimmy Hedman Date: Sun, 2 Nov 2025 23:22:49 +0100 Subject: [PATCH 16/19] Enable IPv6 for host (#11630) --- esphome/components/network/__init__.py | 2 ++ tests/components/network/test-ipv6.host.yaml | 2 ++ 2 files changed, 4 insertions(+) create mode 100644 tests/components/network/test-ipv6.host.yaml diff --git a/esphome/components/network/__init__.py b/esphome/components/network/__init__.py index 502803da1e..1d62b661ca 100644 --- a/esphome/components/network/__init__.py +++ b/esphome/components/network/__init__.py @@ -55,6 +55,7 @@ CONFIG_SCHEMA = cv.Schema( esp32=False, rp2040=False, bk72xx=False, + host=False, ): cv.All( cv.boolean, cv.Any( @@ -64,6 +65,7 @@ CONFIG_SCHEMA = cv.Schema( esp8266_arduino=cv.Version(0, 0, 0), rp2040_arduino=cv.Version(0, 0, 0), bk72xx_arduino=cv.Version(1, 7, 0), + host=cv.Version(0, 0, 0), ), cv.boolean_false, ), diff --git a/tests/components/network/test-ipv6.host.yaml b/tests/components/network/test-ipv6.host.yaml new file mode 100644 index 0000000000..d9eeab89ea --- /dev/null +++ b/tests/components/network/test-ipv6.host.yaml @@ -0,0 +1,2 @@ +network: + enable_ipv6: true From 79378a930e8d10871a765bce6a144b7868b0e50b Mon Sep 17 00:00:00 2001 From: Juan Antonio Aldea Date: Sun, 2 Nov 2025 23:26:20 +0100 Subject: [PATCH 17/19] Use lists inits initialization instead of std::fill (#11532) --- esphome/components/remote_base/abbwelcome_protocol.h | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/esphome/components/remote_base/abbwelcome_protocol.h b/esphome/components/remote_base/abbwelcome_protocol.h index f2d0f5b547..b258bd920b 100644 --- a/esphome/components/remote_base/abbwelcome_protocol.h +++ b/esphome/components/remote_base/abbwelcome_protocol.h @@ -33,19 +33,13 @@ Message Format: class ABBWelcomeData { public: // Make default - ABBWelcomeData() { - std::fill(std::begin(this->data_), std::end(this->data_), 0); - this->data_[0] = 0x55; - this->data_[1] = 0xff; - } + ABBWelcomeData() : data_{0x55, 0xff} {} // Make from initializer_list - ABBWelcomeData(std::initializer_list data) { - std::fill(std::begin(this->data_), std::end(this->data_), 0); + ABBWelcomeData(std::initializer_list data) : data_{} { std::copy_n(data.begin(), std::min(data.size(), this->data_.size()), this->data_.begin()); } // Make from vector - ABBWelcomeData(const std::vector &data) { - std::fill(std::begin(this->data_), std::end(this->data_), 0); + ABBWelcomeData(const std::vector &data) : data_{} { std::copy_n(data.begin(), std::min(data.size(), this->data_.size()), this->data_.begin()); } // Default copy constructor From 8a8a80e1071a3f71938fdd278fca20a5eb5157a1 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Sun, 2 Nov 2025 23:44:52 +0100 Subject: [PATCH 18/19] [nrf52, zigbee] OnlyWith support list of components (#11533) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/config_validation.py | 31 ++++- tests/unit_tests/test_config_validation.py | 129 ++++++++++++++++++++- 2 files changed, 154 insertions(+), 6 deletions(-) diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 359b257992..a3fd271a86 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Callable from contextlib import contextmanager, suppress from dataclasses import dataclass from datetime import datetime @@ -18,6 +19,7 @@ import logging from pathlib import Path import re from string import ascii_letters, digits +import typing import uuid as uuid_ import voluptuous as vol @@ -1763,16 +1765,37 @@ class SplitDefault(Optional): class OnlyWith(Optional): - """Set the default value only if the given component is loaded.""" + """Set the default value only if the given component(s) is/are loaded. - def __init__(self, key, component, default=None): + This validator allows configuration keys to have defaults that are only applied + when specific component(s) are loaded. Supports both single component names and + lists of components. + + Args: + key: Configuration key + component: Single component name (str) or list of component names. + For lists, ALL components must be loaded for the default to apply. + default: Default value to use when condition is met + + Example: + # Single component + cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(MQTTComponent) + + # Multiple components (all must be loaded) + cv.OnlyWith(CONF_ZIGBEE_ID, ["zigbee", "nrf52"]): cv.use_id(Zigbee) + """ + + def __init__(self, key, component: str | list[str], default=None) -> None: super().__init__(key) self._component = component self._default = vol.default_factory(default) @property - def default(self): - if self._component in CORE.loaded_integrations: + def default(self) -> Callable[[], typing.Any] | vol.Undefined: + if isinstance(self._component, list): + if all(c in CORE.loaded_integrations for c in self._component): + return self._default + elif self._component in CORE.loaded_integrations: return self._default return vol.UNDEFINED diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index 2928c5c83a..104cdc2b7a 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -3,6 +3,7 @@ import string from hypothesis import example, given from hypothesis.strategies import builds, integers, ip_addresses, one_of, text import pytest +import voluptuous as vol from esphome import config_validation from esphome.components.esp32.const import ( @@ -301,8 +302,6 @@ def test_split_default(framework, platform, variant, full, idf, arduino, simple) ], ) def test_require_framework_version(framework, platform, message): - import voluptuous as vol - from esphome.const import ( KEY_CORE, KEY_FRAMEWORK_VERSION, @@ -377,3 +376,129 @@ def test_require_framework_version(framework, platform, message): config_validation.require_framework_version( extra_message="test 5", )("test") + + +def test_only_with_single_component_loaded() -> None: + """Test OnlyWith with single component when component is loaded.""" + CORE.loaded_integrations = {"mqtt"} + + schema = config_validation.Schema( + { + config_validation.OnlyWith("mqtt_id", "mqtt", default="test_mqtt"): str, + } + ) + + result = schema({}) + assert result.get("mqtt_id") == "test_mqtt" + + +def test_only_with_single_component_not_loaded() -> None: + """Test OnlyWith with single component when component is not loaded.""" + CORE.loaded_integrations = set() + + schema = config_validation.Schema( + { + config_validation.OnlyWith("mqtt_id", "mqtt", default="test_mqtt"): str, + } + ) + + result = schema({}) + assert "mqtt_id" not in result + + +def test_only_with_list_all_components_loaded() -> None: + """Test OnlyWith with list when all components are loaded.""" + CORE.loaded_integrations = {"zigbee", "nrf52"} + + schema = config_validation.Schema( + { + config_validation.OnlyWith( + "zigbee_id", ["zigbee", "nrf52"], default="test_zigbee" + ): str, + } + ) + + result = schema({}) + assert result.get("zigbee_id") == "test_zigbee" + + +def test_only_with_list_partial_components_loaded() -> None: + """Test OnlyWith with list when only some components are loaded.""" + CORE.loaded_integrations = {"zigbee"} # Only zigbee, not nrf52 + + schema = config_validation.Schema( + { + config_validation.OnlyWith( + "zigbee_id", ["zigbee", "nrf52"], default="test_zigbee" + ): str, + } + ) + + result = schema({}) + assert "zigbee_id" not in result + + +def test_only_with_list_no_components_loaded() -> None: + """Test OnlyWith with list when no components are loaded.""" + CORE.loaded_integrations = set() + + schema = config_validation.Schema( + { + config_validation.OnlyWith( + "zigbee_id", ["zigbee", "nrf52"], default="test_zigbee" + ): str, + } + ) + + result = schema({}) + assert "zigbee_id" not in result + + +def test_only_with_list_multiple_components() -> None: + """Test OnlyWith with list requiring three components.""" + CORE.loaded_integrations = {"comp1", "comp2", "comp3"} + + schema = config_validation.Schema( + { + config_validation.OnlyWith( + "test_id", ["comp1", "comp2", "comp3"], default="test_value" + ): str, + } + ) + + result = schema({}) + assert result.get("test_id") == "test_value" + + # Test with one missing + CORE.loaded_integrations = {"comp1", "comp2"} + result = schema({}) + assert "test_id" not in result + + +def test_only_with_empty_list() -> None: + """Test OnlyWith with empty list (edge case).""" + CORE.loaded_integrations = set() + + schema = config_validation.Schema( + { + config_validation.OnlyWith("test_id", [], default="test_value"): str, + } + ) + + # all([]) returns True, so default should be applied + result = schema({}) + assert result.get("test_id") == "test_value" + + +def test_only_with_user_value_overrides_default() -> None: + """Test OnlyWith respects user-provided values over defaults.""" + CORE.loaded_integrations = {"mqtt"} + + schema = config_validation.Schema( + { + config_validation.OnlyWith("mqtt_id", "mqtt", default="default_id"): str, + } + ) + + result = schema({"mqtt_id": "custom_id"}) + assert result.get("mqtt_id") == "custom_id" From 86402be9e31e7f983011797a1ce684b76c124441 Mon Sep 17 00:00:00 2001 From: Kjell Braden Date: Mon, 3 Nov 2025 00:02:13 +0100 Subject: [PATCH 19/19] actions: fix loop re-entry (#7972) Co-authored-by: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- esphome/components/script/script.h | 38 ++++- esphome/core/base_automation.h | 80 +++++++---- .../fixtures/automation_wait_actions.yaml | 130 ++++++++++++++++++ .../test_action_concurrent_reentry.py | 1 - .../test_automation_wait_actions.py | 104 ++++++++++++++ 5 files changed, 321 insertions(+), 32 deletions(-) create mode 100644 tests/integration/fixtures/automation_wait_actions.yaml create mode 100644 tests/integration/test_automation_wait_actions.py diff --git a/esphome/components/script/script.h b/esphome/components/script/script.h index 58fb67a3ea..870a623f32 100644 --- a/esphome/components/script/script.h +++ b/esphome/components/script/script.h @@ -2,6 +2,7 @@ #include #include +#include #include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/helpers.h" @@ -264,10 +265,22 @@ template class IsRunningCondition : public Condition class ScriptWaitAction : public Action, public Component { public: ScriptWaitAction(C *script) : script_(script) {} + void setup() override { + // Start with loop disabled - only enable when there's work to do + this->disable_loop(); + } + void play_complex(Ts... x) override { this->num_running_++; // Check if we can continue immediately. @@ -275,7 +288,11 @@ template class ScriptWaitAction : public Action, this->play_next_(x...); return; } - this->var_ = std::make_tuple(x...); + + // Store parameters for later execution + this->param_queue_.emplace_front(x...); + // Enable loop now that we have work to do + this->enable_loop(); this->loop(); } @@ -286,15 +303,30 @@ template class ScriptWaitAction : public Action, if (this->script_->is_running()) return; - this->play_next_tuple_(this->var_); + while (!this->param_queue_.empty()) { + auto ¶ms = this->param_queue_.front(); + this->play_next_tuple_(params, typename gens::type()); + this->param_queue_.pop_front(); + } + // Queue is now empty - disable loop until next play_complex + this->disable_loop(); } void play(Ts... x) override { /* ignore - see play_complex */ } + void stop() override { + this->param_queue_.clear(); + this->disable_loop(); + } + protected: + template void play_next_tuple_(const std::tuple &tuple, seq /*unused*/) { + this->play_next_(std::get(tuple)...); + } + C *script_; - std::tuple var_{}; + std::forward_list> param_queue_; }; } // namespace script diff --git a/esphome/core/base_automation.h b/esphome/core/base_automation.h index 1c60dd1c7a..e668a1782a 100644 --- a/esphome/core/base_automation.h +++ b/esphome/core/base_automation.h @@ -10,6 +10,7 @@ #include "esphome/core/helpers.h" #include +#include namespace esphome { @@ -268,32 +269,28 @@ template class WhileAction : public Action { void add_then(const std::initializer_list *> &actions) { this->then_.add_actions(actions); this->then_.add_action(new LambdaAction([this](Ts... x) { - if (this->num_running_ > 0 && this->condition_->check_tuple(this->var_)) { + if (this->num_running_ > 0 && this->condition_->check(x...)) { // play again - if (this->num_running_ > 0) { - this->then_.play_tuple(this->var_); - } + this->then_.play(x...); } else { // condition false, play next - this->play_next_tuple_(this->var_); + this->play_next_(x...); } })); } void play_complex(Ts... x) override { this->num_running_++; - // Store loop parameters - this->var_ = std::make_tuple(x...); // Initial condition check - if (!this->condition_->check_tuple(this->var_)) { + if (!this->condition_->check(x...)) { // If new condition check failed, stop loop if running this->then_.stop(); - this->play_next_tuple_(this->var_); + this->play_next_(x...); return; } if (this->num_running_ > 0) { - this->then_.play_tuple(this->var_); + this->then_.play(x...); } } @@ -305,7 +302,6 @@ template class WhileAction : public Action { protected: Condition *condition_; ActionList then_; - std::tuple var_{}; }; template class RepeatAction : public Action { @@ -317,7 +313,7 @@ template class RepeatAction : public Action { this->then_.add_action(new LambdaAction([this](uint32_t iteration, Ts... x) { iteration++; if (iteration >= this->count_.value(x...)) { - this->play_next_tuple_(this->var_); + this->play_next_(x...); } else { this->then_.play(iteration, x...); } @@ -326,11 +322,10 @@ template class RepeatAction : public Action { void play_complex(Ts... x) override { this->num_running_++; - this->var_ = std::make_tuple(x...); if (this->count_.value(x...) > 0) { this->then_.play(0, x...); } else { - this->play_next_tuple_(this->var_); + this->play_next_(x...); } } @@ -341,15 +336,26 @@ template class RepeatAction : public Action { protected: ActionList then_; - std::tuple var_; }; +/** Wait until a condition is true to continue execution. + * + * Uses queue-based storage to safely handle concurrent executions. + * While concurrent execution from the same trigger is uncommon, it's possible + * (e.g., rapid button presses, high-frequency sensor updates), so we use + * queue-based storage for correctness. + */ template class WaitUntilAction : public Action, public Component { public: WaitUntilAction(Condition *condition) : condition_(condition) {} TEMPLATABLE_VALUE(uint32_t, timeout_value) + void setup() override { + // Start with loop disabled - only enable when there's work to do + this->disable_loop(); + } + void play_complex(Ts... x) override { this->num_running_++; // Check if we can continue immediately. @@ -359,13 +365,14 @@ template class WaitUntilAction : public Action, public Co } return; } - this->var_ = std::make_tuple(x...); - if (this->timeout_value_.has_value()) { - auto f = std::bind(&WaitUntilAction::play_next_, this, x...); - this->set_timeout("timeout", this->timeout_value_.value(x...), f); - } + // Store for later processing + auto now = millis(); + auto timeout = this->timeout_value_.optional_value(x...); + this->var_queue_.emplace_front(now, timeout, std::make_tuple(x...)); + // Enable loop now that we have work to do + this->enable_loop(); this->loop(); } @@ -373,13 +380,32 @@ template class WaitUntilAction : public Action, public Co if (this->num_running_ == 0) return; - if (!this->condition_->check_tuple(this->var_)) { - return; + auto now = millis(); + + this->var_queue_.remove_if([&](auto &queued) { + auto start = std::get(queued); + auto timeout = std::get>(queued); + auto &var = std::get>(queued); + + auto expired = timeout && (now - start) >= *timeout; + + if (!expired && !this->condition_->check_tuple(var)) { + return false; + } + + this->play_next_tuple_(var); + return true; + }); + + // If queue is now empty, disable loop until next play_complex + if (this->var_queue_.empty()) { + this->disable_loop(); } + } - this->cancel_timeout("timeout"); - - this->play_next_tuple_(this->var_); + void stop() override { + this->var_queue_.clear(); + this->disable_loop(); } float get_setup_priority() const override { return setup_priority::DATA; } @@ -387,11 +413,9 @@ template class WaitUntilAction : public Action, public Co void play(Ts... x) override { /* ignore - see play_complex */ } - void stop() override { this->cancel_timeout("timeout"); } - protected: Condition *condition_; - std::tuple var_{}; + std::forward_list, std::tuple>> var_queue_{}; }; template class UpdateComponentAction : public Action { diff --git a/tests/integration/fixtures/automation_wait_actions.yaml b/tests/integration/fixtures/automation_wait_actions.yaml new file mode 100644 index 0000000000..65a61be14f --- /dev/null +++ b/tests/integration/fixtures/automation_wait_actions.yaml @@ -0,0 +1,130 @@ +esphome: + name: test-automation-wait-actions + +host: + +api: + actions: + # Test 1: Trigger wait_until automation 5 times rapidly + - action: test_wait_until + then: + - logger.log: "=== TEST 1: Triggering wait_until automation 5 times ===" + # Publish 5 different values to trigger the on_value automation 5 times + - sensor.template.publish: + id: wait_until_sensor + state: 1 + - sensor.template.publish: + id: wait_until_sensor + state: 2 + - sensor.template.publish: + id: wait_until_sensor + state: 3 + - sensor.template.publish: + id: wait_until_sensor + state: 4 + - sensor.template.publish: + id: wait_until_sensor + state: 5 + # Wait then satisfy the condition so all 5 waiting actions complete + - delay: 100ms + - globals.set: + id: test_flag + value: 'true' + + # Test 2: Trigger script.wait automation 5 times rapidly + - action: test_script_wait + then: + - logger.log: "=== TEST 2: Triggering script.wait automation 5 times ===" + # Start a long-running script + - script.execute: blocking_script + # Publish 5 different values to trigger the on_value automation 5 times + - sensor.template.publish: + id: script_wait_sensor + state: 1 + - sensor.template.publish: + id: script_wait_sensor + state: 2 + - sensor.template.publish: + id: script_wait_sensor + state: 3 + - sensor.template.publish: + id: script_wait_sensor + state: 4 + - sensor.template.publish: + id: script_wait_sensor + state: 5 + + # Test 3: Trigger wait_until timeout automation 5 times rapidly + - action: test_wait_timeout + then: + - logger.log: "=== TEST 3: Triggering timeout automation 5 times ===" + # Publish 5 different values (condition will never be true, all will timeout) + - sensor.template.publish: + id: timeout_sensor + state: 1 + - sensor.template.publish: + id: timeout_sensor + state: 2 + - sensor.template.publish: + id: timeout_sensor + state: 3 + - sensor.template.publish: + id: timeout_sensor + state: 4 + - sensor.template.publish: + id: timeout_sensor + state: 5 + +logger: + level: DEBUG + +globals: + - id: test_flag + type: bool + restore_value: false + initial_value: 'false' + + - id: timeout_flag + type: bool + restore_value: false + initial_value: 'false' + +# Sensors with wait_until/script.wait in their on_value automations +sensor: + # Test 1: on_value automation with wait_until + - platform: template + id: wait_until_sensor + on_value: + # This wait_until will be hit 5 times before any complete + - wait_until: + condition: + lambda: return id(test_flag); + - logger.log: "wait_until automation completed" + + # Test 2: on_value automation with script.wait + - platform: template + id: script_wait_sensor + on_value: + # This script.wait will be hit 5 times before any complete + - script.wait: blocking_script + - logger.log: "script.wait automation completed" + + # Test 3: on_value automation with wait_until timeout + - platform: template + id: timeout_sensor + on_value: + # This wait_until will be hit 5 times, all will timeout + - wait_until: + condition: + lambda: return id(timeout_flag); + timeout: 200ms + - logger.log: "timeout automation completed" + +script: + # Blocking script for script.wait test + - id: blocking_script + mode: single + then: + - logger.log: "Blocking script: START" + - delay: 200ms + - logger.log: "Blocking script: END" diff --git a/tests/integration/test_action_concurrent_reentry.py b/tests/integration/test_action_concurrent_reentry.py index ba67e4c798..aa5801ca2b 100644 --- a/tests/integration/test_action_concurrent_reentry.py +++ b/tests/integration/test_action_concurrent_reentry.py @@ -11,7 +11,6 @@ import pytest from .types import APIClientConnectedFactory, RunCompiledFunction -@pytest.mark.xfail(reason="https://github.com/esphome/issues/issues/6534") @pytest.mark.asyncio async def test_action_concurrent_reentry( yaml_config: str, diff --git a/tests/integration/test_automation_wait_actions.py b/tests/integration/test_automation_wait_actions.py new file mode 100644 index 0000000000..adcb8ba487 --- /dev/null +++ b/tests/integration/test_automation_wait_actions.py @@ -0,0 +1,104 @@ +"""Test concurrent execution of wait_until and script.wait in direct automation actions.""" + +from __future__ import annotations + +import asyncio +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_automation_wait_actions( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """ + Test that wait_until and script.wait correctly handle concurrent executions + when automation actions (not scripts) are triggered multiple times rapidly. + + This tests sensor.on_value automations being triggered 5 times before any complete. + """ + loop = asyncio.get_running_loop() + + # Track completion counts + test_results = { + "wait_until": 0, + "script_wait": 0, + "wait_until_timeout": 0, + } + + # Patterns for log messages + wait_until_complete = re.compile(r"wait_until automation completed") + script_wait_complete = re.compile(r"script\.wait automation completed") + timeout_complete = re.compile(r"timeout automation completed") + + # Test completion futures + test1_complete = loop.create_future() + test2_complete = loop.create_future() + test3_complete = loop.create_future() + + def check_output(line: str) -> None: + """Check log output for completion messages.""" + # Test 1: wait_until concurrent execution + if wait_until_complete.search(line): + test_results["wait_until"] += 1 + if test_results["wait_until"] == 5 and not test1_complete.done(): + test1_complete.set_result(True) + + # Test 2: script.wait concurrent execution + if script_wait_complete.search(line): + test_results["script_wait"] += 1 + if test_results["script_wait"] == 5 and not test2_complete.done(): + test2_complete.set_result(True) + + # Test 3: wait_until with timeout + if timeout_complete.search(line): + test_results["wait_until_timeout"] += 1 + if test_results["wait_until_timeout"] == 5 and not test3_complete.done(): + test3_complete.set_result(True) + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + # Get services + _, services = await client.list_entities_services() + + # Test 1: wait_until in automation - trigger 5 times rapidly + test_service = next((s for s in services if s.name == "test_wait_until"), None) + assert test_service is not None, "test_wait_until service not found" + client.execute_service(test_service, {}) + await asyncio.wait_for(test1_complete, timeout=3.0) + + # Verify Test 1: All 5 triggers should complete + assert test_results["wait_until"] == 5, ( + f"Test 1: Expected 5 wait_until completions, got {test_results['wait_until']}" + ) + + # Test 2: script.wait in automation - trigger 5 times rapidly + test_service = next((s for s in services if s.name == "test_script_wait"), None) + assert test_service is not None, "test_script_wait service not found" + client.execute_service(test_service, {}) + await asyncio.wait_for(test2_complete, timeout=3.0) + + # Verify Test 2: All 5 triggers should complete + assert test_results["script_wait"] == 5, ( + f"Test 2: Expected 5 script.wait completions, got {test_results['script_wait']}" + ) + + # Test 3: wait_until with timeout in automation - trigger 5 times rapidly + test_service = next( + (s for s in services if s.name == "test_wait_timeout"), None + ) + assert test_service is not None, "test_wait_timeout service not found" + client.execute_service(test_service, {}) + await asyncio.wait_for(test3_complete, timeout=3.0) + + # Verify Test 3: All 5 triggers should timeout and complete + assert test_results["wait_until_timeout"] == 5, ( + f"Test 3: Expected 5 timeout completions, got {test_results['wait_until_timeout']}" + )