From 08387ad289dfbff5d239b2ced914d64f90be9189 Mon Sep 17 00:00:00 2001 From: elwin loomis Date: Sat, 5 Sep 2026 16:08:21 -0500 Subject: [PATCH 01/14] [mipi_dsi] Let IDF pick the DPHY PLL reference clock (#18984) Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> --- esphome/components/mipi_dsi/mipi_dsi.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/mipi_dsi/mipi_dsi.cpp b/esphome/components/mipi_dsi/mipi_dsi.cpp index 0850b50c85..0150cc2544 100644 --- a/esphome/components/mipi_dsi/mipi_dsi.cpp +++ b/esphome/components/mipi_dsi/mipi_dsi.cpp @@ -35,8 +35,8 @@ void MipiDsi::setup() { .bus_id = 0, // index from 0, specify the DSI host to use .num_data_lanes = this->lanes_, // Number of data lanes to use, can't set a value that exceeds the chip's capability - .phy_clk_src = MIPI_DSI_PHY_CLK_SRC_DEFAULT, // Clock source for the DPHY - .lane_bit_rate_mbps = this->lane_bit_rate_, // Bit rate of the data lanes, in Mbps + // phy_clk_src left at 0 to enable runtime auto-select. + .lane_bit_rate_mbps = this->lane_bit_rate_, // Bit rate of the data lanes, in Mbps }; auto err = esp_lcd_new_dsi_bus(&bus_config, &this->bus_handle_); if (err != ESP_OK) { From 7e993016442bee0186d40af00ad2bdfd2fb42de0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 16 Sep 2026 17:19:04 -0500 Subject: [PATCH 02/14] [ota] Skip the web_server plaintext warning when web_server ota is disabled (#19348) --- esphome/components/esphome/ota/__init__.py | 12 +++++++-- tests/component_tests/ota/test_esphome_ota.py | 26 +++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index f5eb878260..bcf2a2271c 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -166,9 +166,17 @@ def ota_esphome_final_validate(config: ConfigType) -> None: CONF_PASSWORD, ) # web_server and prometheus keep the shared listener up; the captive - # portal's copy only exists on the fallback AP and is the recovery path + # portal's copy only exists on the fallback AP and is the recovery path. + # web_server `ota: false` gates /update behind the captive portal on + # every listener + web_server_conf = full_conf.get(CONF_WEB_SERVER) + plaintext_update_reachable = ( + web_server_conf.get(CONF_OTA) is not False + if web_server_conf is not None + else "prometheus" in full_conf + ) if ( - (CONF_WEB_SERVER in full_conf or "prometheus" in full_conf) + plaintext_update_reachable and any(conf.get(CONF_PLATFORM) == CONF_WEB_SERVER for conf in full_ota_conf) and any( CONF_ENCRYPTION in conf diff --git a/tests/component_tests/ota/test_esphome_ota.py b/tests/component_tests/ota/test_esphome_ota.py index d3092294dc..235ad902db 100644 --- a/tests/component_tests/ota/test_esphome_ota.py +++ b/tests/component_tests/ota/test_esphome_ota.py @@ -319,6 +319,32 @@ def test_encryption_with_captive_portal_does_not_warn( fv.full_config.reset(token) +@pytest.mark.parametrize("extra", [{}, {"prometheus": {}}]) +def test_encryption_with_web_server_ota_disabled_does_not_warn( + caplog: pytest.LogCaptureFixture, extra: dict[str, Any] +) -> None: + """web_server `ota: false` only serves /update while the captive portal is + active, on every listener, so there is no plaintext endpoint to warn about.""" + full_conf = { + "web_server": {CONF_OTA: False}, + **extra, + CONF_OTA: [ + _make_ota_config(port=3232, **{CONF_ENCRYPTION: {CONF_KEY: OTHER_KEY}}), + {CONF_PLATFORM: "web_server", CONF_ID: ID("ota_ws", is_manual=False)}, + ], + } + token = fv.full_config.set(full_conf) + try: + with caplog.at_level(logging.WARNING): + ota_esphome_final_validate({}) + assert not any( + "OTA encryption does not cover" in record.message + for record in caplog.records + ) + finally: + fv.full_config.reset(token) + + def test_password_with_api_key_warns(caplog: pytest.LogCaptureFixture) -> None: """A static api key makes the device offer encryption and the CLI take it, so the password is dead weight; the config validates with a warning.""" From 3e5797d1531c161a1b945e4b5f35f2f06f3f6fb1 Mon Sep 17 00:00:00 2001 From: rexmoriarty Date: Thu, 17 Sep 2026 04:47:40 -0500 Subject: [PATCH 03/14] [mixer] Don't discard a start request while reaping a stopped task (#19368) --- esphome/components/mixer/speaker/mixer_speaker.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/mixer/speaker/mixer_speaker.cpp b/esphome/components/mixer/speaker/mixer_speaker.cpp index ef21da65c5..85fb445d40 100644 --- a/esphome/components/mixer/speaker/mixer_speaker.cpp +++ b/esphome/components/mixer/speaker/mixer_speaker.cpp @@ -385,7 +385,8 @@ void MixerSpeaker::loop() { // Retries on a subsequent loop if the task is still running on the other core if ((event_group_bits & MIXER_TASK_STATE_STOPPED) && this->task_.deallocate()) { ESP_LOGD(TAG, "Stopped"); - xEventGroupClearBits(this->event_group_, MIXER_TASK_ALL_BITS); + // Keep a start request that arrived while the task was stopping, otherwise it is lost for good + xEventGroupClearBits(this->event_group_, MIXER_TASK_ALL_BITS & ~MIXER_TASK_COMMAND_START); this->all_stopped_since_ms_ = 0; } From 964a54f7a76ffa4432588a67f73746f4bcb83886 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 17 Sep 2026 08:17:11 -0500 Subject: [PATCH 04/14] [esp32][rp2] Print the previous boot crash report before the logger reads it (#19351) --- esphome/components/esp32/crash_handler.cpp | 14 +++++++++----- esphome/components/esp32/crash_handler.h | 7 +------ esphome/components/esp32/hal.cpp | 8 -------- esphome/components/rp2/crash_handler.cpp | 22 +++++++++++++++++----- esphome/components/rp2/crash_handler.h | 3 ++- esphome/core/application.h | 4 ++-- 6 files changed, 31 insertions(+), 27 deletions(-) diff --git a/esphome/components/esp32/crash_handler.cpp b/esphome/components/esp32/crash_handler.cpp index 6f65243aaa..b72a2777c7 100644 --- a/esphome/components/esp32/crash_handler.cpp +++ b/esphome/components/esp32/crash_handler.cpp @@ -173,7 +173,10 @@ static const char *const TAG = "esp32.crash"; // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) static uint32_t s_current_build_time = static_cast(ESPHOME_BUILD_TIME); -void crash_handler_read_and_clear() { +// Validate the NOINIT record. Runs on every has_data() call; re-running is +// harmless and the magic is left alone so the record survives an OTA +// rollback reboot, crash_handler_clear() drops it once an API client has it. +static void read_crash_data() { if (s_raw_crash_data.magic == CRASH_MAGIC && s_raw_crash_data.version == CRASH_DATA_VERSION) { s_crash_data_valid = true; // Clamp counts to prevent out-of-bounds reads from corrupt .noinit data @@ -194,11 +197,12 @@ void crash_handler_read_and_clear() { s_raw_crash_data.other_reg_frame_count = s_raw_crash_data.other_backtrace_count; #endif } - // Don't clear magic here — crash data must survive OTA rollback reboots. - // Magic is cleared by crash_handler_clear() after an API client receives the data. } -bool crash_handler_has_data() { return s_crash_data_valid; } +bool crash_handler_has_data() { + read_crash_data(); + return s_crash_data_valid; +} void crash_handler_clear() { // Only clear the magic so data doesn't survive the next reboot. @@ -426,7 +430,7 @@ static void log_foreign_addresses() { // crashes again during boot, and allowing the CLI's process_stacktrace to match // and decode each address individually. void crash_handler_log() { - if (!s_crash_data_valid) + if (!crash_handler_has_data()) return; ESP_LOGE(TAG, "*** CRASH DETECTED ON PREVIOUS BOOT ***"); diff --git a/esphome/components/esp32/crash_handler.h b/esphome/components/esp32/crash_handler.h index c5e7d145ec..314be80314 100644 --- a/esphome/components/esp32/crash_handler.h +++ b/esphome/components/esp32/crash_handler.h @@ -4,11 +4,6 @@ namespace esphome::esp32 { -/// Read and validate crash data from NOINIT memory. -/// Does not clear the magic marker — call crash_handler_clear() after -/// the data has been delivered to an API client so it survives OTA rollback reboots. -void crash_handler_read_and_clear(); - /// Log crash data if a crash was detected on previous boot. void crash_handler_log(); @@ -16,7 +11,7 @@ void crash_handler_log(); /// Call after the data has been delivered to an API client. void crash_handler_clear(); -/// Returns true if crash data was found this boot. +/// Returns true if crash data was found this boot, reading it first if needed. bool crash_handler_has_data(); } // namespace esphome::esp32 diff --git a/esphome/components/esp32/hal.cpp b/esphome/components/esp32/hal.cpp index f6199d557f..199cb89f51 100644 --- a/esphome/components/esp32/hal.cpp +++ b/esphome/components/esp32/hal.cpp @@ -1,9 +1,6 @@ #ifdef USE_ESP32 -// defines.h must come before crash_handler.h so USE_ESP32_CRASH_HANDLER is set -// before crash_handler.h's #ifdef-guarded namespace block is parsed. #include "esphome/core/defines.h" -#include "crash_handler.h" #include "esphome/core/hal.h" #include @@ -45,11 +42,6 @@ void arch_restart() { } void arch_init() { -#ifdef USE_ESP32_CRASH_HANDLER - // Read crash data from previous boot before anything else - esp32::crash_handler_read_and_clear(); -#endif - // Enable the task watchdog only on the loop task (from which we're currently running) esp_task_wdt_add(nullptr); diff --git a/esphome/components/rp2/crash_handler.cpp b/esphome/components/rp2/crash_handler.cpp index a0fea21637..9bcdc8bee4 100644 --- a/esphome/components/rp2/crash_handler.cpp +++ b/esphome/components/rp2/crash_handler.cpp @@ -55,8 +55,7 @@ namespace esphome::rp2 { static const char *const TAG = "rp2.crash"; -// Placed in .noinit so BSS zero-init cannot race with crash_handler_read_and_clear(). -// The valid field is explicitly cleared in crash_handler_read_and_clear() instead. +// Filled from the watchdog scratch registers on the first read. static struct CrashData { bool valid; uint32_t pc; @@ -64,11 +63,24 @@ static struct CrashData { uint32_t sp; uint32_t backtrace[MAX_BACKTRACE]; uint8_t backtrace_count; -} s_crash_data __attribute__((section(".noinit"))); // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +} s_crash_data; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -bool crash_handler_has_data() { return s_crash_data.valid; } +// Logger::pre_setup() logs the record before App.pre_setup() reaches +// arch_init(), so the first caller reads it and later calls are no-ops. +// The read clears the scratch registers, so it must not run twice, and +// arch_init() keeps its call so the read precedes watchdog_enable(), which +// overwrites scratch[4]. +static bool s_crash_data_read = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +bool crash_handler_has_data() { + crash_handler_read_and_clear(); + return s_crash_data.valid; +} void crash_handler_read_and_clear() { + if (s_crash_data_read) + return; + s_crash_data_read = true; s_crash_data.valid = false; uint32_t magic = watchdog_hw->scratch[0]; if ((magic & 0xFFFF0000) == CRASH_MAGIC_SENTINEL && (magic & 0xFFFF) == CRASH_DATA_VERSION) { @@ -97,7 +109,7 @@ void crash_handler_read_and_clear() { // the device crashes again during boot, and allowing the CLI's process_stacktrace // to match and decode each address individually. void crash_handler_log() { - if (!s_crash_data.valid) + if (!crash_handler_has_data()) return; ESP_LOGE(TAG, "*** CRASH DETECTED ON PREVIOUS BOOT ***"); diff --git a/esphome/components/rp2/crash_handler.h b/esphome/components/rp2/crash_handler.h index 8c43d9fd3b..3aec80b63b 100644 --- a/esphome/components/rp2/crash_handler.h +++ b/esphome/components/rp2/crash_handler.h @@ -9,12 +9,13 @@ namespace esphome::rp2 { /// Read crash data from watchdog scratch registers and clear them. +/// Only the first call reads; later calls are no-ops. void crash_handler_read_and_clear(); /// Log crash data if a crash was detected on previous boot. void crash_handler_log(); -/// Returns true if crash data was found this boot. +/// Returns true if crash data was found this boot, reading it first if needed. bool crash_handler_has_data(); } // namespace esphome::rp2 diff --git a/esphome/core/application.h b/esphome/core/application.h index a12cdc4ac8..2836587dfd 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -67,7 +67,7 @@ static constexpr uint32_t TEARDOWN_TIMEOUT_REBOOT_MS = 1000; // 1 second for qu class Application { public: #ifdef ESPHOME_NAME_ADD_MAC_SUFFIX - // Called before Logger::pre_setup() — must not log (global_logger is not yet set). + // Runs after Logger::pre_setup() (emitted at EARLY_INIT priority), so the app name is not set yet there. /// Pre-setup with MAC suffix: overwrites placeholder in mutable static buffers with actual MAC. void pre_setup(char *name, size_t name_len, char *friendly_name, size_t friendly_name_len) { arch_init(); @@ -87,7 +87,7 @@ class Application { this->friendly_name_ = StringRef(friendly_name, friendly_name_len); } #else - // Called before Logger::pre_setup() — must not log (global_logger is not yet set). + // Runs after Logger::pre_setup() (emitted at EARLY_INIT priority), so the app name is not set yet there. /// Pre-setup without MAC suffix: StringRef points directly at const string literals in flash. void pre_setup(const char *name, size_t name_len, const char *friendly_name, size_t friendly_name_len) { arch_init(); From eaee1cdd8636c60e8cd69eac88bbf5866c645cbb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 17 Sep 2026 09:40:22 -0500 Subject: [PATCH 05/14] [espidf] Switch the Windows console to UTF-8 while idf.py runs (#19352) --- esphome/espidf/runner.py | 99 +++++++++++++++++++---- tests/unit_tests/test_espidf_runner.py | 104 +++++++++++++++++++++++++ 2 files changed, 189 insertions(+), 14 deletions(-) diff --git a/esphome/espidf/runner.py b/esphome/espidf/runner.py index 7ed11d7554..bf563a4f43 100644 --- a/esphome/espidf/runner.py +++ b/esphome/espidf/runner.py @@ -74,6 +74,64 @@ FILTER_IDF_LINES: list[str] = [ r"Stopping at filesystem boundary", ] +# Windows code page identifier for UTF-8, as used by ``chcp 65001``. +UTF8_CODEPAGE = 65001 + + +def _get_kernel32(): + """Return the Windows kernel32 module, or None on any other platform.""" + if sys.platform != "win32": + return None + import ctypes + + return ctypes.windll.kernel32 + + +class _Utf8Console: + """Keep an attached Windows console on UTF-8 for the length of the build. + + The build tree runs in UTF-8 mode, so esp_idf_size draws its table with + Unicode box characters. ``idf.py size`` reaches it through ``cmake -P``, + and CMake re-decodes the child's output with the console code page before + printing it, which turns the table into mojibake on any code page but + UTF-8. Every process in the build shares this console, so switching it + here covers CMake too. The old code pages go back on exit so the user's + terminal is left as it was. + + A console that is already on UTF-8 is left alone. The code page belongs to + the console, not to this process, so a build that overlaps another one + must not save UTF-8 as the page to go back to. + """ + + def __init__(self, kernel32) -> None: + self._kernel32 = kernel32 + self._codepages: tuple[int, int] | None = None + + def __enter__(self) -> None: + kernel32 = self._kernel32 + if kernel32 is None: + return + old_in = kernel32.GetConsoleCP() + old_out = kernel32.GetConsoleOutputCP() + # Both calls return 0 when no console is attached. + if not old_in or not old_out: + return + if old_in == UTF8_CODEPAGE and old_out == UTF8_CODEPAGE: + return + # Record the old pages first so a switch that fails part way through + # still gets put back on exit. + self._codepages = (old_in, old_out) + kernel32.SetConsoleCP(UTF8_CODEPAGE) + kernel32.SetConsoleOutputCP(UTF8_CODEPAGE) + + def __exit__(self, *exc_info: object) -> None: + if self._codepages is None: + return + old_in, old_out = self._codepages + self._codepages = None + self._kernel32.SetConsoleCP(old_in) + self._kernel32.SetConsoleOutputCP(old_out) + def main() -> int: # ---- sys.path fix-up --------------------------------------------------- @@ -269,8 +327,29 @@ def main() -> int: is_verbose = any(arg in ("-v", "--verbose") for arg in sys.argv[2:]) filter_lines = None if is_verbose else FILTER_IDF_LINES or None - stdout_shim = sys.stdout = _FilteringTTYStream(sys.stdout, filter_lines) # type: ignore[assignment] - stderr_shim = sys.stderr = _FilteringTTYStream(sys.stderr, filter_lines) # type: ignore[assignment] + class _FilteredStreams: + """Route ``sys.stdout`` and ``sys.stderr`` through the filtering shims. + + On exit each shim releases a last line that never got its + terminator. The shims made here are drained rather than whatever + ``sys.stdout`` holds by then, which the script is free to replace. + """ + + def __init__(self, filter_lines: list[str] | None) -> None: + self._stdout = _FilteringTTYStream(sys.stdout, filter_lines) + self._stderr = _FilteringTTYStream(sys.stderr, filter_lines) + + def __enter__(self) -> None: + sys.stdout = self._stdout # type: ignore[assignment] + sys.stderr = self._stderr # type: ignore[assignment] + + def __exit__(self, *exc_info: object) -> None: + # Drain stderr from a finally so a surprise from the first one + # cannot strand the second. + try: + self._stdout.drain() + finally: + self._stderr.drain() # Shift argv so the target script sees its own path as argv[0] and # its own arguments starting at argv[1]. runpy.run_path does not @@ -288,19 +367,11 @@ def main() -> int: # If idf.py calls sys.exit(), SystemExit propagates out of run_path # and carries the exit code back to our caller. For normal returns, - # fall through and exit with 0. Either way the streams get a chance to - # release a last line that never got its terminator. Drain the shims we - # made rather than sys.stdout, which the script is free to replace, and - # report instead of raising so cleanup cannot bury the real exit code. - try: + # fall through and exit with 0. Either way the context managers drain + # the streams and put the console back on the way out, and they report + # instead of raising so cleanup cannot bury the real exit code. + with _FilteredStreams(filter_lines), _Utf8Console(_get_kernel32()): runpy.run_path(script_path, run_name="__main__") - finally: - # Drain stderr from a finally so a surprise from the first one cannot - # strand the second. - try: - stdout_shim.drain() - finally: - stderr_shim.drain() return 0 diff --git a/tests/unit_tests/test_espidf_runner.py b/tests/unit_tests/test_espidf_runner.py index e4cc6e137e..71ccfee1f2 100644 --- a/tests/unit_tests/test_espidf_runner.py +++ b/tests/unit_tests/test_espidf_runner.py @@ -209,3 +209,107 @@ def test_runner_streams_output_before_the_build_finishes( # Join before leaving the block, so the reader is done rather than # racing ``Popen`` closing the pipe under it. reader.join(1.0) + + +class _FakeKernel32: + """Stand-in for the Windows kernel32 console code page calls.""" + + def __init__(self, input_cp: int, output_cp: int) -> None: + self.input_cp = input_cp + self.output_cp = output_cp + self.calls: list[tuple[str, int]] = [] + + def GetConsoleCP(self) -> int: # noqa: N802 + return self.input_cp + + def GetConsoleOutputCP(self) -> int: # noqa: N802 + return self.output_cp + + def SetConsoleCP(self, codepage: int) -> int: # noqa: N802 + self.calls.append(("SetConsoleCP", codepage)) + self.input_cp = codepage + return 1 + + def SetConsoleOutputCP(self, codepage: int) -> int: # noqa: N802 + self.calls.append(("SetConsoleOutputCP", codepage)) + self.output_cp = codepage + return 1 + + +def test_main_runs_the_build_with_a_utf8_console( + monkeypatch: pytest.MonkeyPatch, fixture_path: Path +) -> None: + """An attached console is switched to UTF-8 and then put back.""" + kernel32 = _FakeKernel32(850, 850) + monkeypatch.setattr(runner, "_get_kernel32", lambda: kernel32) + + _run_main(monkeypatch, fixture_path / "espidf" / "filtering_probe.py") + + assert kernel32.calls == [ + ("SetConsoleCP", runner.UTF8_CODEPAGE), + ("SetConsoleOutputCP", runner.UTF8_CODEPAGE), + ("SetConsoleCP", 850), + ("SetConsoleOutputCP", 850), + ] + + +def test_main_restores_the_console_when_the_build_dies( + monkeypatch: pytest.MonkeyPatch, fixture_path: Path +) -> None: + """A failing build must not leave the user's console on UTF-8.""" + kernel32 = _FakeKernel32(437, 437) + monkeypatch.setattr(runner, "_get_kernel32", lambda: kernel32) + _prepare_main(monkeypatch, fixture_path / "espidf" / "crashing_probe.py") + + with pytest.raises(SystemExit): + runner.main() + + assert (kernel32.input_cp, kernel32.output_cp) == (437, 437) + + +def test_main_restores_the_console_when_the_switch_fails_part_way( + monkeypatch: pytest.MonkeyPatch, fixture_path: Path +) -> None: + """A failed output page switch must not strand the changed input page.""" + kernel32 = _FakeKernel32(850, 850) + + def _refuse(codepage: int) -> int: + kernel32.calls.append(("SetConsoleOutputCP", codepage)) + return 0 + + kernel32.SetConsoleOutputCP = _refuse # type: ignore[method-assign] + monkeypatch.setattr(runner, "_get_kernel32", lambda: kernel32) + + _run_main(monkeypatch, fixture_path / "espidf" / "filtering_probe.py") + + assert kernel32.input_cp == 850 + assert kernel32.calls[-2:] == [("SetConsoleCP", 850), ("SetConsoleOutputCP", 850)] + + +def test_main_leaves_the_console_alone_when_there_is_none( + monkeypatch: pytest.MonkeyPatch, fixture_path: Path +) -> None: + """Without a console the code page calls return 0 and nothing is set.""" + kernel32 = _FakeKernel32(0, 0) + monkeypatch.setattr(runner, "_get_kernel32", lambda: kernel32) + + _run_main(monkeypatch, fixture_path / "espidf" / "filtering_probe.py") + + assert kernel32.calls == [] + + +def test_main_leaves_a_console_already_on_utf8_alone( + monkeypatch: pytest.MonkeyPatch, fixture_path: Path +) -> None: + """An overlapping build must not save UTF-8 as the page to restore.""" + kernel32 = _FakeKernel32(runner.UTF8_CODEPAGE, runner.UTF8_CODEPAGE) + monkeypatch.setattr(runner, "_get_kernel32", lambda: kernel32) + + _run_main(monkeypatch, fixture_path / "espidf" / "filtering_probe.py") + + assert kernel32.calls == [] + + +@pytest.mark.skipif(sys.platform == "win32", reason="kernel32 exists on Windows") +def test_get_kernel32_is_none_off_windows() -> None: + assert runner._get_kernel32() is None From c732903390d940c2928021955f08284daaf7f042 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 17 Sep 2026 09:57:59 -0500 Subject: [PATCH 06/14] [esp32_hosted] Fix ESP32-P4 build without wifi, espnow or BLE (#19374) --- esphome/components/esp32/__init__.py | 2 +- esphome/components/esp32_hosted/__init__.py | 2 ++ .../esp32_hosted/test-no-wifi.esp32-p4-idf.yaml | 12 ++++++++++++ 3 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 tests/components/esp32_hosted/test-no-wifi.esp32-p4-idf.yaml diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 3f5a34bc73..4597904cc3 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -254,7 +254,7 @@ DEFAULT_EXCLUDED_IDF_COMPONENTS = ( "esp_lcd", # LCD controller drivers - only needed by display component "esp_local_ctrl", # Local control over HTTPS/BLE - ESPHome has native API "esp_phy", # RF PHY - re-included by internal_temperature on the original ESP32; esp_wifi/bt/ieee802154 pull it back - "esp_wifi", # WiFi stack - re-included by request_wifi(), espnow; bt pulls it back for BLE builds + "esp_wifi", # WiFi stack - re-included by request_wifi(), espnow, esp32_hosted; bt pulls it back for BLE builds "espcoredump", # Core dump support - ESPHome has its own debug component "fatfs", # FAT filesystem - ESPHome doesn't use filesystem storage "ieee802154", # 802.15.4 radio - IDF openthread and the Zigbee libs pull it back diff --git a/esphome/components/esp32_hosted/__init__.py b/esphome/components/esp32_hosted/__init__.py index 21626e432b..6943efc3cb 100644 --- a/esphome/components/esp32_hosted/__init__.py +++ b/esphome/components/esp32_hosted/__init__.py @@ -290,6 +290,8 @@ async def to_code(config: ConfigType) -> None: # symbols are simply unused and never register a callback at runtime. if esp32.get_esp32_variant() == esp32.VARIANT_ESP32P4: add_define("USE_ESP_NOW_HOSTED") + # esp_now_hosted.cpp includes esp_now.h, which esp_wifi provides + esp32.include_builtin_idf_component("esp_wifi") # esp-hosted's CustomRpc ("peer data transfer") path — off by default. esp32.add_idf_sdkconfig_option( "CONFIG_ESP_HOSTED_ENABLE_PEER_DATA_TRANSFER", True diff --git a/tests/components/esp32_hosted/test-no-wifi.esp32-p4-idf.yaml b/tests/components/esp32_hosted/test-no-wifi.esp32-p4-idf.yaml new file mode 100644 index 0000000000..2f57abd296 --- /dev/null +++ b/tests/components/esp32_hosted/test-no-wifi.esp32-p4-idf.yaml @@ -0,0 +1,12 @@ +# No wifi, espnow or BLE: nothing else re-includes esp_wifi for the ESP-NOW shim. +esp32_hosted: + variant: ESP32C6 + slot: 1 + active_high: true + reset_pin: GPIO15 + cmd_pin: GPIO13 + clk_pin: GPIO12 + d0_pin: GPIO11 + d1_pin: GPIO10 + d2_pin: GPIO9 + d3_pin: GPIO8 From 0575eb14f2fe6a99b0921e77c522d34619ac345a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 17 Sep 2026 10:20:41 -0500 Subject: [PATCH 07/14] [esphome] Keep the OTA encryption key without the api server so safe mode uploads work (#19349) --- esphome/components/api/api_server.cpp | 26 +++++- esphome/components/api/api_server.h | 5 ++ esphome/components/esphome/ota/__init__.py | 18 ++--- .../components/esphome/ota/ota_esphome.cpp | 22 +++-- esphome/components/esphome/ota/ota_esphome.h | 9 ++- esphome/components/noise/__init__.py | 18 +++-- esphome/core/defines.h | 1 - tests/component_tests/ota/test_esphome_ota.py | 26 +++--- .../host_ota_encrypted_safe_mode.yaml | 13 +++ ...ost_ota_provisioned_api_key_safe_mode.yaml | 11 +++ tests/integration/host_prefs.py | 35 ++++++-- tests/integration/test_host_ota.py | 80 +++++++++++++++++-- tests/integration/test_safe_mode_loop_runs.py | 12 +-- 13 files changed, 211 insertions(+), 65 deletions(-) create mode 100644 tests/integration/fixtures/host_ota_encrypted_safe_mode.yaml create mode 100644 tests/integration/fixtures/host_ota_provisioned_api_key_safe_mode.yaml diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 78ebe5c38e..d74de98c75 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -29,6 +29,29 @@ static const char *const TAG = "api"; // APIServer APIServer *global_api_server = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +#ifdef USE_API_NOISE +static constexpr uint32_t NOISE_PSK_PREF_HASH = 88491486UL; +#endif + +#if defined(USE_API_NOISE) && defined(USE_OTA_ENCRYPTION_PROVISIONED) +bool load_saved_noise_psk(noise::psk_t &out) { + SavedNoisePsk saved; +#ifdef USE_PREFERENCE_KEY_LOOKUP + const bool loaded = + global_preferences->load_from_key(NOISE_PSK_PREF_HASH, reinterpret_cast(&saved), sizeof(saved)); +#else + // Slot backends need the reservation walk; it only lands on the record when the reservations before + // it match a normal boot, otherwise the type checked checksum fails the load + const bool loaded = global_preferences->make_preference(NOISE_PSK_PREF_HASH, true).load(&saved); +#endif + // The all-zeros record means no key + if (!loaded || noise::NoiseContext::is_all_zeros(saved.psk)) + return false; + out = saved.psk; + return true; +} +#endif + APIServer::APIServer() { global_api_server = this; } void APIServer::socket_failed_(const LogString *msg) { @@ -43,8 +66,7 @@ void APIServer::setup() { #ifdef USE_API_NOISE // Always reserve the slot: flash preferences are positional on esp8266, so // a yaml key build must keep the layout of a runtime key build - uint32_t hash = 88491486UL; - this->noise_pref_ = global_preferences->make_preference(hash, true); + this->noise_pref_ = global_preferences->make_preference(NOISE_PSK_PREF_HASH, true); #ifndef USE_API_NOISE_PSK_FROM_YAML // A cleared record loads fine but holds no key if (this->load_and_apply_noise_psk_() && this->noise_ctx_.has_psk()) { diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index 618ea4eb11..4aa0a422dd 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -43,6 +43,11 @@ struct SavedNoisePsk { noise::psk_t psk; } PACKED; // NOLINT #endif +#if defined(USE_API_NOISE) && defined(USE_OTA_ENCRYPTION_PROVISIONED) +/// One-shot read of the provisioned key for a boot without an api server (safe mode); false when +/// there is no key +bool load_saved_noise_psk(noise::psk_t &out); +#endif class APIServer final : public Component, public Controller diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index bcf2a2271c..bace13c17f 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -316,20 +316,16 @@ async def to_code(config: ConfigType) -> None: # One key per device: an api encryption block supplies it (static or # runtime) and offers; the ota block only adds the requirement api_conf = CORE.config.get(CONF_API) or {} - encryption_conf = config.get(CONF_ENCRYPTION) - own_key = None - if encryption_conf is not None and static_encryption_key(api_conf) is None: - own_key = encryption_conf[CONF_KEY] - if own_key is not None: + if key := static_encryption_key(config) or static_encryption_key(api_conf): + # Build time key: the ota keeps its own pointer so safe mode, which + # has no api server, still has it cg.add_define("USE_OTA_ENCRYPTION") - cg.add(var.set_noise_psk(new_psk_progmem(config[CONF_ID], own_key))) + cg.add(var.set_noise_psk(new_psk_progmem(config[CONF_ID], key))) elif CONF_ENCRYPTION in api_conf: + # Runtime key: found in the api server, or in preferences in safe mode cg.add_define("USE_OTA_ENCRYPTION") - cg.add_define("USE_OTA_ENCRYPTION_FROM_API") - if static_encryption_key(api_conf) is None: - # The key arrives at runtime, so the offer has to look for it - cg.add_define("USE_OTA_ENCRYPTION_PROVISIONED") - if encryption_conf is not None: + cg.add_define("USE_OTA_ENCRYPTION_PROVISIONED") + if CONF_ENCRYPTION in config: cg.add_define("USE_OTA_ENCRYPTION_REQUIRED") # Build flag so lwip_fast_select.c (a .c file that can't include defines.h) sees it. diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 3010df1056..d353d01d20 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -1,5 +1,5 @@ #include "ota_esphome.h" -#ifdef USE_OTA_ENCRYPTION_FROM_API +#ifdef USE_OTA_ENCRYPTION_PROVISIONED #include "esphome/components/api/api_server.h" #endif #ifdef USE_OTA @@ -32,11 +32,13 @@ static const char *const TAG = "esphome.ota"; #ifdef USE_OTA_ENCRYPTION const noise::NoiseContext &ESPHomeOTAComponent::noise_context_() const { -#ifdef USE_OTA_ENCRYPTION_FROM_API - return api::global_api_server->get_noise_ctx(); -#else - return this->noise_ctx_; +#ifdef USE_OTA_ENCRYPTION_PROVISIONED + // The api server holds the live key; safe mode never constructs it, and then + // noise_ctx_ holds the saved key setup() found, if any + if (api::global_api_server != nullptr) + return api::global_api_server->get_noise_ctx(); #endif + return this->noise_ctx_; } #endif static constexpr uint16_t OTA_BLOCK_SIZE = 8192; @@ -58,6 +60,16 @@ extern "C" void esphome_wake_ota_component_any_context() { } void ESPHomeOTAComponent::setup() { +#ifdef USE_OTA_ENCRYPTION_PROVISIONED + // Safe mode never constructs the api server, so read the key it saved + noise::psk_t psk; + if (api::global_api_server == nullptr && api::load_saved_noise_psk(psk)) { + this->saved_psk_ = RAMAllocator().make_unique(psk); + if (this->saved_psk_ != nullptr) { + this->noise_ctx_.set_psk(this->saved_psk_->data()); + } + } +#endif this->server_ = socket::socket_ip_loop_monitored(SOCK_STREAM, 0).release(); // monitored for incoming connections if (this->server_ == nullptr) { this->server_failed_(LOG_STR("creation")); diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index 68dd0ffb9e..92ba094c8d 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -44,7 +44,7 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { } #endif // USE_OTA_PASSWORD -#if defined(USE_OTA_ENCRYPTION) && !defined(USE_OTA_ENCRYPTION_FROM_API) +#ifdef USE_OTA_ENCRYPTION /// psk points at 32 bytes that live in flash for the life of the program void set_noise_psk(const uint8_t *psk) { this->noise_ctx_.set_psk(psk); } #endif @@ -86,7 +86,8 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { bool writing{false}; // a produced handshake frame is still being flushed uint8_t frame_buf[noise::FRAME_HEADER_SIZE + 1 + noise::MAX_HANDSHAKE_SIZE]; }; - // The api server's live context when the api has encryption, else our own + // The api server's live context when it exists, otherwise our own (a build + // time key, or the saved key loaded in safe mode) const noise::NoiseContext &noise_context_() const; bool noise_start_session_(uint8_t server_feature_flags); bool handle_noise_handshake_(); @@ -148,8 +149,10 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { RAMUniquePtr auth_buf_; #endif // USE_OTA_PASSWORD #ifdef USE_OTA_ENCRYPTION -#ifndef USE_OTA_ENCRYPTION_FROM_API noise::NoiseContext noise_ctx_; +#ifdef USE_OTA_ENCRYPTION_PROVISIONED + // Backs noise_ctx_ in safe mode, where no api server holds the saved key + RAMUniquePtr saved_psk_; #endif RAMUniquePtr noise_; #endif // USE_OTA_ENCRYPTION diff --git a/esphome/components/noise/__init__.py b/esphome/components/noise/__init__.py index 6067fde164..47cd4cfc67 100644 --- a/esphome/components/noise/__init__.py +++ b/esphome/components/noise/__init__.py @@ -5,11 +5,12 @@ from typing import Any import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import CONF_ENCRYPTION, CONF_KEY -from esphome.core import ID +from esphome.core import CORE, ID from esphome.cpp_generator import MockObj from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] +DOMAIN = "noise" noise_ns = cg.esphome_ns.namespace("noise") @@ -70,11 +71,16 @@ def static_encryption_key(conf: ConfigType) -> str | None: def new_psk_progmem(parent_id: ID, key: str) -> MockObj: """Emit the decoded key as a PROGMEM array; the component keeps a pointer - so the key never occupies RAM.""" - return cg.progmem_array( - ID(f"{parent_id.id}_psk", is_declaration=True, type=cg.uint8), - list(decode_encryption_key(key)), - ) + so the key never occupies RAM. Components sharing one key (api and ota) + share the array.""" + decoded = decode_encryption_key(key) + arrays: dict[bytes, MockObj] = CORE.data.setdefault(DOMAIN, {}) + if (array := arrays.get(decoded)) is None: + array = arrays[decoded] = cg.progmem_array( + ID(f"{parent_id.id}_psk", is_declaration=True, type=cg.uint8), + list(decoded), + ) + return array def encryption_schema(config: ConfigType | None) -> ConfigType: diff --git a/esphome/core/defines.h b/esphome/core/defines.h index b78516c6ef..94f7648f29 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -245,7 +245,6 @@ #define USE_RUNTIME_STATS #define USE_OTA #define USE_OTA_ENCRYPTION -#define USE_OTA_ENCRYPTION_FROM_API #define USE_OTA_ENCRYPTION_PROVISIONED #define USE_OTA_ENCRYPTION_REQUIRED #define USE_OTA_PASSWORD diff --git a/tests/component_tests/ota/test_esphome_ota.py b/tests/component_tests/ota/test_esphome_ota.py index 235ad902db..d8bcd7e275 100644 --- a/tests/component_tests/ota/test_esphome_ota.py +++ b/tests/component_tests/ota/test_esphome_ota.py @@ -476,43 +476,36 @@ def test_static_encryption_key() -> None: ("yaml_name", "defines_present", "defines_absent"), [ # An api key alone compiles the transport in without requiring it; - # the device uses the api server's key, not a copy + # the ota keeps its own pointer to the key so safe mode, which never + # constructs the api server, can still use it ( "api_key_offer", - {"USE_OTA_ENCRYPTION", "USE_OTA_ENCRYPTION_FROM_API"}, + {"USE_OTA_ENCRYPTION"}, {"USE_OTA_ENCRYPTION_REQUIRED", "USE_OTA_ENCRYPTION_PROVISIONED"}, ), # A password still guards plaintext uploads on an offering device ( "api_key_offer_password", - {"USE_OTA_ENCRYPTION", "USE_OTA_ENCRYPTION_FROM_API", "USE_OTA_PASSWORD"}, + {"USE_OTA_ENCRYPTION", "USE_OTA_PASSWORD"}, {"USE_OTA_ENCRYPTION_REQUIRED", "USE_OTA_ENCRYPTION_PROVISIONED"}, ), # The ota encryption block is what makes the device refuse plaintext ( "encryption_required", - { - "USE_OTA_ENCRYPTION", - "USE_OTA_ENCRYPTION_REQUIRED", - "USE_OTA_ENCRYPTION_FROM_API", - }, + {"USE_OTA_ENCRYPTION", "USE_OTA_ENCRYPTION_REQUIRED"}, {"USE_OTA_ENCRYPTION_PROVISIONED"}, ), # Without api encryption the ota key is the device's own ( "own_key", {"USE_OTA_ENCRYPTION", "USE_OTA_ENCRYPTION_REQUIRED"}, - {"USE_OTA_ENCRYPTION_FROM_API", "USE_OTA_ENCRYPTION_PROVISIONED"}, + {"USE_OTA_ENCRYPTION_PROVISIONED"}, ), # A key provisioned at runtime lives in the api server; the device # offers with it once provisioned and never requires it ( "runtime_api_key", - { - "USE_OTA_ENCRYPTION", - "USE_OTA_ENCRYPTION_FROM_API", - "USE_OTA_ENCRYPTION_PROVISIONED", - }, + {"USE_OTA_ENCRYPTION", "USE_OTA_ENCRYPTION_PROVISIONED"}, {"USE_OTA_ENCRYPTION_REQUIRED"}, ), # No api encryption at all keeps the noise glue out of the build @@ -522,7 +515,6 @@ def test_static_encryption_key() -> None: { "USE_OTA_ENCRYPTION", "USE_OTA_ENCRYPTION_REQUIRED", - "USE_OTA_ENCRYPTION_FROM_API", "USE_OTA_ENCRYPTION_PROVISIONED", }, ), @@ -541,8 +533,10 @@ def test_encryption_offer_codegen( assert defines_present <= defines assert not (defines_absent & defines) encrypted = "USE_OTA_ENCRYPTION" in defines_present - own_key = encrypted and "USE_OTA_ENCRYPTION_FROM_API" not in defines_present + own_key = encrypted and "USE_OTA_ENCRYPTION_PROVISIONED" not in defines_present assert ("esphome_esphomeotacomponent_id->set_noise_psk(" in main_cpp) is own_key + # The api shares the ota's array instead of emitting the same key twice + assert main_cpp.count("_psk[] PROGMEM") == (1 if own_key else 0) assert ("set_auth_password(" in main_cpp) is ("USE_OTA_PASSWORD" in defines_present) # The noise transport source compiles only when the define is set assert FILTER_SOURCE_FILES() == ([] if encrypted else ["ota_esphome_noise.cpp"]) diff --git a/tests/integration/fixtures/host_ota_encrypted_safe_mode.yaml b/tests/integration/fixtures/host_ota_encrypted_safe_mode.yaml new file mode 100644 index 0000000000..57f3d57a01 --- /dev/null +++ b/tests/integration/fixtures/host_ota_encrypted_safe_mode.yaml @@ -0,0 +1,13 @@ +esphome: + name: host-ota-test +host: +api: + encryption: + key: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" +ota: + - platform: esphome + port: __OTA_PORT__ + encryption: +safe_mode: +logger: + level: DEBUG diff --git a/tests/integration/fixtures/host_ota_provisioned_api_key_safe_mode.yaml b/tests/integration/fixtures/host_ota_provisioned_api_key_safe_mode.yaml new file mode 100644 index 0000000000..c00954515b --- /dev/null +++ b/tests/integration/fixtures/host_ota_provisioned_api_key_safe_mode.yaml @@ -0,0 +1,11 @@ +esphome: + name: host-ota-test +host: +api: + encryption: +ota: + - platform: esphome + port: __OTA_PORT__ +safe_mode: +logger: + level: DEBUG diff --git a/tests/integration/host_prefs.py b/tests/integration/host_prefs.py index c7f21d8a01..75c3ff81b0 100644 --- a/tests/integration/host_prefs.py +++ b/tests/integration/host_prefs.py @@ -14,6 +14,12 @@ from __future__ import annotations from pathlib import Path import struct +_ENTRY = struct.Struct(" Path: """Return the on-disk prefs file path for a host-platform device.""" @@ -34,16 +40,33 @@ def write_host_prefs(device_name: str, entries: dict[int, bytes]) -> Path: for key, data in entries.items(): if len(data) > 255: raise ValueError(f"Preference data too long: {len(data)} bytes (max 255)") - payload += struct.pack(" Path: - """Write a single preference entry, replacing the file's contents. +def read_host_prefs(device_name: str) -> dict[int, bytes]: + """Read the preference entries of a host-platform device; empty when + the file does not exist.""" + path = host_prefs_path(device_name) + if not path.exists(): + return {} + payload = path.read_bytes() + entries: dict[int, bytes] = {} + pos = 0 + while pos < len(payload): + key, length = _ENTRY.unpack_from(payload, pos) + pos += _ENTRY.size + entries[key] = payload[pos : pos + length] + pos += length + return entries - Returns the path that was written. - """ - return write_host_prefs(device_name, {key: data}) + +def force_safe_mode(device_name: str) -> None: + """Make the next boot of a host-platform device enter safe mode; other + saved preferences are kept.""" + entries = read_host_prefs(device_name) + entries[_SAFE_MODE_RTC_KEY] = struct.pack(" None: + """Provision PROVISIONING_PSK over the api and wait for it to activate.""" + async with api_client_connected(port=dev.api_port, noise_psk=ZERO_PSK) as client: + assert await client.noise_encryption_set_key(PROVISIONING_PSK) is True + await asyncio.sleep(KEY_ACTIVATION_DELAY) + + @pytest.mark.asyncio async def test_host_ota_self_update( yaml_config: str, @@ -227,6 +237,31 @@ async def test_host_ota_encrypted( await dev.ota(None, API_KEY, "encrypted OTA reported failure") +@pytest.mark.asyncio +async def test_host_ota_encrypted_safe_mode( + yaml_config: str, + write_yaml_config: ConfigWriter, + compile_esphome: CompileFunction, + reserved_tcp_port: tuple[int, socket.socket], +) -> None: + """Safe mode never constructs the api server, so an encrypted OTA with the + api key has to run on the ota component's own copy of that key.""" + pytest.importorskip("aioesphomeapi.noise") + dev = _Device( + *await _build( + yaml_config, write_yaml_config, compile_esphome, reserved_tcp_port + ) + ) + # The api port never opens in safe mode, so wait for the log line instead + force_safe_mode(DEVICE_NAME) + async with run_binary(dev.binary_path, line_callback=dev.on_log) as (proc, lines): + dev.proc = proc + await _wait_for_line(lines, "SAFE MODE IS ACTIVE", PORT_WAIT_TIMEOUT) + await _wait_for_port(LOCALHOST, dev.ota_port, PORT_WAIT_TIMEOUT) + # The safe mode boot clears the counter, so the re-exec boots normally + await dev.ota(None, API_KEY, "encrypted OTA in safe mode reported failure") + + @pytest.mark.asyncio async def test_host_ota_api_key_offer_with_password( yaml_config: str, @@ -305,11 +340,7 @@ async def test_host_ota_provisioned_api_key( None, None, "plaintext upload to an unprovisioned device must succeed" ) - async with api_client_connected( - port=dev.api_port, noise_psk=ZERO_PSK - ) as client: - assert await client.noise_encryption_set_key(PROVISIONING_PSK) is True - await asyncio.sleep(KEY_ACTIVATION_DELAY) + await _provision_key(dev, api_client_connected) key = PROVISIONING_PSK.decode() await dev.ota( @@ -319,6 +350,45 @@ async def test_host_ota_provisioned_api_key( await dev.ota(None, None, "plaintext must stay accepted on an offering device") +@pytest.mark.asyncio +async def test_host_ota_provisioned_api_key_safe_mode( + yaml_config: str, + write_yaml_config: ConfigWriter, + compile_esphome: CompileFunction, + reserved_tcp_port: tuple[int, socket.socket], + api_client_connected: APIClientConnectedFactory, +) -> None: + """Safe mode never constructs the api server, so the OTA has to load the + provisioned key from preferences itself to keep encrypting there.""" + pytest.importorskip("aioesphomeapi.noise") + dev = _Device( + *await _build( + yaml_config, write_yaml_config, compile_esphome, reserved_tcp_port + ) + ) + async with run_binary(dev.binary_path, line_callback=dev.on_log) as (proc, _lines): + dev.proc = proc + await _wait_for_port(LOCALHOST, dev.api_port, PORT_WAIT_TIMEOUT) + await _provision_key(dev, api_client_connected) + + # The saved key is already on disk; a host reboot outside an OTA just + # exits, so safe mode takes a second start + force_safe_mode(DEVICE_NAME) + key = PROVISIONING_PSK.decode() + async with run_binary(dev.binary_path, line_callback=dev.on_log) as (proc, lines): + dev.proc = proc + await _wait_for_line(lines, "SAFE MODE IS ACTIVE", PORT_WAIT_TIMEOUT) + await _wait_for_port(LOCALHOST, dev.ota_port, PORT_WAIT_TIMEOUT) + await dev.ota( + None, + key, + "encrypted upload with the provisioned key must succeed in safe mode", + ) + # The re-exec boots normally and the api reads the same record + async with api_client_connected(port=dev.api_port, noise_psk=key): + pass + + @pytest.mark.asyncio async def test_host_ota_rejects_garbage( yaml_config: str, diff --git a/tests/integration/test_safe_mode_loop_runs.py b/tests/integration/test_safe_mode_loop_runs.py index 652877fc09..55756fca13 100644 --- a/tests/integration/test_safe_mode_loop_runs.py +++ b/tests/integration/test_safe_mode_loop_runs.py @@ -25,19 +25,13 @@ from __future__ import annotations import asyncio import re -import struct import pytest from .conftest import run_binary -from .host_prefs import clear_host_prefs, write_host_pref +from .host_prefs import clear_host_prefs, force_safe_mode from .types import CompileFunction, ConfigWriter -# Must match esphome::safe_mode::RTC_KEY in safe_mode.h -SAFE_MODE_RTC_KEY = 233825507 -# Must match esphome::safe_mode::SafeModeComponent::ENTER_SAFE_MODE_MAGIC -ENTER_SAFE_MODE_MAGIC = 0x5AFE5AFE - DEVICE_NAME = "safe-mode-loop-runs" THREAD_LOG_MARKER = "looping component ran in safe mode" @@ -56,9 +50,7 @@ async def test_safe_mode_loop_runs( # Compile finished successfully; pre-populate prefs so the *next* run # enters safe mode immediately. - write_host_pref( - DEVICE_NAME, SAFE_MODE_RTC_KEY, struct.pack(" Date: Sat, 19 Sep 2026 18:08:56 -0400 Subject: [PATCH 08/14] [esp32] Disable newlib nano printf formatting on ESP32-C2 (#19418) Co-authored-by: Claude Fable 5.1 --- esphome/components/esp32/__init__.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 4597904cc3..e9a041b0b1 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -2628,6 +2628,11 @@ async def to_code(config): config.get(CONF_ENGINEERING_SAMPLE, False), ) + # ESP32-C2 defaults to the ROM's newlib "nano" printf, which does not + # understand %zu or %lld and crashes on any %s that follows one. + if variant == VARIANT_ESP32C2: + add_idf_sdkconfig_option("CONFIG_LIBC_NEWLIB_NANO_FORMAT", False) + # Set minimum chip revision for ESP32 variant # Setting this to 3.0 or higher reduces flash size by excluding workaround code, # and for PSRAM users saves significant IRAM by keeping C library functions in ROM. From 92059abf6cc40bbfa1b7865b118c4a60b91092f1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 24 Sep 2026 23:56:28 +0100 Subject: [PATCH 09/14] [web_server] Mask the value of a password text entity, not only its state (#19385) --- esphome/components/web_server/web_server.cpp | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index ec536910e5..58fecd04d8 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -1412,8 +1412,11 @@ json::SerializationBuffer<> WebServer::text_json_(text::Text *obj, const std::st json::JsonBuilder builder; JsonObject root = builder.root(); - const char *state = obj->traits.get_mode() == text::TextMode::TEXT_MODE_PASSWORD ? "********" : value.c_str(); - set_json_icon_state_value(root, obj, "text", state, value.c_str(), start_config); + // A password entity shows the mask and prefills the input with nothing, so the secret never + // reaches the JSON and the mask cannot be written back as the value + const bool password = obj->traits.get_mode() == text::TextMode::TEXT_MODE_PASSWORD; + set_json_icon_state_value(root, obj, "text", password ? "********" : value.c_str(), password ? "" : value.c_str(), + start_config); root[ESPHOME_F("min_length")] = obj->traits.get_min_length(); root[ESPHOME_F("max_length")] = obj->traits.get_max_length(); root[ESPHOME_F("pattern")] = obj->traits.get_pattern_c_str(); From 4af2db7e8a597b4df118df44b4736a8e56ec1f51 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Fri, 25 Sep 2026 05:00:33 +0100 Subject: [PATCH 10/14] [nrf52] set WDT timeout to 30sec for Adafruit bootloader (#19596) Co-authored-by: J. Nick Koston --- esphome/components/nrf52/dfu.cpp | 1 + esphome/components/zephyr/hal.cpp | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/esphome/components/nrf52/dfu.cpp b/esphome/components/nrf52/dfu.cpp index 24dee99726..0122ed9b31 100644 --- a/esphome/components/nrf52/dfu.cpp +++ b/esphome/components/nrf52/dfu.cpp @@ -28,6 +28,7 @@ void DeviceFirmwareUpdate::setup() { this->reset_pin_->digital_write(true); } else { NRF_POWER->GPREGRET = DFU_MAGIC_UF2_RESET; + arch_feed_wdt(); App.reboot(); } } diff --git a/esphome/components/zephyr/hal.cpp b/esphome/components/zephyr/hal.cpp index ad8ed5c95c..10e8340a40 100644 --- a/esphome/components/zephyr/hal.cpp +++ b/esphome/components/zephyr/hal.cpp @@ -27,7 +27,11 @@ void arch_init() { if (device_is_ready(WDT)) { static wdt_timeout_cfg wdt_config{}; wdt_config.flags = WDT_FLAG_RESET_SOC; -#ifdef USE_ZIGBEE +#ifndef USE_BOOTLOADER_MCUBOOT + // Adafruit bootloader doesn't feed the WDT while + // erasing flash during a firmware update, so a shorter timeout can break the update. + wdt_config.window.max = 30000; +#elif defined(USE_ZIGBEE) // zboss thread uses a lot of CPU cycles during startup wdt_config.window.max = 10000; #else From b6d70d4e349561b787d1b3e664c7c16d2952637d Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 28 Sep 2026 00:04:17 +1000 Subject: [PATCH 11/14] [mipi_spi] Fix reversion in dc/cs sequence for spi_16 (#19745) --- esphome/components/mipi_spi/mipi_spi.h | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index 2552451bd7..ee1264cf23 100644 --- a/esphome/components/mipi_spi/mipi_spi.h +++ b/esphome/components/mipi_spi/mipi_spi.h @@ -246,20 +246,18 @@ class MipiSpi : public display::Display, this->write_cmd_addr_data(8, 0x02, 24, cmd << 8, bytes, len); this->disable(); } else if constexpr (BUS_TYPE == BUS_TYPE_OCTAL) { - // Toggle D/C only while holding the bus; on boards where D/C doubles as - // another bus signal, driving it while another device owns the bus - // corrupts that device's transfer. this->enable(); this->dc_pin_->digital_write(false); this->write_cmd_addr_data(0, 0, 0, 0, &cmd, 1, 8); this->dc_pin_->digital_write(true); - this->disable(); + // hold the bus between command and data to avoid a glitch on the D/C line if (len != 0) { - this->enable(); this->write_cmd_addr_data(0, 0, 0, 0, bytes, len, 8); - this->disable(); } + this->disable(); } else if constexpr (BUS_TYPE == BUS_TYPE_SINGLE) { + // Toggle D/C only while holding the bus; works around a quirk in the CoreS3 and W5500 ethernet combination. + // See https://github.com/esphome/esphome/pull/18529 this->enable(); this->dc_pin_->digital_write(false); this->write_byte(cmd); @@ -271,12 +269,14 @@ class MipiSpi : public display::Display, this->disable(); } } else if constexpr (BUS_TYPE == BUS_TYPE_SINGLE_16) { - this->enable(); + // DC must be stable before CS as the clock is gated by CS this->dc_pin_->digital_write(false); + this->enable(); this->write_byte(cmd); - this->dc_pin_->digital_write(true); this->disable(); + this->dc_pin_->digital_write(true); for (size_t i = 0; i != len; i++) { + // must enable and disable for each byte based on empirical testing this->enable(); this->write_byte(0); this->write_byte(bytes[i]); From 43b6b41d16fb0ddf818ae2c6cfd51e34de76f739 Mon Sep 17 00:00:00 2001 From: Suliman Abdulrazzaq <144490671+SulimanAbdulrazzaq@users.noreply.github.com> Date: Mon, 28 Sep 2026 02:12:34 +0300 Subject: [PATCH 12/14] [lvgl] Honor esphome build_flags when generating lv_conf.h (#19743) Co-authored-by: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> --- esphome/components/lvgl/__init__.py | 15 +++-- .../lvgl/test_lv_conf_build_flags.py | 59 +++++++++++++++++++ 2 files changed, 70 insertions(+), 4 deletions(-) create mode 100644 tests/component_tests/lvgl/test_lv_conf_build_flags.py diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 2d2f1d6288..382e483d0c 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -31,6 +31,7 @@ from esphome.components.psram import DOMAIN as PSRAM_DOMAIN import esphome.config_validation as cv from esphome.const import ( CONF_BUFFER_SIZE, + CONF_BUILD_FLAGS, CONF_ESPHOME, CONF_GROUP, CONF_ID, @@ -169,11 +170,17 @@ def generate_lv_conf_h(): all_defines = set( df.LV_DEFINES + tuple(f"LV_USE_{w.upper()}" for w in WIDGET_TYPES) ) - build_flags = ( - CORE.config[CONF_ESPHOME].get(CONF_PLATFORMIO_OPTIONS).get("build_flags", []) + esphome_config = CORE.config[CONF_ESPHOME] + # User build flags come from esphome->build_flags and from the deprecated + # esphome->platformio_options->build_flags (a string or a list). + # Remove before 2026.12.0 + + pio_build_flags = esphome_config.get(CONF_PLATFORMIO_OPTIONS, {}).get( + CONF_BUILD_FLAGS, [] ) - if not isinstance(build_flags, list): - build_flags = [build_flags] + if not isinstance(pio_build_flags, list): + pio_build_flags = [pio_build_flags] + build_flags = [*esphome_config.get(CONF_BUILD_FLAGS, []), *pio_build_flags] # Extract define names from build flags like '-DLV_USE_CHART=1', '-D LV_USE_CHART', # or multiple defines in one string. define_pattern = r'-D\s*([A-Z_][A-Z0-9_]*)(?:=[^\s\'"\]]*)?' diff --git a/tests/component_tests/lvgl/test_lv_conf_build_flags.py b/tests/component_tests/lvgl/test_lv_conf_build_flags.py new file mode 100644 index 0000000000..eb576fa8fa --- /dev/null +++ b/tests/component_tests/lvgl/test_lv_conf_build_flags.py @@ -0,0 +1,59 @@ +"""``generate_lv_conf_h()`` writes ``#define LV_... 0`` for every LVGL option +the configuration does not use, so ``lv_conf.h`` must leave alone the options +the user defines in build flags. Otherwise the header, which is included after +the compiler ``-D`` flags, turns the option off again. + +Build flags can come from ``esphome: build_flags:`` or from the deprecated +``esphome: platformio_options: build_flags:``; both must be honoured. +""" + +from __future__ import annotations + +import logging + +import pytest + +from esphome.components.lvgl import defines as df, generate_lv_conf_h +from esphome.const import CONF_BUILD_FLAGS, CONF_ESPHOME, CONF_PLATFORMIO_OPTIONS +from esphome.core import CORE + + +def _set_esphome_config( + build_flags: list[str] | None = None, + pio_build_flags: list[str] | str | None = None, +) -> None: + pio_options = {} if pio_build_flags is None else {"build_flags": pio_build_flags} + CORE.config = { + CONF_ESPHOME: { + CONF_PLATFORMIO_OPTIONS: pio_options, + CONF_BUILD_FLAGS: build_flags or [], + } + } + + +def test_unused_define_is_disabled_without_build_flag() -> None: + _set_esphome_config() + assert "#define LV_USE_OBSERVER 0" in generate_lv_conf_h().splitlines() + + +@pytest.mark.parametrize( + "flags", + [ + {"build_flags": ["-DLV_USE_OBSERVER=1"]}, + {"build_flags": ["-D LV_USE_OBSERVER"]}, + {"pio_build_flags": ["-DLV_USE_OBSERVER=1"]}, + {"pio_build_flags": "-DLV_USE_OBSERVER=1"}, + ], + ids=["esphome", "esphome-spaced", "platformio_options", "platformio_options-str"], +) +def test_build_flag_define_is_not_disabled(flags: dict) -> None: + _set_esphome_config(**flags) + assert "#define LV_USE_OBSERVER 0" not in generate_lv_conf_h().splitlines() + + +def test_esphome_build_flag_clash_warns(caplog: pytest.LogCaptureFixture) -> None: + _set_esphome_config(build_flags=["-DLV_USE_ARC=1"]) + df.add_define("LV_USE_ARC") + with caplog.at_level(logging.WARNING): + generate_lv_conf_h() + assert "LV_USE_ARC" in caplog.text From eca4c2d0a5de6f1c432f7b0e343339b2b96e4913 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Sep 2026 23:30:51 +0200 Subject: [PATCH 13/14] [ethernet] Move received frames to PSRAM on SPI ethernet chips (#19377) --- .../ethernet/ethernet_component_esp32.cpp | 46 +++++++++++++++++++ .../test-w5500-wifi-psram.esp32-s3-idf.yaml | 15 ++++++ 2 files changed, 61 insertions(+) create mode 100644 tests/components/ethernet/test-w5500-wifi-psram.esp32-s3-idf.yaml diff --git a/esphome/components/ethernet/ethernet_component_esp32.cpp b/esphome/components/ethernet/ethernet_component_esp32.cpp index 1d9903271e..ea732e84ff 100644 --- a/esphome/components/ethernet/ethernet_component_esp32.cpp +++ b/esphome/components/ethernet/ethernet_component_esp32.cpp @@ -10,6 +10,9 @@ #include #include #include "esp_event.h" +#ifdef USE_PSRAM +#include +#endif // IDF 6.0 moved per-chip PHY/MAC drivers to the Espressif Component Registry; // they are no longer included via esp_eth.h and need explicit includes. @@ -71,6 +74,32 @@ static const char *const TAG = "ethernet"; // PHY register size for hex logging static constexpr size_t PHY_REG_SIZE = 2; +// Dual wifi + ethernet SPI builds: the one place internal RAM is short and lwip's other buffers are +// already in PSRAM. Not with L2 TAP, whose filter lives in the glue's input path this replaces. +#if defined(USE_PSRAM) && defined(USE_ETHERNET_SPI) && defined(USE_WIFI) && !defined(CONFIG_ESP_NETIF_L2_TAP) +#define USE_ETHERNET_RX_PSRAM +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) - reported by dump_config() +static bool rx_psram_installed = false; + +// ESP-IDF ethernet drivers malloc() every received frame in internal RAM, where it stays until lwIP +// hands it to the application. Move it to PSRAM; if that fails the frame is passed on where it is. +static esp_err_t eth_input_to_psram(esp_eth_handle_t handle, uint8_t *buffer, uint32_t length, void *priv) { + auto *copy = static_cast(heap_caps_malloc(length, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); + if (copy != nullptr) { + memcpy(copy, buffer, length); + free(buffer); // NOLINT(cppcoreguidelines-no-malloc) - allocated by the driver with malloc() + buffer = copy; + } else { + static bool warned = false; // once, this runs per frame in the driver's task + if (!warned) { + warned = true; + ESP_LOGW(TAG, "PSRAM allocation failed, frame kept in internal RAM (reported once)"); + } + } + return esp_netif_receive(static_cast(priv), buffer, length, nullptr); +} +#endif + void EthernetComponent::log_error_and_mark_failed_(esp_err_t err, const char *message) { ESP_LOGE(TAG, "%s: (%d) %s", message, err, esp_err_to_name(err)); this->mark_failed(); @@ -453,6 +482,16 @@ void EthernetComponent::ethernet_lazy_init_() { /* attach Ethernet driver to TCP/IP stack */ err = esp_netif_attach(this->eth_netif_, esp_eth_new_netif_glue(this->eth_handle_)); ESPHL_ERROR_CHECK(err, "ETH netif attach error"); +#ifdef USE_ETHERNET_RX_PSRAM + // The glue frees every receive buffer with free(), so the replacement buffer must come from the heap + if (esp_psram_is_initialized()) { + err = esp_eth_update_input_path(this->eth_handle_, eth_input_to_psram, this->eth_netif_); + rx_psram_installed = err == ESP_OK; + if (!rx_psram_installed) { + ESP_LOGW(TAG, "PSRAM RX path not installed: %s", esp_err_to_name(err)); + } + } +#endif // Register user defined event handers err = esp_event_handler_register(ETH_EVENT, ESP_EVENT_ANY_ID, &EthernetComponent::eth_event_handler, nullptr); @@ -630,6 +669,13 @@ void EthernetComponent::dump_config() { this->clk_pin_, this->mdc_pin_, this->mdio_pin_, this->phy_addr_); #endif ESP_LOGCONFIG(TAG, " Type: %s", eth_type); +#ifdef USE_ETHERNET_RX_PSRAM + // Only known once the driver is up; with enable_on_boot: false that is after this dump + if (this->ethernet_initialized_) { + ESP_LOGCONFIG(TAG, " RX frames: %s", + rx_psram_installed ? LOG_STR_LITERAL("PSRAM") : LOG_STR_LITERAL("internal RAM")); + } +#endif } network::IPAddresses EthernetComponent::get_ip_addresses() { diff --git a/tests/components/ethernet/test-w5500-wifi-psram.esp32-s3-idf.yaml b/tests/components/ethernet/test-w5500-wifi-psram.esp32-s3-idf.yaml new file mode 100644 index 0000000000..d454dbadc3 --- /dev/null +++ b/tests/components/ethernet/test-w5500-wifi-psram.esp32-s3-idf.yaml @@ -0,0 +1,15 @@ +# W5500 next to wifi with PSRAM: compiles the input path that moves received frames to PSRAM +packages: + ethernet: !include common-w5500.yaml + +psram: + mode: quad + +wifi: + ssid: MySSID + password: password1 + +network: + priority: + - ethernet + - wifi From 7a4ffa98f67c6633e9cc119bb423ceb6e842fa88 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:57:36 +1300 Subject: [PATCH 14/14] Bump version to 2026.9.1 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index a5d6da6333..65d975b61e 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.9.0 +PROJECT_NUMBER = 2026.9.1 # 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 e6165bf16b..0ec6a95869 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.9.0" +__version__ = "2026.9.1" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = (