Merge pull request #19872 from esphome/bump-2026.9.1

2026.9.1
This commit is contained in:
Jesse Hills
2026-09-29 20:12:00 +01:00
committed by GitHub
36 changed files with 643 additions and 129 deletions
+1 -1
View File
@@ -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
+24 -2
View File
@@ -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<uint8_t *>(&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<SavedNoisePsk>(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<SavedNoisePsk>(hash, true);
this->noise_pref_ = global_preferences->make_preference<SavedNoisePsk>(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()) {
+5
View File
@@ -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
+6 -1
View File
@@ -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
@@ -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.
+9 -5
View File
@@ -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<uint32_t>(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 ***");
+1 -6
View File
@@ -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
-8
View File
@@ -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 <esp_clk_tree.h>
@@ -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);
@@ -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
+17 -13
View File
@@ -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
@@ -308,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.
+17 -5
View File
@@ -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<noise::psk_t>().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"));
+6 -3
View File
@@ -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<uint8_t[]> 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<noise::psk_t> saved_psk_;
#endif
RAMUniquePtr<NoiseSession> noise_;
#endif // USE_OTA_ENCRYPTION
@@ -10,6 +10,9 @@
#include <lwip/dns.h>
#include <cinttypes>
#include "esp_event.h"
#ifdef USE_PSRAM
#include <esp_psram.h>
#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<uint8_t *>(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<esp_netif_t *>(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() {
+11 -4
View File
@@ -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\'"\]]*)?'
+2 -2
View File
@@ -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) {
+8 -8
View File
@@ -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]);
@@ -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;
}
+12 -6
View File
@@ -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:
+1
View File
@@ -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();
}
}
+17 -5
View File
@@ -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 ***");
+2 -1
View File
@@ -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
+5 -2
View File
@@ -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();
+5 -1
View File
@@ -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
+1 -1
View File
@@ -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 = (
+2 -2
View File
@@ -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();
-1
View File
@@ -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
+85 -14
View File
@@ -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
@@ -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
+36 -16
View File
@@ -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."""
@@ -450,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
@@ -496,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",
},
),
@@ -515,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"])
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,11 @@
esphome:
name: host-ota-test
host:
api:
encryption:
ota:
- platform: esphome
port: __OTA_PORT__
safe_mode:
logger:
level: DEBUG
+29 -6
View File
@@ -14,6 +14,12 @@ from __future__ import annotations
from pathlib import Path
import struct
_ENTRY = struct.Struct("<IB") # key, data length
# 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
def host_prefs_path(device_name: str) -> 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("<IB", key, len(data)) + data
payload += _ENTRY.pack(key, len(data)) + data
path = host_prefs_path(device_name)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(payload)
return path
def write_host_pref(device_name: str, key: int, data: bytes) -> 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("<I", _ENTER_SAFE_MODE_MAGIC)
write_host_prefs(device_name, entries)
+75 -5
View File
@@ -29,6 +29,7 @@ from .const import (
PROVISIONING_PSK,
ZERO_PSK,
)
from .host_prefs import force_safe_mode
from .types import APIClientConnectedFactory, CompileFunction, ConfigWriter
DEVICE_NAME = "host-ota-test"
@@ -166,6 +167,15 @@ class _Device:
assert self.proc.returncode is None, "process died on rejected OTA"
async def _provision_key(
dev: _Device, api_client_connected: APIClientConnectedFactory
) -> 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,
+2 -10
View File
@@ -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("<I", ENTER_SAFE_MODE_MAGIC)
)
force_safe_mode(DEVICE_NAME)
try:
loop = asyncio.get_running_loop()
+104
View File
@@ -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