Compare commits

..
Author SHA1 Message Date
J. Nick Koston 8b3cebaf64 [uart] Skip uart_debugger.cpp when no debug block is configured 2026-08-23 10:10:48 -05:00
J. Nick KostonandGitHub cf31c08a5c [core] Skip copying entity automation and filter sources when unused (#18602) 2026-08-23 09:05:04 -05:00
J. Nick KostonandGitHub e7574a574b [ota] Restore lazy flash erase for ESP32 OTA with 64 KiB block erase (#18580) 2026-08-23 09:04:48 -05:00
J. Nick KostonandGitHub 33484108a9 [core] Replace a damaged existing file in write_file_if_changed (#18665) 2026-08-23 09:04:24 -05:00
J. Nick KostonandGitHub e697a40fda [core] Register the OTA component in dummy_main like its siblings (#18666) 2026-08-23 09:04:06 -05:00
J. Nick KostonandGitHub d1f065671e [http_request] Abort OTA backend when update fails before first write (#18581) 2026-08-22 22:21:04 -05:00
J. Nick KostonandGitHub 02da5c6484 [ethernet] Remove deprecated get_eth_mac_address_pretty() (#18379) 2026-08-22 22:02:41 -05:00
J. Nick KostonandGitHub b2440cb655 [modbus] Remove deprecated waiting_for_response() (#18381) 2026-08-22 22:02:22 -05:00
J. Nick KostonandGitHub 160d8b8f0c [web_server_idf] Remove deprecated AsyncWebServerRequest::url() (#18382) 2026-08-22 22:02:05 -05:00
J. Nick KostonandGitHub 8899713ef9 [core] Remove deprecated gamma_correct and gamma_uncorrect (#18376) 2026-08-22 22:00:53 -05:00
J. Nick KostonandGitHub f3cdefce21 [wifi] Remove deprecated wifi_ssid() (#18378) 2026-08-22 22:00:38 -05:00
J. Nick KostonandGitHub b115813fbe [esp32] Report abort and task watchdog panics correctly in crash handler (#18575) 2026-08-22 22:00:17 -05:00
J. Nick KostonandGitHub ab45ab316a [core] Remove deprecated entity_base getters (#18375) 2026-08-22 22:00:02 -05:00
J. Nick KostonandGitHub 5b3a6c05bf [core] Remove deprecated esp_log_vprintf_ flash-string overload (#18377) 2026-08-22 21:59:47 -05:00
J. Nick KostonandGitHub cd53681787 [wifi] Inline the remaining trivial WiFiAP and WiFiComponent accessors (#18617) 2026-08-23 02:51:39 +00:00
47 changed files with 472 additions and 617 deletions
@@ -5,6 +5,7 @@ from esphome.automation import Condition, maybe_simple_id
import esphome.codegen as cg
from esphome.components import mqtt, web_server, zigbee
from esphome.components.const import CONF_ON_STATE_CHANGE
from esphome.config_helpers import filter_source_files_from_defines
import esphome.config_validation as cv
from esphome.const import (
CONF_DELAY,
@@ -560,6 +561,11 @@ _CALLBACK_AUTOMATIONS = (
async def _build_binary_sensor_automations(var, config):
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
if config.get(CONF_ON_CLICK) or config.get(CONF_ON_DOUBLE_CLICK):
cg.add_define("USE_BINARY_SENSOR_CLICK_TRIGGER")
if config.get(CONF_ON_MULTI_CLICK):
cg.add_define("USE_BINARY_SENSOR_MULTI_CLICK_TRIGGER")
for conf in config.get(CONF_ON_CLICK, []):
trigger = cg.new_Pvariable(
conf[CONF_TRIGGER_ID], var, conf[CONF_MIN_LENGTH], conf[CONF_MAX_LENGTH]
@@ -673,3 +679,15 @@ async def to_code(config):
async def binary_sensor_invalidate_state_to_code(config, action_id, template_arg, args):
paren = await cg.get_variable(config[CONF_ID])
return cg.new_Pvariable(action_id, template_arg, paren)
# automation.cpp only implements the click/double_click/multi_click triggers
FILTER_SOURCE_FILES = filter_source_files_from_defines(
{
"automation.cpp": (
"USE_BINARY_SENSOR_CLICK_TRIGGER",
"USE_BINARY_SENSOR_MULTI_CLICK_TRIGGER",
),
"filter.cpp": "USE_BINARY_SENSOR_FILTER",
}
)
@@ -1,8 +1,13 @@
#include "esphome/core/defines.h"
#if defined(USE_BINARY_SENSOR_CLICK_TRIGGER) || defined(USE_BINARY_SENSOR_MULTI_CLICK_TRIGGER)
#include "automation.h"
#include "esphome/core/log.h"
namespace esphome::binary_sensor {
#ifdef USE_BINARY_SENSOR_MULTI_CLICK_TRIGGER
static const char *const TAG = "binary_sensor.automation";
// MultiClickTrigger timeout IDs.
@@ -120,6 +125,9 @@ void MultiClickTriggerBase::trigger_() {
this->trigger();
}
#endif // USE_BINARY_SENSOR_MULTI_CLICK_TRIGGER
#ifdef USE_BINARY_SENSOR_CLICK_TRIGGER
bool match_interval(uint32_t min_length, uint32_t max_length, uint32_t length) {
if (max_length == 0) {
return length >= min_length;
@@ -127,4 +135,8 @@ bool match_interval(uint32_t min_length, uint32_t max_length, uint32_t length) {
return length >= min_length && length <= max_length;
}
}
#endif // USE_BINARY_SENSOR_CLICK_TRIGGER
} // namespace esphome::binary_sensor
#endif // USE_BINARY_SENSOR_CLICK_TRIGGER || USE_BINARY_SENSOR_MULTI_CLICK_TRIGGER
+8
View File
@@ -12,6 +12,7 @@ from typing import Any
from esphome import yaml_util
import esphome.codegen as cg
from esphome.components.const import CONF_ENABLE_OTA_DOWNGRADE_PROTECTION
from esphome.config_helpers import filter_source_files_from_defines
import esphome.config_validation as cv
from esphome.const import (
CONF_ADVANCED,
@@ -3451,3 +3452,10 @@ def process_stacktrace(config, line, backtrace_state):
_decode_pc(config, addr.group())
return backtrace_state
# gpio.cpp only implements ESP32InternalGPIOPin and its ISR helpers, which
# are instantiated solely by the pin schema codegen (esp32_pin_to_code)
FILTER_SOURCE_FILES = filter_source_files_from_defines(
{"gpio.cpp": "USE_ESP32_INTERNAL_GPIO"}
)
+54 -7
View File
@@ -124,6 +124,15 @@ static uint8_t IRAM_ATTR capture_riscv_backtrace(RvExcFrame *frame, uint32_t *ou
// Version is uint32_t because it would be padded to 4 bytes anyway before the next
// uint32_t field, so we use the full width rather than wasting 3 bytes of padding.
static constexpr uint32_t CRASH_DATA_VERSION = 4;
#if CONFIG_IDF_TARGET_ARCH_XTENSA
// EXCCAUSE is a 6-bit register; larger recorded values mean the frame's
// cause/vaddr slots were never written (not a real exception frame).
static constexpr uint32_t XTENSA_EXCCAUSE_COUNT = XCHAL_EXCCAUSE_NUM;
#elif CONFIG_IDF_TARGET_ARCH_RISCV
// Synchronous mcause exception codes are small and have no interrupt bit;
// anything else in a non-pseudo record is a stale slot.
static constexpr uint32_t RISCV_EXCEPTION_CAUSE_COUNT = 32;
#endif
struct RawCrashData {
uint32_t version;
uint32_t magic;
@@ -198,10 +207,28 @@ void crash_handler_clear() {
s_raw_crash_data.magic = 0;
}
// Whether the cause slot was written by a real exception frame.
static bool cause_slot_was_written() {
#if CONFIG_IDF_TARGET_ARCH_XTENSA
return s_raw_crash_data.cause < XTENSA_EXCCAUSE_COUNT;
#else
return s_raw_crash_data.cause < RISCV_EXCEPTION_CAUSE_COUNT;
#endif
}
// Look up the exception cause as a human-readable string.
// Tables mirror ESP-IDF's panic_arch_fill_info() which uses local static arrays
// not exposed via any public API.
static const char *get_exception_reason() {
uint8_t exception = s_raw_crash_data.exception;
if (exception == PANIC_EXCEPTION_ABORT || exception == PANIC_EXCEPTION_TWDT) {
// Abort-class panics carry no cause register
return nullptr;
}
if (!cause_slot_was_written()) {
// Garbage from old-build or corrupt records; report just the type
return nullptr;
}
#if CONFIG_IDF_TARGET_ARCH_XTENSA
if (s_raw_crash_data.pseudo_excause) {
// SoC-level panic: watchdog, cache error, etc.
@@ -354,10 +381,11 @@ static const char *const FAULT_ADDR_REG = "MTVAL";
static const char *const FAULT_ADDR_REG_LOWER = "mtval";
#endif
// Whether the fault address is meaningful real CPU faults only, not
// aborts/watchdogs or SoC-level pseudo exceptions.
// Whether the fault address is meaningful: real CPU faults with a validly
// written frame only.
static bool has_fault_addr() {
return s_raw_crash_data.exception == PANIC_EXCEPTION_FAULT && !s_raw_crash_data.pseudo_excause;
return s_raw_crash_data.exception == PANIC_EXCEPTION_FAULT && !s_raw_crash_data.pseudo_excause &&
cause_slot_was_written();
}
// The record was captured by a different firmware build (it survives soft
@@ -458,6 +486,10 @@ void crash_handler_log() {
// into NOINIT memory before the normal panic handler runs.
//
extern "C" {
// Set by IDF's task watchdog (task_wdt.c, no header) before it simulates an
// abort; weak so builds without the task watchdog still link.
extern bool g_twdt_isr __attribute__((weak));
// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming)
// Names are mandated by the --wrap linker mechanism
extern void __real_esp_panic_handler(panic_info_t *info);
@@ -470,6 +502,14 @@ void IRAM_ATTR __wrap_esp_panic_handler(panic_info_t *info) {
s_raw_crash_data.exception = (uint8_t) info->exception;
s_raw_crash_data.pseudo_excause = info->pseudo_excause ? 1 : 0;
s_raw_crash_data.crashed_core = (uint8_t) info->core;
if (g_panic_abort) {
// IDF reclassifies to ABORT only inside esp_panic_handler(), after this
// wrapper captured info->exception; correct it here. TWDT is our own
// distinction (IDF never assigns PANIC_EXCEPTION_TWDT). The abort text is
// not stored; the symbolized backtrace already identifies the site.
bool is_twdt = &g_twdt_isr != nullptr && g_twdt_isr;
s_raw_crash_data.exception = (uint8_t) (is_twdt ? PANIC_EXCEPTION_TWDT : PANIC_EXCEPTION_ABORT);
}
// Zero unconditionally so a null frame doesn't leave stale .noinit data from a previous boot
s_raw_crash_data.cause = 0;
s_raw_crash_data.fault_addr = 0;
@@ -487,8 +527,12 @@ void IRAM_ATTR __wrap_esp_panic_handler(panic_info_t *info) {
// Xtensa: walk the backtrace using the public API
if (info->frame != nullptr) {
auto *xt_frame = (XtExcFrame *) info->frame;
s_raw_crash_data.cause = xt_frame->exccause;
s_raw_crash_data.fault_addr = xt_frame->excvaddr;
if (!g_panic_abort) {
// Abort-class frames carry no useful cause/vaddr: TWDT task snapshots
// never wrote them and abort() traps describe only the synthetic trap.
s_raw_crash_data.cause = xt_frame->exccause;
s_raw_crash_data.fault_addr = xt_frame->excvaddr;
}
s_raw_crash_data.backtrace_count = walk_xtensa_backtrace(xt_frame, s_raw_crash_data.backtrace, MAX_BACKTRACE);
}
@@ -510,8 +554,11 @@ void IRAM_ATTR __wrap_esp_panic_handler(panic_info_t *info) {
// RISC-V: capture MEPC + RA, then scan stack for code addresses
if (info->frame != nullptr) {
auto *rv_frame = (RvExcFrame *) info->frame;
s_raw_crash_data.cause = rv_frame->mcause;
s_raw_crash_data.fault_addr = rv_frame->mtval;
if (!g_panic_abort) {
// See the Xtensa branch: abort-class frames carry no valid cause/vaddr.
s_raw_crash_data.cause = rv_frame->mcause;
s_raw_crash_data.fault_addr = rv_frame->mtval;
}
s_raw_crash_data.backtrace_count =
capture_riscv_backtrace(rv_frame, s_raw_crash_data.backtrace, MAX_BACKTRACE, &s_raw_crash_data.reg_frame_count);
}
+5 -2
View File
@@ -1,4 +1,7 @@
#ifdef USE_ESP32
#include "esphome/core/defines.h"
// Also defines the core ISRInternalGPIOPin methods; those are only reachable
// via ESP32InternalGPIOPin::to_isr(), so the same define gates both safely.
#if defined(USE_ESP32) && defined(USE_ESP32_INTERNAL_GPIO)
#include "gpio.h"
#include "esphome/core/log.h"
@@ -204,4 +207,4 @@ void IRAM_ATTR ISRInternalGPIOPin::pin_mode(gpio::Flags flags) {
} // namespace esphome
#endif // USE_ESP32
#endif // USE_ESP32 && USE_ESP32_INTERNAL_GPIO
+1
View File
@@ -257,6 +257,7 @@ ESP32_PIN_SCHEMA = cv.All(
@pins.PIN_SCHEMA_REGISTRY.register(PLATFORM_ESP32, ESP32_PIN_SCHEMA)
async def esp32_pin_to_code(config):
cg.add_define("USE_ESP32_INTERNAL_GPIO")
var = cg.new_Pvariable(config[CONF_ID])
num = config[CONF_NUMBER]
cg.add(var.set_pin(getattr(gpio_num_t, f"GPIO_NUM_{num}")))
@@ -398,7 +398,7 @@ void ESPHomeOTAComponent::handle_data_() {
this->notify_state_(ota::OTA_STARTED, 0.0f, 0);
#endif
// begin() may block for a few seconds while it locks flash.
// begin() returns quickly; flash sectors are erased incrementally during write().
error_code = this->backend_->begin(ota_size, ota_type);
if (error_code != ota::OTA_RESPONSE_OK)
goto error; // NOLINT(cppcoreguidelines-avoid-goto)
@@ -159,9 +159,6 @@ class EthernetComponent final : public Component {
const char *get_use_address() const { return this->use_address_; }
void set_use_address(const char *use_address) { this->use_address_ = use_address; }
void get_eth_mac_address_raw(uint8_t *mac);
// Remove before 2026.9.0
ESPDEPRECATED("Use get_eth_mac_address_pretty_into_buffer() instead. Removed in 2026.9.0", "2026.3.0")
std::string get_eth_mac_address_pretty();
const char *get_eth_mac_address_pretty_into_buffer(std::span<char, MAC_ADDRESS_PRETTY_BUFFER_SIZE> buf);
eth_duplex_t get_duplex_mode();
eth_speed_t get_link_speed();
@@ -928,11 +928,6 @@ void EthernetComponent::get_eth_mac_address_raw(uint8_t *mac) {
ESPHL_ERROR_CHECK(err, "ETH_CMD_G_MAC error");
}
std::string EthernetComponent::get_eth_mac_address_pretty() {
char buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
return std::string(this->get_eth_mac_address_pretty_into_buffer(buf));
}
const char *EthernetComponent::get_eth_mac_address_pretty_into_buffer(
std::span<char, MAC_ADDRESS_PRETTY_BUFFER_SIZE> buf) {
uint8_t mac[MAC_ADDRESS_SIZE];
@@ -249,11 +249,6 @@ void EthernetComponent::get_eth_mac_address_raw(uint8_t *mac) {
}
}
std::string EthernetComponent::get_eth_mac_address_pretty() {
char buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
return std::string(this->get_eth_mac_address_pretty_into_buffer(buf));
}
const char *EthernetComponent::get_eth_mac_address_pretty_into_buffer(
std::span<char, MAC_ADDRESS_PRETTY_BUFFER_SIZE> buf) {
uint8_t mac[MAC_ADDRESS_SIZE];
@@ -64,8 +64,9 @@ void OtaHttpRequestComponent::flash() {
}
}
void OtaHttpRequestComponent::cleanup_(ota::OTABackendPtr backend, const std::shared_ptr<HttpContainer> &container) {
if (this->update_started_) {
void OtaHttpRequestComponent::cleanup_(ota::OTABackendPtr backend, const std::shared_ptr<HttpContainer> &container,
bool abort_backend) {
if (abort_backend) {
ESP_LOGV(TAG, "Aborting OTA backend");
backend->abort();
}
@@ -106,7 +107,8 @@ uint8_t OtaHttpRequestComponent::do_ota_() {
auto error_code = backend->begin(container->content_length);
if (error_code != ota::OTA_RESPONSE_OK) {
ESP_LOGW(TAG, "backend->begin error: %d", error_code);
this->cleanup_(std::move(backend), container);
// Nothing to abort: begin() failed, so no OTA handle was opened
this->cleanup_(std::move(backend), container, /*abort_backend=*/false);
return error_code;
}
@@ -140,7 +142,7 @@ uint8_t OtaHttpRequestComponent::do_ota_() {
} else {
ESP_LOGE(TAG, "Error reading data: %d", bufsize_or_error);
}
this->cleanup_(std::move(backend), container);
this->cleanup_(std::move(backend), container, /*abort_backend=*/true);
return OTA_CONNECTION_ERROR;
}
@@ -150,14 +152,13 @@ uint8_t OtaHttpRequestComponent::do_ota_() {
md5_receive.add(buf, bufsize_or_error);
// write bytes to OTA backend
this->update_started_ = true;
error_code = backend->write(buf, bufsize_or_error);
if (error_code != ota::OTA_RESPONSE_OK) {
// error code explanation available at
// https://github.com/esphome/esphome/blob/dev/esphome/components/ota/ota_backend.h
ESP_LOGE(TAG, "Error code (%02X) writing binary data to flash at offset %d and size %d", error_code,
container->get_bytes_read() - bufsize_or_error, container->content_length);
this->cleanup_(std::move(backend), container);
this->cleanup_(std::move(backend), container, /*abort_backend=*/true);
return error_code;
}
}
@@ -181,7 +182,7 @@ uint8_t OtaHttpRequestComponent::do_ota_() {
this->md5_computed_ = md5_receive_str;
if (strncmp(this->md5_computed_.c_str(), this->md5_expected_.c_str(), MD5_SIZE) != 0) {
ESP_LOGE(TAG, "MD5 computed: %s - Aborting due to MD5 mismatch", this->md5_computed_.c_str());
this->cleanup_(std::move(backend), container);
this->cleanup_(std::move(backend), container, /*abort_backend=*/true);
return ota::OTA_RESPONSE_ERROR_MD5_MISMATCH;
} else {
backend->set_update_md5(md5_receive_str);
@@ -197,7 +198,7 @@ uint8_t OtaHttpRequestComponent::do_ota_() {
error_code = backend->end();
if (error_code != ota::OTA_RESPONSE_OK) {
ESP_LOGW(TAG, "Error ending update! error_code: %d", error_code);
this->cleanup_(std::move(backend), container);
this->cleanup_(std::move(backend), container, /*abort_backend=*/true);
return error_code;
}
@@ -38,7 +38,7 @@ class OtaHttpRequestComponent final : public ota::OTAComponent, public Parented<
void flash();
protected:
void cleanup_(ota::OTABackendPtr backend, const std::shared_ptr<HttpContainer> &container);
void cleanup_(ota::OTABackendPtr backend, const std::shared_ptr<HttpContainer> &container, bool abort_backend);
uint8_t do_ota_();
std::string get_url_with_auth_(const std::string &url);
bool http_get_md5_();
@@ -51,7 +51,6 @@ class OtaHttpRequestComponent final : public ota::OTAComponent, public Parented<
std::string username_{};
std::string url_{};
int status_ = -1;
bool update_started_ = false;
static const uint16_t HTTP_RECV_BUFFER = 256; // the firmware GET chunk size
};
-3
View File
@@ -618,9 +618,6 @@ class ModbusClientDevice {
inline void clear_tx_queue_for_address() { this->parent_->clear_tx_queue_for_address(this->address_); }
inline void clear_tx_queue_for_device() { this->parent_->clear_tx_queue_for_device(this); }
// If more than one device is connected block sending a new command before a response is received
ESPDEPRECATED("Use ready_for_immediate_send() instead. Removed in 2026.9.0", "2026.3.0")
bool waiting_for_response() { return !this->ready_for_immediate_send(); }
bool ready_for_immediate_send() { return this->parent_->tx_buffer_empty() && !this->parent_->tx_blocked(); }
protected:
+17 -21
View File
@@ -1,6 +1,9 @@
from esphome import automation
import esphome.codegen as cg
from esphome.config_helpers import filter_source_files_from_platform
from esphome.config_helpers import (
filter_source_files_from_defines,
filter_source_files_from_platform,
)
import esphome.config_validation as cv
from esphome.const import (
CONF_ESPHOME,
@@ -171,24 +174,17 @@ _filter_backend_source_files = filter_source_files_from_platform(
)
# USE_OTA_SIGNED_VERIFICATION_MULTI_KEY is set only on ESP32/IDF;
# USE_OTA_PARTITIONS is set by the esphome OTA platform when
# allow_partition_access is enabled.
_filter_define_source_files = filter_source_files_from_defines(
{
"ota_signature_esp_idf.cpp": "USE_OTA_SIGNED_VERIFICATION_MULTI_KEY",
"ota_bootloader_esp_idf.cpp": "USE_OTA_PARTITIONS",
"ota_partitions_esp_idf.cpp": "USE_OTA_PARTITIONS",
}
)
def FILTER_SOURCE_FILES() -> list[str]:
files = _filter_backend_source_files()
# ota_signature_esp_idf.cpp implements multi-key OTA signature verification,
# compiled only when the esp32 component enables it (external RSA signed
# OTA sets USE_OTA_SIGNED_VERIFICATION_MULTI_KEY). The define is set only on
# ESP32/IDF, so this also excludes the file on every other platform. Filter
# it out otherwise so the (otherwise fully #ifdef'd-out) file isn't opened
# and parsed on every build.
if not any(
define.name == "USE_OTA_SIGNED_VERIFICATION_MULTI_KEY"
for define in CORE.defines
):
files.append("ota_signature_esp_idf.cpp")
# ota_bootloader_esp_idf.cpp and ota_partitions_esp_idf.cpp are fully
# #ifdef'd on USE_OTA_PARTITIONS (set by the esphome OTA platform when
# allow_partition_access is enabled). Filter them out otherwise for the
# same reason as above.
if not any(define.name == "USE_OTA_PARTITIONS" for define in CORE.defines):
files.append("ota_bootloader_esp_idf.cpp")
files.append("ota_partitions_esp_idf.cpp")
return files
return _filter_backend_source_files() + _filter_define_source_files()
+13
View File
@@ -66,6 +66,19 @@ enum OTAResponseTypes {
*/
bool version_is_older(const char *candidate, const char *reference);
// 64 KiB flash block; the erase granularity the ESP-IDF backend erases ahead with.
static constexpr size_t OTA_BLOCK_ERASE_SIZE = 64 * 1024;
/** Target erased watermark for lazy block erase-ahead.
*
* Rounds the write end offset up to a block boundary, clamped to the partition
* size. Platform-independent so the arithmetic is host-testable.
*/
constexpr size_t next_erase_end(size_t write_end, size_t partition_size) {
const size_t rounded = (write_end + OTA_BLOCK_ERASE_SIZE - 1) & ~(OTA_BLOCK_ERASE_SIZE - 1);
return rounded < partition_size ? rounded : partition_size;
}
enum OTAState {
OTA_COMPLETED = 0,
OTA_STARTED,
+69 -17
View File
@@ -7,7 +7,7 @@
#include "esphome/core/log.h"
#include <esp_ota_ops.h>
#include <esp_task_wdt.h>
#include <sdkconfig.h>
#include <spi_flash_mmap.h>
#ifdef USE_OTA_DOWNGRADE_PROTECTION
#include <esp_app_desc.h>
@@ -60,27 +60,38 @@ OTAResponseTypes IDFOTABackend::begin(size_t image_size, ota::OTAType ota_type)
return OTA_RESPONSE_ERROR_NO_UPDATE_PARTITION;
}
// esp_ota_begin() erases the destination region, which blocks loopTask and
// scales with the erase size -- a fixed watchdog overruns on large OTA slots.
// An unknown size (0, e.g. web_server uploads) erases the whole partition, so
// budget against the bytes actually erased. ~10ms/KiB (conservative
// ~100 KiB/s erase) over a 15s floor; panic stays on so a stuck erase still
// resets rather than hanging forever.
size_t erase_size = image_size;
if (erase_size == 0 || erase_size > this->partition_->size) {
erase_size = this->partition_->size;
// Both lazy-erase paths below replace esp_ota_begin()'s blocking full erase.
// Size check replaces the one that erase performed (0 = unknown size,
// e.g. web_server uploads).
if (image_size != 0 && image_size > this->partition_->size) {
return OTA_RESPONSE_ERROR_ESP32_NOT_ENOUGH_SPACE;
}
const uint32_t erase_budget_ms = 15000 + (erase_size >> 10) * 10;
watchdog::WatchdogManager watchdog(erase_budget_ms);
esp_err_t err = esp_ota_begin(this->partition_, image_size, &this->update_handle_);
this->written_ = 0;
esp_err_t err;
#ifdef USE_OTA_BLOCK_ERASE_AHEAD
this->erased_end_ = 0;
// Unlike esp_ota_begin(), esp_ota_resume() does not reject a running app in
// ESP_OTA_IMG_PENDING_VERIFY; that state is unreachable here because the app
// was marked valid at boot (esp32/hal.cpp) or just above under USE_OTA_ROLLBACK.
// erase_size 0 (!= OTA_WITH_SEQUENTIAL_WRITES) means no erase; erase_ahead_() handles it
err = esp_ota_resume(this->partition_, 0, 0, &this->update_handle_);
#if defined(CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE) && ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0)
// esp_ota_begin() does this on IDF 5.5+; esp_ota_resume() does not. Prevents
// booting a half-written slot after a crash mid-OTA. Not available on the
// 5.3.3/5.4.2 backports, whose esp_ota_begin() did not invalidate either.
if (err == ESP_OK) {
esp_ota_invalidate_inactive_ota_data_slot();
}
#endif
#else
err = esp_ota_begin(this->partition_, OTA_WITH_SEQUENTIAL_WRITES, &this->update_handle_);
#endif
if (err != ESP_OK) {
ESP_LOGE(TAG, "esp_ota_begin failed (err=0x%X)", err);
ESP_LOGE(TAG, "OTA begin failed (err=0x%X)", err);
esp_ota_abort(this->update_handle_);
this->update_handle_ = 0;
if (err == ESP_ERR_INVALID_SIZE) {
return OTA_RESPONSE_ERROR_ESP32_NOT_ENOUGH_SPACE;
} else if (err == ESP_ERR_FLASH_OP_TIMEOUT || err == ESP_ERR_FLASH_OP_FAIL) {
if (err == ESP_ERR_FLASH_OP_TIMEOUT || err == ESP_ERR_FLASH_OP_FAIL) {
return OTA_RESPONSE_ERROR_WRITING_FLASH;
} else if (err == ESP_ERR_OTA_PARTITION_CONFLICT) {
// This error appears with 1 factory and 1 ota partition
@@ -120,6 +131,17 @@ OTAResponseTypes IDFOTABackend::write(uint8_t *data, size_t len) {
if (!this->is_app_or_bootloader_update_()) {
return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE;
}
#endif
// Overflow can only happen on unknown-size uploads (web_server); known
// sizes were rejected in begin().
if (this->written_ + len > this->partition_->size) {
return OTA_RESPONSE_ERROR_ESP32_NOT_ENOUGH_SPACE;
}
#ifdef USE_OTA_BLOCK_ERASE_AHEAD
OTAResponseTypes erase_result = this->erase_ahead_(len);
if (erase_result != OTA_RESPONSE_OK) {
return erase_result;
}
#endif
esp_err_t err = esp_ota_write(this->update_handle_, data, len);
this->md5_.add(data, len);
@@ -127,14 +149,40 @@ OTAResponseTypes IDFOTABackend::write(uint8_t *data, size_t len) {
ESP_LOGE(TAG, "esp_ota_write failed (err=0x%X)", err);
if (err == ESP_ERR_OTA_VALIDATE_FAILED) {
return OTA_RESPONSE_ERROR_MAGIC;
} else if (err == ESP_ERR_INVALID_SIZE) {
// Sequential-writes fallback: IDF's lazy erase reports overflow here
return OTA_RESPONSE_ERROR_ESP32_NOT_ENOUGH_SPACE;
} else if (err == ESP_ERR_FLASH_OP_TIMEOUT || err == ESP_ERR_FLASH_OP_FAIL) {
return OTA_RESPONSE_ERROR_WRITING_FLASH;
}
return OTA_RESPONSE_ERROR_UNKNOWN;
}
this->written_ += len;
return OTA_RESPONSE_OK;
}
#ifdef USE_OTA_BLOCK_ERASE_AHEAD
OTAResponseTypes IDFOTABackend::erase_ahead_(size_t len) {
const size_t end = this->written_ + len;
if (this->erased_end_ >= end) {
return OTA_RESPONSE_OK;
}
// Round up to a block boundary, clamped to the partition end; IDF splits the
// range into 64 KiB block erases where aligned, sector erases elsewhere.
const size_t erase_to = next_erase_end(end, this->partition_->size);
// A block erase is one uninterruptible flash op (typically ~150 ms, seconds
// on aged flash) and the transfer loop may not have fed the WDT for ~1s.
watchdog::WatchdogManager watchdog(15000);
esp_err_t err = esp_partition_erase_range(this->partition_, this->erased_end_, erase_to - this->erased_end_);
if (err != ESP_OK) {
ESP_LOGE(TAG, "esp_partition_erase_range failed (err=0x%X)", err);
return err == ESP_ERR_INVALID_SIZE ? OTA_RESPONSE_ERROR_ESP32_NOT_ENOUGH_SPACE : OTA_RESPONSE_ERROR_WRITING_FLASH;
}
this->erased_end_ = erase_to;
return OTA_RESPONSE_OK;
}
#endif
OTAResponseTypes IDFOTABackend::end() {
if (this->md5_set_) {
this->md5_.calculate();
@@ -226,6 +274,10 @@ void IDFOTABackend::abort() {
// or not an update is in flight.
esp_ota_abort(this->update_handle_);
this->update_handle_ = 0;
this->written_ = 0;
#ifdef USE_OTA_BLOCK_ERASE_AHEAD
this->erased_end_ = 0;
#endif
}
} // namespace esphome::ota
+18 -1
View File
@@ -5,8 +5,18 @@
#include "esphome/components/md5/md5.h"
#include "esphome/core/defines.h"
#include <esp_idf_version.h>
#include <esp_ota_ops.h>
// esp_ota_resume() (IDF 5.4.2+, backported to 5.3.3) provides a no-erase OTA
// handle, letting write() block-erase 64 KiB ahead of the write cursor
// (~4x faster than the per-sector lazy erase of OTA_WITH_SEQUENTIAL_WRITES,
// used as fallback on older IDF).
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 4, 2) || \
(ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 3, 3) && ESP_IDF_VERSION < ESP_IDF_VERSION_VAL(5, 4, 0))
#define USE_OTA_BLOCK_ERASE_AHEAD
#endif
namespace esphome::ota {
#ifdef USE_OTA_PARTITIONS
@@ -54,6 +64,9 @@ class IDFOTABackend final {
#endif
private:
#ifdef USE_OTA_BLOCK_ERASE_AHEAD
OTAResponseTypes erase_ahead_(size_t len);
#endif
#ifdef USE_OTA_SIGNED_VERIFICATION_MULTI_KEY
// Accept an image signed by any key the running app trusts (up to 3 blocks),
// so rotation and backup keys work. Fails closed. Covers app and bootloader.
@@ -62,7 +75,11 @@ class IDFOTABackend final {
// Keep md5_ first since its digest_ is alignas(32) on DMA-SHA variants; md5_set_ stays last so buf_ packs tightly.
md5::MD5Digest md5_{};
esp_ota_handle_t update_handle_{0};
const esp_partition_t *partition_;
const esp_partition_t *partition_{nullptr};
size_t written_{0}; // Bytes handed to esp_ota_write()
#ifdef USE_OTA_BLOCK_ERASE_AHEAD
size_t erased_end_{0}; // Erased up to this partition offset; must stay >= written_
#endif
char expected_bin_md5_[32];
bool md5_set_{false};
#ifdef USE_OTA_PARTITIONS
@@ -1,6 +1,7 @@
#ifdef USE_ESP32
#include "ota_backend_esp_idf.h"
#include "esphome/components/watchdog/watchdog.h"
#include "esphome/core/defines.h"
#ifdef USE_OTA_PARTITIONS
@@ -69,12 +70,20 @@ OTAResponseTypes IDFOTABackend::setup_bootloader_staging_() {
return OTA_RESPONSE_ERROR_BOOTLOADER_VERIFY;
}
// Erase full size of the bootloader partition in the staging partition
// to avoid copying old data to the bootloader partition later
// to avoid copying old data to the bootloader partition later. Up to
// ESP_BOOTLOADER_SIZE of blocking erase; widen the WDT for its duration.
watchdog::WatchdogManager watchdog(15000);
esp_err_t err = esp_partition_erase_range(this->partition_, 0, this->bootloader_part_->size);
if (err != ESP_OK) {
ESP_LOGW(TAG, "esp_partition_erase_range failed (err=0x%X)", err);
// No critical error, don't return
}
#ifdef USE_OTA_BLOCK_ERASE_AHEAD
if (err == ESP_OK) {
// Skip re-erasing the pre-erased staging region in erase_ahead_()
this->erased_end_ = this->bootloader_part_->size;
}
#endif
err = esp_ota_set_final_partition(this->update_handle_, this->bootloader_part_, false);
if (err != ESP_OK) {
esp_ota_abort(this->update_handle_);
@@ -211,7 +211,7 @@ bool rsa_pss_verify(uint8_t *block, const uint8_t *digest) {
bool IDFOTABackend::verify_signed_image_(const esp_partition_t *incoming) {
// Verification re-hashes the full image (after esp_ota_end already did one
// pass), which can approach the task WDT budget on a large app. Extend it for
// the duration, mirroring the erase budget in begin().
// the duration, scaled to the image size over a 15 s floor.
const uint32_t verify_budget_ms = 15000 + (incoming->size >> 10) * 10;
watchdog::WatchdogManager watchdog(verify_budget_ms);
+6
View File
@@ -5,6 +5,7 @@ from esphome import automation
import esphome.codegen as cg
from esphome.components import mqtt, web_server, zigbee
from esphome.components.const import CONF_B_CONSTANT
from esphome.config_helpers import filter_source_files_from_defines
import esphome.config_validation as cv
from esphome.const import (
CONF_ABOVE,
@@ -1303,3 +1304,8 @@ def _lstsq(a, b):
@coroutine_with_priority(CoroPriority.CORE)
async def to_code(config):
cg.add_global(sensor_ns.using)
FILTER_SOURCE_FILES = filter_source_files_from_defines(
{"filter.cpp": "USE_SENSOR_FILTER"}
)
@@ -1,6 +1,7 @@
from esphome import automation
import esphome.codegen as cg
from esphome.components import mqtt, web_server
from esphome.config_helpers import filter_source_files_from_defines
import esphome.config_validation as cv
from esphome.const import (
CONF_DEVICE_CLASS,
@@ -256,3 +257,8 @@ async def text_sensor_state_to_code(config, condition_id, template_arg, args):
templ = await cg.templatable(config[CONF_STATE], args, cg.std_string)
cg.add(var.set_state(templ))
return var
FILTER_SOURCE_FILES = filter_source_files_from_defines(
{"filter.cpp": "USE_TEXT_SENSOR_FILTER"}
)
+15 -2
View File
@@ -5,7 +5,10 @@ import re
from esphome import automation, pins
import esphome.codegen as cg
from esphome.components.const import CONF_DATA_BITS, CONF_PARITY, CONF_STOP_BITS
from esphome.config_helpers import filter_source_files_from_platform
from esphome.config_helpers import (
filter_source_files_from_defines,
filter_source_files_from_platform,
)
import esphome.config_validation as cv
from esphome.const import (
CONF_AFTER,
@@ -521,7 +524,7 @@ async def final_step():
cg.add_define("USE_UART_WAKE_LOOP_ON_RX")
FILTER_SOURCE_FILES = filter_source_files_from_platform(
_platform_filter = filter_source_files_from_platform(
{
"uart_component_esp_idf.cpp": {
PlatformFramework.ESP32_IDF,
@@ -537,3 +540,13 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform(
},
}
)
# uart_debugger.cpp is fully #ifdef'd on USE_UART_DEBUGGER, set only when a
# debug block is configured.
_define_filter = filter_source_files_from_defines(
{"uart_debugger.cpp": "USE_UART_DEBUGGER"}
)
def FILTER_SOURCE_FILES() -> list[str]:
return _platform_filter() + _define_filter()
+4 -7
View File
@@ -1,5 +1,6 @@
import esphome.codegen as cg
from esphome.components import sensor, time
from esphome.config_helpers import filter_source_files_from_defines
import esphome.config_validation as cv
from esphome.const import (
CONF_TIME_ID,
@@ -10,7 +11,6 @@ from esphome.const import (
STATE_CLASS_TOTAL_INCREASING,
UNIT_SECOND,
)
from esphome.core import CORE
uptime_ns = cg.esphome_ns.namespace("uptime")
UptimeSecondsSensor = uptime_ns.class_(
@@ -62,9 +62,6 @@ async def to_code(config):
cg.add(var.set_time(time_id))
def FILTER_SOURCE_FILES() -> list[str]:
# uptime_timestamp_sensor.cpp is fully #ifdef'd on USE_TIME; skip it
# when no time component is configured.
if not any(define.name == "USE_TIME" for define in CORE.defines):
return ["uptime_timestamp_sensor.cpp"]
return []
FILTER_SOURCE_FILES = filter_source_files_from_defines(
{"uptime_timestamp_sensor.cpp": "USE_TIME"}
)
@@ -117,12 +117,6 @@ class AsyncWebServerRequest {
/// Write URL (without query string) to buffer, returns StringRef pointing to buffer.
/// URL is decoded (e.g., %20 -> space).
StringRef url_to(std::span<char, URL_BUF_SIZE> buffer) const;
// Remove before 2026.9.0
ESPDEPRECATED("Use url_to() instead. Removed in 2026.9.0", "2026.3.0")
std::string url() const {
char buffer[URL_BUF_SIZE];
return std::string(this->url_to(buffer));
}
// NOLINTNEXTLINE(readability-identifier-naming)
size_t contentLength() const { return this->req_->content_len; }
@@ -618,8 +618,6 @@ static const char *eap_phase2_to_str(esp_eap_ttls_phase2_types type) {
}
#endif
float WiFiComponent::get_setup_priority() const { return setup_priority::WIFI; }
void WiFiComponent::setup() {
this->wifi_pre_setup_();
@@ -931,10 +929,6 @@ void WiFiComponent::loop() {
WiFiComponent::WiFiComponent() { global_wifi_component = this; }
#ifdef USE_WIFI_11KV_SUPPORT
void WiFiComponent::set_btm(bool btm) { this->btm_ = btm; }
void WiFiComponent::set_rrm(bool rrm) { this->rrm_ = rrm; }
#endif
network::IPAddresses WiFiComponent::get_ip_addresses() {
if (this->has_sta())
return this->wifi_sta_ip_addresses();
@@ -1327,8 +1321,6 @@ void WiFiComponent::disable() {
this->wifi_mode_(false, false);
}
bool WiFiComponent::is_disabled() { return this->state_ == WIFI_COMPONENT_STATE_DISABLED; }
void WiFiComponent::start_scanning() {
this->action_started_ = millis();
ESP_LOGD(TAG, "Starting scan");
@@ -2196,7 +2188,6 @@ void WiFiComponent::retry_connect() {
}
}
void WiFiComponent::set_reboot_timeout(uint32_t reboot_timeout) { this->reboot_timeout_ = reboot_timeout; }
void WiFiComponent::set_power_save_mode(WiFiPowerSaveMode power_save) {
this->power_save_ = power_save;
#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_POWER_SAVE)
@@ -2204,8 +2195,6 @@ void WiFiComponent::set_power_save_mode(WiFiPowerSaveMode power_save) {
#endif
}
void WiFiComponent::set_passive_scan(bool passive) { this->passive_scan_ = passive; }
bool WiFiComponent::is_captive_portal_active_() {
#ifdef USE_CAPTIVE_PORTAL
return captive_portal::global_captive_portal != nullptr && captive_portal::global_captive_portal->is_active();
@@ -2324,33 +2313,6 @@ void WiFiComponent::save_fast_connect_settings_(const bssid_t &bssid, uint8_t ch
}
#endif
void WiFiAP::set_ssid(const std::string &ssid) { this->ssid_ = CompactString(ssid.c_str(), ssid.size()); }
void WiFiAP::set_ssid(const char *ssid) { this->ssid_ = CompactString(ssid, strlen(ssid)); }
void WiFiAP::set_bssid(const bssid_t &bssid) { this->bssid_ = bssid; }
void WiFiAP::clear_bssid() { this->bssid_ = {}; }
void WiFiAP::set_password(const std::string &password) {
this->password_ = CompactString(password.c_str(), password.size());
}
void WiFiAP::set_password(const char *password) { this->password_ = CompactString(password, strlen(password)); }
#ifdef USE_WIFI_WPA2_EAP
void WiFiAP::set_eap(optional<EAPAuth> eap_auth) { this->eap_ = std::move(eap_auth); }
#endif
void WiFiAP::set_channel(uint8_t channel) { this->channel_ = channel; }
void WiFiAP::clear_channel() { this->channel_ = 0; }
#ifdef USE_WIFI_MANUAL_IP
void WiFiAP::set_manual_ip(optional<ManualIP> manual_ip) { this->manual_ip_ = manual_ip; }
#endif
void WiFiAP::set_hidden(bool hidden) { this->hidden_ = hidden; }
const bssid_t &WiFiAP::get_bssid() const { return this->bssid_; }
bool WiFiAP::has_bssid() const { return this->bssid_ != bssid_t{}; }
#ifdef USE_WIFI_WPA2_EAP
const optional<EAPAuth> &WiFiAP::get_eap() const { return this->eap_; }
#endif
#ifdef USE_WIFI_MANUAL_IP
const optional<ManualIP> &WiFiAP::get_manual_ip() const { return this->manual_ip_; }
#endif
bool WiFiAP::get_hidden() const { return this->hidden_; }
WiFiScanResult::WiFiScanResult(const bssid_t &bssid, const char *ssid, size_t ssid_len, uint8_t channel, int8_t rssi,
bool with_auth, bool is_hidden)
: bssid_(bssid),
+24 -25
View File
@@ -21,6 +21,7 @@
#include <span>
#include <string>
#include <type_traits>
#include <utility>
#include <vector>
#ifdef USE_LIBRETINY
@@ -261,38 +262,38 @@ class WiFiAP {
friend class WiFiScanResult;
public:
void set_ssid(const std::string &ssid);
void set_ssid(const char *ssid);
void set_ssid(const std::string &ssid) { this->ssid_ = CompactString(ssid.c_str(), ssid.size()); }
void set_ssid(const char *ssid) { this->set_ssid(StringRef(ssid)); }
void set_ssid(StringRef ssid) { this->ssid_ = CompactString(ssid.c_str(), ssid.size()); }
void set_bssid(const bssid_t &bssid);
void clear_bssid();
void set_password(const std::string &password);
void set_password(const char *password);
void set_bssid(const bssid_t &bssid) { this->bssid_ = bssid; }
void clear_bssid() { this->bssid_ = {}; }
void set_password(const std::string &password) { this->password_ = CompactString(password.c_str(), password.size()); }
void set_password(const char *password) { this->set_password(StringRef(password)); }
void set_password(StringRef password) { this->password_ = CompactString(password.c_str(), password.size()); }
#ifdef USE_WIFI_WPA2_EAP
void set_eap(optional<EAPAuth> eap_auth);
void set_eap(optional<EAPAuth> eap_auth) { this->eap_ = std::move(eap_auth); }
#endif // USE_WIFI_WPA2_EAP
void set_channel(uint8_t channel);
void clear_channel();
void set_channel(uint8_t channel) { this->channel_ = channel; }
void clear_channel() { this->channel_ = 0; }
void set_priority(int8_t priority) { priority_ = priority; }
#ifdef USE_WIFI_MANUAL_IP
void set_manual_ip(optional<ManualIP> manual_ip);
void set_manual_ip(optional<ManualIP> manual_ip) { this->manual_ip_ = manual_ip; }
#endif
void set_hidden(bool hidden);
void set_hidden(bool hidden) { this->hidden_ = hidden; }
StringRef get_ssid() const { return this->ssid_.ref(); }
StringRef get_password() const { return this->password_.ref(); }
const bssid_t &get_bssid() const;
bool has_bssid() const;
const bssid_t &get_bssid() const { return this->bssid_; }
bool has_bssid() const { return this->bssid_ != bssid_t{}; }
#ifdef USE_WIFI_WPA2_EAP
const optional<EAPAuth> &get_eap() const;
const optional<EAPAuth> &get_eap() const { return this->eap_; }
#endif // USE_WIFI_WPA2_EAP
uint8_t get_channel() const { return this->channel_; }
bool has_channel() const { return this->channel_ != 0; }
int8_t get_priority() const { return priority_; }
#ifdef USE_WIFI_MANUAL_IP
const optional<ManualIP> &get_manual_ip() const;
const optional<ManualIP> &get_manual_ip() const { return this->manual_ip_; }
#endif
bool get_hidden() const;
bool get_hidden() const { return this->hidden_; }
protected:
CompactString ssid_;
@@ -442,6 +443,7 @@ class WiFiComponent final : public Component {
void set_sta(const WiFiAP &ap);
// Returns a copy of the currently selected AP configuration
WiFiAP get_sta() const;
// init_sta/add_sta kept out of line: inlining them into the generated setup() grows flash
void init_sta(size_t count);
void add_sta(const WiFiAP &ap);
void clear_sta();
@@ -461,7 +463,7 @@ class WiFiComponent final : public Component {
void enable();
void disable();
bool is_disabled();
bool is_disabled() { return this->state_ == WIFI_COMPONENT_STATE_DISABLED; }
void start_scanning();
void check_scanning_finished();
void start_connecting(const WiFiAP &ap);
@@ -472,7 +474,7 @@ class WiFiComponent final : public Component {
void retry_connect();
void set_reboot_timeout(uint32_t reboot_timeout);
void set_reboot_timeout(uint32_t reboot_timeout) { this->reboot_timeout_ = reboot_timeout; }
bool is_connected() const { return this->connected_; }
@@ -492,7 +494,7 @@ class WiFiComponent final : public Component {
void set_phy_mode(WiFi8266PhyMode phy_mode) { this->phy_mode_ = phy_mode; }
#endif
void set_passive_scan(bool passive);
void set_passive_scan(bool passive) { this->passive_scan_ = passive; }
void save_wifi_sta(const std::string &ssid, const std::string &password);
void save_wifi_sta(const char *ssid, const char *password);
@@ -506,7 +508,7 @@ class WiFiComponent final : public Component {
void dump_config() override;
void restart_adapter();
/// WIFI setup_priority.
float get_setup_priority() const override;
float get_setup_priority() const override { return setup_priority::WIFI; }
/// Reconnect WiFi if required.
void loop() override;
@@ -515,8 +517,8 @@ class WiFiComponent final : public Component {
bool is_ap_active() const { return this->ap_started_; }
#ifdef USE_WIFI_11KV_SUPPORT
void set_btm(bool btm);
void set_rrm(bool rrm);
void set_btm(bool btm) { this->btm_ = btm; }
void set_rrm(bool rrm) { this->rrm_ = rrm; }
#endif
network::IPAddress get_dns_address(int num);
@@ -550,9 +552,6 @@ class WiFiComponent final : public Component {
void set_sta_priority(bssid_t bssid, int8_t priority);
network::IPAddresses wifi_sta_ip_addresses();
// Remove before 2026.9.0
ESPDEPRECATED("Use wifi_ssid_to() instead. Removed in 2026.9.0", "2026.3.0")
std::string wifi_ssid();
/// Write SSID to buffer without heap allocation.
/// Returns pointer to buffer, or empty string if not connected.
const char *wifi_ssid_to(std::span<char, SSID_BUFFER_SIZE> buffer);
@@ -944,16 +944,6 @@ bssid_t WiFiComponent::wifi_bssid() {
}
return bssid;
}
std::string WiFiComponent::wifi_ssid() {
struct station_config conf {};
if (!wifi_station_get_config(&conf)) {
return "";
}
// conf.ssid is uint8[32], not null-terminated if full
auto *ssid_s = reinterpret_cast<const char *>(conf.ssid);
size_t len = strnlen(ssid_s, sizeof(conf.ssid));
return {ssid_s, len};
}
const char *WiFiComponent::wifi_ssid_to(std::span<char, SSID_BUFFER_SIZE> buffer) {
struct station_config conf {};
if (!wifi_station_get_config(&conf)) {
@@ -1237,18 +1237,6 @@ bssid_t WiFiComponent::wifi_bssid() {
std::copy(info.bssid, info.bssid + 6, bssid.begin());
return bssid;
}
std::string WiFiComponent::wifi_ssid() {
wifi_ap_record_t info{};
esp_err_t err = esp_wifi_sta_get_ap_info(&info);
if (err != ESP_OK) {
// Very verbose only: this is expected during dump_config() before connection is established (PR #9823)
ESP_LOGVV(TAG, "esp_wifi_sta_get_ap_info failed: %s", esp_err_to_name(err));
return "";
}
auto *ssid_s = reinterpret_cast<const char *>(info.ssid);
size_t len = strnlen(ssid_s, sizeof(info.ssid));
return {ssid_s, len};
}
const char *WiFiComponent::wifi_ssid_to(std::span<char, SSID_BUFFER_SIZE> buffer) {
wifi_ap_record_t info{};
esp_err_t err = esp_wifi_sta_get_ap_info(&info);
@@ -762,7 +762,6 @@ bssid_t WiFiComponent::wifi_bssid() {
}
return bssid;
}
std::string WiFiComponent::wifi_ssid() { return WiFi.SSID().c_str(); }
const char *WiFiComponent::wifi_ssid_to(std::span<char, SSID_BUFFER_SIZE> buffer) {
#ifdef USE_BK72XX
LinkStatusTypeDef link_status{};
@@ -265,7 +265,6 @@ bssid_t WiFiComponent::wifi_bssid() {
bssid[i] = raw_bssid[i];
return bssid;
}
std::string WiFiComponent::wifi_ssid() { return WiFi.SSID().c_str(); }
const char *WiFiComponent::wifi_ssid_to(std::span<char, SSID_BUFFER_SIZE> buffer) {
// TODO: Find direct CYW43 API to avoid Arduino String allocation
String ssid = WiFi.SSID();
+25
View File
@@ -151,6 +151,31 @@ def filter_source_files_from_platform(
return filter_source_files
def filter_source_files_from_defines(
files_map: dict[str, str | tuple[str, ...]],
) -> Callable[[], list[str]]:
"""Helper to build a FILTER_SOURCE_FILES function from a define mapping.
Args:
files_map: Dict mapping filename to the define name (or tuple of
define names) that keeps the file in the build; the file is
excluded when none of its defines is set for the current config.
Returns:
Function that returns the files to exclude for the current config.
"""
def filter_source_files() -> list[str]:
defines = {define.name for define in CORE.defines}
return [
filename
for filename, needed in files_map.items()
if defines.isdisjoint((needed,) if isinstance(needed, str) else needed)
]
return filter_source_files
def get_logger_level() -> str:
"""Get the configured logger level.
+3
View File
@@ -43,7 +43,9 @@
#define USE_ALARM_CONTROL_PANEL
#define USE_AREAS
#define USE_BINARY_SENSOR
#define USE_BINARY_SENSOR_CLICK_TRIGGER
#define USE_BINARY_SENSOR_FILTER
#define USE_BINARY_SENSOR_MULTI_CLICK_TRIGGER
#define USE_BLE_DEVICE_IRK
#define USE_BUTTON
#define USE_CAMERA
@@ -281,6 +283,7 @@
// ESP32-specific feature flags
#ifdef USE_ESP32
#define USE_ESP32_CRASH_HANDLER
#define USE_ESP32_INTERNAL_GPIO
#define USE_MQTT_IDF_ENQUEUE
#define USE_ESPHOME_TASK_LOG_BUFFER
#define ESPHOME_TASK_LOG_BUFFER_SIZE 768
-40
View File
@@ -80,24 +80,6 @@ const char *EntityBase::get_device_class_to([[maybe_unused]] std::span<char, MAX
#endif
}
#ifndef USE_ESP8266
// Deprecated device class accessors — not available on ESP8266 (rodata is RAM)
StringRef EntityBase::get_device_class_ref() const {
#ifdef USE_ENTITY_DEVICE_CLASS
return StringRef(entity_device_class_lookup(this->device_class_idx_));
#else
return StringRef(entity_device_class_lookup(0));
#endif
}
std::string EntityBase::get_device_class() const {
#ifdef USE_ENTITY_DEVICE_CLASS
return std::string(entity_device_class_lookup(this->device_class_idx_));
#else
return std::string(entity_device_class_lookup(0));
#endif
}
#endif // !USE_ESP8266
// Entity unit of measurement (from index)
StringRef EntityBase::get_unit_of_measurement_ref() const {
#ifdef USE_ENTITY_UNIT_OF_MEASUREMENT
@@ -106,10 +88,6 @@ StringRef EntityBase::get_unit_of_measurement_ref() const {
return StringRef(entity_uom_lookup(0));
#endif
}
std::string EntityBase::get_unit_of_measurement() const {
return std::string(this->get_unit_of_measurement_ref().c_str());
}
// Entity icon — buffer-based API for PROGMEM safety on ESP8266
const char *EntityBase::get_icon_to([[maybe_unused]] std::span<char, MAX_ICON_LENGTH> buffer) const {
#ifdef USE_ENTITY_ICON
@@ -129,24 +107,6 @@ const char *EntityBase::get_icon_to([[maybe_unused]] std::span<char, MAX_ICON_LE
#endif
}
#ifndef USE_ESP8266
// Deprecated icon accessors — not available on ESP8266 (rodata is RAM)
StringRef EntityBase::get_icon_ref() const {
#ifdef USE_ENTITY_ICON
return StringRef(entity_icon_lookup(this->icon_idx_));
#else
return StringRef(entity_icon_lookup(0));
#endif
}
std::string EntityBase::get_icon() const {
#ifdef USE_ENTITY_ICON
return std::string(entity_icon_lookup(this->icon_idx_));
#else
return std::string(entity_icon_lookup(0));
#endif
}
#endif // !USE_ESP8266
// Calculate Object ID Hash directly from name using snake_case + sanitize
void EntityBase::calc_object_id_() {
this->object_id_hash_ = fnv1_hash_object_id(this->name_.c_str(), this->name_.size());
-46
View File
@@ -109,60 +109,14 @@ class EntityBase {
// On ESP8266: copies from PROGMEM to buffer, returns buffer pointer.
const char *get_device_class_to(std::span<char, MAX_DEVICE_CLASS_LENGTH> buffer) const;
#ifdef USE_ESP8266
// On ESP8266, rodata is RAM. Device classes are in PROGMEM and cannot be accessed
// directly as const char*. Use get_device_class_to() with a stack buffer instead.
template<typename T = int> StringRef get_device_class_ref() const {
static_assert(sizeof(T) == 0, "get_device_class_ref() unavailable on ESP8266 (rodata is RAM). "
"Use get_device_class_to() with a stack buffer.");
return StringRef("");
}
template<typename T = int> std::string get_device_class() const {
static_assert(sizeof(T) == 0, "get_device_class() unavailable on ESP8266 (rodata is RAM). "
"Use get_device_class_to() with a stack buffer.");
return "";
}
#else
// Deprecated: use get_device_class_to() instead. Device classes are in PROGMEM.
ESPDEPRECATED("Use get_device_class_to() instead. Will be removed in ESPHome 2026.9.0", "2026.3.0")
StringRef get_device_class_ref() const;
ESPDEPRECATED("Use get_device_class_to() instead. Will be removed in ESPHome 2026.9.0", "2026.3.0")
std::string get_device_class() const;
#endif
// Get unit of measurement as StringRef (from packed index)
StringRef get_unit_of_measurement_ref() const;
/// Get the unit of measurement as std::string (deprecated, prefer get_unit_of_measurement_ref())
ESPDEPRECATED("Use get_unit_of_measurement_ref() instead for better performance (avoids string copy). Will be "
"removed in ESPHome 2026.9.0",
"2026.3.0")
std::string get_unit_of_measurement() const;
// Get this entity's icon into a stack buffer.
// On ESP32: returns pointer to PROGMEM string directly (buffer unused).
// On ESP8266: copies from PROGMEM to buffer, returns buffer pointer.
const char *get_icon_to(std::span<char, MAX_ICON_LENGTH> buffer) const;
#ifdef USE_ESP8266
// On ESP8266, rodata is RAM. Icons are in PROGMEM and cannot be accessed
// directly as const char*. Use get_icon_to() with a stack buffer instead.
template<typename T = int> StringRef get_icon_ref() const {
static_assert(sizeof(T) == 0,
"get_icon_ref() unavailable on ESP8266 (rodata is RAM). Use get_icon_to() with a stack buffer.");
return StringRef("");
}
template<typename T = int> std::string get_icon() const {
static_assert(sizeof(T) == 0,
"get_icon() unavailable on ESP8266 (rodata is RAM). Use get_icon_to() with a stack buffer.");
return "";
}
#else
// Deprecated: use get_icon_to() instead. Icons are in PROGMEM.
ESPDEPRECATED("Use get_icon_to() instead. Will be removed in ESPHome 2026.9.0", "2026.3.0")
StringRef get_icon_ref() const;
ESPDEPRECATED("Use get_icon_to() instead. Will be removed in ESPHome 2026.9.0", "2026.3.0")
std::string get_icon() const;
#endif
#ifdef USE_DEVICES
// Get this entity's device id
uint32_t get_device_id() const {
-17
View File
@@ -723,23 +723,6 @@ bool base64_decode_int32_vector(const std::string &base64, std::vector<int32_t>
// Colors
float gamma_correct(float value, float gamma) {
if (value <= 0.0f)
return 0.0f;
if (gamma <= 0.0f)
return value;
return powf(value, gamma); // NOLINT - deprecated, removal 2026.9.0
}
float gamma_uncorrect(float value, float gamma) {
if (value <= 0.0f)
return 0.0f;
if (gamma <= 0.0f)
return value;
return powf(value, 1 / gamma); // NOLINT - deprecated, removal 2026.9.0
}
void rgb_to_hsv(float red, float green, float blue, int &hue, float &saturation, float &value) {
float max_color_value = std::max({red, green, blue});
float min_color_value = std::min({red, green, blue});
-9
View File
@@ -1646,15 +1646,6 @@ bool base64_decode_int32_vector(const std::string &base64, std::vector<int32_t>
/// @name Colors
///@{
/// Applies gamma correction of \p gamma to \p value.
// Remove before 2026.9.0
ESPDEPRECATED("Use LightState::gamma_correct_lut() instead. Removed in 2026.9.0.", "2026.3.0")
float gamma_correct(float value, float gamma);
/// Reverts gamma correction of \p gamma to \p value.
// Remove before 2026.9.0
ESPDEPRECATED("Use LightState::gamma_uncorrect_lut() instead. Removed in 2026.9.0.", "2026.3.0")
float gamma_uncorrect(float value, float gamma);
/// Convert \p red, \p green and \p blue (all 0-1) values to \p hue (0-360), \p saturation (0-1) and \p value (0-1).
void rgb_to_hsv(float red, float green, float blue, int &hue, float &saturation, float &value);
/// Convert \p hue (0-360), \p saturation (0-1) and \p value (0-1) to \p red, \p green and \p blue (all 0-1).
-10
View File
@@ -60,16 +60,6 @@ void HOT esp_log_vprintf_(int level, const char *tag, int line, const char *form
#endif
}
#ifdef USE_STORE_LOG_STR_IN_FLASH
// Remove before 2026.9.0
void HOT esp_log_vprintf_(int level, const char *tag, int line, const __FlashStringHelper *format, va_list args) {
#ifdef USE_LOGGER
ESPHOME_DEBUG_ASSERT(logger::global_logger != nullptr);
logger::global_logger->log_vprintf_(static_cast<uint8_t>(level), tag, line, format, args);
#endif
}
#endif
#ifdef USE_ESP32
int HOT esp_idf_log_vprintf_(const char *format, va_list args) { // NOLINT
#ifdef USE_LOGGER
-5
View File
@@ -68,11 +68,6 @@ void esp_log_printf_(int level, const char *tag, int line, const char *format, .
void esp_log_printf_(int level, const char *tag, int line, const __FlashStringHelper *format, ...);
#endif
void esp_log_vprintf_(int level, const char *tag, int line, const char *format, va_list args); // NOLINT
#ifdef USE_STORE_LOG_STR_IN_FLASH
// Remove before 2026.9.0
__attribute__((deprecated("Use esp_log_printf_() instead. Removed in 2026.9.0."))) void esp_log_vprintf_(
int level, const char *tag, int line, const __FlashStringHelper *format, va_list args);
#endif
#if defined(USE_ESP32)
int esp_idf_log_vprintf_(const char *format, va_list args); // NOLINT
#endif
+30 -108
View File
@@ -26,7 +26,6 @@ from __future__ import annotations
import csv
import json
import logging
import math
from pathlib import Path
_LOGGER = logging.getLogger(__name__)
@@ -35,6 +34,8 @@ _SIZE_SUFFIXES = {"K": 1024, "M": 1024 * 1024}
def _parse_size(token: str) -> int:
token = token.strip()
if not token:
return 0
if token.startswith(("0x", "0X")):
return int(token, 16)
suffix = token[-1].upper()
@@ -43,19 +44,16 @@ def _parse_size(token: str) -> int:
return int(token)
def _find_app_partition_size(partitions_csv: Path) -> int | None:
"""The firmware's app partition size; None when there is nothing to find.
def _find_app_partition_size(partitions_csv: Path) -> int:
"""Return the size of the firmware's app partition.
Mirrors PlatformIO's ``platform-espressif32/builder/main.py::
_update_max_upload_size``: take the first ``app``-type partition
whose subtype is ``factory`` or ``ota_0``. Order matters because
layouts like Adafruit's ``partitions-4MB-tinyuf2.csv`` repurpose
``factory`` for a UF2 bootloader before the real OTA slot, so a
naive "prefer factory" rule would pick the wrong row. No qualifying
row is legitimate absence (None); a build cannot succeed with a
missing or malformed table (gen_esp32part consumes it first), so
those states belong to the backstop -- the missing-file raise just
names that one cleanly.
naive "prefer factory" rule would pick the wrong row. Raises
``ValueError`` if no qualifying partition is present.
"""
if not partitions_csv.is_file():
raise ValueError(f"partitions.csv not found at {partitions_csv}")
@@ -66,7 +64,7 @@ def _find_app_partition_size(partitions_csv: Path) -> int | None:
ptype, psubtype, psize = cells[1], cells[2], cells[4]
if ptype in ("app", "0") and psubtype in ("factory", "ota_0"):
return _parse_size(psize)
return None
raise ValueError(f"No app+factory or app+ota_0 partition in {partitions_csv}")
def _format_bar(used: int, total: int) -> str:
@@ -82,109 +80,33 @@ def _format_bar(used: int, total: int) -> str:
def print_summary(size_json: Path, partitions_csv: Path | None) -> None:
"""Print PlatformIO-shaped RAM and Flash one-liners; never fails the build."""
try:
_print_summary(size_json, partitions_csv)
except Exception as e: # noqa: BLE001 # pylint: disable=broad-exception-caught
# Backstop for shapes the named guards below miss; warning so a
# regression here cannot go missing indefinitely
_LOGGER.warning(
"Skipping size summary for %s: %s: %s",
size_json,
type(e).__name__,
e,
exc_info=True,
)
"""Print PlatformIO-shaped RAM and Flash one-liners.
def _print_summary(size_json: Path, partitions_csv: Path | None) -> None:
# The build's own POST_BUILD step writes this file; its absence or an
# unexpected shape is a regression signal, so these skips warn.
# FileNotFoundError lands in the OSError arm with the path in its text.
Failures are non-fatal: the build has already succeeded, we just couldn't
summarize. Logs the cause at debug level.
"""
if not size_json.is_file():
_LOGGER.debug("Skipping size summary: %s not found", size_json)
return
try:
data = json.loads(size_json.read_text(encoding="utf-8"))
except (OSError, ValueError) as e:
# ValueError covers JSONDecodeError and a non-UTF-8 (truncated) file
_LOGGER.warning("Skipping size summary: cannot read %s: %s", size_json, e)
return
if not isinstance(data, dict):
# Non-object JSON has no .get
_LOGGER.warning("Skipping size summary: unexpected shape in %s", size_json)
except (OSError, json.JSONDecodeError) as e:
_LOGGER.debug("Skipping size summary: %s", e)
return
if (ram := _ram_line(data, size_json)) is not None:
print(ram)
if (flash := _flash_line(data, size_json, partitions_csv)) is not None:
print(flash)
memory_types = data.get("memory_types", {})
ram_region = memory_types.get("DRAM") or memory_types.get("DIRAM") or {}
ram_used = ram_region.get("used")
ram_total = ram_region.get("size")
if ram_total and ram_used is not None:
print(f"RAM: {_format_bar(ram_used, ram_total)}")
def _dict_get(mapping: object, key: str) -> object:
"""dict.get that reads None from any non-dict."""
return mapping.get(key) if isinstance(mapping, dict) else None
def _present_but_not_dict(value: object) -> bool:
return value is not None and not isinstance(value, dict)
def _is_number(value: object) -> bool:
# bool subclasses int; NaN/Infinity are valid JSON for json.loads
return (
isinstance(value, (int, float))
and not isinstance(value, bool)
and math.isfinite(value)
)
def _ram_line(data: dict, size_json: Path) -> str | None:
"""The formatted RAM line, or None (already logged) to skip it."""
memory_types = data.get("memory_types")
ram_region = None
if isinstance(memory_types, dict):
# Key presence, not truthiness: a falsy DRAM value is corrupt, not
# absent, and must not fall through to DIRAM
for key in ("DRAM", "DIRAM"):
if key in memory_types:
ram_region = memory_types[key]
break
used = _dict_get(ram_region, "used")
total = _dict_get(ram_region, "size")
if _is_number(used) and _is_number(total) and total > 0:
return f"RAM: {_format_bar(int(used), int(total))}"
malformed = (
_present_but_not_dict(memory_types)
or _present_but_not_dict(ram_region)
or any(v is not None and not _is_number(v) for v in (used, total))
)
if malformed:
# A structurally corrupt report, not a variant without the region
_LOGGER.warning("Skipping RAM summary: malformed memory_types in %s", size_json)
else:
# A variant may name its RAM region differently; healthy builds
# must not warn
_LOGGER.debug(
"Skipping RAM summary: no usable DRAM/DIRAM region in %s", size_json
)
return None
def _flash_line(data: dict, size_json: Path, partitions_csv: Path | None) -> str | None:
"""The formatted Flash line, or None (already logged) to skip it.
Owns both sides of the bar, so nothing after a print can raise: the
blanket guard is left for genuinely unforeseen shapes.
"""
image_size = data.get("image_size")
if not _is_number(image_size):
_LOGGER.warning("Skipping Flash summary: no usable image_size in %s", size_json)
return None
if partitions_csv is None:
_LOGGER.debug("Skipping Flash summary: no partition table given")
return None
app_size = _find_app_partition_size(partitions_csv)
if not app_size:
# No qualifying row (a zero-size row has nothing to report either):
# legitimate for non-app layouts
_LOGGER.debug("Skipping Flash summary: no app partition in %s", partitions_csv)
return None
return f"Flash: {_format_bar(int(image_size), app_size)}"
if image_size is None or partitions_csv is None:
return
try:
app_size = _find_app_partition_size(partitions_csv)
except ValueError as e:
_LOGGER.debug("Skipping Flash summary: %s", e)
return
print(f"Flash: {_format_bar(image_size, app_size)}")
+2 -2
View File
@@ -456,8 +456,8 @@ def run_compile(config, verbose: bool) -> int:
rc = run_idf_py(*args, jobs=config[CONF_ESPHOME].get(CONF_COMPILE_PROCESS_LIMIT))
if rc == 0:
size_json = CORE.relative_build_path("build", "esp_idf_size.json")
# size_summary owns the missing-table policy
print_summary(size_json, CORE.relative_build_path("partitions.csv"))
partitions = CORE.relative_build_path("partitions.csv")
print_summary(size_json, partitions if partitions.is_file() else None)
return rc
+12 -1
View File
@@ -552,7 +552,18 @@ def write_file_if_changed(path: Path, text: str) -> bool:
"""
src_content = None
if path.is_file():
src_content = read_file(path)
try:
src_content = path.read_text(encoding="utf-8")
except UnicodeDecodeError as err:
# Replace a damaged file rather than abort the regeneration that
# fixes it; an OSError may hide an intact file, so it still raises
_LOGGER.warning("Replacing damaged file %s: %s", path, err)
with suppress(OSError):
path.unlink(missing_ok=True)
except OSError as err:
from esphome.core import EsphomeError
raise EsphomeError(f"Error reading file {path}: {err}") from err
if src_content == text:
return False
write_file(path, text)
@@ -136,3 +136,19 @@ binary_sensor:
invalid_cooldown: 2s
then:
- logger.log: "Click with custom cooldown"
# Test on_click and on_double_click (compiles match_interval via
# USE_BINARY_SENSOR_CLICK_TRIGGER)
- platform: template
id: click_triggers
name: "Click Triggers"
on_click:
min_length: 50ms
max_length: 350ms
then:
- logger.log: "Clicked"
on_double_click:
min_length: 50ms
max_length: 350ms
then:
- logger.log: "Double clicked"
+41
View File
@@ -0,0 +1,41 @@
// Pins the lazy erase-ahead arithmetic used by the ESP-IDF OTA backend: the
// erased watermark must always cover the write end, stay 64 KiB block-aligned
// until the clamp, and never exceed the partition.
#include <gtest/gtest.h>
#include "esphome/components/ota/ota_backend.h"
namespace esphome::ota::testing {
static constexpr size_t BLOCK = 64 * 1024;
static constexpr size_t PART = 1835008; // 0x1C0000, a real app slot size
TEST(NextEraseEnd, FirstWriteRoundsUpToOneBlock) { EXPECT_EQ(next_erase_end(1024, PART), BLOCK); }
TEST(NextEraseEnd, ExactBlockBoundaryDoesNotOverErase) { EXPECT_EQ(next_erase_end(BLOCK, PART), BLOCK); }
TEST(NextEraseEnd, StraddlingWriteCoversNextBlock) { EXPECT_EQ(next_erase_end(BLOCK + 1, PART), 2 * BLOCK); }
TEST(NextEraseEnd, ClampsToPartitionEnd) {
// Partition sizes are sector multiples but not always block multiples
constexpr size_t part = 27 * BLOCK + 4096;
EXPECT_EQ(next_erase_end(27 * BLOCK + 1, part), part);
EXPECT_EQ(next_erase_end(part, part), part);
}
// Bootloader staging seeds erased_end_ mid-block (e.g. 0x8000); the target for
// a write past that seed must still cover the write end.
TEST(NextEraseEnd, MidBlockSeedStillCovered) { EXPECT_EQ(next_erase_end(0x8000 + 1024, PART), BLOCK); }
TEST(NextEraseEnd, SweepAlwaysCoversWriteEndWithinPartition) {
for (size_t end = 1; end <= PART; end += 4093) {
const size_t erased = next_erase_end(end, PART);
ASSERT_GE(erased, end);
ASSERT_LE(erased, PART);
// Block-aligned unless clamped at the partition end
ASSERT_TRUE(erased == PART || erased % BLOCK == 0);
}
}
} // namespace esphome::ota::testing
+1
View File
@@ -29,6 +29,7 @@ void setup() {
auto *ota = new esphome::ESPHomeOTAComponent(); // NOLINT
ota->set_port(8266);
App.register_component_(ota);
App.setup();
}
+24
View File
@@ -6,6 +6,7 @@ from unittest.mock import patch
import pytest
from esphome.config_helpers import (
filter_source_files_from_defines,
filter_source_files_from_platform,
frameworks_for_platforms,
get_logger_level,
@@ -18,6 +19,7 @@ from esphome.const import (
KEY_TARGET_PLATFORM,
PlatformFramework,
)
from esphome.core import Define
def test_filter_source_files_from_platform_esp32() -> None:
@@ -148,3 +150,25 @@ def test_frameworks_for_platforms_derives_and_rejects_unknown() -> None:
}
with pytest.raises(ValueError, match="unknown platform"):
frameworks_for_platforms(["esp32", "not_a_platform"])
def test_filter_source_files_from_defines() -> None:
"""Files are excluded unless one of their defines is set."""
files_map: dict[str, str | tuple[str, ...]] = {
"filter.cpp": "USE_SENSOR_FILTER",
"automation.cpp": ("USE_CLICK", "USE_MULTI_CLICK"),
}
filter_func: Callable[[], list[str]] = filter_source_files_from_defines(files_map)
with patch("esphome.config_helpers.CORE") as mock_core:
mock_core.defines = {Define("USE_SENSOR_FILTER")}
assert filter_func() == ["automation.cpp"]
mock_core.defines = {Define("USE_MULTI_CLICK")}
assert filter_func() == ["filter.cpp"]
mock_core.defines = {Define("USE_SENSOR_FILTER"), Define("USE_CLICK")}
assert filter_func() == []
mock_core.defines = set()
assert sorted(filter_func()) == ["automation.cpp", "filter.cpp"]
+25
View File
@@ -253,6 +253,31 @@ class Test_write_file_if_changed:
assert dst.read_text() == text
def test_damaged_existing_file_is_replaced(
self, tmp_path: Path, caplog: pytest.LogCaptureFixture
):
"""A non-UTF-8 existing file is logged and overwritten."""
dst = tmp_path / "generated.txt"
dst.write_bytes(b"\xff\xfe")
assert helpers.write_file_if_changed(dst, "fresh content") is True
assert dst.read_text(encoding="utf-8") == "fresh content"
assert "Replacing damaged file" in caplog.text
def test_unreadable_existing_file_still_raises(self, tmp_path: Path):
"""An OSError on the comparison read still raises EsphomeError."""
dst = tmp_path / "generated.txt"
dst.write_text("intact")
with (
patch.object(Path, "read_text", side_effect=OSError("permission denied")),
pytest.raises(EsphomeError, match="Error reading file"),
):
helpers.write_file_if_changed(dst, "fresh content")
assert dst.exists()
def test_dst_does_not_exist(self, tmp_path: Path):
text = "A files are unique.\n"
dst = tmp_path / "file-a.txt"
+1 -200
View File
@@ -3,9 +3,7 @@
from __future__ import annotations
import json
import logging
from pathlib import Path
from unittest.mock import patch
import pytest
@@ -71,18 +69,6 @@ def _s3_size_data() -> dict:
}
def _dram_size_data(image_size: int = 100) -> dict:
return {"memory_types": {"DRAM": {"used": 1, "size": 2}}, "image_size": image_size}
def _write_partitions(
tmp_path: Path, size: str, ptype: str = "app", subtype: str = "ota_0"
) -> Path:
partitions = tmp_path / "partitions.csv"
partitions.write_text(f"app0, {ptype}, {subtype}, 0x10000, {size},\n")
return partitions
def test_print_summary_esp32_uses_dram(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
@@ -126,15 +112,11 @@ def test_print_summary_skips_when_diram_total_collapses(
def test_print_summary_handles_missing_json(
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
caplog: pytest.LogCaptureFixture,
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""Missing size json is non-fatal and prints nothing."""
print_summary(tmp_path / "does_not_exist.json", partitions_csv=None)
assert capsys.readouterr().out == ""
assert "cannot read" in caplog.text
assert "Skipping size summary for" not in caplog.text
def test_print_summary_handles_no_memory_types(
@@ -144,184 +126,3 @@ def test_print_summary_handles_no_memory_types(
size_json = _write_size_json(tmp_path, {"image_size": 0})
print_summary(size_json, partitions_csv=None)
assert capsys.readouterr().out == ""
def test_print_summary_non_dict_json_is_skipped(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""Valid JSON that is not an object must not raise past a linked build."""
size_json = tmp_path / "size.json"
size_json.write_text("[]")
print_summary(size_json, tmp_path / "partitions.csv")
assert capsys.readouterr().out == ""
def test_print_summary_unreadable_partitions_is_skipped(
tmp_path: Path, capsys: pytest.CaptureFixture[str], caplog: pytest.LogCaptureFixture
) -> None:
"""An OSError reading the partition table skips the summary, not the build."""
size_json = _write_size_json(tmp_path, _dram_size_data())
partitions = _write_partitions(tmp_path, "1M")
real_read_text = Path.read_text
def fail_partitions_read(self: Path, *args: object, **kwargs: object) -> str:
if self == partitions:
raise OSError("permission denied")
return real_read_text(self, *args, **kwargs)
with patch.object(Path, "read_text", fail_partitions_read):
print_summary(size_json, partitions)
# An impossible post-build state is the backstop's business
out = capsys.readouterr().out
assert "RAM:" in out and "Flash:" not in out
assert "Skipping size summary for" in caplog.text
def test_print_summary_happy_path_prints_both_bars(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""A well-formed size report and partition table print both bars."""
size_json = tmp_path / "size.json"
size_json.write_text(
'{"memory_types": {"DRAM": {"used": 1000, "size": 2000}}, "image_size": 100000}'
)
partitions = _write_partitions(tmp_path, "0x180000")
print_summary(size_json, partitions)
out = capsys.readouterr().out
assert "RAM:" in out and "Flash:" in out
@pytest.mark.parametrize(
"payload",
[
{"memory_types": []},
{"memory_types": {"DRAM": 5}},
{"memory_types": {"DRAM": {"used": "x", "size": "y"}}, "image_size": 1},
{"memory_types": {"DRAM": []}},
{"memory_types": {"DRAM": {"used": True, "size": True}}},
],
)
def test_print_summary_nested_bad_shapes_never_raise(
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
caplog: pytest.LogCaptureFixture,
payload: dict,
) -> None:
"""Corrupt nested shapes hit the named malformed guard, not the blanket."""
size_json = _write_size_json(tmp_path, payload)
print_summary(size_json, None)
# No half-formed bar for CI to scrape; every payload fails before printing
assert capsys.readouterr().out == ""
assert "malformed memory_types" in caplog.text
assert "Skipping size summary for" not in caplog.text
def test_print_summary_absent_region_stays_quiet(
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
caplog: pytest.LogCaptureFixture,
) -> None:
"""A well-shaped report without DRAM/DIRAM is a variant difference, not
a broken artifact: debug, never a per-build warning."""
size_json = _write_size_json(tmp_path, {"memory_types": {}, "image_size": 1})
with caplog.at_level(logging.DEBUG, logger="esphome.espidf.size_summary"):
print_summary(size_json, None)
assert "RAM:" not in capsys.readouterr().out
assert "no usable DRAM/DIRAM region" in caplog.text
assert not [
r for r in caplog.records if r.levelno >= logging.WARNING and "RAM" in r.message
]
def test_print_summary_non_numeric_image_size_warns_by_name(
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
caplog: pytest.LogCaptureFixture,
) -> None:
"""A non-numeric image_size hits the named guard, not the blanket."""
size_json = _write_size_json(
tmp_path,
{"memory_types": {"DRAM": {"used": 1, "size": 2}}, "image_size": "x"},
)
print_summary(size_json, _write_partitions(tmp_path, "0x100000"))
assert "Flash:" not in capsys.readouterr().out
assert "no usable image_size" in caplog.text
assert "Skipping size summary for" not in caplog.text
def test_print_summary_blanket_guard_catches_the_rest(
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
caplog: pytest.LogCaptureFixture,
) -> None:
"""A genuinely unforeseen failure warns via the blanket backstop and
never raises past a linked build."""
size_json = _write_size_json(tmp_path, _dram_size_data())
with patch(
"esphome.espidf.size_summary._flash_line",
side_effect=RuntimeError("unforeseen"),
):
print_summary(size_json, None)
assert "Skipping size summary for" in caplog.text
@pytest.mark.parametrize("cell", ["1M", "1048576"], ids=["suffixed", "decimal"])
def test_print_summary_suffixed_size_cell(
tmp_path: Path, capsys: pytest.CaptureFixture[str], cell: str
) -> None:
"""K/M suffixes and plain decimals parse like PlatformIO's rule."""
size_json = _write_size_json(tmp_path, _dram_size_data())
partitions = tmp_path / "partitions.csv"
partitions.write_text(
f"# comment row\nshort,row\napp0, app, ota_0, 0x10000, {cell},\n"
)
print_summary(size_json, partitions)
assert "from 1048576 bytes" in capsys.readouterr().out
def test_print_summary_missing_or_appless_partitions_stay_quiet(
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
caplog: pytest.LogCaptureFixture,
) -> None:
"""A table without a qualifying app row is a legitimate layout: the
Flash line drops at debug, never at warning."""
size_json = _write_size_json(tmp_path, _dram_size_data())
partitions = _write_partitions(tmp_path, "0x1000", ptype="data", subtype="spiffs")
with caplog.at_level(logging.DEBUG, logger="esphome.espidf.size_summary"):
print_summary(size_json, partitions)
out = capsys.readouterr().out
assert "Flash:" not in out
# Quiet means debug-logged, not unlogged
assert "Skipping Flash summary: no app partition" in caplog.text
assert not [r for r in caplog.records if r.levelno >= logging.WARNING]
def test_print_summary_corrupt_size_json_warns(
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
caplog: pytest.LogCaptureFixture,
) -> None:
"""The build's own size report failing to parse is a regression signal."""
size_json = tmp_path / "size.json"
size_json.write_text("not json {{{")
print_summary(size_json, None)
size_json.write_bytes(b"\xff\xfe\x00")
print_summary(size_json, None)
assert capsys.readouterr().out == ""
# The named arm, not the blanket, for both damage classes
assert caplog.text.count("cannot read") == 2
assert "Skipping size summary for" not in caplog.text
def test_print_summary_missing_partitions_named_in_backstop(
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
caplog: pytest.LogCaptureFixture,
) -> None:
"""A vanished table is an impossible post-build state; the backstop
reports it by name instead of a bare FileNotFoundError."""
size_json = _write_size_json(tmp_path, _dram_size_data())
print_summary(size_json, tmp_path / "nope.csv")
assert "Flash:" not in capsys.readouterr().out
assert "partitions.csv not found" in caplog.text