diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 28437e6302..41dd02458e 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -7,6 +7,7 @@ - [ ] Bugfix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) +- [ ] Developer breaking change (an API change that could break external components) - [ ] Code quality improvements to existing code or addition of tests - [ ] Other diff --git a/.github/actions/restore-python/action.yml b/.github/actions/restore-python/action.yml index f314e79ad9..c4ac3d1a9e 100644 --- a/.github/actions/restore-python/action.yml +++ b/.github/actions/restore-python/action.yml @@ -17,7 +17,7 @@ runs: steps: - name: Set up Python ${{ inputs.python-version }} id: python - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: python-version: ${{ inputs.python-version }} - name: Restore Python virtual environment diff --git a/.github/workflows/auto-label-pr.yml b/.github/workflows/auto-label-pr.yml index 998f3315c6..d09072d814 100644 --- a/.github/workflows/auto-label-pr.yml +++ b/.github/workflows/auto-label-pr.yml @@ -68,6 +68,7 @@ jobs: 'bugfix', 'new-feature', 'breaking-change', + 'developer-breaking-change', 'code-quality' ]; @@ -367,6 +368,7 @@ jobs: { pattern: /- \[x\] Bugfix \(non-breaking change which fixes an issue\)/i, label: 'bugfix' }, { pattern: /- \[x\] New feature \(non-breaking change which adds functionality\)/i, label: 'new-feature' }, { pattern: /- \[x\] Breaking change \(fix or feature that would cause existing functionality to not work as expected\)/i, label: 'breaking-change' }, + { pattern: /- \[x\] Developer breaking change \(an API change that could break external components\)/i, label: 'developer-breaking-change' }, { pattern: /- \[x\] Code quality improvements to existing code or addition of tests/i, label: 'code-quality' } ]; diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index b377ca76d8..2bee5ed211 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -23,7 +23,7 @@ jobs: - name: Checkout uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - name: Set up Python - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: python-version: "3.11" diff --git a/.github/workflows/ci-clang-tidy-hash.yml b/.github/workflows/ci-clang-tidy-hash.yml index 9556b99015..1826ed27cf 100644 --- a/.github/workflows/ci-clang-tidy-hash.yml +++ b/.github/workflows/ci-clang-tidy-hash.yml @@ -24,7 +24,7 @@ jobs: uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - name: Set up Python - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: python-version: "3.11" diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index 5287d92b10..c76d9cf2a5 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -45,7 +45,7 @@ jobs: steps: - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - name: Set up Python - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: python-version: "3.11" - name: Set up Docker Buildx diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9c2fab0912..9cfc02d5cf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,7 +42,7 @@ jobs: run: echo key="${{ hashFiles('requirements.txt', 'requirements_test.txt', '.pre-commit-config.yaml') }}" >> $GITHUB_OUTPUT - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore Python virtual environment @@ -240,7 +240,7 @@ jobs: uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - name: Set up Python 3.13 id: python - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: python-version: "3.13" - name: Restore Python virtual environment diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 497ecd29e7..1ff810d869 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -62,7 +62,7 @@ jobs: steps: - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - name: Set up Python - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: python-version: "3.x" - name: Build @@ -94,7 +94,7 @@ jobs: steps: - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - name: Set up Python - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: python-version: "3.11" diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index 8f95fa68ee..baaa29df2c 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -22,7 +22,7 @@ jobs: path: lib/home-assistant - name: Setup Python - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: python-version: 3.13 diff --git a/esphome/components/alpha3/alpha3.cpp b/esphome/components/alpha3/alpha3.cpp index 55fd196822..f22a8e2444 100644 --- a/esphome/components/alpha3/alpha3.cpp +++ b/esphome/components/alpha3/alpha3.cpp @@ -58,7 +58,7 @@ void Alpha3::handle_geni_response_(const uint8_t *response, uint16_t length) { if (this->response_offset_ >= this->response_length_) { ESP_LOGD(TAG, "[%s] GENI response begin", this->parent_->address_str()); if (length < GENI_RESPONSE_HEADER_LENGTH) { - ESP_LOGW(TAG, "[%s] response to short", this->parent_->address_str()); + ESP_LOGW(TAG, "[%s] response too short", this->parent_->address_str()); return; } if (response[0] != 36 || response[2] != 248 || response[3] != 231 || response[4] != 10) { diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 59c6029334..d372af3e6a 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -854,6 +854,10 @@ def _configure_lwip_max_sockets(conf: dict) -> None: async def to_code(config): cg.add_platformio_option("board", config[CONF_BOARD]) cg.add_platformio_option("board_upload.flash_size", config[CONF_FLASH_SIZE]) + cg.add_platformio_option( + "board_upload.maximum_size", + int(config[CONF_FLASH_SIZE].removesuffix("MB")) * 1024 * 1024, + ) cg.set_cpp_standard("gnu++20") cg.add_build_flag("-DUSE_ESP32") cg.add_define("ESPHOME_BOARD", config[CONF_BOARD]) diff --git a/esphome/components/esp8266/core.h b/esphome/components/esp8266/core.h index ac33305669..1abe67be86 100644 --- a/esphome/components/esp8266/core.h +++ b/esphome/components/esp8266/core.h @@ -7,8 +7,6 @@ extern const uint8_t ESPHOME_ESP8266_GPIO_INITIAL_MODE[16]; extern const uint8_t ESPHOME_ESP8266_GPIO_INITIAL_LEVEL[16]; -namespace esphome { -namespace esp8266 {} // namespace esp8266 -} // namespace esphome +namespace esphome::esp8266 {} // namespace esphome::esp8266 #endif // USE_ESP8266 diff --git a/esphome/components/esp8266/gpio.cpp b/esphome/components/esp8266/gpio.cpp index ee3683c67d..17a495bc1d 100644 --- a/esphome/components/esp8266/gpio.cpp +++ b/esphome/components/esp8266/gpio.cpp @@ -3,8 +3,7 @@ #include "gpio.h" #include "esphome/core/log.h" -namespace esphome { -namespace esp8266 { +namespace esphome::esp8266 { static const char *const TAG = "esp8266"; @@ -110,9 +109,11 @@ void ESP8266GPIOPin::digital_write(bool value) { } void ESP8266GPIOPin::detach_interrupt() const { detachInterrupt(pin_); } -} // namespace esp8266 +} // namespace esphome::esp8266 -using namespace esp8266; +namespace esphome { + +using esp8266::ISRPinArg; bool IRAM_ATTR ISRInternalGPIOPin::digital_read() { auto *arg = reinterpret_cast(this->arg_); diff --git a/esphome/components/esp8266/gpio.h b/esphome/components/esp8266/gpio.h index a1b6d79b3b..213a5c54be 100644 --- a/esphome/components/esp8266/gpio.h +++ b/esphome/components/esp8266/gpio.h @@ -5,8 +5,7 @@ #include "esphome/core/hal.h" #include -namespace esphome { -namespace esp8266 { +namespace esphome::esp8266 { class ESP8266GPIOPin : public InternalGPIOPin { public: @@ -33,7 +32,6 @@ class ESP8266GPIOPin : public InternalGPIOPin { gpio::Flags flags_{}; }; -} // namespace esp8266 -} // namespace esphome +} // namespace esphome::esp8266 #endif // USE_ESP8266 diff --git a/esphome/components/esp8266/preferences.cpp b/esphome/components/esp8266/preferences.cpp index a26e9cc498..197d244dc4 100644 --- a/esphome/components/esp8266/preferences.cpp +++ b/esphome/components/esp8266/preferences.cpp @@ -15,24 +15,24 @@ extern "C" { #include #include -namespace esphome { -namespace esp8266 { +namespace esphome::esp8266 { static const char *const TAG = "esp8266.preferences"; -static bool s_prevent_write = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) static uint32_t *s_flash_storage = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +static bool s_prevent_write = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) static bool s_flash_dirty = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -static const uint32_t ESP_RTC_USER_MEM_START = 0x60001200; +static constexpr uint32_t ESP_RTC_USER_MEM_START = 0x60001200; +static constexpr uint32_t ESP_RTC_USER_MEM_SIZE_WORDS = 128; +static constexpr uint32_t ESP_RTC_USER_MEM_SIZE_BYTES = ESP_RTC_USER_MEM_SIZE_WORDS * 4; + #define ESP_RTC_USER_MEM ((uint32_t *) ESP_RTC_USER_MEM_START) -static const uint32_t ESP_RTC_USER_MEM_SIZE_WORDS = 128; -static const uint32_t ESP_RTC_USER_MEM_SIZE_BYTES = ESP_RTC_USER_MEM_SIZE_WORDS * 4; #ifdef USE_ESP8266_PREFERENCES_FLASH -static const uint32_t ESP8266_FLASH_STORAGE_SIZE = 128; +static constexpr uint32_t ESP8266_FLASH_STORAGE_SIZE = 128; #else -static const uint32_t ESP8266_FLASH_STORAGE_SIZE = 64; +static constexpr uint32_t ESP8266_FLASH_STORAGE_SIZE = 64; #endif static inline bool esp_rtc_user_mem_read(uint32_t index, uint32_t *dest) { @@ -284,10 +284,10 @@ void setup_preferences() { } void preferences_prevent_write(bool prevent) { s_prevent_write = prevent; } -} // namespace esp8266 +} // namespace esphome::esp8266 +namespace esphome { ESPPreferences *global_preferences; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) - } // namespace esphome #endif // USE_ESP8266 diff --git a/esphome/components/esp8266/preferences.h b/esphome/components/esp8266/preferences.h index edec915794..16cf80a129 100644 --- a/esphome/components/esp8266/preferences.h +++ b/esphome/components/esp8266/preferences.h @@ -2,13 +2,11 @@ #ifdef USE_ESP8266 -namespace esphome { -namespace esp8266 { +namespace esphome::esp8266 { void setup_preferences(); void preferences_prevent_write(bool prevent); -} // namespace esp8266 -} // namespace esphome +} // namespace esphome::esp8266 #endif // USE_ESP8266 diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index f2bcb6cc06..6b3b9c97ef 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -279,6 +279,8 @@ KEYBOARD_MODES = LvConstant( ) ROLLER_MODES = LvConstant("LV_ROLLER_MODE_", "NORMAL", "INFINITE") TILE_DIRECTIONS = DIRECTIONS.extend("HOR", "VER", "ALL") +SCROLL_DIRECTIONS = TILE_DIRECTIONS.extend("NONE") +SNAP_DIRECTIONS = LvConstant("LV_SCROLL_SNAP_", "NONE", "START", "END", "CENTER") CHILD_ALIGNMENTS = LvConstant( "LV_ALIGN_", "TOP_LEFT", @@ -511,6 +513,9 @@ CONF_ROLLOVER = "rollover" CONF_ROOT_BACK_BTN = "root_back_btn" CONF_SCALE_LINES = "scale_lines" CONF_SCROLLBAR_MODE = "scrollbar_mode" +CONF_SCROLL_DIR = "scroll_dir" +CONF_SCROLL_SNAP_X = "scroll_snap_x" +CONF_SCROLL_SNAP_Y = "scroll_snap_y" CONF_SELECTED_INDEX = "selected_index" CONF_SELECTED_TEXT = "selected_text" CONF_SHOW_SNOW = "show_snow" diff --git a/esphome/components/lvgl/layout.py b/esphome/components/lvgl/layout.py index a6aa816fda..b27a0b54a2 100644 --- a/esphome/components/lvgl/layout.py +++ b/esphome/components/lvgl/layout.py @@ -36,6 +36,8 @@ from .defines import ( ) from .lv_validation import padding, size +CONF_MULTIPLE_WIDGETS_PER_CELL = "multiple_widgets_per_cell" + cell_alignments = LV_CELL_ALIGNMENTS.one_of grid_alignments = LV_GRID_ALIGNMENTS.one_of flex_alignments = LV_FLEX_ALIGNMENTS.one_of @@ -170,10 +172,14 @@ class DirectionalLayout(FlexLayout): def validate(self, config): assert config[CONF_LAYOUT].lower() == self.direction - config[CONF_LAYOUT] = { + layout = { **FLEX_HV_STYLE, CONF_FLEX_FLOW: "LV_FLEX_FLOW_" + self.flow.upper(), } + if pad_all := config.get("pad_all"): + layout[CONF_PAD_ROW] = pad_all + layout[CONF_PAD_COLUMN] = pad_all + config[CONF_LAYOUT] = layout return config @@ -220,6 +226,7 @@ class GridLayout(Layout): cv.Optional(CONF_GRID_ROW_ALIGN): grid_alignments, cv.Optional(CONF_PAD_ROW): padding, cv.Optional(CONF_PAD_COLUMN): padding, + cv.Optional(CONF_MULTIPLE_WIDGETS_PER_CELL, default=False): cv.boolean, }, { cv.Optional(CONF_GRID_CELL_ROW_POS): cv.positive_int, @@ -263,6 +270,7 @@ class GridLayout(Layout): # should be guaranteed to be a dict at this point assert isinstance(layout, dict) assert layout.get(CONF_TYPE).lower() == TYPE_GRID + allow_multiple = layout.get(CONF_MULTIPLE_WIDGETS_PER_CELL, False) rows = len(layout[CONF_GRID_ROWS]) columns = len(layout[CONF_GRID_COLUMNS]) used_cells = [[None] * columns for _ in range(rows)] @@ -299,7 +307,10 @@ class GridLayout(Layout): f"exceeds grid size {rows}x{columns}", [CONF_WIDGETS, index], ) - if used_cells[row + i][column + j] is not None: + if ( + not allow_multiple + and used_cells[row + i][column + j] is not None + ): raise cv.Invalid( f"Cell span {row + i}/{column + j} already occupied by widget at index {used_cells[row + i][column + j]}", [CONF_WIDGETS, index], diff --git a/esphome/components/lvgl/lv_validation.py b/esphome/components/lvgl/lv_validation.py index 23c322c31f..9c1dd22085 100644 --- a/esphome/components/lvgl/lv_validation.py +++ b/esphome/components/lvgl/lv_validation.py @@ -40,7 +40,7 @@ from .helpers import ( lv_fonts_used, requires_component, ) -from .types import lv_font_t, lv_gradient_t +from .types import lv_gradient_t opacity_consts = LvConstant("LV_OPA_", "TRANSP", "COVER") @@ -498,7 +498,9 @@ class LvFont(LValidator): esphome_fonts_used.add(fontval) return requires_component("font")(fontval) - super().__init__(validator, lv_font_t) + # Use font::Font* as return type for lambdas returning ESPHome fonts + # The inline overloads in lvgl_esphome.h handle conversion to lv_font_t* + super().__init__(validator, Font.operator("ptr")) async def process(self, value, args=()): if is_lv_font(value): diff --git a/esphome/components/lvgl/schemas.py b/esphome/components/lvgl/schemas.py index 6b77f66abb..f2704f99de 100644 --- a/esphome/components/lvgl/schemas.py +++ b/esphome/components/lvgl/schemas.py @@ -1,6 +1,7 @@ from esphome import config_validation as cv from esphome.automation import Trigger, validate_automation from esphome.components.time import RealTimeClock +from esphome.config_validation import prepend_path from esphome.const import ( CONF_ARGS, CONF_FORMAT, @@ -19,7 +20,14 @@ from esphome.core import TimePeriod from esphome.core.config import StartupTrigger from . import defines as df, lv_validation as lvalid -from .defines import CONF_TIME_FORMAT, LV_GRAD_DIR +from .defines import ( + CONF_SCROLL_DIR, + CONF_SCROLL_SNAP_X, + CONF_SCROLL_SNAP_Y, + CONF_SCROLLBAR_MODE, + CONF_TIME_FORMAT, + LV_GRAD_DIR, +) from .helpers import CONF_IF_NAN, requires_component, validate_printf from .layout import ( FLEX_OBJ_SCHEMA, @@ -233,9 +241,19 @@ STYLE_SCHEMA = cv.Schema({cv.Optional(k): v for k, v in STYLE_PROPS.items()}).ex cv.Optional(df.CONF_SCROLLBAR_MODE): df.LvConstant( "LV_SCROLLBAR_MODE_", "OFF", "ON", "ACTIVE", "AUTO" ).one_of, + cv.Optional(CONF_SCROLL_DIR): df.SCROLL_DIRECTIONS.one_of, + cv.Optional(CONF_SCROLL_SNAP_X): df.SNAP_DIRECTIONS.one_of, + cv.Optional(CONF_SCROLL_SNAP_Y): df.SNAP_DIRECTIONS.one_of, } ) +OBJ_PROPERTIES = { + CONF_SCROLL_SNAP_X, + CONF_SCROLL_SNAP_Y, + CONF_SCROLL_DIR, + CONF_SCROLLBAR_MODE, +} + # Also allow widget specific properties for use in style definitions FULL_STYLE_SCHEMA = STYLE_SCHEMA.extend( { @@ -422,7 +440,10 @@ def any_widget_schema(extras=None): def validator(value): if isinstance(value, dict): # Convert to list + is_dict = True value = [{k: v} for k, v in value.items()] + else: + is_dict = False if not isinstance(value, list): raise cv.Invalid("Expected a list of widgets") result = [] @@ -443,7 +464,9 @@ def any_widget_schema(extras=None): ) # Apply custom validation value = widget_type.validate(value or {}) - result.append({key: container_validator(value)}) + path = [key] if is_dict else [index, key] + with prepend_path(path): + result.append({key: container_validator(value)}) return result return validator diff --git a/esphome/components/lvgl/widgets/__init__.py b/esphome/components/lvgl/widgets/__init__.py index 187b5828c2..2e7948522e 100644 --- a/esphome/components/lvgl/widgets/__init__.py +++ b/esphome/components/lvgl/widgets/__init__.py @@ -21,7 +21,6 @@ from ..defines import ( CONF_MAIN, CONF_PAD_COLUMN, CONF_PAD_ROW, - CONF_SCROLLBAR_MODE, CONF_STYLES, CONF_WIDGETS, OBJ_FLAGS, @@ -45,7 +44,7 @@ from ..lvcode import ( lv_obj, lv_Pvariable, ) -from ..schemas import ALL_STYLES, STYLE_REMAP, WIDGET_TYPES +from ..schemas import ALL_STYLES, OBJ_PROPERTIES, STYLE_REMAP, WIDGET_TYPES from ..types import LV_STATE, LvType, WidgetType, lv_coord_t, lv_obj_t, lv_obj_t_ptr EVENT_LAMB = "event_lamb__" @@ -414,7 +413,8 @@ async def set_obj_properties(w: Widget, config): w.add_state(state) cond.else_() w.clear_state(state) - await w.set_property(CONF_SCROLLBAR_MODE, config, lv_name="obj") + for property in OBJ_PROPERTIES: + await w.set_property(property, config, lv_name="obj") async def add_widgets(parent: Widget, config: dict): diff --git a/esphome/components/lvgl/widgets/arc.py b/esphome/components/lvgl/widgets/arc.py index ef4da0d815..21530441f8 100644 --- a/esphome/components/lvgl/widgets/arc.py +++ b/esphome/components/lvgl/widgets/arc.py @@ -20,7 +20,13 @@ from ..defines import ( CONF_START_ANGLE, literal, ) -from ..lv_validation import get_start_value, lv_angle_degrees, lv_float, lv_int +from ..lv_validation import ( + get_start_value, + lv_angle_degrees, + lv_float, + lv_int, + lv_positive_int, +) from ..lvcode import lv, lv_expr, lv_obj from ..types import LvNumber, NumberType from . import Widget @@ -36,13 +42,20 @@ ARC_SCHEMA = cv.Schema( cv.Optional(CONF_ROTATION, default=0.0): lv_angle_degrees, cv.Optional(CONF_ADJUSTABLE, default=False): bool, cv.Optional(CONF_MODE, default="NORMAL"): ARC_MODES.one_of, - cv.Optional(CONF_CHANGE_RATE, default=720): cv.uint16_t, + cv.Optional(CONF_CHANGE_RATE, default=720): lv_positive_int, } ) ARC_MODIFY_SCHEMA = cv.Schema( { cv.Optional(CONF_VALUE): lv_float, + cv.Optional(CONF_MIN_VALUE): lv_int, + cv.Optional(CONF_MAX_VALUE): lv_int, + cv.Optional(CONF_START_ANGLE): lv_angle_degrees, + cv.Optional(CONF_END_ANGLE): lv_angle_degrees, + cv.Optional(CONF_ROTATION): lv_angle_degrees, + cv.Optional(CONF_MODE): ARC_MODES.one_of, + cv.Optional(CONF_CHANGE_RATE): lv_positive_int, } ) @@ -58,17 +71,34 @@ class ArcType(NumberType): ) async def to_code(self, w: Widget, config): - if CONF_MIN_VALUE in config: + if CONF_MIN_VALUE in config and CONF_MAX_VALUE in config: max_value = await lv_int.process(config[CONF_MAX_VALUE]) min_value = await lv_int.process(config[CONF_MIN_VALUE]) lv.arc_set_range(w.obj, min_value, max_value) - start = await lv_angle_degrees.process(config[CONF_START_ANGLE]) - end = await lv_angle_degrees.process(config[CONF_END_ANGLE]) - rotation = await lv_angle_degrees.process(config[CONF_ROTATION]) - lv.arc_set_bg_angles(w.obj, start, end) - lv.arc_set_rotation(w.obj, rotation) - lv.arc_set_mode(w.obj, literal(config[CONF_MODE])) - lv.arc_set_change_rate(w.obj, config[CONF_CHANGE_RATE]) + elif CONF_MIN_VALUE in config: + max_value = w.get_property(CONF_MAX_VALUE) + min_value = await lv_int.process(config[CONF_MIN_VALUE]) + lv.arc_set_range(w.obj, min_value, max_value) + elif CONF_MAX_VALUE in config: + max_value = await lv_int.process(config[CONF_MAX_VALUE]) + min_value = w.get_property(CONF_MIN_VALUE) + lv.arc_set_range(w.obj, min_value, max_value) + + await w.set_property( + CONF_START_ANGLE, + await lv_angle_degrees.process(config.get(CONF_START_ANGLE)), + ) + await w.set_property( + CONF_END_ANGLE, await lv_angle_degrees.process(config.get(CONF_END_ANGLE)) + ) + await w.set_property( + CONF_ROTATION, await lv_angle_degrees.process(config.get(CONF_ROTATION)) + ) + await w.set_property(CONF_MODE, config) + await w.set_property( + CONF_CHANGE_RATE, + await lv_positive_int.process(config.get(CONF_CHANGE_RATE)), + ) if CONF_ADJUSTABLE in config: if not config[CONF_ADJUSTABLE]: @@ -78,9 +108,7 @@ class ArcType(NumberType): # For some reason arc does not get automatically added to the default group lv.group_add_obj(lv_expr.group_get_default(), w.obj) - value = await get_start_value(config) - if value is not None: - lv.arc_set_value(w.obj, value) + await w.set_property(CONF_VALUE, await get_start_value(config)) arc_spec = ArcType() diff --git a/esphome/components/lvgl/widgets/line.py b/esphome/components/lvgl/widgets/line.py index bd90edbefc..57cb965737 100644 --- a/esphome/components/lvgl/widgets/line.py +++ b/esphome/components/lvgl/widgets/line.py @@ -6,7 +6,7 @@ from esphome.core import Lambda from ..defines import CONF_MAIN, call_lambda from ..lvcode import lv_add from ..schemas import point_schema -from ..types import LvCompound, LvType +from ..types import LvCompound, LvType, lv_coord_t from . import Widget, WidgetType CONF_LINE = "line" @@ -23,9 +23,7 @@ LINE_SCHEMA = { async def process_coord(coord): if isinstance(coord, Lambda): - coord = call_lambda( - await cg.process_lambda(coord, [], return_type="lv_coord_t") - ) + coord = call_lambda(await cg.process_lambda(coord, [], return_type=lv_coord_t)) if not coord.endswith("()"): coord = f"static_cast({coord})" return cg.RawExpression(coord) diff --git a/esphome/components/nextion/nextion_upload_arduino.cpp b/esphome/components/nextion/nextion_upload_arduino.cpp index b4d217d7aa..baea938729 100644 --- a/esphome/components/nextion/nextion_upload_arduino.cpp +++ b/esphome/components/nextion/nextion_upload_arduino.cpp @@ -174,6 +174,9 @@ bool Nextion::upload_tft(uint32_t baud_rate, bool exit_reparse) { // Check if baud rate is supported this->original_baud_rate_ = this->parent_->get_baud_rate(); + if (baud_rate <= 0) { + baud_rate = this->original_baud_rate_; + } ESP_LOGD(TAG, "Baud rate: %" PRIu32, baud_rate); // Define the configuration for the HTTP client diff --git a/esphome/components/nextion/nextion_upload_idf.cpp b/esphome/components/nextion/nextion_upload_idf.cpp index 3b0d65643d..942e3dd6c3 100644 --- a/esphome/components/nextion/nextion_upload_idf.cpp +++ b/esphome/components/nextion/nextion_upload_idf.cpp @@ -177,6 +177,9 @@ bool Nextion::upload_tft(uint32_t baud_rate, bool exit_reparse) { // Check if baud rate is supported this->original_baud_rate_ = this->parent_->get_baud_rate(); + if (baud_rate <= 0) { + baud_rate = this->original_baud_rate_; + } ESP_LOGD(TAG, "Baud rate: %" PRIu32, baud_rate); // Define the configuration for the HTTP client diff --git a/esphome/components/script/script.h b/esphome/components/script/script.h index a9a83b518c..cd1a084f16 100644 --- a/esphome/components/script/script.h +++ b/esphome/components/script/script.h @@ -278,7 +278,12 @@ template class ScriptWaitAction : public Action, void setup() override { // Start with loop disabled - only enable when there's work to do - this->disable_loop(); + // IMPORTANT: Only disable if num_running_ is 0, otherwise play_complex() was already + // called before our setup() (e.g., from on_boot trigger at same priority level) + // and we must not undo its enable_loop() call + if (this->num_running_ == 0) { + this->disable_loop(); + } } void play_complex(const Ts &...x) override { diff --git a/esphome/components/sht4x/sht4x.cpp b/esphome/components/sht4x/sht4x.cpp index 617b19ef3e..9d29746f0b 100644 --- a/esphome/components/sht4x/sht4x.cpp +++ b/esphome/components/sht4x/sht4x.cpp @@ -7,6 +7,7 @@ namespace sht4x { static const char *const TAG = "sht4x"; static const uint8_t MEASURECOMMANDS[] = {0xFD, 0xF6, 0xE0}; +static const uint8_t SERIAL_NUMBER_COMMAND = 0x89; void SHT4XComponent::start_heater_() { uint8_t cmd[] = {MEASURECOMMANDS[this->heater_command_]}; @@ -17,6 +18,17 @@ void SHT4XComponent::start_heater_() { } } +void SHT4XComponent::read_serial_number_() { + uint16_t buffer[2]; + if (!this->get_8bit_register(SERIAL_NUMBER_COMMAND, buffer, 2, 1)) { + ESP_LOGE(TAG, "Get serial number failed"); + this->serial_number_ = 0; + return; + } + this->serial_number_ = (uint32_t(buffer[0]) << 16) | (uint32_t(buffer[1])); + ESP_LOGD(TAG, "Serial number: %08" PRIx32, this->serial_number_); +} + void SHT4XComponent::setup() { auto err = this->write(nullptr, 0); if (err != i2c::ERROR_OK) { @@ -24,6 +36,8 @@ void SHT4XComponent::setup() { return; } + this->read_serial_number_(); + if (std::isfinite(this->duty_cycle_) && this->duty_cycle_ > 0.0f) { uint32_t heater_interval = static_cast(static_cast(this->heater_time_) / this->duty_cycle_); ESP_LOGD(TAG, "Heater interval: %" PRIu32, heater_interval); @@ -54,11 +68,18 @@ void SHT4XComponent::setup() { } void SHT4XComponent::dump_config() { - ESP_LOGCONFIG(TAG, "SHT4x:"); + ESP_LOGCONFIG(TAG, + "SHT4x:\n" + " Serial number: %08" PRIx32, + this->serial_number_); + LOG_I2C_DEVICE(this); if (this->is_failed()) { ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); } + if (this->serial_number_ == 0) { + ESP_LOGW(TAG, "Get serial number failed"); + } } void SHT4XComponent::update() { diff --git a/esphome/components/sht4x/sht4x.h b/esphome/components/sht4x/sht4x.h index accc7323be..aec0f3d7f8 100644 --- a/esphome/components/sht4x/sht4x.h +++ b/esphome/components/sht4x/sht4x.h @@ -36,7 +36,9 @@ class SHT4XComponent : public PollingComponent, public sensirion_common::Sensiri float duty_cycle_; void start_heater_(); + void read_serial_number_(); uint8_t heater_command_; + uint32_t serial_number_; sensor::Sensor *temp_sensor_{nullptr}; sensor::Sensor *humidity_sensor_{nullptr}; diff --git a/esphome/components/wifi_info/wifi_info_text_sensor.cpp b/esphome/components/wifi_info/wifi_info_text_sensor.cpp index e843ae8998..abd590b168 100644 --- a/esphome/components/wifi_info/wifi_info_text_sensor.cpp +++ b/esphome/components/wifi_info/wifi_info_text_sensor.cpp @@ -14,7 +14,7 @@ static constexpr size_t MAX_STATE_LENGTH = 255; void IPAddressWiFiInfo::setup() { wifi::global_wifi_component->add_on_ip_state_callback( - [this](network::IPAddresses ips, network::IPAddress dns1_ip, network::IPAddress dns2_ip) { + [this](const network::IPAddresses &ips, const network::IPAddress &dns1_ip, const network::IPAddress &dns2_ip) { this->state_callback_(ips); }); } @@ -24,7 +24,7 @@ void IPAddressWiFiInfo::dump_config() { LOG_TEXT_SENSOR("", "IP Address", this); void IPAddressWiFiInfo::state_callback_(const network::IPAddresses &ips) { this->publish_state(ips[0].str()); uint8_t sensor = 0; - for (auto &ip : ips) { + for (const auto &ip : ips) { if (ip.is_set()) { if (this->ip_sensors_[sensor] != nullptr) { this->ip_sensors_[sensor]->publish_state(ip.str()); @@ -40,14 +40,14 @@ void IPAddressWiFiInfo::state_callback_(const network::IPAddresses &ips) { void DNSAddressWifiInfo::setup() { wifi::global_wifi_component->add_on_ip_state_callback( - [this](network::IPAddresses ips, network::IPAddress dns1_ip, network::IPAddress dns2_ip) { + [this](const network::IPAddresses &ips, const network::IPAddress &dns1_ip, const network::IPAddress &dns2_ip) { this->state_callback_(dns1_ip, dns2_ip); }); } void DNSAddressWifiInfo::dump_config() { LOG_TEXT_SENSOR("", "DNS Address", this); } -void DNSAddressWifiInfo::state_callback_(network::IPAddress dns1_ip, network::IPAddress dns2_ip) { +void DNSAddressWifiInfo::state_callback_(const network::IPAddress &dns1_ip, const network::IPAddress &dns2_ip) { std::string dns_results = dns1_ip.str() + " " + dns2_ip.str(); this->publish_state(dns_results); } @@ -87,7 +87,7 @@ void ScanResultsWiFiInfo::state_callback_(const wifi::wifi_scan_vector_tadd_on_wifi_connect_state_callback( - [this](const std::string &ssid, wifi::bssid_t bssid) { this->state_callback_(ssid); }); + [this](const std::string &ssid, const wifi::bssid_t &bssid) { this->state_callback_(ssid); }); } void SSIDWiFiInfo::dump_config() { LOG_TEXT_SENSOR("", "SSID", this); } @@ -100,12 +100,12 @@ void SSIDWiFiInfo::state_callback_(const std::string &ssid) { this->publish_stat void BSSIDWiFiInfo::setup() { wifi::global_wifi_component->add_on_wifi_connect_state_callback( - [this](const std::string &ssid, wifi::bssid_t bssid) { this->state_callback_(bssid); }); + [this](const std::string &ssid, const wifi::bssid_t &bssid) { this->state_callback_(bssid); }); } void BSSIDWiFiInfo::dump_config() { LOG_TEXT_SENSOR("", "BSSID", this); } -void BSSIDWiFiInfo::state_callback_(wifi::bssid_t bssid) { +void BSSIDWiFiInfo::state_callback_(const wifi::bssid_t &bssid) { char buf[18] = "unknown"; if (mac_address_is_valid(bssid.data())) { format_mac_addr_upper(bssid.data(), buf); diff --git a/esphome/components/wifi_info/wifi_info_text_sensor.h b/esphome/components/wifi_info/wifi_info_text_sensor.h index df9cd4eb3f..12666b4059 100644 --- a/esphome/components/wifi_info/wifi_info_text_sensor.h +++ b/esphome/components/wifi_info/wifi_info_text_sensor.h @@ -26,7 +26,7 @@ class DNSAddressWifiInfo : public Component, public text_sensor::TextSensor { void dump_config() override; protected: - void state_callback_(network::IPAddress dns1_ip, network::IPAddress dns2_ip); + void state_callback_(const network::IPAddress &dns1_ip, const network::IPAddress &dns2_ip); }; class ScanResultsWiFiInfo : public Component, public text_sensor::TextSensor { @@ -54,7 +54,7 @@ class BSSIDWiFiInfo : public Component, public text_sensor::TextSensor { void dump_config() override; protected: - void state_callback_(wifi::bssid_t bssid); + void state_callback_(const wifi::bssid_t &bssid); }; class MacAddressWifiInfo : public Component, public text_sensor::TextSensor { diff --git a/requirements.txt b/requirements.txt index df036eeccc..a5c919e95f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile esptool==5.1.0 click==8.1.7 esphome-dashboard==20251013.0 -aioesphomeapi==42.7.0 +aioesphomeapi==42.8.0 zeroconf==0.148.0 puremagic==1.30 ruamel.yaml==0.18.16 # dashboard_import diff --git a/tests/component_tests/binary_sensor/test_binary_sensor.py b/tests/component_tests/binary_sensor/test_binary_sensor.py index 32d74027ba..86e0705023 100644 --- a/tests/component_tests/binary_sensor/test_binary_sensor.py +++ b/tests/component_tests/binary_sensor/test_binary_sensor.py @@ -29,7 +29,7 @@ def test_binary_sensor_sets_mandatory_fields(generate_main): ) # Then - assert 'bs_1->set_name("test bs1");' in main_cpp + assert 'bs_1->set_name_and_object_id("test bs1", "test_bs1");' in main_cpp assert "bs_1->set_pin(" in main_cpp diff --git a/tests/component_tests/button/test_button.py b/tests/component_tests/button/test_button.py index 512ef42b44..b21665288c 100644 --- a/tests/component_tests/button/test_button.py +++ b/tests/component_tests/button/test_button.py @@ -26,7 +26,7 @@ def test_button_sets_mandatory_fields(generate_main): main_cpp = generate_main("tests/component_tests/button/test_button.yaml") # Then - assert 'wol_1->set_name("wol_test_1");' in main_cpp + assert 'wol_1->set_name_and_object_id("wol_test_1", "wol_test_1");' in main_cpp assert "wol_2->set_macaddr(18, 52, 86, 120, 144, 171);" in main_cpp diff --git a/tests/component_tests/text/test_text.py b/tests/component_tests/text/test_text.py index ffc0fd780a..56dee205b4 100644 --- a/tests/component_tests/text/test_text.py +++ b/tests/component_tests/text/test_text.py @@ -27,7 +27,7 @@ def test_text_sets_mandatory_fields(generate_main): main_cpp = generate_main("tests/component_tests/text/test_text.yaml") # Then - assert 'it_1->set_name("test 1 text");' in main_cpp + assert 'it_1->set_name_and_object_id("test 1 text", "test_1_text");' in main_cpp def test_text_config_value_internal_set(generate_main): diff --git a/tests/component_tests/text_sensor/test_text_sensor.py b/tests/component_tests/text_sensor/test_text_sensor.py index 1c4ef6633d..934ee67cef 100644 --- a/tests/component_tests/text_sensor/test_text_sensor.py +++ b/tests/component_tests/text_sensor/test_text_sensor.py @@ -25,9 +25,18 @@ def test_text_sensor_sets_mandatory_fields(generate_main): main_cpp = generate_main("tests/component_tests/text_sensor/test_text_sensor.yaml") # Then - assert 'ts_1->set_name("Template Text Sensor 1");' in main_cpp - assert 'ts_2->set_name("Template Text Sensor 2");' in main_cpp - assert 'ts_3->set_name("Template Text Sensor 3");' in main_cpp + assert ( + 'ts_1->set_name_and_object_id("Template Text Sensor 1", "template_text_sensor_1");' + in main_cpp + ) + assert ( + 'ts_2->set_name_and_object_id("Template Text Sensor 2", "template_text_sensor_2");' + in main_cpp + ) + assert ( + 'ts_3->set_name_and_object_id("Template Text Sensor 3", "template_text_sensor_3");' + in main_cpp + ) def test_text_sensor_config_value_internal_set(generate_main): diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index 5839643638..30866a603c 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -537,6 +537,9 @@ lvgl: - tileview: id: tileview_id scrollbar_mode: active + scroll_dir: all + scroll_elastic: true + scroll_momentum: true on_value: then: - if: @@ -546,7 +549,10 @@ lvgl: - logger.log: "tile 1 is now showing" tiles: - id: tile_1 + scroll_snap_y: center + scroll_snap_x: start layout: vertical + pad_all: 6px row: 0 column: 0 dir: ALL @@ -781,6 +787,18 @@ lvgl: arc_color: 0xFFFF00 focused: arc_color: 0x808080 + on_click: + then: + - lvgl.arc.update: + id: lv_arc_1 + value: !lambda return (int)((float)rand() / RAND_MAX * 100); + min_value: !lambda return (int)((float)rand() / RAND_MAX * 100); + max_value: !lambda return (int)((float)rand() / RAND_MAX * 100); + start_angle: !lambda return (int)((float)rand() / RAND_MAX * 100); + end_angle: !lambda return (int)((float)rand() / RAND_MAX * 100); + rotation: !lambda return (int)((float)rand() / RAND_MAX * 100); + change_rate: !lambda return (uint)((float)rand() / RAND_MAX * 100); + mode: NORMAL - bar: id: bar_id align: top_mid @@ -881,6 +899,7 @@ lvgl: grid_columns: [40, fr(1), fr(1)] pad_row: 6px pad_column: 0 + multiple_widgets_per_cell: true widgets: - image: grid_cell_row_pos: 0 @@ -905,6 +924,10 @@ lvgl: grid_cell_row_pos: 1 grid_cell_column_pos: 0 text: "Grid cell 1/0" + - label: + grid_cell_row_pos: 1 + grid_cell_column_pos: 0 + text: "Duplicate for 1/0" - label: styles: bdr_style grid_cell_row_pos: 1 @@ -1027,6 +1050,7 @@ lvgl: opa: 0% - id: page3 layout: Horizontal + pad_all: 6px widgets: - keyboard: id: lv_keyboard diff --git a/tests/integration/fixtures/script_wait_on_boot.yaml b/tests/integration/fixtures/script_wait_on_boot.yaml new file mode 100644 index 0000000000..8736b02294 --- /dev/null +++ b/tests/integration/fixtures/script_wait_on_boot.yaml @@ -0,0 +1,54 @@ +esphome: + name: test-script-wait-on-boot + on_boot: + # Use default priority (600.0) which is same as ScriptWaitAction's setup priority + # This tests the race condition where on_boot runs before ScriptWaitAction::setup() + then: + - logger.log: "=== on_boot: Starting boot sequence ===" + - script.execute: show_start_page + - script.wait: show_start_page + - logger.log: "=== on_boot: First script completed, starting second ===" + - script.execute: flip_thru_pages + - script.wait: flip_thru_pages + - logger.log: "=== on_boot: All boot scripts completed successfully ===" + +host: + +api: + actions: + # Manual trigger for additional testing + - action: test_script_wait + then: + - logger.log: "=== Manual test: Starting ===" + - script.execute: show_start_page + - script.wait: show_start_page + - logger.log: "=== Manual test: First script completed ===" + - script.execute: flip_thru_pages + - script.wait: flip_thru_pages + - logger.log: "=== Manual test: All completed ===" + +logger: + level: DEBUG + +script: + # First script - simulates display initialization + - id: show_start_page + mode: single + then: + - logger.log: "show_start_page: Starting" + - delay: 100ms + - logger.log: "show_start_page: After delay 1" + - delay: 100ms + - logger.log: "show_start_page: Completed" + + # Second script - simulates page flip sequence + - id: flip_thru_pages + mode: single + then: + - logger.log: "flip_thru_pages: Starting" + - delay: 50ms + - logger.log: "flip_thru_pages: Page 1" + - delay: 50ms + - logger.log: "flip_thru_pages: Page 2" + - delay: 50ms + - logger.log: "flip_thru_pages: Completed" diff --git a/tests/integration/test_script_wait_on_boot.py b/tests/integration/test_script_wait_on_boot.py new file mode 100644 index 0000000000..478090f782 --- /dev/null +++ b/tests/integration/test_script_wait_on_boot.py @@ -0,0 +1,130 @@ +"""Integration test for script.wait during on_boot (issue #12043). + +This test verifies that script.wait works correctly when triggered from on_boot. +The issue was that ScriptWaitAction::setup() unconditionally disabled the loop, +even if play_complex() had already been called (from an on_boot trigger at the +same priority level) and enabled it. + +The race condition occurs because: +1. on_boot's default priority is 600.0 (setup_priority::DATA) +2. ScriptWaitAction's default setup priority is also DATA (600.0) +3. When they have the same priority, if on_boot runs first and triggers a script, + ScriptWaitAction::play_complex() enables the loop +4. Then ScriptWaitAction::setup() runs and unconditionally disables the loop +5. The wait never completes because the loop is disabled + +The fix adds a conditional check (like WaitUntilAction has) to only disable the +loop in setup() if num_running_ is 0. +""" + +from __future__ import annotations + +import asyncio +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_script_wait_on_boot( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that script.wait works correctly when triggered from on_boot. + + This reproduces issue #12043 where script.wait would hang forever when + triggered from on_boot due to a race condition in ScriptWaitAction::setup(). + """ + test_complete = asyncio.Event() + + # Track progress through the boot sequence + boot_started = False + first_script_started = False + first_script_completed = False + first_wait_returned = False + second_script_started = False + second_script_completed = False + all_completed = False + + # Patterns for boot sequence logs + boot_start_pattern = re.compile(r"on_boot: Starting boot sequence") + show_start_pattern = re.compile(r"show_start_page: Starting") + show_complete_pattern = re.compile(r"show_start_page: Completed") + first_wait_pattern = re.compile(r"on_boot: First script completed") + flip_start_pattern = re.compile(r"flip_thru_pages: Starting") + flip_complete_pattern = re.compile(r"flip_thru_pages: Completed") + all_complete_pattern = re.compile(r"on_boot: All boot scripts completed") + + def check_output(line: str) -> None: + """Check log output for boot sequence progress.""" + nonlocal boot_started, first_script_started, first_script_completed + nonlocal first_wait_returned, second_script_started, second_script_completed + nonlocal all_completed + + if boot_start_pattern.search(line): + boot_started = True + elif show_start_pattern.search(line): + first_script_started = True + elif show_complete_pattern.search(line): + first_script_completed = True + elif first_wait_pattern.search(line): + first_wait_returned = True + elif flip_start_pattern.search(line): + second_script_started = True + elif flip_complete_pattern.search(line): + second_script_completed = True + elif all_complete_pattern.search(line): + all_completed = True + test_complete.set() + + 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 == "test-script-wait-on-boot" + + # Wait for on_boot sequence to complete + # The boot sequence should complete automatically + # Timeout is generous to allow for delays in the scripts + try: + await asyncio.wait_for(test_complete.wait(), timeout=5.0) + except TimeoutError: + # Build a detailed error message showing where the boot sequence got stuck + progress = [] + if boot_started: + progress.append("boot started") + if first_script_started: + progress.append("show_start_page started") + if first_script_completed: + progress.append("show_start_page completed") + if first_wait_returned: + progress.append("first script.wait returned") + if second_script_started: + progress.append("flip_thru_pages started") + if second_script_completed: + progress.append("flip_thru_pages completed") + + if not first_wait_returned and first_script_completed: + pytest.fail( + f"Test timed out - script.wait hung after show_start_page completed! " + f"This is the issue #12043 bug. Progress: {', '.join(progress)}" + ) + else: + pytest.fail( + f"Test timed out. Progress: {', '.join(progress) if progress else 'none'}" + ) + + # Verify the complete boot sequence executed in order + assert boot_started, "on_boot did not start" + assert first_script_started, "show_start_page did not start" + assert first_script_completed, "show_start_page did not complete" + assert first_wait_returned, "First script.wait did not return" + assert second_script_started, "flip_thru_pages did not start" + assert second_script_completed, "flip_thru_pages did not complete" + assert all_completed, "Boot sequence did not complete" diff --git a/tests/unit_tests/core/test_entity_helpers.py b/tests/unit_tests/core/test_entity_helpers.py index 9ba5367413..01de0f27f9 100644 --- a/tests/unit_tests/core/test_entity_helpers.py +++ b/tests/unit_tests/core/test_entity_helpers.py @@ -27,8 +27,13 @@ from esphome.helpers import sanitize, snake_case from .common import load_config_from_fixture -# Pre-compiled regex pattern for extracting object IDs from expressions +# Pre-compiled regex patterns for extracting object IDs from expressions +# Matches both old format: .set_object_id("obj_id") +# and new format: .set_name_and_object_id("name", "obj_id") OBJECT_ID_PATTERN = re.compile(r'\.set_object_id\(["\'](.*?)["\']\)') +COMBINED_PATTERN = re.compile( + r'\.set_name_and_object_id\(["\'].*?["\']\s*,\s*["\'](.*?)["\']\)' +) FIXTURES_DIR = Path(__file__).parent.parent / "fixtures" / "core" / "entity_helpers" @@ -273,8 +278,10 @@ def setup_test_environment() -> Generator[list[str], None, None]: def extract_object_id_from_expressions(expressions: list[str]) -> str | None: """Extract the object ID that was set from the generated expressions.""" for expr in expressions: - # Look for set_object_id calls with regex to handle various formats - # Matches: var.set_object_id("temperature_2") or var.set_object_id('temperature_2') + # First try new combined format: .set_name_and_object_id("name", "obj_id") + if match := COMBINED_PATTERN.search(expr): + return match.group(1) + # Fall back to old format: .set_object_id("obj_id") if match := OBJECT_ID_PATTERN.search(expr): return match.group(1) return None