From 01ac22391348410d4a806b5d7b45a23fe8236bbc Mon Sep 17 00:00:00 2001 From: Edward Firmo <94725493+edwardtfn@users.noreply.github.com> Date: Mon, 27 Apr 2026 22:30:40 +0200 Subject: [PATCH 01/22] [nextion] Unify TFT upload ack timeout to 5000ms (#15960) --- esphome/components/nextion/nextion_upload_arduino.cpp | 11 +++++++++-- esphome/components/nextion/nextion_upload_esp32.cpp | 11 +++++++++-- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/esphome/components/nextion/nextion_upload_arduino.cpp b/esphome/components/nextion/nextion_upload_arduino.cpp index c79c68552e..e0d18352ff 100644 --- a/esphome/components/nextion/nextion_upload_arduino.cpp +++ b/esphome/components/nextion/nextion_upload_arduino.cpp @@ -16,6 +16,13 @@ namespace esphome::nextion { static const char *const TAG = "nextion.upload.arduino"; static constexpr size_t NEXTION_MAX_RESPONSE_LOG_BYTES = 16; +// Timeout for display acknowledgment during TFT upload (ms). +// A single value is used for all chunks; the happy path returns as soon as +// 0x05/0x08 arrives, so this only bounds failed-detection latency. Field +// reports showed the previous 500ms steady-state value was too tight for +// some firmware variants. +static constexpr uint32_t NEXTION_UPLOAD_ACK_TIMEOUT_MS = 5000; + // Followed guide // https://unofficialnextion.com/t/nextion-upload-protocol-v1-2-the-fast-one/1044/2 @@ -80,14 +87,14 @@ int Nextion::upload_by_chunks_(HTTPClient &http_client, uint32_t &range_start) { recv_string.clear(); this->write_array(buffer, buffer_size); App.feed_wdt(); - this->recv_ret_string_(recv_string, this->upload_first_chunk_sent_ ? 500 : 5000, true); + this->recv_ret_string_(recv_string, NEXTION_UPLOAD_ACK_TIMEOUT_MS, true); this->content_length_ -= read_len; const float upload_percentage = 100.0f * (this->tft_size_ - this->content_length_) / this->tft_size_; ESP_LOGD(TAG, "Upload: %0.2f%% (%" PRIu32 " left, heap: %" PRIu32 ")", upload_percentage, this->content_length_, EspClass::getFreeHeap()); this->upload_first_chunk_sent_ = true; if (recv_string.empty()) { - ESP_LOGW(TAG, "No response from display during upload"); + ESP_LOGW(TAG, "No response from display after %" PRIu32 "ms", NEXTION_UPLOAD_ACK_TIMEOUT_MS); allocator.deallocate(buffer, 4096); buffer = nullptr; return -1; diff --git a/esphome/components/nextion/nextion_upload_esp32.cpp b/esphome/components/nextion/nextion_upload_esp32.cpp index 40a284dc46..db4558e2fe 100644 --- a/esphome/components/nextion/nextion_upload_esp32.cpp +++ b/esphome/components/nextion/nextion_upload_esp32.cpp @@ -19,6 +19,13 @@ namespace esphome::nextion { static const char *const TAG = "nextion.upload.esp32"; static constexpr size_t NEXTION_MAX_RESPONSE_LOG_BYTES = 16; +// Timeout for display acknowledgment during TFT upload (ms). +// A single value is used for all chunks; the happy path returns as soon as +// 0x05/0x08 arrives, so this only bounds failed-detection latency. Field +// reports showed the previous 500ms steady-state value was too tight for +// some firmware variants. +static constexpr uint32_t NEXTION_UPLOAD_ACK_TIMEOUT_MS = 5000; + // Followed guide // https://unofficialnextion.com/t/nextion-upload-protocol-v1-2-the-fast-one/1044/2 @@ -96,7 +103,7 @@ int Nextion::upload_by_chunks_(esp_http_client_handle_t http_client, uint32_t &r recv_string.clear(); this->write_array(buffer, buffer_size); App.feed_wdt(); - this->recv_ret_string_(recv_string, upload_first_chunk_sent_ ? 500 : 5000, true); + this->recv_ret_string_(recv_string, NEXTION_UPLOAD_ACK_TIMEOUT_MS, true); this->content_length_ -= read_len; const float upload_percentage = 100.0f * (this->tft_size_ - this->content_length_) / this->tft_size_; #ifdef USE_PSRAM @@ -109,7 +116,7 @@ int Nextion::upload_by_chunks_(esp_http_client_handle_t http_client, uint32_t &r #endif upload_first_chunk_sent_ = true; if (recv_string.empty()) { - ESP_LOGW(TAG, "No response from display during upload"); + ESP_LOGW(TAG, "No response from display after %" PRIu32 "ms", NEXTION_UPLOAD_ACK_TIMEOUT_MS); allocator.deallocate(buffer, 4096); buffer = nullptr; return -1; From a34836c2906bd5a1454bca01a135ed9cb1d9e2ea Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 28 Apr 2026 11:27:08 +1200 Subject: [PATCH 02/22] [esp32_touch] Feed wdt (#16066) --- esphome/components/esp32_touch/esp32_touch.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/esp32_touch/esp32_touch.cpp b/esphome/components/esp32_touch/esp32_touch.cpp index e44bc807e9..54bbbe52ed 100644 --- a/esphome/components/esp32_touch/esp32_touch.cpp +++ b/esphome/components/esp32_touch/esp32_touch.cpp @@ -216,6 +216,7 @@ void ESP32TouchComponent::setup() { // Do initial oneshot scans to populate baseline values for (uint32_t i = 0; i < ONESHOT_SCAN_COUNT; i++) { err = touch_sensor_trigger_oneshot_scanning(this->sens_handle_, ONESHOT_SCAN_TIMEOUT_MS); + App.feed_wdt(); // 3 scans with 2s timeout might exceed WDT, so feed it here to be safe if (err != ESP_OK) { ESP_LOGW(TAG, "Oneshot scan %" PRIu32 " failed: %s", i, esp_err_to_name(err)); } From 39a69385fba1ea825906e5ef8759f9a8c3a2351c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Apr 2026 19:57:42 -0500 Subject: [PATCH 03/22] [image] Fix RGB565+alpha rendering for multi-frame animations (#16017) Co-authored-by: Claude --- esphome/components/animation/animation.cpp | 7 ++- esphome/components/image/__init__.py | 21 ++++--- tests/component_tests/image/test_init.py | 69 ++++++++++++++++++++++ 3 files changed, 89 insertions(+), 8 deletions(-) diff --git a/esphome/components/animation/animation.cpp b/esphome/components/animation/animation.cpp index c2ae3b2f76..2f59a7fa5a 100644 --- a/esphome/components/animation/animation.cpp +++ b/esphome/components/animation/animation.cpp @@ -62,7 +62,12 @@ void Animation::set_frame(int frame) { } void Animation::update_data_start_() { - const uint32_t image_size = this->get_width_stride() * this->height_; + uint32_t image_size = this->get_width_stride() * this->height_; + // RGB565 with an alpha channel stores the alpha plane immediately after the RGB + // plane within each frame, so the per-frame stride includes the alpha bytes. + if (this->type_ == image::IMAGE_TYPE_RGB565 && this->transparency_ == image::TRANSPARENCY_ALPHA_CHANNEL) { + image_size += static_cast(this->width_) * this->height_; + } this->data_start_ = this->animation_data_start_ + image_size * this->current_frame_; } diff --git a/esphome/components/image/__init__.py b/esphome/components/image/__init__.py index 8375ab91d3..365554f7d2 100644 --- a/esphome/components/image/__init__.py +++ b/esphome/components/image/__init__.py @@ -744,21 +744,28 @@ async def write_image(config, all_frames=False): if frame_count <= 1: _LOGGER.warning("Image file %s has no animation frames", path) - total_rows = height * frame_count - encoder = IMAGE_TYPE[type](width, total_rows, transparency, dither, invert_alpha) - if byte_order := config.get(CONF_BYTE_ORDER): - # Check for valid type has already been done in validate_settings - encoder.set_big_endian(byte_order == "BIG_ENDIAN") + # Encode each frame with its own encoder and concatenate. This keeps every + # frame self-contained on disk (e.g. RGB565+alpha emits [RGB plane | alpha plane] + # per frame) so animation frame stepping in image.cpp / animation.cpp stays + # correct without needing to know the total frame count. + byte_order = config.get(CONF_BYTE_ORDER) + combined_data: list[int] = [] + encoder: ImageEncoder | None = None for frame_index in range(frame_count): image.seek(frame_index) + encoder = IMAGE_TYPE[type](width, height, transparency, dither, invert_alpha) + if byte_order is not None: + # Check for valid type has already been done in validate_settings + encoder.set_big_endian(byte_order == "BIG_ENDIAN") pixels = encoder.convert(image.resize((width, height)), path).getdata() for row in range(height): for col in range(width): encoder.encode(pixels[row * width + col]) encoder.end_row() - encoder.end_image() + encoder.end_image() + combined_data.extend(encoder.data) - rhs = [HexInt(x) for x in encoder.data] + rhs = [HexInt(x) for x in combined_data] prog_arr = cg.progmem_array(config[CONF_RAW_DATA_ID], rhs) image_type = get_image_type_enum(type) trans_value = get_transparency_enum(encoder.transparency) diff --git a/tests/component_tests/image/test_init.py b/tests/component_tests/image/test_init.py index 6f73888c7d..f7f60a1f4d 100644 --- a/tests/component_tests/image/test_init.py +++ b/tests/component_tests/image/test_init.py @@ -7,10 +7,12 @@ from pathlib import Path from typing import Any from unittest.mock import MagicMock, patch +from PIL import Image as PILImage import pytest from esphome import config_validation as cv from esphome.components.image import ( + CONF_ALPHA_CHANNEL, CONF_INVERT_ALPHA, CONF_OPAQUE, CONF_TRANSPARENCY, @@ -411,3 +413,70 @@ async def test_svg_with_mm_dimensions_succeeds( assert 30 < height < 50, ( f"Height should be around 39 pixels for 10mm at 100dpi, got {height}" ) + + +@pytest.mark.asyncio +async def test_rgb565_alpha_animation_layout_per_frame( + tmp_path: Path, + mock_progmem_array: MagicMock, +) -> None: + """RGB565+alpha animations must store each frame as a self-contained + [RGB plane | alpha plane] block. Animation::update_data_start_ steps frames + with a single per-frame stride, so any cross-frame layout (all RGB then all + alpha) makes the C++ alpha read land in the next frame's RGB bytes — that + was the regression behind issue #15999. + """ + # Build a 2-frame APNG where each frame is a solid color with a known + # alpha. APNG preserves full RGBA per pixel (GIF only has 1-bit alpha so + # round-tripping mid-range alpha values does not work). Frame 0 is fully + # opaque red, frame 1 is fully transparent blue. + width = 4 + height = 3 + frame0 = PILImage.new("RGBA", (width, height), (255, 0, 0, 0xFF)) + frame1 = PILImage.new("RGBA", (width, height), (0, 0, 255, 0x00)) + apng_path = tmp_path / "anim.png" + frame0.save( + apng_path, + format="PNG", + save_all=True, + append_images=[frame1], + duration=100, + loop=0, + ) + + config = { + CONF_FILE: str(apng_path), + CONF_TYPE: "RGB565", + CONF_TRANSPARENCY: CONF_ALPHA_CHANNEL, + CONF_DITHER: "NONE", + CONF_INVERT_ALPHA: False, + CONF_RAW_DATA_ID: "test_raw_data_id", + } + + _, _, _, _, _, frame_count = await write_image(config, all_frames=True) + assert frame_count == 2 + + # Recover the bytes handed to progmem_array. Signature is (id_, rhs). + _, raw_data = mock_progmem_array.call_args.args + data = [int(x) for x in raw_data] + + rgb_size = width * height * 2 + alpha_size = width * height + frame_size = rgb_size + alpha_size + assert len(data) == frame_size * frame_count, ( + "RGB565+alpha animation buffer must be (RGB + alpha) per frame, not " + "all RGB followed by all alpha" + ) + + # Frame 0: RGB plane is red, alpha plane is 0xFF. Frame 1: alpha plane is + # 0x00. If the layout regresses to [all RGB | all alpha], the alpha bytes + # would all land at the tail of the buffer and the per-frame slices below + # would point at RGB565 noise instead. + frame0_alpha = data[rgb_size : rgb_size + alpha_size] + frame1_alpha = data[frame_size + rgb_size : frame_size + rgb_size + alpha_size] + assert all(a == 0xFF for a in frame0_alpha), ( + f"Frame 0 alpha plane should be opaque, got {frame0_alpha}" + ) + assert all(a == 0x00 for a in frame1_alpha), ( + f"Frame 1 alpha plane should be transparent, got {frame1_alpha}" + ) From c26ea52620a5f3a88a2eb7a2e4b5446dedf77195 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 23 Apr 2026 12:35:00 +1000 Subject: [PATCH 04/22] [lvgl] Triggers on tabview tabs fix (#15935) --- esphome/components/lvgl/widgets/tabview.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/lvgl/widgets/tabview.py b/esphome/components/lvgl/widgets/tabview.py index 108bb38df5..5e9e0494dd 100644 --- a/esphome/components/lvgl/widgets/tabview.py +++ b/esphome/components/lvgl/widgets/tabview.py @@ -22,7 +22,7 @@ from ..defines import ( literal, ) from ..lv_validation import animated, lv_int, size -from ..lvcode import LocalVariable, lv, lv_assign, lv_expr, lv_obj +from ..lvcode import LocalVariable, lv, lv_assign, lv_expr, lv_obj, lv_Pvariable from ..schemas import container_schema, part_schema from ..types import LV_EVENT, LvType, ObjUpdateAction, lv_obj_t, lv_obj_t_ptr from . import Widget, WidgetType, add_widgets, get_widgets, set_obj_properties @@ -83,8 +83,8 @@ class TabviewType(WidgetType): await w.set_property("tab_bar_size", await size.process(config[CONF_SIZE])) for tab_conf in config[CONF_TABS]: w_id = tab_conf[CONF_ID] - tab_obj = cg.Pvariable(w_id, cg.nullptr, type_=lv_tab_t) - tab_widget = Widget.create(w_id, tab_obj, obj_spec) + tab_obj = lv_Pvariable(lv_tab_t, w_id) + tab_widget = Widget.create(w_id, tab_obj, obj_spec, tab_conf) lv_assign(tab_obj, lv_expr.tabview_add_tab(w.obj, tab_conf[CONF_NAME])) await set_obj_properties(tab_widget, tab_conf) await add_widgets(tab_widget, tab_conf) From b753ee4e94d060a55464c55e1a94a96b135aeed1 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 23 Apr 2026 14:35:13 +1200 Subject: [PATCH 05/22] [time] Handle Windows EINVAL when validating POSIX TZ strings (#15934) --- esphome/components/time/__init__.py | 7 +++ tests/unit_tests/components/test_time.py | 67 +++++++++++++++++++++++- 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/esphome/components/time/__init__.py b/esphome/components/time/__init__.py index 7ac0abeee0..9e79c8e6c2 100644 --- a/esphome/components/time/__init__.py +++ b/esphome/components/time/__init__.py @@ -1,3 +1,4 @@ +import errno from importlib import resources import logging @@ -74,6 +75,12 @@ def _load_tzdata(iana_key: str) -> bytes | None: return (resources.files(package) / resource).read_bytes() except (FileNotFoundError, ModuleNotFoundError, IsADirectoryError): return None + except OSError as e: + # Windows raises EINVAL for paths with NTFS-illegal chars (e.g. '<'/'>' + # in POSIX TZ strings like "<+08>-8" that validate_tz feeds back here). + if e.errno == errno.EINVAL: + return None + raise def _extract_tz_string(tzfile: bytes) -> str: diff --git a/tests/unit_tests/components/test_time.py b/tests/unit_tests/components/test_time.py index 48988fb03f..6325bfbe75 100644 --- a/tests/unit_tests/components/test_time.py +++ b/tests/unit_tests/components/test_time.py @@ -1,6 +1,11 @@ """Tests for time component cron expression parsing.""" -from esphome.components.time import _parse_cron_part +import errno +from unittest.mock import MagicMock, patch + +import pytest + +from esphome.components.time import _load_tzdata, _parse_cron_part, validate_tz def test_star_slash_seconds() -> None: @@ -78,3 +83,63 @@ def test_range() -> None: def test_single_value() -> None: assert _parse_cron_part("30", 0, 59, {}) == {30} + + +def _mock_resources_with_error(error: Exception) -> MagicMock: + """Return a mock of importlib.resources.files where read_bytes raises error.""" + leaf = MagicMock() + leaf.read_bytes.side_effect = error + package = MagicMock() + package.__truediv__.return_value = leaf + return MagicMock(return_value=package) + + +def test_load_tzdata_returns_none_on_windows_einval() -> None: + """On Windows, opening a tzdata path with NTFS-illegal chars raises OSError(EINVAL). + + Regression test for crash when the system TZ resolves to a POSIX string like + "<+08>-8" (Asia/Shanghai, IST, etc.) and is fed back into _load_tzdata by + validate_tz to check whether it is also a valid IANA key. + """ + err = OSError(errno.EINVAL, "Invalid argument") + with patch( + "esphome.components.time.resources.files", + _mock_resources_with_error(err), + ): + assert _load_tzdata("<+08>-8") is None + + +def test_load_tzdata_propagates_unexpected_oserror() -> None: + """Unrelated OSErrors (e.g. PermissionError) must not be swallowed.""" + with ( + patch( + "esphome.components.time.resources.files", + _mock_resources_with_error( + PermissionError(errno.EACCES, "Permission denied") + ), + ), + pytest.raises(PermissionError), + ): + _load_tzdata("Some/Zone") + + +def test_load_tzdata_returns_none_on_file_not_found() -> None: + """Existing behavior: missing tz file returns None rather than raising.""" + with patch( + "esphome.components.time.resources.files", + _mock_resources_with_error(FileNotFoundError()), + ): + assert _load_tzdata("Not/A/Zone") is None + + +def test_validate_tz_accepts_posix_string_when_read_bytes_raises_einval() -> None: + """validate_tz must not crash when _load_tzdata hits the Windows EINVAL path. + + Simulates the Windows case where the auto-detected POSIX TZ string is fed + back through _load_tzdata and the underlying read_bytes raises errno 22. + """ + with patch( + "esphome.components.time.resources.files", + _mock_resources_with_error(OSError(errno.EINVAL, "Invalid argument")), + ): + assert validate_tz("<+08>-8") == "<+08>-8" From 6a5919ee8764571ce4dff8c3d939d1230531e112 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Apr 2026 02:19:59 -0500 Subject: [PATCH 06/22] [deep_sleep] Fix sleep_duration codegen type to uint32_t (#15965) --- esphome/components/deep_sleep/__init__.py | 2 +- tests/components/deep_sleep/common.yaml | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/esphome/components/deep_sleep/__init__.py b/esphome/components/deep_sleep/__init__.py index 16329bb0fa..a98b7e60ef 100644 --- a/esphome/components/deep_sleep/__init__.py +++ b/esphome/components/deep_sleep/__init__.py @@ -413,7 +413,7 @@ async def deep_sleep_enter_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) if CONF_SLEEP_DURATION in config: - template_ = await cg.templatable(config[CONF_SLEEP_DURATION], args, cg.int32) + template_ = await cg.templatable(config[CONF_SLEEP_DURATION], args, cg.uint32) cg.add(var.set_sleep_duration(template_)) if CONF_UNTIL in config: diff --git a/tests/components/deep_sleep/common.yaml b/tests/components/deep_sleep/common.yaml index c090cb83e2..7a1a709965 100644 --- a/tests/components/deep_sleep/common.yaml +++ b/tests/components/deep_sleep/common.yaml @@ -4,3 +4,9 @@ esphome: - deep_sleep.prevent - delay: 1s - deep_sleep.allow + - if: + condition: + lambda: 'return false;' + then: + - deep_sleep.enter: + sleep_duration: 60min From 4137d93cbfc34778fed7b8b4697a8eb04c7e6777 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Apr 2026 03:42:36 -0500 Subject: [PATCH 07/22] [wifi] Fix stale wifi.connected after state transition (#15966) --- esphome/components/wifi/wifi_component.cpp | 2 ++ esphome/components/wifi/wifi_component_esp8266.cpp | 2 ++ esphome/components/wifi/wifi_component_esp_idf.cpp | 2 ++ esphome/components/wifi/wifi_component_libretiny.cpp | 2 ++ esphome/components/wifi/wifi_component_pico_w.cpp | 2 ++ 5 files changed, 10 insertions(+) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 7b31a22ed5..6b49368933 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -1570,6 +1570,8 @@ void WiFiComponent::check_connecting_finished(uint32_t now) { #endif this->state_ = WIFI_COMPONENT_STATE_STA_CONNECTED; + // Refresh is_connected() cache; loop()'s refresh ran before this transition. + this->update_connected_state_(); this->num_retried_ = 0; this->print_connect_params_(); diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index cb53d3ac1b..d1a31cdfc9 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -948,6 +948,8 @@ void WiFiComponent::process_pending_callbacks_() { #ifdef USE_WIFI_CONNECT_STATE_LISTENERS if (this->pending_.disconnect) { this->pending_.disconnect = false; + // Refresh is_connected() cache here, not in the SDK callback (sys context). + this->update_connected_state_(); this->notify_disconnect_state_listeners_(); } #endif diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index 4097df80af..e166fadb27 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -796,6 +796,8 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { s_sta_connected = false; s_sta_connecting = false; error_from_callback_ = true; + // Refresh is_connected() cache; error_from_callback_ makes it false. + this->update_connected_state_(); #ifdef USE_WIFI_CONNECT_STATE_LISTENERS this->notify_disconnect_state_listeners_(); #endif diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index 9565ffa747..b721364631 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -536,6 +536,8 @@ void WiFiComponent::wifi_process_event_(LTWiFiEvent *event) { this->error_from_callback_ = true; } + // Refresh is_connected() cache; sta_state_/error_from_callback_ make it false. + this->update_connected_state_(); #ifdef USE_WIFI_CONNECT_STATE_LISTENERS this->notify_disconnect_state_listeners_(); #endif diff --git a/esphome/components/wifi/wifi_component_pico_w.cpp b/esphome/components/wifi/wifi_component_pico_w.cpp index 1cfeee3c1b..a50dfd8c80 100644 --- a/esphome/components/wifi/wifi_component_pico_w.cpp +++ b/esphome/components/wifi/wifi_component_pico_w.cpp @@ -342,6 +342,8 @@ void WiFiComponent::wifi_loop_() { s_sta_was_connected = false; s_sta_had_ip = false; ESP_LOGV(TAG, "Disconnected"); + // Refresh is_connected() cache; driver link status reports disconnected. + this->update_connected_state_(); #ifdef USE_WIFI_CONNECT_STATE_LISTENERS this->notify_disconnect_state_listeners_(); #endif From 433bbdb0163dabcbf31afb3d93d500a6a406020c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Apr 2026 07:23:41 -0500 Subject: [PATCH 08/22] [rotary_encoder][at581x] Fix templatable int field types (#16015) --- esphome/components/at581x/__init__.py | 8 ++++---- esphome/components/rotary_encoder/sensor.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/esphome/components/at581x/__init__.py b/esphome/components/at581x/__init__.py index 94b68db4b3..5031b72cce 100644 --- a/esphome/components/at581x/__init__.py +++ b/esphome/components/at581x/__init__.py @@ -183,19 +183,19 @@ async def at581x_settings_to_code(config, action_id, template_arg, args): cg.add(var.set_sensing_distance(template_)) if selfcheck := config.get(CONF_POWERON_SELFCHECK_TIME): - template_ = await cg.templatable(selfcheck, args, cg.int32) + template_ = await cg.templatable(selfcheck, args, cg.int_) cg.add(var.set_poweron_selfcheck_time(template_)) if protect := config.get(CONF_PROTECT_TIME): - template_ = await cg.templatable(protect, args, cg.int32) + template_ = await cg.templatable(protect, args, cg.int_) cg.add(var.set_protect_time(template_)) if trig_base := config.get(CONF_TRIGGER_BASE): - template_ = await cg.templatable(trig_base, args, cg.int32) + template_ = await cg.templatable(trig_base, args, cg.int_) cg.add(var.set_trigger_base(template_)) if trig_keep := config.get(CONF_TRIGGER_KEEP): - template_ = await cg.templatable(trig_keep, args, cg.int32) + template_ = await cg.templatable(trig_keep, args, cg.int_) cg.add(var.set_trigger_keep(template_)) if (stage_gain := config.get(CONF_STAGE_GAIN)) is not None: diff --git a/esphome/components/rotary_encoder/sensor.py b/esphome/components/rotary_encoder/sensor.py index 21239863e4..0e5a03523d 100644 --- a/esphome/components/rotary_encoder/sensor.py +++ b/esphome/components/rotary_encoder/sensor.py @@ -129,6 +129,6 @@ async def to_code(config): async def sensor_template_publish_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_VALUE], args, cg.int32) + template_ = await cg.templatable(config[CONF_VALUE], args, cg.int_) cg.add(var.set_value(template_)) return var From aea88aef5e9fdd053ed61007e2a17ccbd4b6982e Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Mon, 27 Apr 2026 17:41:28 +0000 Subject: [PATCH 09/22] [esp32][wifi] Fix bootloop and WiFi connection issue if nvs partition is missing or has non-default label (#16025) Co-authored-by: J. Nick Koston --- esphome/components/esp32/preferences.cpp | 18 +++++++++++++++++- .../components/wifi/wifi_component_esp_idf.cpp | 5 ++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp32/preferences.cpp b/esphome/components/esp32/preferences.cpp index bc0a34ebe8..72a0d979d9 100644 --- a/esphome/components/esp32/preferences.cpp +++ b/esphome/components/esp32/preferences.cpp @@ -22,6 +22,12 @@ struct NVSData { static std::vector s_pending_save; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +// open() runs from app_main() before the logger is initialized, so any failure +// must be deferred until after global_logger is set. This is emitted from the +// first make_preference() call, which runs from the generated setup() after +// log->pre_setup() has run at EARLY_INIT priority. +static esp_err_t s_open_err = ESP_OK; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + bool ESP32PreferenceBackend::save(const uint8_t *data, size_t len) { // try find in pending saves and update that for (auto &obj : s_pending_save) { @@ -74,12 +80,14 @@ bool ESP32PreferenceBackend::load(uint8_t *data, size_t len) { } void ESP32Preferences::open() { + // Runs from app_main() before the logger is initialized; any logging here + // must be deferred. See s_open_err and make_preference() below. nvs_flash_init(); esp_err_t err = nvs_open("esphome", NVS_READWRITE, &this->nvs_handle); if (err == 0) return; - ESP_LOGW(TAG, "nvs_open failed: %s - erasing NVS", esp_err_to_name(err)); + s_open_err = err; nvs_flash_deinit(); nvs_flash_erase(); nvs_flash_init(); @@ -91,6 +99,14 @@ void ESP32Preferences::open() { } ESPPreferenceObject ESP32Preferences::make_preference(size_t length, uint32_t type) { + if (s_open_err != ESP_OK) { + if (this->nvs_handle == 0) { + ESP_LOGW(TAG, "nvs_open failed: %s - NVS unavailable", esp_err_to_name(s_open_err)); + } else { + ESP_LOGW(TAG, "nvs_open failed: %s - erased NVS", esp_err_to_name(s_open_err)); + } + s_open_err = ESP_OK; + } auto *pref = new ESP32PreferenceBackend(); // NOLINT(cppcoreguidelines-owning-memory) pref->nvs_handle = this->nvs_handle; pref->key = type; diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index e166fadb27..a6a48409bc 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -179,7 +179,10 @@ void WiFiComponent::wifi_pre_setup_() { #endif // USE_WIFI_AP wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); - // cfg.nvs_enable = false; + if (global_preferences->nvs_handle == 0) { + ESP_LOGW(TAG, "starting wifi without nvs"); + cfg.nvs_enable = false; + } err = esp_wifi_init(&cfg); if (err != ERR_OK) { ESP_LOGE(TAG, "esp_wifi_init failed: %s", esp_err_to_name(err)); From a186f6fea9663d7daaf937021ea801073313f5d4 Mon Sep 17 00:00:00 2001 From: Edward Firmo <94725493+edwardtfn@users.noreply.github.com> Date: Mon, 27 Apr 2026 22:30:40 +0200 Subject: [PATCH 10/22] [nextion] Unify TFT upload ack timeout to 5000ms (#15960) --- esphome/components/nextion/nextion_upload_arduino.cpp | 11 +++++++++-- esphome/components/nextion/nextion_upload_esp32.cpp | 11 +++++++++-- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/esphome/components/nextion/nextion_upload_arduino.cpp b/esphome/components/nextion/nextion_upload_arduino.cpp index c79c68552e..e0d18352ff 100644 --- a/esphome/components/nextion/nextion_upload_arduino.cpp +++ b/esphome/components/nextion/nextion_upload_arduino.cpp @@ -16,6 +16,13 @@ namespace esphome::nextion { static const char *const TAG = "nextion.upload.arduino"; static constexpr size_t NEXTION_MAX_RESPONSE_LOG_BYTES = 16; +// Timeout for display acknowledgment during TFT upload (ms). +// A single value is used for all chunks; the happy path returns as soon as +// 0x05/0x08 arrives, so this only bounds failed-detection latency. Field +// reports showed the previous 500ms steady-state value was too tight for +// some firmware variants. +static constexpr uint32_t NEXTION_UPLOAD_ACK_TIMEOUT_MS = 5000; + // Followed guide // https://unofficialnextion.com/t/nextion-upload-protocol-v1-2-the-fast-one/1044/2 @@ -80,14 +87,14 @@ int Nextion::upload_by_chunks_(HTTPClient &http_client, uint32_t &range_start) { recv_string.clear(); this->write_array(buffer, buffer_size); App.feed_wdt(); - this->recv_ret_string_(recv_string, this->upload_first_chunk_sent_ ? 500 : 5000, true); + this->recv_ret_string_(recv_string, NEXTION_UPLOAD_ACK_TIMEOUT_MS, true); this->content_length_ -= read_len; const float upload_percentage = 100.0f * (this->tft_size_ - this->content_length_) / this->tft_size_; ESP_LOGD(TAG, "Upload: %0.2f%% (%" PRIu32 " left, heap: %" PRIu32 ")", upload_percentage, this->content_length_, EspClass::getFreeHeap()); this->upload_first_chunk_sent_ = true; if (recv_string.empty()) { - ESP_LOGW(TAG, "No response from display during upload"); + ESP_LOGW(TAG, "No response from display after %" PRIu32 "ms", NEXTION_UPLOAD_ACK_TIMEOUT_MS); allocator.deallocate(buffer, 4096); buffer = nullptr; return -1; diff --git a/esphome/components/nextion/nextion_upload_esp32.cpp b/esphome/components/nextion/nextion_upload_esp32.cpp index 40a284dc46..db4558e2fe 100644 --- a/esphome/components/nextion/nextion_upload_esp32.cpp +++ b/esphome/components/nextion/nextion_upload_esp32.cpp @@ -19,6 +19,13 @@ namespace esphome::nextion { static const char *const TAG = "nextion.upload.esp32"; static constexpr size_t NEXTION_MAX_RESPONSE_LOG_BYTES = 16; +// Timeout for display acknowledgment during TFT upload (ms). +// A single value is used for all chunks; the happy path returns as soon as +// 0x05/0x08 arrives, so this only bounds failed-detection latency. Field +// reports showed the previous 500ms steady-state value was too tight for +// some firmware variants. +static constexpr uint32_t NEXTION_UPLOAD_ACK_TIMEOUT_MS = 5000; + // Followed guide // https://unofficialnextion.com/t/nextion-upload-protocol-v1-2-the-fast-one/1044/2 @@ -96,7 +103,7 @@ int Nextion::upload_by_chunks_(esp_http_client_handle_t http_client, uint32_t &r recv_string.clear(); this->write_array(buffer, buffer_size); App.feed_wdt(); - this->recv_ret_string_(recv_string, upload_first_chunk_sent_ ? 500 : 5000, true); + this->recv_ret_string_(recv_string, NEXTION_UPLOAD_ACK_TIMEOUT_MS, true); this->content_length_ -= read_len; const float upload_percentage = 100.0f * (this->tft_size_ - this->content_length_) / this->tft_size_; #ifdef USE_PSRAM @@ -109,7 +116,7 @@ int Nextion::upload_by_chunks_(esp_http_client_handle_t http_client, uint32_t &r #endif upload_first_chunk_sent_ = true; if (recv_string.empty()) { - ESP_LOGW(TAG, "No response from display during upload"); + ESP_LOGW(TAG, "No response from display after %" PRIu32 "ms", NEXTION_UPLOAD_ACK_TIMEOUT_MS); allocator.deallocate(buffer, 4096); buffer = nullptr; return -1; From 191d3bc7e400ddfe228587c1a512b60f7c03706a Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 28 Apr 2026 11:27:08 +1200 Subject: [PATCH 11/22] [esp32_touch] Feed wdt (#16066) --- esphome/components/esp32_touch/esp32_touch.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/esp32_touch/esp32_touch.cpp b/esphome/components/esp32_touch/esp32_touch.cpp index e44bc807e9..54bbbe52ed 100644 --- a/esphome/components/esp32_touch/esp32_touch.cpp +++ b/esphome/components/esp32_touch/esp32_touch.cpp @@ -216,6 +216,7 @@ void ESP32TouchComponent::setup() { // Do initial oneshot scans to populate baseline values for (uint32_t i = 0; i < ONESHOT_SCAN_COUNT; i++) { err = touch_sensor_trigger_oneshot_scanning(this->sens_handle_, ONESHOT_SCAN_TIMEOUT_MS); + App.feed_wdt(); // 3 scans with 2s timeout might exceed WDT, so feed it here to be safe if (err != ESP_OK) { ESP_LOGW(TAG, "Oneshot scan %" PRIu32 " failed: %s", i, esp_err_to_name(err)); } From 3ac0939f55a79655f52648696473806d84109b22 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Apr 2026 19:57:42 -0500 Subject: [PATCH 12/22] [image] Fix RGB565+alpha rendering for multi-frame animations (#16017) Co-authored-by: Claude --- esphome/components/animation/animation.cpp | 7 ++- esphome/components/image/__init__.py | 21 ++++--- tests/component_tests/image/test_init.py | 69 ++++++++++++++++++++++ 3 files changed, 89 insertions(+), 8 deletions(-) diff --git a/esphome/components/animation/animation.cpp b/esphome/components/animation/animation.cpp index c2ae3b2f76..2f59a7fa5a 100644 --- a/esphome/components/animation/animation.cpp +++ b/esphome/components/animation/animation.cpp @@ -62,7 +62,12 @@ void Animation::set_frame(int frame) { } void Animation::update_data_start_() { - const uint32_t image_size = this->get_width_stride() * this->height_; + uint32_t image_size = this->get_width_stride() * this->height_; + // RGB565 with an alpha channel stores the alpha plane immediately after the RGB + // plane within each frame, so the per-frame stride includes the alpha bytes. + if (this->type_ == image::IMAGE_TYPE_RGB565 && this->transparency_ == image::TRANSPARENCY_ALPHA_CHANNEL) { + image_size += static_cast(this->width_) * this->height_; + } this->data_start_ = this->animation_data_start_ + image_size * this->current_frame_; } diff --git a/esphome/components/image/__init__.py b/esphome/components/image/__init__.py index 8375ab91d3..365554f7d2 100644 --- a/esphome/components/image/__init__.py +++ b/esphome/components/image/__init__.py @@ -744,21 +744,28 @@ async def write_image(config, all_frames=False): if frame_count <= 1: _LOGGER.warning("Image file %s has no animation frames", path) - total_rows = height * frame_count - encoder = IMAGE_TYPE[type](width, total_rows, transparency, dither, invert_alpha) - if byte_order := config.get(CONF_BYTE_ORDER): - # Check for valid type has already been done in validate_settings - encoder.set_big_endian(byte_order == "BIG_ENDIAN") + # Encode each frame with its own encoder and concatenate. This keeps every + # frame self-contained on disk (e.g. RGB565+alpha emits [RGB plane | alpha plane] + # per frame) so animation frame stepping in image.cpp / animation.cpp stays + # correct without needing to know the total frame count. + byte_order = config.get(CONF_BYTE_ORDER) + combined_data: list[int] = [] + encoder: ImageEncoder | None = None for frame_index in range(frame_count): image.seek(frame_index) + encoder = IMAGE_TYPE[type](width, height, transparency, dither, invert_alpha) + if byte_order is not None: + # Check for valid type has already been done in validate_settings + encoder.set_big_endian(byte_order == "BIG_ENDIAN") pixels = encoder.convert(image.resize((width, height)), path).getdata() for row in range(height): for col in range(width): encoder.encode(pixels[row * width + col]) encoder.end_row() - encoder.end_image() + encoder.end_image() + combined_data.extend(encoder.data) - rhs = [HexInt(x) for x in encoder.data] + rhs = [HexInt(x) for x in combined_data] prog_arr = cg.progmem_array(config[CONF_RAW_DATA_ID], rhs) image_type = get_image_type_enum(type) trans_value = get_transparency_enum(encoder.transparency) diff --git a/tests/component_tests/image/test_init.py b/tests/component_tests/image/test_init.py index 6f73888c7d..f7f60a1f4d 100644 --- a/tests/component_tests/image/test_init.py +++ b/tests/component_tests/image/test_init.py @@ -7,10 +7,12 @@ from pathlib import Path from typing import Any from unittest.mock import MagicMock, patch +from PIL import Image as PILImage import pytest from esphome import config_validation as cv from esphome.components.image import ( + CONF_ALPHA_CHANNEL, CONF_INVERT_ALPHA, CONF_OPAQUE, CONF_TRANSPARENCY, @@ -411,3 +413,70 @@ async def test_svg_with_mm_dimensions_succeeds( assert 30 < height < 50, ( f"Height should be around 39 pixels for 10mm at 100dpi, got {height}" ) + + +@pytest.mark.asyncio +async def test_rgb565_alpha_animation_layout_per_frame( + tmp_path: Path, + mock_progmem_array: MagicMock, +) -> None: + """RGB565+alpha animations must store each frame as a self-contained + [RGB plane | alpha plane] block. Animation::update_data_start_ steps frames + with a single per-frame stride, so any cross-frame layout (all RGB then all + alpha) makes the C++ alpha read land in the next frame's RGB bytes — that + was the regression behind issue #15999. + """ + # Build a 2-frame APNG where each frame is a solid color with a known + # alpha. APNG preserves full RGBA per pixel (GIF only has 1-bit alpha so + # round-tripping mid-range alpha values does not work). Frame 0 is fully + # opaque red, frame 1 is fully transparent blue. + width = 4 + height = 3 + frame0 = PILImage.new("RGBA", (width, height), (255, 0, 0, 0xFF)) + frame1 = PILImage.new("RGBA", (width, height), (0, 0, 255, 0x00)) + apng_path = tmp_path / "anim.png" + frame0.save( + apng_path, + format="PNG", + save_all=True, + append_images=[frame1], + duration=100, + loop=0, + ) + + config = { + CONF_FILE: str(apng_path), + CONF_TYPE: "RGB565", + CONF_TRANSPARENCY: CONF_ALPHA_CHANNEL, + CONF_DITHER: "NONE", + CONF_INVERT_ALPHA: False, + CONF_RAW_DATA_ID: "test_raw_data_id", + } + + _, _, _, _, _, frame_count = await write_image(config, all_frames=True) + assert frame_count == 2 + + # Recover the bytes handed to progmem_array. Signature is (id_, rhs). + _, raw_data = mock_progmem_array.call_args.args + data = [int(x) for x in raw_data] + + rgb_size = width * height * 2 + alpha_size = width * height + frame_size = rgb_size + alpha_size + assert len(data) == frame_size * frame_count, ( + "RGB565+alpha animation buffer must be (RGB + alpha) per frame, not " + "all RGB followed by all alpha" + ) + + # Frame 0: RGB plane is red, alpha plane is 0xFF. Frame 1: alpha plane is + # 0x00. If the layout regresses to [all RGB | all alpha], the alpha bytes + # would all land at the tail of the buffer and the per-frame slices below + # would point at RGB565 noise instead. + frame0_alpha = data[rgb_size : rgb_size + alpha_size] + frame1_alpha = data[frame_size + rgb_size : frame_size + rgb_size + alpha_size] + assert all(a == 0xFF for a in frame0_alpha), ( + f"Frame 0 alpha plane should be opaque, got {frame0_alpha}" + ) + assert all(a == 0x00 for a in frame1_alpha), ( + f"Frame 1 alpha plane should be transparent, got {frame1_alpha}" + ) From 95b5ab7e78fabb794c69eb04669279fdfe9d767b Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 28 Apr 2026 12:58:29 +1200 Subject: [PATCH 13/22] Bump version to 2026.4.3 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 1cd12551dd..0e6a845ed8 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.4.2 +PROJECT_NUMBER = 2026.4.3 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index ef37cb2df6..89b6ff15ee 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.4.2" +__version__ = "2026.4.3" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From a03de7cea2fa0747a59fe7502f4d072fa68cf25e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Apr 2026 20:23:08 -0500 Subject: [PATCH 14/22] [core] Freshen loop_component_start_time_ before scheduler dispatch (#16064) --- esphome/core/application.h | 4 ++++ esphome/core/scheduler.cpp | 2 ++ 2 files changed, 6 insertions(+) diff --git a/esphome/core/application.h b/esphome/core/application.h index 185ee4163b..221081a0e4 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -377,12 +377,16 @@ class Application { protected: friend Component; + friend class Scheduler; #ifdef USE_RUNTIME_STATS friend class runtime_stats::RuntimeStatsCollector; #endif friend void ::setup(); friend void ::original_setup(); + /// Freshen the cached loop component start time. Called by Scheduler before each dispatch. + void set_loop_component_start_time_(uint32_t now) { this->loop_component_start_time_ = now; } + /// Walk all registered components looking for any whose component_state_ /// has the given flag set. Used by Component::status_clear_*_slow_path_() /// (which is a friend) to decide whether to clear the corresponding bit on diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index d83d67d6e4..11884ce4ba 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -772,6 +772,8 @@ Scheduler::SchedulerItem *HOT Scheduler::pop_raw_locked_() { // Helper to execute a scheduler item uint32_t HOT Scheduler::execute_item_(SchedulerItem *item, uint32_t now) { App.set_current_component(item->component); + // Freshen so callbacks reading App.get_loop_component_start_time() see this item's dispatch time. + App.set_loop_component_start_time_(now); WarnIfComponentBlockingGuard guard{item->component, now}; item->callback(); uint32_t end = guard.finish(); From 42c9fdc87ef42eb7e5f894abe587d2f321915d8a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Apr 2026 23:39:08 -0500 Subject: [PATCH 15/22] [feedback] Use App.get_loop_component_start_time() and constexpr timeout id (#16063) --- .../components/feedback/feedback_cover.cpp | 20 +++++++++---------- esphome/components/feedback/feedback_cover.h | 6 ++---- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/esphome/components/feedback/feedback_cover.cpp b/esphome/components/feedback/feedback_cover.cpp index 1dff210cd6..672e99949b 100644 --- a/esphome/components/feedback/feedback_cover.cpp +++ b/esphome/components/feedback/feedback_cover.cpp @@ -3,11 +3,12 @@ #include "esphome/core/log.h" #include "esphome/core/application.h" -namespace esphome { -namespace feedback { +namespace esphome::feedback { static const char *const TAG = "feedback.cover"; +static constexpr uint32_t DIRECTION_CHANGE_TIMEOUT_ID = 1; + using namespace esphome::cover; void FeedbackCover::setup() { @@ -37,7 +38,7 @@ void FeedbackCover::setup() { } #endif - this->last_recompute_time_ = this->start_dir_time_ = millis(); + this->last_recompute_time_ = this->start_dir_time_ = App.get_loop_component_start_time(); } CoverTraits FeedbackCover::get_traits() { @@ -135,7 +136,7 @@ void FeedbackCover::set_close_endstop(binary_sensor::BinarySensor *close_endstop #endif void FeedbackCover::endstop_reached_(bool open_endstop) { - const uint32_t now = millis(); + const uint32_t now = App.get_loop_component_start_time(); this->position = open_endstop ? COVER_OPEN : COVER_CLOSED; @@ -174,7 +175,7 @@ void FeedbackCover::set_current_operation_(cover::CoverOperation operation, bool if (!is_triggered || (this->open_feedback_ == nullptr || this->close_feedback_ == nullptr)) #endif { - auto now = millis(); + const uint32_t now = App.get_loop_component_start_time(); this->current_operation = operation; this->start_dir_time_ = this->last_recompute_time_ = now; this->publish_state(); @@ -306,7 +307,7 @@ void FeedbackCover::control(const CoverCall &call) { void FeedbackCover::stop_prev_trigger_() { if (this->direction_change_waittime_.has_value()) { - this->cancel_timeout("direction_change"); + this->cancel_timeout(DIRECTION_CHANGE_TIMEOUT_ID); } if (this->prev_command_trigger_ != nullptr) { this->prev_command_trigger_->stop_action(); @@ -377,7 +378,7 @@ void FeedbackCover::start_direction_(CoverOperation dir) { ESP_LOGD(TAG, "'%s' - Reversing direction.", this->name_.c_str()); this->start_direction_(COVER_OPERATION_IDLE); - this->set_timeout("direction_change", *this->direction_change_waittime_, + this->set_timeout(DIRECTION_CHANGE_TIMEOUT_ID, *this->direction_change_waittime_, [this, dir]() { this->start_direction_(dir); }); } else { @@ -395,7 +396,7 @@ void FeedbackCover::recompute_position_() { if (this->current_operation == COVER_OPERATION_IDLE) return; - const uint32_t now = millis(); + const uint32_t now = App.get_loop_component_start_time(); float dir; float action_dur; float min_pos; @@ -451,5 +452,4 @@ void FeedbackCover::recompute_position_() { this->last_recompute_time_ = now; } -} // namespace feedback -} // namespace esphome +} // namespace esphome::feedback diff --git a/esphome/components/feedback/feedback_cover.h b/esphome/components/feedback/feedback_cover.h index 6be8939413..ed6f7490f8 100644 --- a/esphome/components/feedback/feedback_cover.h +++ b/esphome/components/feedback/feedback_cover.h @@ -8,8 +8,7 @@ #endif #include "esphome/components/cover/cover.h" -namespace esphome { -namespace feedback { +namespace esphome::feedback { class FeedbackCover : public cover::Cover, public Component { public: @@ -85,5 +84,4 @@ class FeedbackCover : public cover::Cover, public Component { uint32_t update_interval_{1000}; }; -} // namespace feedback -} // namespace esphome +} // namespace esphome::feedback From 792f2e83630088536f33bf6d79d07fb75a15078b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Apr 2026 00:29:42 -0500 Subject: [PATCH 16/22] [ota] Add wall-clock timeout to OTA data transfer loop (#16047) --- esphome/components/esphome/ota/ota_esphome.cpp | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 47f661a8ea..be771eb689 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -292,6 +292,7 @@ void ESPHomeOTAComponent::handle_data_() { bool update_started = false; size_t total = 0; uint32_t last_progress = 0; + uint32_t last_data_ms = 0; uint8_t buf[OTA_BUFFER_SIZE]; char *sbuf = reinterpret_cast(buf); size_t ota_size; @@ -350,8 +351,18 @@ void ESPHomeOTAComponent::handle_data_() { // Acknowledge MD5 OK - 1 byte this->write_byte_(ota::OTA_RESPONSE_BIN_MD5_OK); + // Track when we last received data so a silently-vanished peer (no FIN/RST + // delivered, e.g. uploader killed mid-transfer or NAT/router dropped state) + // can't wedge the device indefinitely. Without this, the loop only exits + // on actual data, EOF, or a non-EWOULDBLOCK error from read(), and lwIP + // TCP keepalive isn't enabled here. + last_data_ms = millis(); while (total < ota_size) { - // TODO: timeout check + if (millis() - last_data_ms > OTA_SOCKET_TIMEOUT_DATA) { + ESP_LOGW(TAG, "No data received for %u ms", (unsigned) OTA_SOCKET_TIMEOUT_DATA); + error_code = ota::OTA_RESPONSE_ERROR_UNKNOWN; + goto error; // NOLINT(cppcoreguidelines-avoid-goto) + } size_t remaining = ota_size - total; size_t requested = remaining < OTA_BUFFER_SIZE ? remaining : OTA_BUFFER_SIZE; ssize_t read = this->client_->read(buf, requested); @@ -369,6 +380,7 @@ void ESPHomeOTAComponent::handle_data_() { goto error; // NOLINT(cppcoreguidelines-avoid-goto) } + last_data_ms = millis(); error_code = this->backend_->write(buf, read); if (error_code != ota::OTA_RESPONSE_OK) { ESP_LOGW(TAG, "Flash write err %d", error_code); From 49d3df2698b91f42cdeb8d723a5cc01c18467000 Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Tue, 28 Apr 2026 05:27:20 -0500 Subject: [PATCH 17/22] [automation] Fix codegen type for component.resume update_interval (#16069) Co-authored-by: Claude Opus 4.7 (1M context) --- esphome/automation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/automation.py b/esphome/automation.py index 97d9a0a47a..20eb9358ca 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -597,7 +597,7 @@ async def component_resume_action_to_code( comp = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, comp) if CONF_UPDATE_INTERVAL in config: - template_ = await cg.templatable(config[CONF_UPDATE_INTERVAL], args, int) + template_ = await cg.templatable(config[CONF_UPDATE_INTERVAL], args, cg.uint32) cg.add(var.set_update_interval(template_)) return var From 41458d72e00f56f3a9850a2191b47bc4384c5d3b Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Tue, 28 Apr 2026 14:58:34 +0400 Subject: [PATCH 18/22] [esp32] Make Arduino app metadata reproducible (#16053) --- esphome/components/esp32/__init__.py | 9 +++--- .../config/reproducible_build_arduino.yaml | 8 ++++++ tests/component_tests/esp32/test_esp32.py | 28 +++++++++++++++++++ 3 files changed, 41 insertions(+), 4 deletions(-) create mode 100644 tests/component_tests/esp32/config/reproducible_build_arduino.yaml diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 78a1715ccf..eb023ce32c 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1724,15 +1724,16 @@ async def to_code(config): CORE.relative_internal_path(".espressif") ) + # Both ESP-IDF and ESP32 Arduino builds generate IDF app metadata. Keep + # volatile build path/time data out of the binary so equivalent projects can + # produce reproducible outputs and downstream tooling can reuse artifacts. + add_idf_sdkconfig_option("CONFIG_APP_REPRODUCIBLE_BUILD", True) + if conf[CONF_TYPE] == FRAMEWORK_ESP_IDF: cg.add_build_flag("-DUSE_ESP_IDF") cg.add_build_flag("-DUSE_ESP32_FRAMEWORK_ESP_IDF") if use_platformio: cg.add_platformio_option("framework", "espidf") - # Strip volatile build path/time metadata from PlatformIO-managed - # ESP-IDF builds so equivalent projects can produce reproducible - # outputs and downstream tooling can safely reuse artifacts. - add_idf_sdkconfig_option("CONFIG_APP_REPRODUCIBLE_BUILD", True) # Wrap std::__throw_* functions to abort immediately, eliminating ~3KB of # exception class overhead. See throw_stubs.cpp for implementation. diff --git a/tests/component_tests/esp32/config/reproducible_build_arduino.yaml b/tests/component_tests/esp32/config/reproducible_build_arduino.yaml new file mode 100644 index 0000000000..a5433a441d --- /dev/null +++ b/tests/component_tests/esp32/config/reproducible_build_arduino.yaml @@ -0,0 +1,8 @@ +esphome: + name: test + +esp32: + board: esp32dev + variant: esp32 + framework: + type: arduino diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index c39a4aafc8..203f484107 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -16,6 +16,7 @@ from esphome.const import ( CONF_ESPHOME, CONF_IGNORE_PIN_VALIDATION_ERROR, CONF_NUMBER, + KEY_NATIVE_IDF, PlatformFramework, ) from esphome.core import CORE @@ -243,3 +244,30 @@ def test_platformio_idf_enables_reproducible_build( sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] assert sdkconfig.get("CONFIG_APP_REPRODUCIBLE_BUILD") is True + + +def test_platformio_arduino_enables_reproducible_build( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Test PlatformIO Arduino builds enable reproducible app metadata.""" + generate_main(component_config_path("reproducible_build_arduino.yaml")) + + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert sdkconfig.get("CONFIG_APP_REPRODUCIBLE_BUILD") is True + + +def test_native_idf_enables_reproducible_build( + component_config_path: Callable[[str], Path], +) -> None: + """Test native ESP-IDF builds enable reproducible app metadata.""" + from esphome.__main__ import generate_cpp_contents + from esphome.config import read_config + + CORE.config_path = component_config_path("reproducible_build.yaml") + CORE.config = read_config({}) + CORE.data[KEY_NATIVE_IDF] = True + generate_cpp_contents(CORE.config) + + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert sdkconfig.get("CONFIG_APP_REPRODUCIBLE_BUILD") is True From 876c8c4c2a160f0aa7cd558c1ea452cad0fb59a3 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 28 Apr 2026 22:59:02 +1200 Subject: [PATCH 19/22] [ci-custom] Lint imports of esphome.components.const outside components (#16068) Co-authored-by: Claude Opus 4.7 (1M context) --- script/ci-custom.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/script/ci-custom.py b/script/ci-custom.py index 4d71df74cf..b257a3818b 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -511,6 +511,40 @@ def lint_no_std_string_view(fname, match): ) +@lint_re_check( + r"(?:" + # `from esphome.components.const import ...` + r"from\s+esphome\.components\.const\s+import" + r"|" + # `import esphome.components.const` (with optional `as` alias) + r"import\s+esphome\.components\.const\b" + r"|" + # `from esphome.components import [(] ... const ... [)]` + # Handles parenthesized + multiline import lists by allowing newlines inside + # the parens via [^)]*. Single-line form falls back to the [^#\n]* branch. + r"from\s+esphome\.components\s+import\s*" + r"(?:\([^)]*\bconst\b[^)]*\)|(?:[^#\n]*[\s,])?\bconst\b)" + r")", + include=["*.py"], + exclude=[ + "esphome/components/*", + "tests/*", + "script/ci-custom.py", + ], +) +def lint_no_components_const_outside_components(fname, match): + return ( + f"Constants in {highlight('esphome/components/const/__init__.py')} are intended " + f"to be shared only between components in {highlight('esphome/components/')}. " + f"Code outside this folder must not import from " + f"{highlight('esphome.components.const')}.\n" + f"For core code (used outside {highlight('esphome/components/')}), define the " + f"constant in {highlight('esphome/const.py')} instead. When adding a new " + f"{highlight('CONF_')} constant there, bump {highlight('CONST_PY_MAX_CONF')} " + f"in this file accordingly (see {highlight('lint_const_py_frozen')})." + ) + + @lint_post_check def lint_constants_usage(): errs = [] From 52f80618d4b8e1b8f206bd360ca4f76116a69b7d Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Tue, 28 Apr 2026 22:00:29 +1000 Subject: [PATCH 20/22] [lvgl] Allow a binary sensor to report checked or pressed state (#16073) Co-authored-by: J. Nick Koston --- .../components/lvgl/binary_sensor/__init__.py | 35 ++++++++++++++----- tests/components/lvgl/lvgl-package.yaml | 15 ++++++++ 2 files changed, 41 insertions(+), 9 deletions(-) diff --git a/esphome/components/lvgl/binary_sensor/__init__.py b/esphome/components/lvgl/binary_sensor/__init__.py index f9df7d23fa..aa68e76421 100644 --- a/esphome/components/lvgl/binary_sensor/__init__.py +++ b/esphome/components/lvgl/binary_sensor/__init__.py @@ -4,15 +4,25 @@ from esphome.components.binary_sensor import ( new_binary_sensor, ) import esphome.config_validation as cv +from esphome.const import CONF_STATE -from ..defines import CONF_WIDGET -from ..lvcode import EVENT_ARG, LambdaContext, LvContext, lvgl_static -from ..types import LV_EVENT, lv_pseudo_button_t +from ..defines import CONF_WIDGET, LV_OBJ_FLAG, LvConstant +from ..lvcode import EVENT_ARG, UPDATE_EVENT, LambdaContext, LvContext, lvgl_static +from ..types import LV_EVENT, LV_STATE, lv_pseudo_button_t from ..widgets import Widget, get_widgets, wait_for_widgets +STATE_PRESSED = "PRESSED" +STATE_CHECKED = "CHECKED" + +BS_STATE = LvConstant( + "LV_STATE_", + STATE_PRESSED, + STATE_CHECKED, +) CONFIG_SCHEMA = binary_sensor_schema(BinarySensor).extend( { cv.Required(CONF_WIDGET): cv.use_id(lv_pseudo_button_t), + cv.Optional(CONF_STATE, default=STATE_PRESSED): BS_STATE.one_of, } ) @@ -22,16 +32,23 @@ async def to_code(config): widget = await get_widgets(config, CONF_WIDGET) widget = widget[0] assert isinstance(widget, Widget) + state = await BS_STATE.process(config[CONF_STATE]) await wait_for_widgets() - async with LambdaContext(EVENT_ARG) as pressed_ctx: - pressed_ctx.add(sensor.publish_state(widget.is_pressed())) + is_pressed = str(state) == str(LV_STATE.PRESSED) + test_expr = widget.is_pressed() if is_pressed else widget.is_checked() + async with LambdaContext(EVENT_ARG) as test_ctx: + test_ctx.add(sensor.publish_state(test_expr)) async with LvContext() as ctx: - ctx.add(sensor.publish_initial_state(widget.is_pressed())) + ctx.add(sensor.publish_initial_state(test_expr)) + if is_pressed: + events = [LV_EVENT.PRESSED, LV_EVENT.RELEASED] + widget.add_flag(LV_OBJ_FLAG.CLICKABLE) + else: + events = [LV_EVENT.VALUE_CHANGED, UPDATE_EVENT] ctx.add( lvgl_static.add_event_cb( widget.obj, - await pressed_ctx.get_lambda(), - LV_EVENT.PRESSED, - LV_EVENT.RELEASED, + await test_ctx.get_lambda(), + *events, ) ) diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index d3565c6c59..d6e237199a 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -16,10 +16,19 @@ binary_sensor: platform: template - id: left_sensor platform: template + - platform: lvgl + name: Button A pressed + widget: button_a + state: pressed + - platform: lvgl + name: Button A checked + widget: button_a + state: checked - platform: lvgl id: button_checker name: LVGL button widget: button_button + state: checked on_state: then: - lvgl.checkbox.update: @@ -29,6 +38,12 @@ binary_sensor: auto y = x; // block inlining of one line return return y; + - platform: lvgl + id: button_presser + name: Button pressed + widget: button_button + state: pressed + lvgl: id: lvgl_id rotation: 90 From 8921e3bb3f821fff077300f04d7d52bc86f41997 Mon Sep 17 00:00:00 2001 From: Egor Vorontsov Date: Tue, 28 Apr 2026 15:49:16 +0300 Subject: [PATCH 21/22] [api] add open states for `lock` to `api.proto` (#15901) 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/api/api.proto | 2 ++ esphome/components/api/api_pb2.h | 2 ++ esphome/components/api/api_pb2_dump.cpp | 4 ++++ 3 files changed, 8 insertions(+) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 1c33d92bea..c0fd990eca 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -1419,6 +1419,8 @@ enum LockState { LOCK_STATE_JAMMED = 3; LOCK_STATE_LOCKING = 4; LOCK_STATE_UNLOCKING = 5; + LOCK_STATE_OPENING = 6; + LOCK_STATE_OPEN = 7; } enum LockCommand { LOCK_UNLOCK = 0; diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index a8e01c017f..7b82f1884d 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -181,6 +181,8 @@ enum LockState : uint32_t { LOCK_STATE_JAMMED = 3, LOCK_STATE_LOCKING = 4, LOCK_STATE_UNLOCKING = 5, + LOCK_STATE_OPENING = 6, + LOCK_STATE_OPEN = 7, }; enum LockCommand : uint32_t { LOCK_UNLOCK = 0, diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 541f5d4d11..5258b355ce 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -487,6 +487,10 @@ template<> const char *proto_enum_to_string(enums::LockState v return ESPHOME_PSTR("LOCK_STATE_LOCKING"); case enums::LOCK_STATE_UNLOCKING: return ESPHOME_PSTR("LOCK_STATE_UNLOCKING"); + case enums::LOCK_STATE_OPENING: + return ESPHOME_PSTR("LOCK_STATE_OPENING"); + case enums::LOCK_STATE_OPEN: + return ESPHOME_PSTR("LOCK_STATE_OPEN"); default: return ESPHOME_PSTR("UNKNOWN"); } From 0759a3c6815e88fd333fef2ed843027ce24d445a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Apr 2026 08:48:13 -0500 Subject: [PATCH 22/22] [core] Split wake.{h,cpp} into per-platform files (#15978) --- esphome/core/config.py | 23 ++ esphome/core/defines.h | 15 +- esphome/core/time_64.cpp | 4 +- esphome/core/time_64.h | 14 +- esphome/core/wake.h | 214 ++---------------- esphome/core/wake/wake_esp8266.cpp | 21 ++ esphome/core/wake/wake_esp8266.h | 47 ++++ esphome/core/wake/wake_freertos.cpp | 33 +++ esphome/core/wake/wake_freertos.h | 60 +++++ esphome/core/wake/wake_generic.cpp | 17 ++ esphome/core/wake/wake_generic.h | 31 +++ esphome/core/{wake.cpp => wake/wake_host.cpp} | 89 +------- esphome/core/wake/wake_host.h | 64 ++++++ esphome/core/wake/wake_rp2040.cpp | 58 +++++ esphome/core/wake/wake_rp2040.h | 31 +++ esphome/loader.py | 50 ++-- tests/unit_tests/test_loader.py | 164 ++++++++++++++ 17 files changed, 632 insertions(+), 303 deletions(-) create mode 100644 esphome/core/wake/wake_esp8266.cpp create mode 100644 esphome/core/wake/wake_esp8266.h create mode 100644 esphome/core/wake/wake_freertos.cpp create mode 100644 esphome/core/wake/wake_freertos.h create mode 100644 esphome/core/wake/wake_generic.cpp create mode 100644 esphome/core/wake/wake_generic.h rename esphome/core/{wake.cpp => wake/wake_host.cpp} (74%) create mode 100644 esphome/core/wake/wake_host.h create mode 100644 esphome/core/wake/wake_rp2040.cpp create mode 100644 esphome/core/wake/wake_rp2040.h diff --git a/esphome/core/config.py b/esphome/core/config.py index 018e05f17b..14161a7c8b 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -792,6 +792,29 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( PlatformFramework.RTL87XX_ARDUINO, PlatformFramework.LN882X_ARDUINO, }, + # Per-platform wake implementations — wake.h dispatches to exactly one of + # these based on USE_*, so the others can be skipped at the source level + # too. Header files next to each .cpp are always copied (the dispatcher + # #include's them) but compile to empty TUs on the wrong platform anyway. + "wake/wake_freertos.cpp": { + PlatformFramework.ESP32_ARDUINO, + PlatformFramework.ESP32_IDF, + PlatformFramework.BK72XX_ARDUINO, + PlatformFramework.RTL87XX_ARDUINO, + PlatformFramework.LN882X_ARDUINO, + }, + "wake/wake_esp8266.cpp": { + PlatformFramework.ESP8266_ARDUINO, + }, + "wake/wake_rp2040.cpp": { + PlatformFramework.RP2040_ARDUINO, + }, + "wake/wake_host.cpp": { + PlatformFramework.HOST_NATIVE, + }, + "wake/wake_generic.cpp": { + PlatformFramework.NRF52_ZEPHYR, + }, # Note: lock_free_queue.h and event_pool.h are header files and don't need to be filtered # as they are only included when needed by the preprocessor } diff --git a/esphome/core/defines.h b/esphome/core/defines.h index f929b224ca..daca55d68a 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -17,8 +17,21 @@ #define ESPHOME_DEBUG_SCHEDULER #define ESPHOME_DEBUG_API -// Default threading model for static analysis (ESP32 is multi-threaded with atomics) +// Threading model for static analysis. Match what the real codegen picks per +// platform (see esphome/components//__init__.py ThreadModel.*): +// USE_ESP8266 / USE_RP2040 / USE_NRF52 → SINGLE +// USE_BK72XX (ARMv5TE, no LDREX/STREX) → MULTI_NO_ATOMICS +// everything else (ESP32, host, RTL87XX, LN882X) → MULTI_ATOMICS +// Without this the clang-tidy envs end up with USE_ +// + MULTI_ATOMICS simultaneously, a combination that can never occur in a +// real build. +#if defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_NRF52) +#define ESPHOME_THREAD_SINGLE +#elif defined(USE_BK72XX) +#define ESPHOME_THREAD_MULTI_NO_ATOMICS +#else #define ESPHOME_THREAD_MULTI_ATOMICS +#endif // logger #define ESPHOME_LOG_LEVEL ESPHOME_LOG_LEVEL_VERY_VERBOSE diff --git a/esphome/core/time_64.cpp b/esphome/core/time_64.cpp index cf651c3e91..25076228d5 100644 --- a/esphome/core/time_64.cpp +++ b/esphome/core/time_64.cpp @@ -22,8 +22,8 @@ static const char *const TAG = "time_64"; #ifdef ESPHOME_THREAD_SINGLE // Storage for Millis64Impl inline compute() — defined here so all TUs share one copy. -uint32_t Millis64Impl::last_millis_{0}; -uint16_t Millis64Impl::millis_major_{0}; +uint32_t Millis64Impl::last_millis{0}; +uint16_t Millis64Impl::millis_major{0}; #else uint64_t Millis64Impl::compute(uint32_t now) { diff --git a/esphome/core/time_64.h b/esphome/core/time_64.h index 592e645d41..d82373dbfe 100644 --- a/esphome/core/time_64.h +++ b/esphome/core/time_64.h @@ -21,8 +21,8 @@ class Millis64Impl { #ifdef ESPHOME_THREAD_SINGLE // Storage defined in time_64.cpp — declared here so the inline body can access them. - static uint32_t last_millis_; - static uint16_t millis_major_; + static uint32_t last_millis; + static uint16_t millis_major; static inline uint64_t ESPHOME_ALWAYS_INLINE compute(uint32_t now) { // Half the 32-bit range - used to detect rollovers vs normal time progression @@ -30,17 +30,17 @@ class Millis64Impl { // Single-core platforms have no concurrency, so this is a simple implementation // that just tracks 32-bit rollover (every 49.7 days) without any locking or atomics. - uint16_t major = millis_major_; - uint32_t last = last_millis_; + uint16_t major = millis_major; + uint32_t last = last_millis; // Check for rollover if (now < last && (last - now) > HALF_MAX_UINT32) { - millis_major_++; + millis_major++; major++; - last_millis_ = now; + last_millis = now; } else if (now > last) { // Only update if time moved forward - last_millis_ = now; + last_millis = now; } // Combine major (high 32 bits) and now (low 32 bits) into 64-bit time diff --git a/esphome/core/wake.h b/esphome/core/wake.h index 0cfca94a78..a2f732fcdb 100644 --- a/esphome/core/wake.h +++ b/esphome/core/wake.h @@ -3,6 +3,10 @@ /// @file wake.h /// Platform-specific main loop wake primitives. /// Always available on all platforms — no opt-in needed. +/// +/// The public API for callers lives here; the per-platform implementations +/// live under esphome/core/wake/ and are included at the bottom of this file +/// based on the active USE_* platform define. #include "esphome/core/defines.h" #include "esphome/core/hal.h" @@ -11,21 +15,6 @@ #include #endif -#if defined(USE_ESP32) || defined(USE_LIBRETINY) -#include "esphome/core/main_task.h" -#endif -#ifdef USE_ESP8266 -#include -#elif defined(USE_RP2040) -#include -#include -#endif - -#ifdef USE_HOST -#include -#include -#endif - namespace esphome { // === Wake flag for ESP8266/RP2040 === @@ -67,184 +56,19 @@ __attribute__((always_inline)) inline bool wake_request_take() { } #endif -// === ESP32 / LibreTiny (FreeRTOS) === -#if defined(USE_ESP32) || defined(USE_LIBRETINY) - -/// Wake the main loop from any context (ISR or task). -/// always_inline so callers placed in IRAM keep the whole wake path in IRAM. -__attribute__((always_inline)) inline void wake_main_task_any_context() { - // Set the wake-requested flag BEFORE the task notification so the consumer - // (Application::loop() gate) is guaranteed to see it on its next gate check. - wake_request_set(); - if (in_isr_context()) { - BaseType_t px_higher_priority_task_woken = pdFALSE; - esphome_main_task_notify_from_isr(&px_higher_priority_task_woken); -#ifdef portYIELD_FROM_ISR - portYIELD_FROM_ISR(px_higher_priority_task_woken); -#else - // ARM9 FreeRTOS port (BK72xx) does not define portYIELD_FROM_ISR; the IRQ - // exit sequence performs the context switch if one was requested. - (void) px_higher_priority_task_woken; -#endif - } else { - esphome_main_task_notify(); - } -} - -/// IRAM_ATTR entry points — defined in wake.cpp. -void wake_loop_isrsafe(BaseType_t *px_higher_priority_task_woken); -void wake_loop_any_context(); - -inline void wake_loop_threadsafe() { - wake_request_set(); - esphome_main_task_notify(); -} - -namespace internal { -inline void ESPHOME_ALWAYS_INLINE wakeable_delay(uint32_t ms) { - // Fast path (with USE_LWIP_FAST_SELECT): FreeRTOS task notifications posted by the lwip - // event_callback wrapper (see lwip_fast_select.c) are the single source of truth for - // socket wake-ups. Every NETCONN_EVT_RCVPLUS posts an xTaskNotifyGive, so any notification - // that lands between wakes keeps the counter non-zero (next ulTaskNotifyTake returns - // immediately) or wakes a blocked Take directly. Additional wake sources: - // wake_loop_threadsafe() from background tasks, and the ms timeout. - if (ms == 0) [[unlikely]] { - yield(); - return; - } - ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(ms)); -} -} // namespace internal - -// === ESP8266 === -#elif defined(USE_ESP8266) - -/// Inline implementation — IRAM callers inline this directly. -inline void ESPHOME_ALWAYS_INLINE wake_loop_impl() { - // Set the wake-requested flag BEFORE esp_schedule so the consumer is - // guaranteed to see it on its next gate check. - wake_request_set(); - g_main_loop_woke = true; - esp_schedule(); -} - -/// IRAM_ATTR entry point for ISR callers — defined in wake.cpp. -void wake_loop_any_context(); - -/// Non-ISR: always inline. -inline void wake_loop_threadsafe() { wake_loop_impl(); } - -/// ISR-safe: no task_woken arg because ESP8266 has no FreeRTOS. Caller must be IRAM_ATTR. -inline void ESPHOME_ALWAYS_INLINE wake_loop_isrsafe() { wake_loop_impl(); } - -namespace internal { -inline void ESPHOME_ALWAYS_INLINE wakeable_delay(uint32_t ms) { - if (ms == 0) [[unlikely]] { - delay(0); - return; - } - if (g_main_loop_woke) { - g_main_loop_woke = false; - return; - } - esp_delay(ms, []() { return !g_main_loop_woke; }); -} -} // namespace internal - -// === RP2040 === -#elif defined(USE_RP2040) - -inline void wake_loop_any_context() { - // Set the wake-requested flag BEFORE the SEV so the consumer is guaranteed - // to see it on its next gate check. - wake_request_set(); - g_main_loop_woke = true; - __sev(); -} - -inline void wake_loop_threadsafe() { wake_loop_any_context(); } - -/// RP2040 wakeable delay uses file-scope state (alarm callback + flag) — defined in wake.cpp. -namespace internal { -void wakeable_delay(uint32_t ms); -} // namespace internal - -// === Host / Zephyr / other === -#else - -#ifdef USE_HOST -/// Host: wakes select() via UDP loopback socket. Defined in wake.cpp. -void wake_loop_threadsafe(); - -/// Register a socket file descriptor with the host select() loop. Not -/// thread-safe — main loop only. Returns false if fd is invalid or -/// >= FD_SETSIZE. -bool wake_register_fd(int fd); - -/// Unregister a socket file descriptor. Not thread-safe — main loop only. -void wake_unregister_fd(int fd); - -/// One-time setup of the loopback wake socket. Called from Application::setup(). -void wake_setup(); - -// wake_fd_ready() and wake_drain_notifications() are defined inline at the -// bottom of this file — they need internal::g_read_fds / g_wake_socket_fd in -// scope, which depend on USE_HOST-only includes pulled in above. -#else -/// Zephyr is currently the only platform without a wake mechanism. -/// wake_loop_threadsafe() is a no-op and wakeable_delay() falls back to delay(). -/// TODO: implement proper Zephyr wake using k_poll / k_sem or similar. -inline void wake_loop_threadsafe() {} -#endif - -inline void wake_loop_any_context() { wake_loop_threadsafe(); } - -namespace internal { -#ifdef USE_HOST -/// Host wakeable_delay uses select() over the registered fds — defined in wake.cpp. -void wakeable_delay(uint32_t ms); -#else -inline void ESPHOME_ALWAYS_INLINE wakeable_delay(uint32_t ms) { - if (ms == 0) [[unlikely]] { - yield(); - return; - } - delay(ms); -} -#endif -} // namespace internal - -#endif - -#ifdef USE_HOST -namespace internal { -// File-scope state owned by wake.cpp. Accessed inline by wake_drain_notifications() -// and wake_fd_ready() so the hot path stays in the header. -// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) -extern int g_wake_socket_fd; -// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) -extern fd_set g_read_fds; -} // namespace internal - -inline bool ESPHOME_ALWAYS_INLINE wake_fd_ready(int fd) { return FD_ISSET(fd, &internal::g_read_fds); } - -// Small buffer for draining wake notification bytes (1 byte sent per wake). -// Sized to drain multiple notifications per recvfrom() without wasting stack. -inline constexpr size_t WAKE_NOTIFY_DRAIN_BUFFER_SIZE = 16; - -inline void ESPHOME_ALWAYS_INLINE wake_drain_notifications() { - // Called from main loop to drain any pending wake notifications. - // Must check wake_fd_ready() to avoid blocking on empty socket. - if (internal::g_wake_socket_fd >= 0 && wake_fd_ready(internal::g_wake_socket_fd)) { - char buffer[WAKE_NOTIFY_DRAIN_BUFFER_SIZE]; - // Drain all pending notifications with non-blocking reads. Multiple wake events - // may have triggered multiple writes, so drain until EWOULDBLOCK. We control - // both ends of this loopback socket (always 1 byte per wake), so no error - // checking — any error indicates catastrophic system failure. - while (::recvfrom(internal::g_wake_socket_fd, buffer, sizeof(buffer), 0, nullptr, nullptr) > 0) { - } - } -} -#endif // USE_HOST - } // namespace esphome + +// Per-platform implementations. Each header re-enters namespace esphome {} and +// guards its body with the matching USE_* check, so only one contributes code +// for the active target. +#if defined(USE_ESP32) || defined(USE_LIBRETINY) +#include "esphome/core/wake/wake_freertos.h" +#elif defined(USE_ESP8266) +#include "esphome/core/wake/wake_esp8266.h" +#elif defined(USE_RP2040) +#include "esphome/core/wake/wake_rp2040.h" +#elif defined(USE_HOST) +#include "esphome/core/wake/wake_host.h" +#else +#include "esphome/core/wake/wake_generic.h" +#endif diff --git a/esphome/core/wake/wake_esp8266.cpp b/esphome/core/wake/wake_esp8266.cpp new file mode 100644 index 0000000000..9ced43c6df --- /dev/null +++ b/esphome/core/wake/wake_esp8266.cpp @@ -0,0 +1,21 @@ +#include "esphome/core/defines.h" + +#ifdef USE_ESP8266 + +#include "esphome/core/hal.h" +#include "esphome/core/wake.h" + +namespace esphome { + +// === Wake-requested flag + main-loop woke flag storage === +// ESP8266 is always ESPHOME_THREAD_SINGLE. +// NOLINTBEGIN(cppcoreguidelines-avoid-non-const-global-variables) +volatile uint8_t g_wake_requested = 0; +volatile bool g_main_loop_woke = false; +// NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables) + +void IRAM_ATTR wake_loop_any_context() { wake_loop_impl(); } + +} // namespace esphome + +#endif // USE_ESP8266 diff --git a/esphome/core/wake/wake_esp8266.h b/esphome/core/wake/wake_esp8266.h new file mode 100644 index 0000000000..80cd61035b --- /dev/null +++ b/esphome/core/wake/wake_esp8266.h @@ -0,0 +1,47 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_ESP8266 + +#include "esphome/core/hal.h" + +#include + +namespace esphome { + +/// Inline implementation — IRAM callers inline this directly. +inline void ESPHOME_ALWAYS_INLINE wake_loop_impl() { + // Set the wake-requested flag BEFORE esp_schedule so the consumer is + // guaranteed to see it on its next gate check. + wake_request_set(); + g_main_loop_woke = true; + esp_schedule(); +} + +/// IRAM_ATTR entry point for ISR callers — defined in wake_esp8266.cpp. +void wake_loop_any_context(); + +/// Non-ISR: always inline. +inline void wake_loop_threadsafe() { wake_loop_impl(); } + +/// ISR-safe: no task_woken arg because ESP8266 has no FreeRTOS. Caller must be IRAM_ATTR. +inline void ESPHOME_ALWAYS_INLINE wake_loop_isrsafe() { wake_loop_impl(); } + +namespace internal { +inline void ESPHOME_ALWAYS_INLINE wakeable_delay(uint32_t ms) { + if (ms == 0) [[unlikely]] { + delay(0); + return; + } + if (g_main_loop_woke) { + g_main_loop_woke = false; + return; + } + esp_delay(ms, []() { return !g_main_loop_woke; }); +} +} // namespace internal + +} // namespace esphome + +#endif // USE_ESP8266 diff --git a/esphome/core/wake/wake_freertos.cpp b/esphome/core/wake/wake_freertos.cpp new file mode 100644 index 0000000000..0bf700daa8 --- /dev/null +++ b/esphome/core/wake/wake_freertos.cpp @@ -0,0 +1,33 @@ +#include "esphome/core/defines.h" + +#if defined(USE_ESP32) || defined(USE_LIBRETINY) + +#include "esphome/core/hal.h" +#include "esphome/core/wake.h" + +namespace esphome { + +// === Wake-requested flag storage === +// ESP32 is always MULTI_ATOMICS; LibreTiny is MULTI_ATOMICS on chips with +// proper atomics (e.g. RTL8720) and MULTI_NO_ATOMICS on others (e.g. BK72XX). +#ifdef ESPHOME_THREAD_MULTI_ATOMICS +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +std::atomic g_wake_requested{0}; +#else +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +volatile uint8_t g_wake_requested = 0; +#endif + +void IRAM_ATTR wake_loop_isrsafe(BaseType_t *px_higher_priority_task_woken) { + // ISR-safe: set flag before notify so the wake is visible on the next gate + // check. wake_request_set() is just an aligned 8-bit store / atomic store + // and is safe from IRAM. + wake_request_set(); + esphome_main_task_notify_from_isr(px_higher_priority_task_woken); +} + +void IRAM_ATTR wake_loop_any_context() { wake_main_task_any_context(); } + +} // namespace esphome + +#endif // USE_ESP32 || USE_LIBRETINY diff --git a/esphome/core/wake/wake_freertos.h b/esphome/core/wake/wake_freertos.h new file mode 100644 index 0000000000..167a422c61 --- /dev/null +++ b/esphome/core/wake/wake_freertos.h @@ -0,0 +1,60 @@ +#pragma once + +#include "esphome/core/defines.h" + +#if defined(USE_ESP32) || defined(USE_LIBRETINY) + +#include "esphome/core/hal.h" +#include "esphome/core/main_task.h" + +namespace esphome { + +/// Wake the main loop from any context (ISR or task). +/// always_inline so callers placed in IRAM keep the whole wake path in IRAM. +__attribute__((always_inline)) inline void wake_main_task_any_context() { + // Set the wake-requested flag BEFORE the task notification so the consumer + // (Application::loop() gate) is guaranteed to see it on its next gate check. + wake_request_set(); + if (in_isr_context()) { + BaseType_t px_higher_priority_task_woken = pdFALSE; + esphome_main_task_notify_from_isr(&px_higher_priority_task_woken); +#ifdef portYIELD_FROM_ISR + portYIELD_FROM_ISR(px_higher_priority_task_woken); +#else + // ARM9 FreeRTOS port (BK72xx) does not define portYIELD_FROM_ISR; the IRQ + // exit sequence performs the context switch if one was requested. + (void) px_higher_priority_task_woken; +#endif + } else { + esphome_main_task_notify(); + } +} + +/// IRAM_ATTR entry points — defined in wake_freertos.cpp. +void wake_loop_isrsafe(BaseType_t *px_higher_priority_task_woken); +void wake_loop_any_context(); + +inline void wake_loop_threadsafe() { + wake_request_set(); + esphome_main_task_notify(); +} + +namespace internal { +inline void ESPHOME_ALWAYS_INLINE wakeable_delay(uint32_t ms) { + // Fast path (with USE_LWIP_FAST_SELECT): FreeRTOS task notifications posted by the lwip + // event_callback wrapper (see lwip_fast_select.c) are the single source of truth for + // socket wake-ups. Every NETCONN_EVT_RCVPLUS posts an xTaskNotifyGive, so any notification + // that lands between wakes keeps the counter non-zero (next ulTaskNotifyTake returns + // immediately) or wakes a blocked Take directly. Additional wake sources: + // wake_loop_threadsafe() from background tasks, and the ms timeout. + if (ms == 0) [[unlikely]] { + yield(); + return; + } + ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(ms)); +} +} // namespace internal + +} // namespace esphome + +#endif // USE_ESP32 || USE_LIBRETINY diff --git a/esphome/core/wake/wake_generic.cpp b/esphome/core/wake/wake_generic.cpp new file mode 100644 index 0000000000..40044e4311 --- /dev/null +++ b/esphome/core/wake/wake_generic.cpp @@ -0,0 +1,17 @@ +#include "esphome/core/defines.h" + +#if !defined(USE_ESP32) && !defined(USE_LIBRETINY) && !defined(USE_ESP8266) && !defined(USE_RP2040) && \ + !defined(USE_HOST) + +#include "esphome/core/wake.h" + +namespace esphome { + +// === Wake-requested flag storage === +// Fallback platforms (currently only Zephyr/NRF52) are ESPHOME_THREAD_SINGLE. +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +volatile uint8_t g_wake_requested = 0; + +} // namespace esphome + +#endif // fallback guard diff --git a/esphome/core/wake/wake_generic.h b/esphome/core/wake/wake_generic.h new file mode 100644 index 0000000000..85424b6138 --- /dev/null +++ b/esphome/core/wake/wake_generic.h @@ -0,0 +1,31 @@ +#pragma once + +#include "esphome/core/defines.h" + +#if !defined(USE_ESP32) && !defined(USE_LIBRETINY) && !defined(USE_ESP8266) && !defined(USE_RP2040) && \ + !defined(USE_HOST) + +#include "esphome/core/hal.h" + +namespace esphome { + +/// Zephyr is currently the only platform without a wake mechanism. +/// wake_loop_threadsafe() is a no-op and wakeable_delay() falls back to delay(). +/// TODO: implement proper Zephyr wake using k_poll / k_sem or similar. +inline void wake_loop_threadsafe() {} + +inline void wake_loop_any_context() { wake_loop_threadsafe(); } + +namespace internal { +inline void ESPHOME_ALWAYS_INLINE wakeable_delay(uint32_t ms) { + if (ms == 0) [[unlikely]] { + yield(); + return; + } + delay(ms); +} +} // namespace internal + +} // namespace esphome + +#endif // fallback guard diff --git a/esphome/core/wake.cpp b/esphome/core/wake/wake_host.cpp similarity index 74% rename from esphome/core/wake.cpp rename to esphome/core/wake/wake_host.cpp index cac88ae91e..9d2a650ca2 100644 --- a/esphome/core/wake.cpp +++ b/esphome/core/wake/wake_host.cpp @@ -1,12 +1,11 @@ -#include "esphome/core/wake.h" -#include "esphome/core/hal.h" -#include "esphome/core/log.h" - -#ifdef USE_ESP8266 -#include -#endif +#include "esphome/core/defines.h" #ifdef USE_HOST + +#include "esphome/core/hal.h" +#include "esphome/core/log.h" +#include "esphome/core/wake.h" + #include #include #include @@ -15,88 +14,19 @@ #include #include #include -#endif namespace esphome { // === Wake-requested flag storage === -#ifdef ESPHOME_THREAD_MULTI_ATOMICS +// Host is always ESPHOME_THREAD_MULTI_ATOMICS. // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) std::atomic g_wake_requested{0}; -#else -// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) -volatile uint8_t g_wake_requested = 0; -#endif - -// === ESP32 / LibreTiny — IRAM_ATTR entry points === -#if defined(USE_ESP32) || defined(USE_LIBRETINY) -void IRAM_ATTR wake_loop_isrsafe(BaseType_t *px_higher_priority_task_woken) { - // ISR-safe: set flag before notify so the wake is visible on the next gate - // check. wake_request_set() is just an aligned 8-bit store / atomic store - // and is safe from IRAM. - wake_request_set(); - esphome_main_task_notify_from_isr(px_higher_priority_task_woken); -} -void IRAM_ATTR wake_loop_any_context() { wake_main_task_any_context(); } -#endif - -// === ESP8266 / RP2040 === -#if defined(USE_ESP8266) || defined(USE_RP2040) -// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) -volatile bool g_main_loop_woke = false; -#endif - -#ifdef USE_ESP8266 -void IRAM_ATTR wake_loop_any_context() { wake_loop_impl(); } -#endif - -// === RP2040 — wakeable_delay (needs file-scope state for alarm callback) === -#ifdef USE_RP2040 -// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) -static volatile bool s_delay_expired = false; - -static int64_t alarm_callback_(alarm_id_t id, void *user_data) { - (void) id; - (void) user_data; - s_delay_expired = true; - __sev(); - return 0; -} - -namespace internal { -void wakeable_delay(uint32_t ms) { - if (ms == 0) [[unlikely]] { - yield(); - return; - } - if (g_main_loop_woke) { - g_main_loop_woke = false; - return; - } - s_delay_expired = false; - alarm_id_t alarm = add_alarm_in_ms(ms, alarm_callback_, nullptr, true); - if (alarm <= 0) { - delay(ms); - return; - } - while (!g_main_loop_woke && !s_delay_expired) { - __wfe(); - } - if (!s_delay_expired) - cancel_alarm(alarm); - g_main_loop_woke = false; -} -} // namespace internal -#endif // USE_RP2040 - -// === Host (UDP loopback socket + select() based fd watcher) === -#ifdef USE_HOST static const char *const TAG = "wake"; namespace internal { // File-scope state — referenced inline by wake_drain_notifications() and -// wake_fd_ready() in wake.h, and by the bodies in this file. +// wake_fd_ready() in wake_host.h, and by the bodies in this file. // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) int g_wake_socket_fd = -1; // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) @@ -271,6 +201,7 @@ void wake_setup() { return; } } -#endif // USE_HOST } // namespace esphome + +#endif // USE_HOST diff --git a/esphome/core/wake/wake_host.h b/esphome/core/wake/wake_host.h new file mode 100644 index 0000000000..9756ed4c39 --- /dev/null +++ b/esphome/core/wake/wake_host.h @@ -0,0 +1,64 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_HOST + +#include "esphome/core/hal.h" + +#include +#include + +namespace esphome { + +/// Host: wakes select() via UDP loopback socket. Defined in wake_host.cpp. +void wake_loop_threadsafe(); + +/// Register a socket file descriptor with the host select() loop. Not +/// thread-safe — main loop only. Returns false if fd is invalid or +/// >= FD_SETSIZE. +bool wake_register_fd(int fd); + +/// Unregister a socket file descriptor. Not thread-safe — main loop only. +void wake_unregister_fd(int fd); + +/// One-time setup of the loopback wake socket. Called from Application::setup(). +void wake_setup(); + +inline void wake_loop_any_context() { wake_loop_threadsafe(); } + +namespace internal { +/// Host wakeable_delay uses select() over the registered fds — defined in wake_host.cpp. +void wakeable_delay(uint32_t ms); + +// File-scope state owned by wake_host.cpp. Accessed inline by +// wake_drain_notifications() and wake_fd_ready() so the hot path stays in the header. +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +extern int g_wake_socket_fd; +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +extern fd_set g_read_fds; +} // namespace internal + +inline bool ESPHOME_ALWAYS_INLINE wake_fd_ready(int fd) { return FD_ISSET(fd, &internal::g_read_fds); } + +// Small buffer for draining wake notification bytes (1 byte sent per wake). +// Sized to drain multiple notifications per recvfrom() without wasting stack. +inline constexpr size_t WAKE_NOTIFY_DRAIN_BUFFER_SIZE = 16; + +inline void ESPHOME_ALWAYS_INLINE wake_drain_notifications() { + // Called from main loop to drain any pending wake notifications. + // Must check wake_fd_ready() to avoid blocking on empty socket. + if (internal::g_wake_socket_fd >= 0 && wake_fd_ready(internal::g_wake_socket_fd)) { + char buffer[WAKE_NOTIFY_DRAIN_BUFFER_SIZE]; + // Drain all pending notifications with non-blocking reads. Multiple wake events + // may have triggered multiple writes, so drain until EWOULDBLOCK. We control + // both ends of this loopback socket (always 1 byte per wake), so no error + // checking — any error indicates catastrophic system failure. + while (::recvfrom(internal::g_wake_socket_fd, buffer, sizeof(buffer), 0, nullptr, nullptr) > 0) { + } + } +} + +} // namespace esphome + +#endif // USE_HOST diff --git a/esphome/core/wake/wake_rp2040.cpp b/esphome/core/wake/wake_rp2040.cpp new file mode 100644 index 0000000000..b18248dbd2 --- /dev/null +++ b/esphome/core/wake/wake_rp2040.cpp @@ -0,0 +1,58 @@ +#include "esphome/core/defines.h" + +#ifdef USE_RP2040 + +#include "esphome/core/hal.h" +#include "esphome/core/wake.h" + +#include +#include + +namespace esphome { + +// === Wake-requested flag + main-loop woke flag storage === +// RP2040 is always ESPHOME_THREAD_SINGLE. +// NOLINTBEGIN(cppcoreguidelines-avoid-non-const-global-variables) +volatile uint8_t g_wake_requested = 0; +volatile bool g_main_loop_woke = false; +// NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables) + +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +static volatile bool s_delay_expired = false; + +static int64_t alarm_callback_(alarm_id_t id, void *user_data) { + (void) id; + (void) user_data; + s_delay_expired = true; + __sev(); + return 0; +} + +namespace internal { +void wakeable_delay(uint32_t ms) { + if (ms == 0) [[unlikely]] { + yield(); + return; + } + if (g_main_loop_woke) { + g_main_loop_woke = false; + return; + } + s_delay_expired = false; + alarm_id_t alarm = add_alarm_in_ms(ms, alarm_callback_, nullptr, true); + if (alarm <= 0) { + delay(ms); + return; + } + while (!g_main_loop_woke && !s_delay_expired) { + __wfe(); + } + if (!s_delay_expired) + cancel_alarm(alarm); + g_main_loop_woke = false; +} +} // namespace internal + +} // namespace esphome + +#endif // USE_RP2040 diff --git a/esphome/core/wake/wake_rp2040.h b/esphome/core/wake/wake_rp2040.h new file mode 100644 index 0000000000..ea1242f535 --- /dev/null +++ b/esphome/core/wake/wake_rp2040.h @@ -0,0 +1,31 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_RP2040 + +#include "esphome/core/hal.h" + +#include +#include + +namespace esphome { + +inline void wake_loop_any_context() { + // Set the wake-requested flag BEFORE the SEV so the consumer is guaranteed + // to see it on its next gate check. + wake_request_set(); + g_main_loop_woke = true; + __sev(); +} + +inline void wake_loop_threadsafe() { wake_loop_any_context(); } + +/// RP2040 wakeable delay uses file-scope state (alarm callback + flag) — defined in wake_rp2040.cpp. +namespace internal { +void wakeable_delay(uint32_t ms); +} // namespace internal + +} // namespace esphome + +#endif // USE_RP2040 diff --git a/esphome/loader.py b/esphome/loader.py index 68664aaa26..9390b8094b 100644 --- a/esphome/loader.py +++ b/esphome/loader.py @@ -31,8 +31,9 @@ class FileResource: class ComponentManifest: - def __init__(self, module: ModuleType): + def __init__(self, module: ModuleType, recursive_sources: bool = False): self.module = module + self.recursive_sources = recursive_sources @property def package(self) -> str: @@ -108,8 +109,10 @@ class ComponentManifest: def resources(self) -> list[FileResource]: """Return a list of all file resources defined in the package of this component. - This will return all cpp source files that are located in the same folder as the - loaded .py file (does not look through subdirectories) + By default only files directly in the package directory are returned. Manifests + constructed with ``recursive_sources=True`` also descend into non-subpackage + subdirectories (subdirectories without an ``__init__.py``), so core code can + live under ``esphome/core//`` without every component paying the cost. """ ret: list[FileResource] = [] @@ -121,23 +124,30 @@ class ComponentManifest: set(filter_source_files_func()) if filter_source_files_func else set() ) - # Process all resources - for resource in ( - r.name - for r in importlib.resources.files(self.package).iterdir() - if r.is_file() - ): - if Path(resource).suffix not in SOURCE_FILE_EXTENSIONS: - continue - if not importlib.resources.files(self.package).joinpath(resource).is_file(): - # Not a resource = this is a directory (yeah this is confusing) - continue + root = importlib.resources.files(self.package) - # Skip excluded files - if resource in excluded_files: - continue + for child in root.iterdir(): + name = child.name + if child.is_file(): + if Path(name).suffix not in SOURCE_FILE_EXTENSIONS: + continue + if name in excluded_files: + continue + ret.append(FileResource(self.package, name)) + elif self.recursive_sources and child.is_dir() and name != "__pycache__": + # Skip Python subpackages — they load as their own components. + if child.joinpath("__init__.py").is_file(): + continue + for sub in child.iterdir(): + if not sub.is_file(): + continue + if Path(sub.name).suffix not in SOURCE_FILE_EXTENSIONS: + continue + resource = f"{name}/{sub.name}" + if resource in excluded_files: + continue + ret.append(FileResource(self.package, resource)) - ret.append(FileResource(self.package, resource)) return ret @@ -237,7 +247,9 @@ def get_platform(domain: str, platform: str) -> ComponentManifest | None: _COMPONENT_CACHE: dict[str, ComponentManifest] = {} CORE_COMPONENTS_PATH = (Path(__file__).parent / "components").resolve() -_COMPONENT_CACHE["esphome"] = ComponentManifest(esphome.core.config) +_COMPONENT_CACHE["esphome"] = ComponentManifest( + esphome.core.config, recursive_sources=True +) def _replace_component_manifest(domain: str, manifest: ComponentManifest) -> None: diff --git a/tests/unit_tests/test_loader.py b/tests/unit_tests/test_loader.py index a42cc5cca7..3fb0eca4a0 100644 --- a/tests/unit_tests/test_loader.py +++ b/tests/unit_tests/test_loader.py @@ -158,3 +158,167 @@ def test_component_manifest_resources_with_filter_source_files() -> None: # Verify the correct number of resources assert len(resources) == 3 # test.cpp, test.h, common.cpp + + +# --------------------------------------------------------------------------- +# recursive_sources — used only by the core "esphome" manifest so that files +# in esphome/core//*.cpp (e.g. esphome/core/wake/wake_host.cpp) are +# discovered without promoting / to a Python subpackage. +# --------------------------------------------------------------------------- + + +def _mock_file(filename: str) -> MagicMock: + m = MagicMock() + m.name = filename + m.is_file.return_value = True + m.is_dir.return_value = False + return m + + +def _mock_dir(dirname: str, children: list, has_init: bool = False) -> MagicMock: + """Mock a directory entry with an iterdir() and joinpath('__init__.py').""" + d = MagicMock() + d.name = dirname + d.is_file.return_value = False + d.is_dir.return_value = True + d.iterdir.return_value = children + init_marker = MagicMock() + init_marker.is_file.return_value = has_init + d.joinpath.return_value = init_marker + return d + + +def test_component_manifest_resources_non_recursive_skips_subdirs() -> None: + """Default (recursive_sources=False) does not descend into subdirectories.""" + mock_module = MagicMock() + mock_module.__package__ = "esphome.components.test_component" + # No FILTER_SOURCE_FILES. + del mock_module.FILTER_SOURCE_FILES + + manifest = ComponentManifest(mock_module) # recursive_sources defaults to False + + top_level = [ + _mock_file("top.cpp"), + _mock_dir("subdir", [_mock_file("nested.cpp")]), + ] + with patch("importlib.resources.files") as mock_files_func: + pkg = MagicMock() + pkg.iterdir.return_value = top_level + mock_files_func.return_value = pkg + + names = [r.resource for r in manifest.resources] + + assert names == ["top.cpp"] + + +def test_component_manifest_resources_recursive_walks_non_subpackage_subdirs() -> None: + """With recursive_sources=True, a subdir without __init__.py is walked.""" + mock_module = MagicMock() + mock_module.__package__ = "esphome.core" + del mock_module.FILTER_SOURCE_FILES + + manifest = ComponentManifest(mock_module, recursive_sources=True) + + wake_dir = _mock_dir( + "wake", + [ + _mock_file("wake_host.cpp"), + _mock_file("wake_host.h"), + _mock_file("README.md"), # wrong suffix, excluded + ], + has_init=False, + ) + top_level = [ + _mock_file("wake.h"), + wake_dir, + ] + with patch("importlib.resources.files") as mock_files_func: + pkg = MagicMock() + pkg.iterdir.return_value = top_level + mock_files_func.return_value = pkg + + names = sorted(r.resource for r in manifest.resources) + + assert names == ["wake.h", "wake/wake_host.cpp", "wake/wake_host.h"] + + +def test_component_manifest_resources_recursive_skips_subpackages() -> None: + """Subdirectories that ARE Python subpackages (contain __init__.py) are + skipped even with recursive_sources=True — those load as their own + ComponentManifest and would otherwise be double-counted.""" + mock_module = MagicMock() + mock_module.__package__ = "esphome.components.haier" + del mock_module.FILTER_SOURCE_FILES + + manifest = ComponentManifest(mock_module, recursive_sources=True) + + button_pkg = _mock_dir( + "button", + [_mock_file("self_cleaning.cpp")], + has_init=True, # Python subpackage — must be skipped. + ) + top_level = [ + _mock_file("haier.cpp"), + button_pkg, + ] + with patch("importlib.resources.files") as mock_files_func: + pkg = MagicMock() + pkg.iterdir.return_value = top_level + mock_files_func.return_value = pkg + + names = [r.resource for r in manifest.resources] + + assert names == ["haier.cpp"] + + +def test_component_manifest_resources_recursive_skips_pycache() -> None: + """__pycache__ inside a recursive walk must never be descended into.""" + mock_module = MagicMock() + mock_module.__package__ = "esphome.core" + del mock_module.FILTER_SOURCE_FILES + + manifest = ComponentManifest(mock_module, recursive_sources=True) + + # __pycache__ is_dir=True but must be skipped without checking __init__.py + # or calling iterdir (would yield compiled artifacts). + pycache = _mock_dir("__pycache__", [_mock_file("wake.cpython-314.pyc")]) + top_level = [ + _mock_file("wake.h"), + pycache, + ] + with patch("importlib.resources.files") as mock_files_func: + pkg = MagicMock() + pkg.iterdir.return_value = top_level + mock_files_func.return_value = pkg + + names = [r.resource for r in manifest.resources] + + assert names == ["wake.h"] + + +def test_component_manifest_resources_recursive_filter_source_files_supports_subpaths() -> ( + None +): + """FILTER_SOURCE_FILES entries using '/'-joined subpaths exclude files + inside a recursively-walked subdir.""" + mock_module = MagicMock() + mock_module.__package__ = "esphome.core" + mock_module.FILTER_SOURCE_FILES = lambda: ["wake/wake_host.cpp"] + + manifest = ComponentManifest(mock_module, recursive_sources=True) + + wake_dir = _mock_dir( + "wake", + [ + _mock_file("wake_host.cpp"), # excluded + _mock_file("wake_freertos.cpp"), # kept + ], + ) + with patch("importlib.resources.files") as mock_files_func: + pkg = MagicMock() + pkg.iterdir.return_value = [wake_dir] + mock_files_func.return_value = pkg + + names = [r.resource for r in manifest.resources] + + assert names == ["wake/wake_freertos.cpp"]