diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index be761cee3d..817ea1d2be 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -58,7 +58,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0 + uses: github/codeql-action/init@6bc82e05fd0ea64601dd4b465378bbcf57de0314 # v4.32.1 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -86,6 +86,6 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0 + uses: github/codeql-action/analyze@6bc82e05fd0ea64601dd4b465378bbcf57de0314 # v4.32.1 with: category: "/language:${{matrix.language}}" diff --git a/esphome/analyze_memory/cli.py b/esphome/analyze_memory/cli.py index a77e17afce..72a73dbdd4 100644 --- a/esphome/analyze_memory/cli.py +++ b/esphome/analyze_memory/cli.py @@ -4,6 +4,8 @@ from __future__ import annotations from collections import defaultdict from collections.abc import Callable +import heapq +from operator import itemgetter import sys from typing import TYPE_CHECKING @@ -29,6 +31,10 @@ class MemoryAnalyzerCLI(MemoryAnalyzer): ) # Lower threshold for RAM symbols (RAM is more constrained) RAM_SYMBOL_SIZE_THRESHOLD: int = 24 + # Number of top symbols to show in the largest symbols report + TOP_SYMBOLS_LIMIT: int = 30 + # Width for symbol name display in top symbols report + COL_TOP_SYMBOL_NAME: int = 55 # Column width constants COL_COMPONENT: int = 29 @@ -147,6 +153,37 @@ class MemoryAnalyzerCLI(MemoryAnalyzer): section_label = f" [{section[1:]}]" # .data -> [data], .bss -> [bss] return f"{demangled} ({size:,} B){section_label}" + def _add_top_symbols(self, lines: list[str]) -> None: + """Add a section showing the top largest symbols in the binary.""" + # Collect all symbols from all components: (symbol, demangled, size, section, component) + all_symbols = [ + (symbol, demangled, size, section, component) + for component, symbols in self._component_symbols.items() + for symbol, demangled, size, section in symbols + ] + + # Get top N symbols by size using heapq for efficiency + top_symbols = heapq.nlargest( + self.TOP_SYMBOLS_LIMIT, all_symbols, key=itemgetter(2) + ) + + lines.append("") + lines.append(f"Top {self.TOP_SYMBOLS_LIMIT} Largest Symbols:") + # Calculate truncation limit from column width (leaving room for "...") + truncate_limit = self.COL_TOP_SYMBOL_NAME - 3 + for i, (_, demangled, size, section, component) in enumerate(top_symbols): + # Format section label + section_label = f"[{section[1:]}]" if section else "" + # Truncate demangled name if too long + demangled_display = ( + f"{demangled[:truncate_limit]}..." + if len(demangled) > self.COL_TOP_SYMBOL_NAME + else demangled + ) + lines.append( + f"{i + 1:>2}. {size:>7,} B {section_label:<8} {demangled_display:<{self.COL_TOP_SYMBOL_NAME}} {component}" + ) + def generate_report(self, detailed: bool = False) -> str: """Generate a formatted memory report.""" components = sorted( @@ -248,6 +285,9 @@ class MemoryAnalyzerCLI(MemoryAnalyzer): "RAM", ) + # Top largest symbols in the binary + self._add_top_symbols(lines) + # Add ESPHome core detailed analysis if there are core symbols if self._esphome_core_symbols: self._add_section_header(lines, f"{_COMPONENT_CORE} Detailed Analysis") diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 597da25883..d25934c60b 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -45,6 +45,7 @@ service APIConnection { rpc time_command (TimeCommandRequest) returns (void) {} rpc update_command (UpdateCommandRequest) returns (void) {} rpc valve_command (ValveCommandRequest) returns (void) {} + rpc water_heater_command (WaterHeaterCommandRequest) returns (void) {} rpc subscribe_bluetooth_le_advertisements(SubscribeBluetoothLEAdvertisementsRequest) returns (void) {} rpc bluetooth_device_request(BluetoothDeviceRequest) returns (void) {} diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 1626f395e6..839de29de7 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1385,7 +1385,7 @@ uint16_t APIConnection::try_send_water_heater_info(EntityBase *entity, APIConnec is_single); } -void APIConnection::on_water_heater_command_request(const WaterHeaterCommandRequest &msg) { +void APIConnection::water_heater_command(const WaterHeaterCommandRequest &msg) { ENTITY_COMMAND_MAKE_CALL(water_heater::WaterHeater, water_heater, water_heater) if (msg.has_fields & enums::WATER_HEATER_COMMAND_HAS_MODE) call.set_mode(static_cast(msg.mode)); diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 21bf4c4073..b839a2a97b 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -170,7 +170,7 @@ class APIConnection final : public APIServerConnection { #ifdef USE_WATER_HEATER bool send_water_heater_state(water_heater::WaterHeater *water_heater); - void on_water_heater_command_request(const WaterHeaterCommandRequest &msg) override; + void water_heater_command(const WaterHeaterCommandRequest &msg) override; #endif #ifdef USE_IR_RF diff --git a/esphome/components/api/api_pb2_service.cpp b/esphome/components/api/api_pb2_service.cpp index 4b7148e6c0..af0a2d0ca2 100644 --- a/esphome/components/api/api_pb2_service.cpp +++ b/esphome/components/api/api_pb2_service.cpp @@ -746,6 +746,11 @@ void APIServerConnection::on_update_command_request(const UpdateCommandRequest & #ifdef USE_VALVE void APIServerConnection::on_valve_command_request(const ValveCommandRequest &msg) { this->valve_command(msg); } #endif +#ifdef USE_WATER_HEATER +void APIServerConnection::on_water_heater_command_request(const WaterHeaterCommandRequest &msg) { + this->water_heater_command(msg); +} +#endif #ifdef USE_BLUETOOTH_PROXY void APIServerConnection::on_subscribe_bluetooth_le_advertisements_request( const SubscribeBluetoothLEAdvertisementsRequest &msg) { diff --git a/esphome/components/api/api_pb2_service.h b/esphome/components/api/api_pb2_service.h index 200991c282..80a61c1041 100644 --- a/esphome/components/api/api_pb2_service.h +++ b/esphome/components/api/api_pb2_service.h @@ -303,6 +303,9 @@ class APIServerConnection : public APIServerConnectionBase { #ifdef USE_VALVE virtual void valve_command(const ValveCommandRequest &msg) = 0; #endif +#ifdef USE_WATER_HEATER + virtual void water_heater_command(const WaterHeaterCommandRequest &msg) = 0; +#endif #ifdef USE_BLUETOOTH_PROXY virtual void subscribe_bluetooth_le_advertisements(const SubscribeBluetoothLEAdvertisementsRequest &msg) = 0; #endif @@ -432,6 +435,9 @@ class APIServerConnection : public APIServerConnectionBase { #ifdef USE_VALVE void on_valve_command_request(const ValveCommandRequest &msg) override; #endif +#ifdef USE_WATER_HEATER + void on_water_heater_command_request(const WaterHeaterCommandRequest &msg) override; +#endif #ifdef USE_BLUETOOTH_PROXY void on_subscribe_bluetooth_le_advertisements_request(const SubscribeBluetoothLEAdvertisementsRequest &msg) override; #endif diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index cd448be9dc..3611fa724e 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -126,10 +126,14 @@ COMPILER_OPTIMIZATIONS = { # - "sdmmc": driver -> esp_driver_sdmmc -> sdmmc dependency chain DEFAULT_EXCLUDED_IDF_COMPONENTS = ( "cmock", # Unit testing mock framework - ESPHome doesn't use IDF's testing + "driver", # Legacy driver shim - only needed by esp32_touch, esp32_can for legacy headers "esp_adc", # ADC driver - only needed by adc component + "esp_driver_dac", # DAC driver - only needed by esp32_dac component "esp_driver_i2s", # I2S driver - only needed by i2s_audio component + "esp_driver_mcpwm", # MCPWM driver - ESPHome doesn't use motor control PWM "esp_driver_rmt", # RMT driver - only needed by remote_transmitter/receiver, neopixelbus "esp_driver_touch_sens", # Touch sensor driver - only needed by esp32_touch + "esp_driver_twai", # TWAI/CAN driver - only needed by esp32_can component "esp_eth", # Ethernet driver - only needed by ethernet component "esp_hid", # HID host/device support - ESPHome doesn't implement HID functionality "esp_http_client", # HTTP client - only needed by http_request component @@ -140,9 +144,11 @@ DEFAULT_EXCLUDED_IDF_COMPONENTS = ( "espcoredump", # Core dump support - ESPHome has its own debug component "fatfs", # FAT filesystem - ESPHome doesn't use filesystem storage "mqtt", # ESP-IDF MQTT library - ESPHome has its own MQTT implementation + "openthread", # Thread protocol - only needed by openthread component "perfmon", # Xtensa performance monitor - ESPHome has its own debug component "protocomm", # Protocol communication for provisioning - unused by ESPHome "spiffs", # SPIFFS filesystem - ESPHome doesn't use filesystem storage (IDF only) + "ulp", # ULP coprocessor - not currently used by any ESPHome component "unity", # Unit testing framework - ESPHome doesn't use IDF's testing "wear_levelling", # Flash wear levelling for fatfs - unused since fatfs unused "wifi_provisioning", # WiFi provisioning - ESPHome uses its own improv implementation diff --git a/esphome/components/esp32_can/canbus.py b/esphome/components/esp32_can/canbus.py index 0768b35507..7245ba7513 100644 --- a/esphome/components/esp32_can/canbus.py +++ b/esphome/components/esp32_can/canbus.py @@ -15,6 +15,7 @@ from esphome.components.esp32 import ( VARIANT_ESP32S2, VARIANT_ESP32S3, get_esp32_variant, + include_builtin_idf_component, ) import esphome.config_validation as cv from esphome.const import ( @@ -121,6 +122,10 @@ def get_default_tx_enqueue_timeout(bit_rate): async def to_code(config): + # Legacy driver component provides driver/twai.h header + include_builtin_idf_component("driver") + # Also enable esp_driver_twai for future migration to new API + include_builtin_idf_component("esp_driver_twai") var = cg.new_Pvariable(config[CONF_ID]) await canbus.register_canbus(var, config) diff --git a/esphome/components/esp32_dac/output.py b/esphome/components/esp32_dac/output.py index daace596d3..7c63d7bd11 100644 --- a/esphome/components/esp32_dac/output.py +++ b/esphome/components/esp32_dac/output.py @@ -1,7 +1,12 @@ from esphome import pins import esphome.codegen as cg from esphome.components import output -from esphome.components.esp32 import VARIANT_ESP32, VARIANT_ESP32S2, get_esp32_variant +from esphome.components.esp32 import ( + VARIANT_ESP32, + VARIANT_ESP32S2, + get_esp32_variant, + include_builtin_idf_component, +) import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_NUMBER, CONF_PIN @@ -38,6 +43,7 @@ CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( async def to_code(config): + include_builtin_idf_component("esp_driver_dac") var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await output.register_output(var, config) diff --git a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp index ebcdd5f36e..dac2b01425 100644 --- a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp +++ b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp @@ -34,14 +34,29 @@ static const char *const ESP_HOSTED_VERSION_STR = STRINGIFY(ESP_HOSTED_VERSION_M ESP_HOSTED_VERSION_MINOR_1) "." STRINGIFY(ESP_HOSTED_VERSION_PATCH_1); #ifdef USE_ESP32_HOSTED_HTTP_UPDATE +// Parse an integer from str, advancing ptr past the number +// Returns false if no digits were parsed +static bool parse_int(const char *&ptr, int &value) { + char *end; + value = static_cast(strtol(ptr, &end, 10)); + if (end == ptr) + return false; + ptr = end; + return true; +} + // Parse version string "major.minor.patch" into components -// Returns true if parsing succeeded +// Returns true if at least major.minor was parsed static bool parse_version(const std::string &version_str, int &major, int &minor, int &patch) { major = minor = patch = 0; - if (sscanf(version_str.c_str(), "%d.%d.%d", &major, &minor, &patch) >= 2) { - return true; - } - return false; + const char *ptr = version_str.c_str(); + + if (!parse_int(ptr, major) || *ptr++ != '.' || !parse_int(ptr, minor)) + return false; + if (*ptr == '.') + parse_int(++ptr, patch); + + return true; } // Compare two versions, returns: diff --git a/esphome/components/esp32_touch/__init__.py b/esphome/components/esp32_touch/__init__.py index 6accb89c35..a02370a343 100644 --- a/esphome/components/esp32_touch/__init__.py +++ b/esphome/components/esp32_touch/__init__.py @@ -269,6 +269,8 @@ CONFIG_SCHEMA = cv.All( async def to_code(config): # Re-enable ESP-IDF's touch sensor driver (excluded by default to save compile time) include_builtin_idf_component("esp_driver_touch_sens") + # Legacy driver component provides driver/touch_sensor.h header + include_builtin_idf_component("driver") touch = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(touch, config) diff --git a/esphome/components/logger/logger.cpp b/esphome/components/logger/logger.cpp index 3a726d4046..25243ff3f6 100644 --- a/esphome/components/logger/logger.cpp +++ b/esphome/components/logger/logger.cpp @@ -128,22 +128,7 @@ void HOT Logger::log_vprintf_(uint8_t level, const char *tag, int line, const ch // Note: USE_STORE_LOG_STR_IN_FLASH is only defined for ESP8266. // // This function handles format strings stored in flash memory (PROGMEM) to save RAM. -// The buffer is used in a special way to avoid allocating extra memory: -// -// Memory layout during execution: -// Step 1: Copy format string from flash to buffer -// tx_buffer_: [format_string][null][.....................] -// tx_buffer_at_: ------------------^ -// msg_start: saved here -----------^ -// -// Step 2: format_log_to_buffer_with_terminator_ reads format string from beginning -// and writes formatted output starting at msg_start position -// tx_buffer_: [format_string][null][formatted_message][null] -// tx_buffer_at_: -------------------------------------^ -// -// Step 3: Output the formatted message (starting at msg_start) -// write_msg_ and callbacks receive: this->tx_buffer_ + msg_start -// which points to: [formatted_message][null] +// Uses vsnprintf_P to read the format string directly from flash without copying to RAM. // void Logger::log_vprintf_(uint8_t level, const char *tag, int line, const __FlashStringHelper *format, va_list args) { // NOLINT @@ -153,35 +138,25 @@ void Logger::log_vprintf_(uint8_t level, const char *tag, int line, const __Flas RecursionGuard guard(global_recursion_guard_); this->tx_buffer_at_ = 0; - // Copy format string from progmem - auto *format_pgm_p = reinterpret_cast(format); - char ch = '.'; - while (this->tx_buffer_at_ < this->tx_buffer_size_ && ch != '\0') { - this->tx_buffer_[this->tx_buffer_at_++] = ch = (char) progmem_read_byte(format_pgm_p++); - } + // Write header, format body directly from flash, and write footer + this->write_header_to_buffer_(level, tag, line, nullptr, this->tx_buffer_, &this->tx_buffer_at_, + this->tx_buffer_size_); + this->format_body_to_buffer_P_(this->tx_buffer_, &this->tx_buffer_at_, this->tx_buffer_size_, + reinterpret_cast(format), args); + this->write_footer_to_buffer_(this->tx_buffer_, &this->tx_buffer_at_, this->tx_buffer_size_); - // Buffer full from copying format - RAII guard handles cleanup on return - if (this->tx_buffer_at_ >= this->tx_buffer_size_) { - return; - } - - // Save the offset before calling format_log_to_buffer_with_terminator_ - // since it will increment tx_buffer_at_ to the end of the formatted string - uint16_t msg_start = this->tx_buffer_at_; - this->format_log_to_buffer_with_terminator_(level, tag, line, this->tx_buffer_, args, this->tx_buffer_, - &this->tx_buffer_at_, this->tx_buffer_size_); - - uint16_t msg_length = - this->tx_buffer_at_ - msg_start; // Don't subtract 1 - tx_buffer_at_ is already at the null terminator position + // Ensure null termination + uint16_t null_pos = this->tx_buffer_at_ >= this->tx_buffer_size_ ? this->tx_buffer_size_ - 1 : this->tx_buffer_at_; + this->tx_buffer_[null_pos] = '\0'; // Listeners get message first (before console write) #ifdef USE_LOG_LISTENERS for (auto *listener : this->log_listeners_) - listener->on_log(level, tag, this->tx_buffer_ + msg_start, msg_length); + listener->on_log(level, tag, this->tx_buffer_, this->tx_buffer_at_); #endif - // Write to console starting at the msg_start - this->write_tx_buffer_to_console_(msg_start, &msg_length); + // Write to console + this->write_tx_buffer_to_console_(); } #endif // USE_STORE_LOG_STR_IN_FLASH diff --git a/esphome/components/logger/logger.h b/esphome/components/logger/logger.h index fe9cab4993..40ac9a38aa 100644 --- a/esphome/components/logger/logger.h +++ b/esphome/components/logger/logger.h @@ -597,31 +597,40 @@ class Logger : public Component { *buffer_at = pos; } + // Helper to process vsnprintf return value and strip trailing newlines. + // Updates buffer_at with the formatted length, handling truncation: + // - When vsnprintf truncates (ret >= remaining), it writes (remaining - 1) chars + null terminator + // - When it doesn't truncate (ret < remaining), it writes ret chars + null terminator + __attribute__((always_inline)) static inline void process_vsnprintf_result(const char *buffer, uint16_t *buffer_at, + uint16_t remaining, int ret) { + if (ret < 0) + return; // Encoding error, do not increment buffer_at + *buffer_at += (ret >= remaining) ? (remaining - 1) : static_cast(ret); + // Remove all trailing newlines right after formatting + while (*buffer_at > 0 && buffer[*buffer_at - 1] == '\n') + (*buffer_at)--; + } + inline void HOT format_body_to_buffer_(char *buffer, uint16_t *buffer_at, uint16_t buffer_size, const char *format, va_list args) { - // Get remaining capacity in the buffer + // Check remaining capacity in the buffer if (*buffer_at >= buffer_size) return; const uint16_t remaining = buffer_size - *buffer_at; - - const int ret = vsnprintf(buffer + *buffer_at, remaining, format, args); - - if (ret < 0) { - return; // Encoding error, do not increment buffer_at - } - - // Update buffer_at with the formatted length (handle truncation) - // When vsnprintf truncates (ret >= remaining), it writes (remaining - 1) chars + null terminator - // When it doesn't truncate (ret < remaining), it writes ret chars + null terminator - uint16_t formatted_len = (ret >= remaining) ? (remaining - 1) : ret; - *buffer_at += formatted_len; - - // Remove all trailing newlines right after formatting - while (*buffer_at > 0 && buffer[*buffer_at - 1] == '\n') { - (*buffer_at)--; - } + process_vsnprintf_result(buffer, buffer_at, remaining, vsnprintf(buffer + *buffer_at, remaining, format, args)); } +#ifdef USE_STORE_LOG_STR_IN_FLASH + // ESP8266 variant that reads format string directly from flash using vsnprintf_P + inline void HOT format_body_to_buffer_P_(char *buffer, uint16_t *buffer_at, uint16_t buffer_size, PGM_P format, + va_list args) { + if (*buffer_at >= buffer_size) + return; + const uint16_t remaining = buffer_size - *buffer_at; + process_vsnprintf_result(buffer, buffer_at, remaining, vsnprintf_P(buffer + *buffer_at, remaining, format, args)); + } +#endif + inline void HOT write_footer_to_buffer_(char *buffer, uint16_t *buffer_at, uint16_t buffer_size) { static constexpr uint16_t RESET_COLOR_LEN = sizeof(ESPHOME_LOG_RESET_COLOR) - 1; this->write_body_to_buffer_(ESPHOME_LOG_RESET_COLOR, RESET_COLOR_LEN, buffer, buffer_at, buffer_size); diff --git a/esphome/components/openthread/__init__.py b/esphome/components/openthread/__init__.py index 26c05a0a86..89d335c574 100644 --- a/esphome/components/openthread/__init__.py +++ b/esphome/components/openthread/__init__.py @@ -4,6 +4,7 @@ from esphome.components.esp32 import ( VARIANT_ESP32C6, VARIANT_ESP32H2, add_idf_sdkconfig_option, + include_builtin_idf_component, only_on_variant, require_vfs_select, ) @@ -172,6 +173,9 @@ FINAL_VALIDATE_SCHEMA = _final_validate async def to_code(config): + # Re-enable openthread IDF component (excluded by default) + include_builtin_idf_component("openthread") + cg.add_define("USE_OPENTHREAD") # OpenThread SRP needs access to mDNS services after setup diff --git a/esphome/components/sx126x/__init__.py b/esphome/components/sx126x/__init__.py index ed878ed0d4..413eb139d6 100644 --- a/esphome/components/sx126x/__init__.py +++ b/esphome/components/sx126x/__init__.py @@ -213,7 +213,7 @@ CONFIG_SCHEMA = ( cv.Optional(CONF_ON_PACKET): automation.validate_automation(single=True), cv.Optional(CONF_PA_POWER, default=17): cv.int_range(min=-3, max=22), cv.Optional(CONF_PA_RAMP, default="40us"): cv.enum(RAMP), - cv.Optional(CONF_PAYLOAD_LENGTH, default=0): cv.int_range(min=0, max=256), + cv.Optional(CONF_PAYLOAD_LENGTH, default=0): cv.int_range(min=0, max=255), cv.Optional(CONF_PREAMBLE_DETECT, default=2): cv.int_range(min=0, max=4), cv.Optional(CONF_PREAMBLE_SIZE, default=8): cv.int_range(min=1, max=65535), cv.Required(CONF_RST_PIN): pins.gpio_output_pin_schema, diff --git a/esphome/components/time/real_time_clock.h b/esphome/components/time/real_time_clock.h index 70469e11b0..19aa1a4f4a 100644 --- a/esphome/components/time/real_time_clock.h +++ b/esphome/components/time/real_time_clock.h @@ -62,7 +62,7 @@ class RealTimeClock : public PollingComponent { void apply_timezone_(); #endif - CallbackManager time_sync_callback_; + LazyCallbackManager time_sync_callback_; }; template class TimeHasTimeCondition : public Condition { diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index 98e8c02d07..f09a39d2bb 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -359,6 +359,10 @@ void Component::defer(const std::string &name, std::function &&f) { // void Component::defer(const char *name, std::function &&f) { // NOLINT App.scheduler.set_timeout(this, name, 0, std::move(f)); } +void Component::defer(uint32_t id, std::function &&f) { // NOLINT + App.scheduler.set_timeout(this, id, 0, std::move(f)); +} +bool Component::cancel_defer(uint32_t id) { return App.scheduler.cancel_timeout(this, id); } void Component::set_timeout(uint32_t timeout, std::function &&f) { // NOLINT App.scheduler.set_timeout(this, static_cast(nullptr), timeout, std::move(f)); } diff --git a/esphome/core/component.h b/esphome/core/component.h index 49349d4199..97f2afe1a4 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -494,11 +494,15 @@ class Component { /// Defer a callback to the next loop() call. void defer(std::function &&f); // NOLINT + /// Defer a callback with a numeric ID (zero heap allocation) + void defer(uint32_t id, std::function &&f); // NOLINT + /// Cancel a defer callback using the specified name, name must not be empty. // Remove before 2026.7.0 ESPDEPRECATED("Use const char* overload instead. Removed in 2026.7.0", "2026.1.0") bool cancel_defer(const std::string &name); // NOLINT bool cancel_defer(const char *name); // NOLINT + bool cancel_defer(uint32_t id); // NOLINT // Ordered for optimal packing on 32-bit systems const LogString *component_source_{nullptr}; diff --git a/esphome/wizard.py b/esphome/wizard.py index d77450b04d..f5e8a1e462 100644 --- a/esphome/wizard.py +++ b/esphome/wizard.py @@ -1,5 +1,7 @@ +import base64 from pathlib import Path import random +import secrets import string from typing import Literal, NotRequired, TypedDict, Unpack import unicodedata @@ -116,7 +118,6 @@ class WizardFileKwargs(TypedDict): board: str ssid: NotRequired[str] psk: NotRequired[str] - password: NotRequired[str] ota_password: NotRequired[str] api_encryption_key: NotRequired[str] friendly_name: NotRequired[str] @@ -144,9 +145,7 @@ def wizard_file(**kwargs: Unpack[WizardFileKwargs]) -> str: config += API_CONFIG - # Configure API - if "password" in kwargs: - config += f' password: "{kwargs["password"]}"\n' + # Configure API encryption if "api_encryption_key" in kwargs: config += f' encryption:\n key: "{kwargs["api_encryption_key"]}"\n' @@ -155,8 +154,6 @@ def wizard_file(**kwargs: Unpack[WizardFileKwargs]) -> str: config += " - platform: esphome\n" if "ota_password" in kwargs: config += f' password: "{kwargs["ota_password"]}"' - elif "password" in kwargs: - config += f' password: "{kwargs["password"]}"' # Configuring wifi config += "\n\nwifi:\n" @@ -205,7 +202,6 @@ class WizardWriteKwargs(TypedDict): platform: NotRequired[str] ssid: NotRequired[str] psk: NotRequired[str] - password: NotRequired[str] ota_password: NotRequired[str] api_encryption_key: NotRequired[str] friendly_name: NotRequired[str] @@ -232,7 +228,7 @@ def wizard_write(path: Path, **kwargs: Unpack[WizardWriteKwargs]) -> bool: else: # "basic" board = kwargs["board"] - for key in ("ssid", "psk", "password", "ota_password"): + for key in ("ssid", "psk", "ota_password"): if key in kwargs: kwargs[key] = sanitize_double_quotes(kwargs[key]) if "platform" not in kwargs: @@ -522,26 +518,54 @@ def wizard(path: Path) -> int: "Almost there! ESPHome can automatically upload custom firmwares over WiFi " "(over the air) and integrates into Home Assistant with a native API." ) + safe_print() + sleep(0.5) + + # Generate encryption key (32 bytes, base64 encoded) for secure API communication + noise_psk = secrets.token_bytes(32) + api_encryption_key = base64.b64encode(noise_psk).decode() + safe_print( - f"This can be insecure if you do not trust the WiFi network. Do you want to set a {color(AnsiFore.GREEN, 'password')} for connecting to this ESP?" + "For secure API communication, I've generated a random encryption key." + ) + safe_print() + safe_print( + f"Your {color(AnsiFore.GREEN, 'API encryption key')} is: " + f"{color(AnsiFore.BOLD_WHITE, api_encryption_key)}" + ) + safe_print() + safe_print("You'll need this key when adding the device to Home Assistant.") + sleep(1) + + safe_print() + safe_print( + f"Do you want to set a {color(AnsiFore.GREEN, 'password')} for OTA updates? " + "This can be insecure if you do not trust the WiFi network." ) safe_print() sleep(0.25) safe_print("Press ENTER for no password") - password = safe_input(color(AnsiFore.BOLD_WHITE, "(password): ")) + ota_password = safe_input(color(AnsiFore.BOLD_WHITE, "(password): ")) else: - ssid, password, psk = "", "", "" + ssid, psk = "", "" + api_encryption_key = None + ota_password = "" - if not wizard_write( - path=path, - name=name, - platform=platform, - board=board, - ssid=ssid, - psk=psk, - password=password, - type="basic", - ): + kwargs = { + "path": path, + "name": name, + "platform": platform, + "board": board, + "ssid": ssid, + "psk": psk, + "type": "basic", + } + if api_encryption_key: + kwargs["api_encryption_key"] = api_encryption_key + if ota_password: + kwargs["ota_password"] = ota_password + + if not wizard_write(**kwargs): return 1 safe_print() diff --git a/tests/integration/fixtures/scheduler_numeric_id_test.yaml b/tests/integration/fixtures/scheduler_numeric_id_test.yaml index bf60f2fda9..1669f026f5 100644 --- a/tests/integration/fixtures/scheduler_numeric_id_test.yaml +++ b/tests/integration/fixtures/scheduler_numeric_id_test.yaml @@ -20,6 +20,9 @@ globals: - id: retry_counter type: int initial_value: '0' + - id: defer_counter + type: int + initial_value: '0' - id: tests_done type: bool initial_value: 'false' @@ -136,11 +139,49 @@ script: App.scheduler.cancel_retry(component1, 6002U); ESP_LOGI("test", "Cancelled numeric retry 6002"); + // Test 12: defer with numeric ID (Component method) + class TestDeferComponent : public Component { + public: + void test_defer_methods() { + // Test defer with uint32_t ID - should execute on next loop + this->defer(7001U, []() { + ESP_LOGI("test", "Component numeric defer 7001 fired"); + id(defer_counter) += 1; + }); + + // Test another defer with numeric ID + this->defer(7002U, []() { + ESP_LOGI("test", "Component numeric defer 7002 fired"); + id(defer_counter) += 1; + }); + } + }; + + static TestDeferComponent test_defer_component; + test_defer_component.test_defer_methods(); + + // Test 13: cancel_defer with numeric ID (Component method) + class TestCancelDeferComponent : public Component { + public: + void test_cancel_defer() { + // Set a defer that should be cancelled + this->defer(8001U, []() { + ESP_LOGE("test", "ERROR: Numeric defer 8001 should have been cancelled"); + }); + // Cancel it immediately + bool cancelled = this->cancel_defer(8001U); + ESP_LOGI("test", "Cancelled numeric defer 8001: %s", cancelled ? "true" : "false"); + } + }; + + static TestCancelDeferComponent test_cancel_defer_component; + test_cancel_defer_component.test_cancel_defer(); + - id: report_results then: - lambda: |- - ESP_LOGI("test", "Final results - Timeouts: %d, Intervals: %d, Retries: %d", - id(timeout_counter), id(interval_counter), id(retry_counter)); + ESP_LOGI("test", "Final results - Timeouts: %d, Intervals: %d, Retries: %d, Defers: %d", + id(timeout_counter), id(interval_counter), id(retry_counter), id(defer_counter)); sensor: - platform: template diff --git a/tests/integration/test_scheduler_numeric_id_test.py b/tests/integration/test_scheduler_numeric_id_test.py index 510256b9a4..c1958db685 100644 --- a/tests/integration/test_scheduler_numeric_id_test.py +++ b/tests/integration/test_scheduler_numeric_id_test.py @@ -19,6 +19,7 @@ async def test_scheduler_numeric_id_test( timeout_count = 0 interval_count = 0 retry_count = 0 + defer_count = 0 # Events for each test completion numeric_timeout_1001_fired = asyncio.Event() @@ -33,6 +34,9 @@ async def test_scheduler_numeric_id_test( max_id_timeout_fired = asyncio.Event() numeric_retry_done = asyncio.Event() numeric_retry_cancelled = asyncio.Event() + numeric_defer_7001_fired = asyncio.Event() + numeric_defer_7002_fired = asyncio.Event() + numeric_defer_cancelled = asyncio.Event() final_results_logged = asyncio.Event() # Track interval counts @@ -40,7 +44,7 @@ async def test_scheduler_numeric_id_test( numeric_retry_count = 0 def on_log_line(line: str) -> None: - nonlocal timeout_count, interval_count, retry_count + nonlocal timeout_count, interval_count, retry_count, defer_count nonlocal numeric_interval_count, numeric_retry_count # Strip ANSI color codes @@ -105,15 +109,27 @@ async def test_scheduler_numeric_id_test( elif "Cancelled numeric retry 6002" in clean_line: numeric_retry_cancelled.set() + # Check for numeric defer tests + elif "Component numeric defer 7001 fired" in clean_line: + numeric_defer_7001_fired.set() + + elif "Component numeric defer 7002 fired" in clean_line: + numeric_defer_7002_fired.set() + + elif "Cancelled numeric defer 8001: true" in clean_line: + numeric_defer_cancelled.set() + # Check for final results elif "Final results" in clean_line: match = re.search( - r"Timeouts: (\d+), Intervals: (\d+), Retries: (\d+)", clean_line + r"Timeouts: (\d+), Intervals: (\d+), Retries: (\d+), Defers: (\d+)", + clean_line, ) if match: timeout_count = int(match.group(1)) interval_count = int(match.group(2)) retry_count = int(match.group(3)) + defer_count = int(match.group(4)) final_results_logged.set() async with ( @@ -201,6 +217,23 @@ async def test_scheduler_numeric_id_test( "Numeric retry 6002 should have been cancelled" ) + # Wait for numeric defer tests + try: + await asyncio.wait_for(numeric_defer_7001_fired.wait(), timeout=0.5) + except TimeoutError: + pytest.fail("Numeric defer 7001 did not fire within 0.5 seconds") + + try: + await asyncio.wait_for(numeric_defer_7002_fired.wait(), timeout=0.5) + except TimeoutError: + pytest.fail("Numeric defer 7002 did not fire within 0.5 seconds") + + # Verify numeric defer was cancelled + try: + await asyncio.wait_for(numeric_defer_cancelled.wait(), timeout=0.5) + except TimeoutError: + pytest.fail("Numeric defer 8001 cancel confirmation not received") + # Wait for final results try: await asyncio.wait_for(final_results_logged.wait(), timeout=3.0) @@ -215,3 +248,4 @@ async def test_scheduler_numeric_id_test( assert retry_count >= 2, ( f"Expected at least 2 retry attempts, got {retry_count}" ) + assert defer_count >= 2, f"Expected at least 2 defer fires, got {defer_count}" diff --git a/tests/unit_tests/test_wizard.py b/tests/unit_tests/test_wizard.py index fd53a0b0b7..eb44c1c20f 100644 --- a/tests/unit_tests/test_wizard.py +++ b/tests/unit_tests/test_wizard.py @@ -25,7 +25,6 @@ def default_config() -> dict[str, Any]: "board": "esp01_1m", "ssid": "test_ssid", "psk": "test_psk", - "password": "", } @@ -37,7 +36,7 @@ def wizard_answers() -> list[str]: "nodemcuv2", # board "SSID", # ssid "psk", # wifi password - "ota_pass", # ota password + "", # ota password (empty for no password) ] @@ -105,16 +104,35 @@ def test_config_file_should_include_ota_when_password_set( default_config: dict[str, Any], ): """ - The Over-The-Air update should be enabled when a password is set + The Over-The-Air update should be enabled when an OTA password is set """ # Given - default_config["password"] = "foo" + default_config["ota_password"] = "foo" # When config = wz.wizard_file(**default_config) # Then assert "ota:" in config + assert 'password: "foo"' in config + + +def test_config_file_should_include_api_encryption_key( + default_config: dict[str, Any], +): + """ + The API encryption key should be included when set + """ + # Given + default_config["api_encryption_key"] = "test_encryption_key_base64==" + + # When + config = wz.wizard_file(**default_config) + + # Then + assert "api:" in config + assert "encryption:" in config + assert 'key: "test_encryption_key_base64=="' in config def test_wizard_write_sets_platform( @@ -556,3 +574,61 @@ def test_wizard_write_protects_existing_config( # Then assert result is False # Should return False when file exists assert config_file.read_text() == original_content + + +def test_wizard_accepts_ota_password( + tmp_path: Path, monkeypatch: MonkeyPatch, wizard_answers: list[str] +): + """ + The wizard should pass ota_password to wizard_write when the user provides one + """ + + # Given + wizard_answers[5] = "my_ota_password" # Set OTA password + config_file = tmp_path / "test.yaml" + input_mock = MagicMock(side_effect=wizard_answers) + monkeypatch.setattr("builtins.input", input_mock) + monkeypatch.setattr(wz, "safe_print", lambda t=None, end=None: 0) + monkeypatch.setattr(wz, "sleep", lambda _: 0) + wizard_write_mock = MagicMock(return_value=True) + monkeypatch.setattr(wz, "wizard_write", wizard_write_mock) + + # When + retval = wz.wizard(config_file) + + # Then + assert retval == 0 + call_kwargs = wizard_write_mock.call_args.kwargs + assert "ota_password" in call_kwargs + assert call_kwargs["ota_password"] == "my_ota_password" + + +def test_wizard_accepts_rpipico_board(tmp_path: Path, monkeypatch: MonkeyPatch): + """ + The wizard should handle rpipico board which doesn't support WiFi. + This tests the branch where api_encryption_key is None. + """ + + # Given + wizard_answers_rp2040 = [ + "test-node", # Name of the node + "RP2040", # platform + "rpipico", # board (no WiFi support) + ] + config_file = tmp_path / "test.yaml" + input_mock = MagicMock(side_effect=wizard_answers_rp2040) + monkeypatch.setattr("builtins.input", input_mock) + monkeypatch.setattr(wz, "safe_print", lambda t=None, end=None: 0) + monkeypatch.setattr(wz, "sleep", lambda _: 0) + wizard_write_mock = MagicMock(return_value=True) + monkeypatch.setattr(wz, "wizard_write", wizard_write_mock) + + # When + retval = wz.wizard(config_file) + + # Then + assert retval == 0 + call_kwargs = wizard_write_mock.call_args.kwargs + # rpipico doesn't support WiFi, so no api_encryption_key or ota_password + assert "api_encryption_key" not in call_kwargs + assert "ota_password" not in call_kwargs