From 18991686ab389d153c2af110daee17d8124fcf31 Mon Sep 17 00:00:00 2001 From: rwrozelle Date: Mon, 2 Feb 2026 10:48:08 -0500 Subject: [PATCH 1/8] [mqtt] resolve warnings related to use of ip.str() (#13719) --- esphome/components/mqtt/mqtt_backend_esp32.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/mqtt/mqtt_backend_esp32.h b/esphome/components/mqtt/mqtt_backend_esp32.h index bd2d2a67b2..adba0cf004 100644 --- a/esphome/components/mqtt/mqtt_backend_esp32.h +++ b/esphome/components/mqtt/mqtt_backend_esp32.h @@ -139,7 +139,8 @@ class MQTTBackendESP32 final : public MQTTBackend { this->lwt_retain_ = retain; } void set_server(network::IPAddress ip, uint16_t port) final { - this->host_ = ip.str(); + char ip_buf[network::IP_ADDRESS_BUFFER_SIZE]; + this->host_ = ip.str_to(ip_buf); this->port_ = port; } void set_server(const char *host, uint16_t port) final { From aa8ccfc32b0e2f7c347911f27dc8dce922f2b16b Mon Sep 17 00:00:00 2001 From: Roger Fachini Date: Mon, 2 Feb 2026 08:00:11 -0800 Subject: [PATCH 2/8] [ethernet] Add on_connect and on_disconnect triggers (#13677) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- esphome/components/ethernet/__init__.py | 18 +++++++++++++++++- .../components/ethernet/ethernet_component.cpp | 9 +++++++++ .../components/ethernet/ethernet_component.h | 13 +++++++++++++ esphome/core/defines.h | 2 ++ tests/components/ethernet/common-dm9051.yaml | 4 ++++ tests/components/ethernet/common-dp83848.yaml | 4 ++++ tests/components/ethernet/common-ip101.yaml | 4 ++++ tests/components/ethernet/common-jl1101.yaml | 4 ++++ tests/components/ethernet/common-ksz8081.yaml | 4 ++++ .../components/ethernet/common-ksz8081rna.yaml | 4 ++++ tests/components/ethernet/common-lan8670.yaml | 4 ++++ tests/components/ethernet/common-lan8720.yaml | 4 ++++ tests/components/ethernet/common-openeth.yaml | 4 ++++ tests/components/ethernet/common-rtl8201.yaml | 4 ++++ tests/components/ethernet/common-w5500.yaml | 4 ++++ 15 files changed, 85 insertions(+), 1 deletion(-) diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 8d4a1aaf8e..23436cc5be 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -1,6 +1,6 @@ import logging -from esphome import pins +from esphome import automation, pins import esphome.codegen as cg from esphome.components.esp32 import ( VARIANT_ESP32, @@ -35,6 +35,8 @@ from esphome.const import ( CONF_MODE, CONF_MOSI_PIN, CONF_NUMBER, + CONF_ON_CONNECT, + CONF_ON_DISCONNECT, CONF_PAGE_ID, CONF_PIN, CONF_POLLING_INTERVAL, @@ -237,6 +239,8 @@ BASE_SCHEMA = cv.Schema( cv.Optional(CONF_DOMAIN, default=".local"): cv.domain_name, cv.Optional(CONF_USE_ADDRESS): cv.string_strict, cv.Optional(CONF_MAC_ADDRESS): cv.mac_address, + cv.Optional(CONF_ON_CONNECT): automation.validate_automation(single=True), + cv.Optional(CONF_ON_DISCONNECT): automation.validate_automation(single=True), } ).extend(cv.COMPONENT_SCHEMA) @@ -430,6 +434,18 @@ async def to_code(config): if CORE.using_arduino: cg.add_library("WiFi", None) + if on_connect_config := config.get(CONF_ON_CONNECT): + cg.add_define("USE_ETHERNET_CONNECT_TRIGGER") + await automation.build_automation( + var.get_connect_trigger(), [], on_connect_config + ) + + if on_disconnect_config := config.get(CONF_ON_DISCONNECT): + cg.add_define("USE_ETHERNET_DISCONNECT_TRIGGER") + await automation.build_automation( + var.get_disconnect_trigger(), [], on_disconnect_config + ) + CORE.add_job(final_step) diff --git a/esphome/components/ethernet/ethernet_component.cpp b/esphome/components/ethernet/ethernet_component.cpp index 70f8ce1204..af7fed608b 100644 --- a/esphome/components/ethernet/ethernet_component.cpp +++ b/esphome/components/ethernet/ethernet_component.cpp @@ -309,6 +309,9 @@ void EthernetComponent::loop() { this->dump_connect_params_(); this->status_clear_warning(); +#ifdef USE_ETHERNET_CONNECT_TRIGGER + this->connect_trigger_.trigger(); +#endif } else if (now - this->connect_begin_ > 15000) { ESP_LOGW(TAG, "Connecting failed; reconnecting"); this->start_connect_(); @@ -318,10 +321,16 @@ void EthernetComponent::loop() { if (!this->started_) { ESP_LOGI(TAG, "Stopped connection"); this->state_ = EthernetComponentState::STOPPED; +#ifdef USE_ETHERNET_DISCONNECT_TRIGGER + this->disconnect_trigger_.trigger(); +#endif } else if (!this->connected_) { ESP_LOGW(TAG, "Connection lost; reconnecting"); this->state_ = EthernetComponentState::CONNECTING; this->start_connect_(); +#ifdef USE_ETHERNET_DISCONNECT_TRIGGER + this->disconnect_trigger_.trigger(); +#endif } else { this->finish_connect_(); // When connected and stable, disable the loop to save CPU cycles diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index 34380047d1..5a2869c5a7 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -4,6 +4,7 @@ #include "esphome/core/defines.h" #include "esphome/core/hal.h" #include "esphome/core/helpers.h" +#include "esphome/core/automation.h" #include "esphome/components/network/ip_address.h" #ifdef USE_ESP32 @@ -119,6 +120,12 @@ class EthernetComponent : public Component { void add_ip_state_listener(EthernetIPStateListener *listener) { this->ip_state_listeners_.push_back(listener); } #endif +#ifdef USE_ETHERNET_CONNECT_TRIGGER + Trigger<> *get_connect_trigger() { return &this->connect_trigger_; } +#endif +#ifdef USE_ETHERNET_DISCONNECT_TRIGGER + Trigger<> *get_disconnect_trigger() { return &this->disconnect_trigger_; } +#endif protected: static void eth_event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, void *event_data); static void got_ip_event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, void *event_data); @@ -190,6 +197,12 @@ class EthernetComponent : public Component { StaticVector ip_state_listeners_; #endif +#ifdef USE_ETHERNET_CONNECT_TRIGGER + Trigger<> connect_trigger_; +#endif +#ifdef USE_ETHERNET_DISCONNECT_TRIGGER + Trigger<> disconnect_trigger_; +#endif private: // Stores a pointer to a string literal (static storage duration). // ONLY set from Python-generated code with string literals - never dynamic strings. diff --git a/esphome/core/defines.h b/esphome/core/defines.h index e98cdd0ba0..1edc648084 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -240,6 +240,8 @@ #define USE_ETHERNET_KSZ8081 #define USE_ETHERNET_MANUAL_IP #define USE_ETHERNET_IP_STATE_LISTENERS +#define USE_ETHERNET_CONNECT_TRIGGER +#define USE_ETHERNET_DISCONNECT_TRIGGER #define ESPHOME_ETHERNET_IP_STATE_LISTENERS 2 #endif diff --git a/tests/components/ethernet/common-dm9051.yaml b/tests/components/ethernet/common-dm9051.yaml index 4526e7732d..bb8c74b820 100644 --- a/tests/components/ethernet/common-dm9051.yaml +++ b/tests/components/ethernet/common-dm9051.yaml @@ -13,3 +13,7 @@ ethernet: subnet: 255.255.255.0 domain: .local mac_address: "02:AA:BB:CC:DD:01" + on_connect: + - logger.log: "Ethernet connected!" + on_disconnect: + - logger.log: "Ethernet disconnected!" diff --git a/tests/components/ethernet/common-dp83848.yaml b/tests/components/ethernet/common-dp83848.yaml index f9069c5fb9..809613c79d 100644 --- a/tests/components/ethernet/common-dp83848.yaml +++ b/tests/components/ethernet/common-dp83848.yaml @@ -13,3 +13,7 @@ ethernet: subnet: 255.255.255.0 domain: .local mac_address: "02:AA:BB:CC:DD:01" + on_connect: + - logger.log: "Ethernet connected!" + on_disconnect: + - logger.log: "Ethernet disconnected!" diff --git a/tests/components/ethernet/common-ip101.yaml b/tests/components/ethernet/common-ip101.yaml index cea7a5cc35..41716a7850 100644 --- a/tests/components/ethernet/common-ip101.yaml +++ b/tests/components/ethernet/common-ip101.yaml @@ -13,3 +13,7 @@ ethernet: subnet: 255.255.255.0 domain: .local mac_address: "02:AA:BB:CC:DD:01" + on_connect: + - logger.log: "Ethernet connected!" + on_disconnect: + - logger.log: "Ethernet disconnected!" diff --git a/tests/components/ethernet/common-jl1101.yaml b/tests/components/ethernet/common-jl1101.yaml index 7b0a2dfdc4..d70a576c81 100644 --- a/tests/components/ethernet/common-jl1101.yaml +++ b/tests/components/ethernet/common-jl1101.yaml @@ -13,3 +13,7 @@ ethernet: subnet: 255.255.255.0 domain: .local mac_address: "02:AA:BB:CC:DD:01" + on_connect: + - logger.log: "Ethernet connected!" + on_disconnect: + - logger.log: "Ethernet disconnected!" diff --git a/tests/components/ethernet/common-ksz8081.yaml b/tests/components/ethernet/common-ksz8081.yaml index 65541832c2..e2add8d370 100644 --- a/tests/components/ethernet/common-ksz8081.yaml +++ b/tests/components/ethernet/common-ksz8081.yaml @@ -13,3 +13,7 @@ ethernet: subnet: 255.255.255.0 domain: .local mac_address: "02:AA:BB:CC:DD:01" + on_connect: + - logger.log: "Ethernet connected!" + on_disconnect: + - logger.log: "Ethernet disconnected!" diff --git a/tests/components/ethernet/common-ksz8081rna.yaml b/tests/components/ethernet/common-ksz8081rna.yaml index f04cba15b2..1bb404f720 100644 --- a/tests/components/ethernet/common-ksz8081rna.yaml +++ b/tests/components/ethernet/common-ksz8081rna.yaml @@ -13,3 +13,7 @@ ethernet: subnet: 255.255.255.0 domain: .local mac_address: "02:AA:BB:CC:DD:01" + on_connect: + - logger.log: "Ethernet connected!" + on_disconnect: + - logger.log: "Ethernet disconnected!" diff --git a/tests/components/ethernet/common-lan8670.yaml b/tests/components/ethernet/common-lan8670.yaml index fb751ebd23..ae4953974c 100644 --- a/tests/components/ethernet/common-lan8670.yaml +++ b/tests/components/ethernet/common-lan8670.yaml @@ -12,3 +12,7 @@ ethernet: gateway: 192.168.178.1 subnet: 255.255.255.0 domain: .local + on_connect: + - logger.log: "Ethernet connected!" + on_disconnect: + - logger.log: "Ethernet disconnected!" diff --git a/tests/components/ethernet/common-lan8720.yaml b/tests/components/ethernet/common-lan8720.yaml index 838d57df28..742800fdf4 100644 --- a/tests/components/ethernet/common-lan8720.yaml +++ b/tests/components/ethernet/common-lan8720.yaml @@ -13,3 +13,7 @@ ethernet: subnet: 255.255.255.0 domain: .local mac_address: "02:AA:BB:CC:DD:01" + on_connect: + - logger.log: "Ethernet connected!" + on_disconnect: + - logger.log: "Ethernet disconnected!" diff --git a/tests/components/ethernet/common-openeth.yaml b/tests/components/ethernet/common-openeth.yaml index fbb7579598..26595dbc52 100644 --- a/tests/components/ethernet/common-openeth.yaml +++ b/tests/components/ethernet/common-openeth.yaml @@ -1,2 +1,6 @@ ethernet: type: OPENETH + on_connect: + - logger.log: "Ethernet connected!" + on_disconnect: + - logger.log: "Ethernet disconnected!" diff --git a/tests/components/ethernet/common-rtl8201.yaml b/tests/components/ethernet/common-rtl8201.yaml index 0e7cbe73c6..d5a60f6e98 100644 --- a/tests/components/ethernet/common-rtl8201.yaml +++ b/tests/components/ethernet/common-rtl8201.yaml @@ -13,3 +13,7 @@ ethernet: subnet: 255.255.255.0 domain: .local mac_address: "02:AA:BB:CC:DD:01" + on_connect: + - logger.log: "Ethernet connected!" + on_disconnect: + - logger.log: "Ethernet disconnected!" diff --git a/tests/components/ethernet/common-w5500.yaml b/tests/components/ethernet/common-w5500.yaml index b3e96f000d..1f8b8650dd 100644 --- a/tests/components/ethernet/common-w5500.yaml +++ b/tests/components/ethernet/common-w5500.yaml @@ -13,3 +13,7 @@ ethernet: subnet: 255.255.255.0 domain: .local mac_address: "02:AA:BB:CC:DD:01" + on_connect: + - logger.log: "Ethernet connected!" + on_disconnect: + - logger.log: "Ethernet disconnected!" From 6892805094426d903b0dd2650ef8531e861f0271 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 2 Feb 2026 22:00:46 +0100 Subject: [PATCH 3/8] [api] Align water_heater_command with standard entity command pattern (#13655) --- esphome/components/api/api.proto | 1 + esphome/components/api/api_connection.cpp | 2 +- esphome/components/api/api_connection.h | 2 +- esphome/components/api/api_pb2_service.cpp | 5 +++++ esphome/components/api/api_pb2_service.h | 6 ++++++ 5 files changed, 14 insertions(+), 2 deletions(-) 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 From 848c2371590f1839704f1ddeeeda65b539c7506e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 2 Feb 2026 22:05:27 +0100 Subject: [PATCH 4/8] [time] Use lazy callback for time sync to save 8 bytes (#13652) --- esphome/components/time/real_time_clock.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 { From 4f0894e970468d1ecf375866d25d46977903e13c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 2 Feb 2026 22:05:39 +0100 Subject: [PATCH 5/8] [analyze-memory] Add top 30 largest symbols to report (#13673) --- esphome/analyze_memory/cli.py | 40 +++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) 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") From c089d9aeac70588cf56b20fa9c353cc081060839 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 2 Feb 2026 22:21:52 +0100 Subject: [PATCH 6/8] [esp32_hosted] Replace sscanf with strtol for version parsing (#13658) --- .../update/esp32_hosted_update.cpp | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) 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: From 1119003eb5208206eae7526246968dd703ca1dd2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 2 Feb 2026 22:22:11 +0100 Subject: [PATCH 7/8] [core] Add missing uint32_t ID overloads for defer() and cancel_defer() (#13720) --- esphome/core/component.cpp | 4 ++ esphome/core/component.h | 4 ++ .../fixtures/scheduler_numeric_id_test.yaml | 45 ++++++++++++++++++- .../test_scheduler_numeric_id_test.py | 38 +++++++++++++++- 4 files changed, 87 insertions(+), 4 deletions(-) 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/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}" From da947d060f9e14b9f8b241290e80b5cf2d405aae Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 2 Feb 2026 19:20:24 -0500 Subject: [PATCH 8/8] [wizard] Use API encryption key instead of deprecated password (#13634) Co-authored-by: Claude Opus 4.5 --- esphome/wizard.py | 66 +++++++++++++++++--------- tests/unit_tests/test_wizard.py | 84 +++++++++++++++++++++++++++++++-- 2 files changed, 125 insertions(+), 25 deletions(-) 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/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