diff --git a/esphome/build_helpers/idedata.py b/esphome/build_helpers/idedata.py index be1f7fd25c..88c70aacab 100644 --- a/esphome/build_helpers/idedata.py +++ b/esphome/build_helpers/idedata.py @@ -258,6 +258,22 @@ def _cc_path_from_cxx(cxx_path: str) -> str: return f"{stem}{suffix}" +def _cache_usable(cached: object) -> bool: + """Check a cached idedata dict against the guarantees of the write path. + + Caches written by older versions predate the launcher rejection and the + include-union shape; serving one would bypass both. The dict check also + keeps "in" from substring-matching a bare JSON string. + """ + if not isinstance(cached, dict) or "cc_path" not in cached: + return False + cxx_path = cached.get("cxx_path") + if not isinstance(cxx_path, str) or _is_launcher(cxx_path): + return False + includes = cached.get("includes") + return isinstance(includes, dict) and isinstance(includes.get("build"), list) + + def load_or_build_idedata( compile_commands: Path, elf_path: Path, @@ -283,10 +299,11 @@ def load_or_build_idedata( # look like unexplained slow builds _LOGGER.warning("Discarding unreadable idedata cache %s: %s", cache, err) else: - # Rebuild pre-cc_path caches on the field, not the timestamp; - # the type check keeps "in" from substring-matching a string - if isinstance(cached, dict) and "cc_path" in cached: + if _cache_usable(cached): + # Re-stamp so a relocated build dir cannot serve a stale ELF path + cached["prog_path"] = str(elf_path) return cached + _LOGGER.debug("Regenerating idedata: cache %s fails validation", cache) data = idedata_from_build(compile_commands, launcher) data["prog_path"] = str(elf_path) @@ -320,6 +337,9 @@ def idedata_from_build(compile_commands: Path, launcher: str | None = None) -> d project-wide superset (as PlatformIO's idedata provides). """ entries = json.loads(Path(compile_commands).read_text(encoding="utf-8")) + if not isinstance(entries, list): + # A TypeError here would escape IDEDATA_BEST_EFFORT_ERRORS + raise EsphomeError(f"{compile_commands} is not a compile-command list") representative = _pick_entry(entries) cxx_path, defines, rep_includes, cxx_flags = parse_entry(representative, launcher) @@ -339,7 +359,7 @@ def idedata_from_build(compile_commands: Path, launcher: str | None = None) -> d # may hold different include sets. command = entry["command"] if "@" in command: - return f"unique:{entry['file']}" + return f"unique:{entry.get('output') or command}" return command.replace(entry.get("file", ""), "").replace( entry.get("output", ""), "" ) diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index 5800e0bd9e..1ab6f7103f 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -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", + } +) diff --git a/esphome/components/binary_sensor/automation.cpp b/esphome/components/binary_sensor/automation.cpp index b13e4a88dd..1a3c1f7536 100644 --- a/esphome/components/binary_sensor/automation.cpp +++ b/esphome/components/binary_sensor/automation.cpp @@ -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 diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 501c2e525f..cde0cfd68b 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -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"} +) diff --git a/esphome/components/esp32/crash_handler.cpp b/esphome/components/esp32/crash_handler.cpp index b61dad7386..6f65243aaa 100644 --- a/esphome/components/esp32/crash_handler.cpp +++ b/esphome/components/esp32/crash_handler.cpp @@ -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); } diff --git a/esphome/components/esp32/gpio.cpp b/esphome/components/esp32/gpio.cpp index 4b53d3a172..74665f3126 100644 --- a/esphome/components/esp32/gpio.cpp +++ b/esphome/components/esp32/gpio.cpp @@ -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 diff --git a/esphome/components/esp32/gpio.py b/esphome/components/esp32/gpio.py index 321dd3d498..98aac209ec 100644 --- a/esphome/components/esp32/gpio.py +++ b/esphome/components/esp32/gpio.py @@ -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}"))) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 9cbb25b373..74f84b71fb 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -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) diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index 2da070b5e0..1482e7a828 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -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 buf); eth_duplex_t get_duplex_mode(); eth_speed_t get_link_speed(); diff --git a/esphome/components/ethernet/ethernet_component_esp32.cpp b/esphome/components/ethernet/ethernet_component_esp32.cpp index 4af2d5f93c..069478e70c 100644 --- a/esphome/components/ethernet/ethernet_component_esp32.cpp +++ b/esphome/components/ethernet/ethernet_component_esp32.cpp @@ -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 buf) { uint8_t mac[MAC_ADDRESS_SIZE]; diff --git a/esphome/components/ethernet/ethernet_component_rp2.cpp b/esphome/components/ethernet/ethernet_component_rp2.cpp index 7f4db4fab7..94d84cc891 100644 --- a/esphome/components/ethernet/ethernet_component_rp2.cpp +++ b/esphome/components/ethernet/ethernet_component_rp2.cpp @@ -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 buf) { uint8_t mac[MAC_ADDRESS_SIZE]; diff --git a/esphome/components/http_request/ota/ota_http_request.cpp b/esphome/components/http_request/ota/ota_http_request.cpp index 8893b96c65..7e7594c3c3 100644 --- a/esphome/components/http_request/ota/ota_http_request.cpp +++ b/esphome/components/http_request/ota/ota_http_request.cpp @@ -64,8 +64,9 @@ void OtaHttpRequestComponent::flash() { } } -void OtaHttpRequestComponent::cleanup_(ota::OTABackendPtr backend, const std::shared_ptr &container) { - if (this->update_started_) { +void OtaHttpRequestComponent::cleanup_(ota::OTABackendPtr backend, const std::shared_ptr &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; } diff --git a/esphome/components/http_request/ota/ota_http_request.h b/esphome/components/http_request/ota/ota_http_request.h index a706331d9a..9bb748f175 100644 --- a/esphome/components/http_request/ota/ota_http_request.h +++ b/esphome/components/http_request/ota/ota_http_request.h @@ -38,7 +38,7 @@ class OtaHttpRequestComponent final : public ota::OTAComponent, public Parented< void flash(); protected: - void cleanup_(ota::OTABackendPtr backend, const std::shared_ptr &container); + void cleanup_(ota::OTABackendPtr backend, const std::shared_ptr &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 }; diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index bb303c43a8..e5cbba88ec 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -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: diff --git a/esphome/components/ota/__init__.py b/esphome/components/ota/__init__.py index 5240db9e8f..a2e6953a16 100644 --- a/esphome/components/ota/__init__.py +++ b/esphome/components/ota/__init__.py @@ -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() diff --git a/esphome/components/ota/ota_backend.h b/esphome/components/ota/ota_backend.h index aa93df60a5..1c24fc320a 100644 --- a/esphome/components/ota/ota_backend.h +++ b/esphome/components/ota/ota_backend.h @@ -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, diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index eb23ad82dd..f33f37bbeb 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -7,7 +7,7 @@ #include "esphome/core/log.h" #include -#include +#include #include #ifdef USE_OTA_DOWNGRADE_PROTECTION #include @@ -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 diff --git a/esphome/components/ota/ota_backend_esp_idf.h b/esphome/components/ota/ota_backend_esp_idf.h index 9dffd5429e..c991f896e8 100644 --- a/esphome/components/ota/ota_backend_esp_idf.h +++ b/esphome/components/ota/ota_backend_esp_idf.h @@ -5,8 +5,18 @@ #include "esphome/components/md5/md5.h" #include "esphome/core/defines.h" +#include #include +// 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 diff --git a/esphome/components/ota/ota_bootloader_esp_idf.cpp b/esphome/components/ota/ota_bootloader_esp_idf.cpp index 57b5529350..5a83d92689 100644 --- a/esphome/components/ota/ota_bootloader_esp_idf.cpp +++ b/esphome/components/ota/ota_bootloader_esp_idf.cpp @@ -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_); diff --git a/esphome/components/ota/ota_signature_esp_idf.cpp b/esphome/components/ota/ota_signature_esp_idf.cpp index 71dcc0eb83..501d6ac241 100644 --- a/esphome/components/ota/ota_signature_esp_idf.cpp +++ b/esphome/components/ota/ota_signature_esp_idf.cpp @@ -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); diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 6ad76046a1..79d4ce5e0c 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -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"} +) diff --git a/esphome/components/text_sensor/__init__.py b/esphome/components/text_sensor/__init__.py index a3f4999a8f..29399a51b7 100644 --- a/esphome/components/text_sensor/__init__.py +++ b/esphome/components/text_sensor/__init__.py @@ -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"} +) diff --git a/esphome/components/uptime/sensor/__init__.py b/esphome/components/uptime/sensor/__init__.py index debeb41444..dd76bb5a87 100644 --- a/esphome/components/uptime/sensor/__init__.py +++ b/esphome/components/uptime/sensor/__init__.py @@ -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"} +) diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index baa55898bb..6469b4c564 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -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 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; } diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 5ed5fc9094..b8a31f97a3 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -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 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 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 &WiFiAP::get_eap() const { return this->eap_; } -#endif -#ifdef USE_WIFI_MANUAL_IP -const optional &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), diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index c54fbc004b..ada7be4ba4 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -21,6 +21,7 @@ #include #include #include +#include #include #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 eap_auth); + void set_eap(optional 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 manual_ip); + void set_manual_ip(optional 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 &get_eap() const; + const optional &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 &get_manual_ip() const; + const optional &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 buffer); diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index acaa94b13c..005d655d88 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -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(conf.ssid); - size_t len = strnlen(ssid_s, sizeof(conf.ssid)); - return {ssid_s, len}; -} const char *WiFiComponent::wifi_ssid_to(std::span buffer) { struct station_config conf {}; if (!wifi_station_get_config(&conf)) { diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index 24cb060edb..32d46887b6 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -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(info.ssid); - size_t len = strnlen(ssid_s, sizeof(info.ssid)); - return {ssid_s, len}; -} const char *WiFiComponent::wifi_ssid_to(std::span buffer) { wifi_ap_record_t info{}; esp_err_t err = esp_wifi_sta_get_ap_info(&info); diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index 66c397a8ad..e3c08416e8 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -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 buffer) { #ifdef USE_BK72XX LinkStatusTypeDef link_status{}; diff --git a/esphome/components/wifi/wifi_component_pico_w.cpp b/esphome/components/wifi/wifi_component_pico_w.cpp index 69af9e9a4e..325bcf2652 100644 --- a/esphome/components/wifi/wifi_component_pico_w.cpp +++ b/esphome/components/wifi/wifi_component_pico_w.cpp @@ -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 buffer) { // TODO: Find direct CYW43 API to avoid Arduino String allocation String ssid = WiFi.SSID(); diff --git a/esphome/config_helpers.py b/esphome/config_helpers.py index c82c2b3dbe..60bed1537e 100644 --- a/esphome/config_helpers.py +++ b/esphome/config_helpers.py @@ -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. diff --git a/esphome/core/defines.h b/esphome/core/defines.h index bb4960aec7..20aca3776f 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -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 diff --git a/esphome/core/entity_base.cpp b/esphome/core/entity_base.cpp index fc6ac503b5..21a5fc3706 100644 --- a/esphome/core/entity_base.cpp +++ b/esphome/core/entity_base.cpp @@ -80,24 +80,6 @@ const char *EntityBase::get_device_class_to([[maybe_unused]] std::spandevice_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 buffer) const { #ifdef USE_ENTITY_ICON @@ -129,24 +107,6 @@ const char *EntityBase::get_icon_to([[maybe_unused]] std::spanicon_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()); diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index 5f2e173d8d..f38e30bf52 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -109,60 +109,14 @@ class EntityBase { // On ESP8266: copies from PROGMEM to buffer, returns buffer pointer. const char *get_device_class_to(std::span 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 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 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 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 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 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 { diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index a276020be4..ded8051df8 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -723,23 +723,6 @@ bool base64_decode_int32_vector(const std::string &base64, std::vector // 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}); diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 5a9c120b84..b13d92ccce 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -1646,15 +1646,6 @@ bool base64_decode_int32_vector(const std::string &base64, std::vector /// @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). diff --git a/esphome/core/log.cpp b/esphome/core/log.cpp index 0da457adec..9fcddfeff6 100644 --- a/esphome/core/log.cpp +++ b/esphome/core/log.cpp @@ -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(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 diff --git a/esphome/core/log.h b/esphome/core/log.h index 72e06cabac..272e516808 100644 --- a/esphome/core/log.h +++ b/esphome/core/log.h @@ -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 diff --git a/esphome/espidf/size_summary.py b/esphome/espidf/size_summary.py index 5237495843..5338cd2135 100644 --- a/esphome/espidf/size_summary.py +++ b/esphome/espidf/size_summary.py @@ -37,7 +37,7 @@ _SIZE_SUFFIXES = {"K": 1024, "M": 1024 * 1024} def _parse_size(token: str) -> int: token = token.strip() if not token: - return 0 + raise ValueError("blank partition size cell") if token.startswith(("0x", "0X")): return int(token, 16) suffix = token[-1].upper() @@ -76,6 +76,14 @@ def print_summary(size_json: Path, partitions_csv: Path | None) -> None: summarize. Logs the cause at warning level, so a missing RAM/Flash line (which CI's memory-impact extraction greps for) is diagnosable. """ + try: + _print_summary(size_json, partitions_csv) + except Exception as e: # noqa: BLE001 # pylint: disable=broad-exception-caught + # Backstop for nested shapes the named guards below miss + _LOGGER.warning("Skipping size summary: %s", e) + + +def _print_summary(size_json: Path, partitions_csv: Path | None) -> None: if not size_json.is_file(): _LOGGER.warning("Skipping size summary: %s not found", size_json) return diff --git a/esphome/helpers.py b/esphome/helpers.py index 9b2a461ccd..7aa1a9a88c 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -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) diff --git a/esphome/platformio/extra_script.py b/esphome/platformio/extra_script.py index f1847dc959..d1550a680b 100644 --- a/esphome/platformio/extra_script.py +++ b/esphome/platformio/extra_script.py @@ -36,6 +36,12 @@ def apply_extra_script( extra_script = component.data.get("build", {}).get("extraScript") if not extra_script: return + if not isinstance(extra_script, str): + # A list/dict value would raise an opaque TypeError on the join below + raise EsphomeError( + f"extraScript of library {component.name} must be a string, " + f"got {type(extra_script).__name__}" + ) # Resolve and confine to the library's source dir so a malicious # library.json can't escape (e.g. ``"extraScript": "../../etc/passwd"``). source_path = component.source_dir @@ -77,13 +83,16 @@ def apply_extra_script( # Keys we know how to translate back into ESPHome's build-flag pipeline. # Other env.Append kwargs are recorded but ignored downstream. -_CAPTURED_KEYS = frozenset({"LIBPATH", "LIBS", "CPPDEFINES", "LINKFLAGS", "CPPFLAGS"}) +_CAPTURED_KEYS = frozenset( + {"CPPPATH", "LIBPATH", "LIBS", "CPPDEFINES", "LINKFLAGS", "CPPFLAGS"} +) @dataclass class ExtraScriptResult: """Build-var deltas captured from a PIO extra-script ``env.Append`` call.""" + cpppath: list[str] = field(default_factory=list) libpath: list[str] = field(default_factory=list) libs: list[str] = field(default_factory=list) cppdefines: list[str | tuple[str, str]] = field(default_factory=list) @@ -240,14 +249,18 @@ def captured_as_build_flags( return good library_root = library_dir.resolve() - for path in _strs(result.libpath, "LIBPATH"): + + def _anchored(path: str) -> str: # Anchor relative paths to library_dir; the script's CWD has been # restored by now resolved = (library_dir / path).resolve() try: - flags.append(f"-L{resolved.relative_to(library_root)}") + return str(resolved.relative_to(library_root)) except ValueError: - flags.append(f"-L{resolved}") + return str(resolved) + + flags.extend(f"-I{_anchored(path)}" for path in _strs(result.cpppath, "CPPPATH")) + flags.extend(f"-L{_anchored(path)}" for path in _strs(result.libpath, "LIBPATH")) flags.extend(f"-l{lib}" for lib in _strs(result.libs, "LIBS")) for define in result.cppdefines: # SCons also accepts dict/list CPPDEFINES; formatting those blind diff --git a/esphome/platformio/library.py b/esphome/platformio/library.py index 79933c07e8..ce74d828da 100644 --- a/esphome/platformio/library.py +++ b/esphome/platformio/library.py @@ -1137,8 +1137,8 @@ def convert_libraries( node = nodes[key] if frozenset(node.requirements) != resolved_requirements[key]: # An earlier wave entry grew this node's requirements after - # the drain resolved it; downloading the superseded version - # would be wasted work, and the next wave re-resolves it + # the drain resolved it; skip parsing and walking a manifest + # the next wave will replace (its archive is already fetched) worklist.append(key) continue component.download(salt=salt, namespace=backend.cache_key) @@ -1205,9 +1205,9 @@ def convert_libraries( component.data.get("dependencies"), component.name ): if "version" not in dependency: - # Cannot resolve from the registry; the arduino-backend - # PR adds the reconciliation that reports real drops - _LOGGER.debug( + # Cannot resolve from the registry; common for bundled + # names (Wire, SPI) -- add_library() is the fix if real + _LOGGER.info( "Skip version-less dependency %r of %s", dependency.get("name"), component.name, diff --git a/tests/components/binary_sensor/common.yaml b/tests/components/binary_sensor/common.yaml index 4f4cf6ea59..d0a16cc99c 100644 --- a/tests/components/binary_sensor/common.yaml +++ b/tests/components/binary_sensor/common.yaml @@ -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" diff --git a/tests/components/ota/test_erase_ahead.cpp b/tests/components/ota/test_erase_ahead.cpp new file mode 100644 index 0000000000..f84dd8a85d --- /dev/null +++ b/tests/components/ota/test_erase_ahead.cpp @@ -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 + +#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 diff --git a/tests/unit_tests/build_helpers/test_idedata.py b/tests/unit_tests/build_helpers/test_idedata.py index dd2494a093..fec276a92c 100644 --- a/tests/unit_tests/build_helpers/test_idedata.py +++ b/tests/unit_tests/build_helpers/test_idedata.py @@ -574,11 +574,101 @@ def test_load_or_build_idedata_never_caches_a_launcher(tmp_path: Path) -> None: assert not cache.exists() +@pytest.mark.parametrize( + "cached", + ( + {"cc_path": "/x/gcc", "cxx_path": "/opt/homebrew/bin/ccache"}, + {"cc_path": "/x/gcc", "cxx_path": "/tools/g++"}, + {"cc_path": "/x/gcc", "cxx_path": "/tools/g++", "includes": {}}, + ), + ids=("launcher-cxx", "no-includes", "no-build-list"), +) +def test_load_or_build_idedata_regenerates_invalid_cache( + tmp_path: Path, cached: dict +) -> None: + """A cache written by an older version fails validation and regenerates.""" + compile_commands = _write_compile_commands(tmp_path) + cache = tmp_path / "c.json" + cache.write_text(json.dumps(cached)) + os.utime(cache, (compile_commands.stat().st_mtime + 5,) * 2) + with patch.object(idedata, "get_toolchain_includes", return_value=[]): + data = idedata.load_or_build_idedata( + compile_commands, tmp_path / "f.elf", cache + ) + assert data["cxx_path"] == "/tools/g++" + assert "includes" in data + + +def test_load_or_build_idedata_cache_hit_restamps_prog_path(tmp_path: Path) -> None: + """A served cache carries the current ELF path, not the one it was written with.""" + compile_commands = _write_compile_commands(tmp_path) + cache = tmp_path / "c.json" + cache.write_text( + json.dumps( + { + "cc_path": "/tools/gcc", + "cxx_path": "/tools/g++", + "includes": {"build": [], "toolchain": []}, + "prog_path": "/old/location/firmware.elf", + } + ) + ) + os.utime(cache, (compile_commands.stat().st_mtime + 5,) * 2) + data = idedata.load_or_build_idedata( + compile_commands, tmp_path / "firmware.elf", cache + ) + assert data["prog_path"] == str(tmp_path / "firmware.elf") + + +def test_idedata_from_build_non_list_compile_db_raises(tmp_path: Path) -> None: + """Valid JSON that is not a list raises by name, inside the best-effort tuple.""" + compile_commands = tmp_path / "compile_commands.json" + for bad in ("{}", "null", '"text"'): + compile_commands.write_text(bad) + with pytest.raises(EsphomeError, match="not a compile-command list"): + idedata.idedata_from_build(compile_commands) + + +def test_idedata_from_build_same_file_rsp_commands_never_dedupe( + tmp_path: Path, +) -> None: + """Two objects built from one source with different .rsp files keep both + include sets; the rsp sentinel keys on the output, not the source.""" + file = f"{ABS}build/src/esphome/core/shared.cpp" + entries = [] + for name in ("a", "b"): + rsp = tmp_path / f"{name}.o.rsp" + rsp.write_text(f"-I{ABS}inc/{name}") + entries.append( + { + "directory": str(tmp_path), + "file": file, + "command": f"/tools/g++ @{rsp.name} -c {file} -o {name}.o", + "output": f"{name}.o", + } + ) + compile_commands = tmp_path / "compile_commands.json" + compile_commands.write_text(json.dumps(entries)) + with patch.object(idedata, "get_toolchain_includes", return_value=[]): + data = idedata.idedata_from_build(compile_commands) + joined = " ".join(data["includes"]["build"]) + assert "inc/a" in joined and "inc/b" in joined + + def test_load_or_build_idedata_cache_hit_skips_rebuild(tmp_path: Path) -> None: """A valid cache newer than the compile DB is served without re-parsing.""" compile_commands = _write_compile_commands(tmp_path) cache = tmp_path / "c.json" - cache.write_text(json.dumps({"cc_path": "/tools/gcc", "cached": True})) + cache.write_text( + json.dumps( + { + "cc_path": "/tools/gcc", + "cxx_path": "/tools/g++", + "includes": {"build": ["/inc"], "toolchain": []}, + "cached": True, + } + ) + ) os.utime(cache, (compile_commands.stat().st_mtime + 5,) * 2) with patch.object(idedata, "idedata_from_build") as mock_build: data = idedata.load_or_build_idedata( diff --git a/tests/unit_tests/test_config_helpers.py b/tests/unit_tests/test_config_helpers.py index 88913c0f23..e53016dfc3 100644 --- a/tests/unit_tests/test_config_helpers.py +++ b/tests/unit_tests/test_config_helpers.py @@ -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"] diff --git a/tests/unit_tests/test_helpers.py b/tests/unit_tests/test_helpers.py index 6e00e5b80f..eaa7d5a8dc 100644 --- a/tests/unit_tests/test_helpers.py +++ b/tests/unit_tests/test_helpers.py @@ -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" diff --git a/tests/unit_tests/test_platformio_extra_script.py b/tests/unit_tests/test_platformio_extra_script.py index f7ac268bf4..dcb181caa3 100644 --- a/tests/unit_tests/test_platformio_extra_script.py +++ b/tests/unit_tests/test_platformio_extra_script.py @@ -302,6 +302,33 @@ def test_apply_extra_script_missing_script_raises(tmp_path) -> None: apply_extra_script(c, board_mcu=lambda: "esp8266", pio_platform="espressif8266") +@pytest.mark.parametrize("bad", (["a.py"], {"esp32": "a.py"}), ids=("list", "dict")) +def test_apply_extra_script_non_string_raises(tmp_path, bad) -> None: + """A non-string extraScript fails naming the library, not with a TypeError.""" + + c = IDFComponent("owner/name", "1.0", source=URLSource("http://dummy")) + c.path = tmp_path + c.data = {"build": {"extraScript": bad}} + with pytest.raises(EsphomeError, match="of library owner/name must be a string"): + apply_extra_script(c, board_mcu=lambda: "esp8266", pio_platform="espressif8266") + + +def test_extra_script_cpppath_captured_as_include_flags(tmp_path, monkeypatch): + """CPPPATH entries translate to -I flags anchored like LIBPATH.""" + + (tmp_path / "include").mkdir() + outside = tmp_path.parent / "system_inc" + outside.mkdir(exist_ok=True) + elsewhere = tmp_path.parent / "not_the_library_dir" + elsewhere.mkdir(exist_ok=True) + monkeypatch.chdir(elsewhere) + + result = ExtraScriptResult(cpppath=["include", str(outside), 7]) + flags = captured_as_build_flags(result, library_dir=tmp_path) + + assert flags == ["-Iinclude", f"-I{outside.resolve()}"] + + def test_run_extra_script_failure_discards_partial_capture(tmp_path, caplog) -> None: """A crashed script yields an empty result: half-applied flags could build wrong-output firmware that links cleanly.""" @@ -396,6 +423,6 @@ def test_uncaptured_append_key_warns_once(caplog) -> None: env = _FakeSConsEnv( board_mcu="esp8266", pio_env="esphome_esp8266", pio_platform="espressif8266" ) - env.Append(CPPPATH=["a"]) - env.Append(CPPPATH=["b"]) - assert caplog.text.count("env.Append(CPPPATH=...) is not captured") == 1 + env.Append(RANLIBFLAGS=["a"]) + env.Append(RANLIBFLAGS=["b"]) + assert caplog.text.count("env.Append(RANLIBFLAGS=...) is not captured") == 1 diff --git a/tests/unit_tests/test_size_summary.py b/tests/unit_tests/test_size_summary.py index c5d117b953..8647eec67f 100644 --- a/tests/unit_tests/test_size_summary.py +++ b/tests/unit_tests/test_size_summary.py @@ -205,8 +205,19 @@ def test_print_summary_non_dict_json_warns(tmp_path, caplog) -> None: def test_print_summary_zero_app_partition_warns(tmp_path, caplog) -> None: - """A malformed partition row parsing to 0 must not render a 0% bar for - CI's memory-impact extraction to ingest.""" + """A partition row with size 0 drops the Flash bar instead of rendering 0%.""" + size_json = tmp_path / "size.json" + size_json.write_text( + '{"memory_types": {"DRAM": {"used": 1, "size": 2}}, "image_size": 100}' + ) + partitions = tmp_path / "partitions.csv" + partitions.write_text("app0, app, ota_0, 0x10000, 0,\n") + print_summary(size_json, partitions) + assert "app partition size is" in caplog.text + + +def test_print_summary_blank_partition_size_warns(tmp_path, caplog) -> None: + """A blank size cell raises ValueError by name instead of parsing to 0.""" size_json = tmp_path / "size.json" size_json.write_text( '{"memory_types": {"DRAM": {"used": 1, "size": 2}}, "image_size": 100}' @@ -214,4 +225,21 @@ def test_print_summary_zero_app_partition_warns(tmp_path, caplog) -> None: partitions = tmp_path / "partitions.csv" partitions.write_text("app0, app, ota_0, 0x10000, ,\n") print_summary(size_json, partitions) - assert "app partition size is" in caplog.text + assert "blank partition size cell" in caplog.text + + +@pytest.mark.parametrize( + "payload", + ( + '{"memory_types": []}', + '{"memory_types": {"DRAM": 5}}', + '{"memory_types": {"DRAM": {"used": "x", "size": "y"}}}', + ), + ids=("non-dict-memory-types", "scalar-region", "non-numeric-sizes"), +) +def test_print_summary_nested_shapes_never_raise(tmp_path, caplog, payload) -> None: + """The blanket guard keeps unexpected nested shapes from raising.""" + size_json = tmp_path / "size.json" + size_json.write_text(payload) + print_summary(size_json, tmp_path / "partitions.csv") + assert "Skipping size summary" in caplog.text