From 9de7df7b5b3c142be008d38ef5a5e24e3b08cc71 Mon Sep 17 00:00:00 2001 From: David Woodhouse Date: Thu, 18 Dec 2025 00:06:52 +0000 Subject: [PATCH 01/33] Add build info to image (#12425) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/__main__.py | 39 + esphome/components/api/api_connection.cpp | 5 +- esphome/components/mqtt/mqtt_component.cpp | 10 +- esphome/components/sen5x/sen5x.cpp | 7 +- esphome/components/sgp30/sgp30.cpp | 6 +- esphome/components/sgp4x/sgp4x.cpp | 7 +- .../version/version_text_sensor.cpp | 26 +- esphome/components/wifi/wifi_component.cpp | 2 +- esphome/core/__init__.py | 18 + esphome/core/application.cpp | 16 +- esphome/core/application.h | 35 +- esphome/core/build_info_data.h | 10 + esphome/core/config.py | 1 - esphome/core/helpers.cpp | 11 - esphome/core/helpers.h | 12 +- esphome/core/progmem.h | 2 + esphome/helpers.py | 11 +- esphome/writer.py | 109 ++- esphome/yaml_util.py | 12 +- tests/dummy_main.cpp | 2 +- tests/integration/fixtures/build_info.yaml | 31 + tests/integration/test_build_info.py | 117 +++ tests/unit_tests/conftest.py | 1 + tests/unit_tests/core/test_config.py | 71 ++ tests/unit_tests/test_main.py | 197 +++++ tests/unit_tests/test_writer.py | 725 ++++++++++++++++++ tests/unit_tests/test_yaml_util.py | 28 + 27 files changed, 1458 insertions(+), 53 deletions(-) create mode 100644 esphome/core/build_info_data.h create mode 100644 tests/integration/fixtures/build_info.yaml create mode 100644 tests/integration/test_build_info.py diff --git a/esphome/__main__.py b/esphome/__main__.py index 55fbbc6c8a..119ab957a3 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -518,10 +518,49 @@ def compile_program(args: ArgsProtocol, config: ConfigType) -> int: rc = platformio_api.run_compile(config, CORE.verbose) if rc != 0: return rc + + # Check if firmware was rebuilt and emit build_info + create manifest + _check_and_emit_build_info() + idedata = platformio_api.get_idedata(config) return 0 if idedata is not None else 1 +def _check_and_emit_build_info() -> None: + """Check if firmware was rebuilt and emit build_info.""" + import json + + firmware_path = CORE.firmware_bin + build_info_json_path = CORE.relative_build_path("build_info.json") + + # Check if both files exist + if not firmware_path.exists() or not build_info_json_path.exists(): + return + + # Check if firmware is newer than build_info (indicating a relink occurred) + if firmware_path.stat().st_mtime <= build_info_json_path.stat().st_mtime: + return + + # Read build_info from JSON + try: + with open(build_info_json_path, encoding="utf-8") as f: + build_info = json.load(f) + except (OSError, json.JSONDecodeError) as e: + _LOGGER.debug("Failed to read build_info: %s", e) + return + + config_hash = build_info.get("config_hash") + build_time_str = build_info.get("build_time_str") + + if config_hash is None or build_time_str is None: + return + + # Emit build_info with human-readable time + _LOGGER.info( + "Build Info: config_hash=0x%08x build_time_str=%s", config_hash, build_time_str + ) + + def upload_using_esptool( config: ConfigType, port: str, file: str, speed: int ) -> str | int: diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 5186e5afda..85f4566f3c 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1472,7 +1472,10 @@ bool APIConnection::send_device_info_response(const DeviceInfoRequest &msg) { resp.set_esphome_version(ESPHOME_VERSION_REF); - resp.set_compilation_time(App.get_compilation_time_ref()); + // Stack buffer for build time string + char build_time_str[Application::BUILD_TIME_STR_SIZE]; + App.get_build_time_string(build_time_str); + resp.set_compilation_time(StringRef(build_time_str)); // Manufacturer string - define once, handle ESP8266 PROGMEM separately #if defined(USE_ESP8266) || defined(USE_ESP32) diff --git a/esphome/components/mqtt/mqtt_component.cpp b/esphome/components/mqtt/mqtt_component.cpp index 5d2bedae79..9db1b1f7c8 100644 --- a/esphome/components/mqtt/mqtt_component.cpp +++ b/esphome/components/mqtt/mqtt_component.cpp @@ -154,7 +154,15 @@ bool MQTTComponent::send_discovery_() { device_info[MQTT_DEVICE_MANUFACTURER] = model == nullptr ? ESPHOME_PROJECT_NAME : std::string(ESPHOME_PROJECT_NAME, model - ESPHOME_PROJECT_NAME); #else - device_info[MQTT_DEVICE_SW_VERSION] = ESPHOME_VERSION " (" + App.get_compilation_time_ref() + ")"; + static const char ver_fmt[] PROGMEM = ESPHOME_VERSION " (config hash 0x%08" PRIx32 ")"; +#ifdef USE_ESP8266 + char fmt_buf[sizeof(ver_fmt)]; + strcpy_P(fmt_buf, ver_fmt); + const char *fmt = fmt_buf; +#else + const char *fmt = ver_fmt; +#endif + device_info[MQTT_DEVICE_SW_VERSION] = str_sprintf(fmt, App.get_config_hash()); device_info[MQTT_DEVICE_MODEL] = ESPHOME_BOARD; #if defined(USE_ESP8266) || defined(USE_ESP32) device_info[MQTT_DEVICE_MANUFACTURER] = "Espressif"; diff --git a/esphome/components/sen5x/sen5x.cpp b/esphome/components/sen5x/sen5x.cpp index ffb9e2bc02..c72ccf2595 100644 --- a/esphome/components/sen5x/sen5x.cpp +++ b/esphome/components/sen5x/sen5x.cpp @@ -1,4 +1,5 @@ #include "sen5x.h" +#include "esphome/core/application.h" #include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -154,10 +155,10 @@ void SEN5XComponent::setup() { if (this->voc_sensor_ && this->store_baseline_) { uint32_t combined_serial = encode_uint24(this->serial_number_[0], this->serial_number_[1], this->serial_number_[2]); - // Hash with compilation time and serial number + // Hash with config hash, version, and serial number // This ensures the baseline storage is cleared after OTA - // Serial numbers are unique to each sensor, so mulitple sensors can be used without conflict - uint32_t hash = fnv1_hash(App.get_compilation_time_ref() + std::to_string(combined_serial)); + // Serial numbers are unique to each sensor, so multiple sensors can be used without conflict + uint32_t hash = fnv1a_hash_extend(App.get_config_version_hash(), std::to_string(combined_serial)); this->pref_ = global_preferences->make_preference(hash, true); if (this->pref_.load(&this->voc_baselines_storage_)) { diff --git a/esphome/components/sgp30/sgp30.cpp b/esphome/components/sgp30/sgp30.cpp index fa548ce94e..1326356437 100644 --- a/esphome/components/sgp30/sgp30.cpp +++ b/esphome/components/sgp30/sgp30.cpp @@ -72,10 +72,10 @@ void SGP30Component::setup() { return; } - // Hash with compilation time and serial number + // Hash with config hash, version, and serial number // This ensures the baseline storage is cleared after OTA - // Serial numbers are unique to each sensor, so mulitple sensors can be used without conflict - uint32_t hash = fnv1_hash(App.get_compilation_time_ref() + std::to_string(this->serial_number_)); + // Serial numbers are unique to each sensor, so multiple sensors can be used without conflict + uint32_t hash = fnv1a_hash_extend(App.get_config_version_hash(), std::to_string(this->serial_number_)); this->pref_ = global_preferences->make_preference(hash, true); if (this->store_baseline_ && this->pref_.load(&this->baselines_storage_)) { diff --git a/esphome/components/sgp4x/sgp4x.cpp b/esphome/components/sgp4x/sgp4x.cpp index a0c957d608..7c0f51c782 100644 --- a/esphome/components/sgp4x/sgp4x.cpp +++ b/esphome/components/sgp4x/sgp4x.cpp @@ -1,4 +1,5 @@ #include "sgp4x.h" +#include "esphome/core/application.h" #include "esphome/core/log.h" #include "esphome/core/hal.h" #include @@ -56,10 +57,10 @@ void SGP4xComponent::setup() { ESP_LOGD(TAG, "Version 0x%0X", featureset); if (this->store_baseline_) { - // Hash with compilation time and serial number + // Hash with config hash, version, and serial number // This ensures the baseline storage is cleared after OTA - // Serial numbers are unique to each sensor, so mulitple sensors can be used without conflict - uint32_t hash = fnv1_hash(App.get_compilation_time_ref() + std::to_string(this->serial_number_)); + // Serial numbers are unique to each sensor, so multiple sensors can be used without conflict + uint32_t hash = fnv1a_hash_extend(App.get_config_version_hash(), std::to_string(this->serial_number_)); this->pref_ = global_preferences->make_preference(hash, true); if (this->pref_.load(&this->voc_baselines_storage_)) { diff --git a/esphome/components/version/version_text_sensor.cpp b/esphome/components/version/version_text_sensor.cpp index 78d0fb501b..584b8abfb2 100644 --- a/esphome/components/version/version_text_sensor.cpp +++ b/esphome/components/version/version_text_sensor.cpp @@ -1,8 +1,9 @@ #include "version_text_sensor.h" -#include "esphome/core/log.h" #include "esphome/core/application.h" +#include "esphome/core/log.h" #include "esphome/core/version.h" #include "esphome/core/helpers.h" +#include "esphome/core/progmem.h" namespace esphome { namespace version { @@ -10,11 +11,26 @@ namespace version { static const char *const TAG = "version.text_sensor"; void VersionTextSensor::setup() { - if (this->hide_timestamp_) { - this->publish_state(ESPHOME_VERSION); - } else { - this->publish_state(str_sprintf(ESPHOME_VERSION " %s", App.get_compilation_time_ref().c_str())); + static const char PREFIX[] PROGMEM = ESPHOME_VERSION " (config hash 0x"; + static const char BUILT_STR[] PROGMEM = ", built "; + // Buffer size: PREFIX + 8 hex chars + BUILT_STR + BUILD_TIME_STR_SIZE + ")" + null + constexpr size_t buf_size = sizeof(PREFIX) + 8 + sizeof(BUILT_STR) + esphome::Application::BUILD_TIME_STR_SIZE + 2; + char version_str[buf_size]; + + ESPHOME_strncpy_P(version_str, PREFIX, sizeof(version_str)); + + size_t len = strlen(version_str); + snprintf(version_str + len, sizeof(version_str) - len, "%08" PRIx32, App.get_config_hash()); + + if (!this->hide_timestamp_) { + size_t len = strlen(version_str); + ESPHOME_strncat_P(version_str, BUILT_STR, sizeof(version_str) - len - 1); + ESPHOME_strncat_P(version_str, ESPHOME_BUILD_TIME_STR, sizeof(version_str) - strlen(version_str) - 1); } + + strncat(version_str, ")", sizeof(version_str) - strlen(version_str) - 1); + version_str[sizeof(version_str) - 1] = '\0'; + this->publish_state(version_str); } float VersionTextSensor::get_setup_priority() const { return setup_priority::DATA; } void VersionTextSensor::set_hide_timestamp(bool hide_timestamp) { this->hide_timestamp_ = hide_timestamp; } diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index a5e8c4a59d..a550aa679d 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -375,7 +375,7 @@ void WiFiComponent::start() { get_mac_address_pretty_into_buffer(mac_s)); this->last_connected_ = millis(); - uint32_t hash = this->has_sta() ? fnv1_hash(App.get_compilation_time_ref().c_str()) : 88491487UL; + uint32_t hash = this->has_sta() ? App.get_config_version_hash() : 88491487UL; this->pref_ = global_preferences->make_preference(hash, true); #ifdef USE_WIFI_FAST_CONNECT diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 721cd5787d..ad9844a3bf 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -608,6 +608,8 @@ class EsphomeCore: self.current_component: str | None = None # Address cache for DNS and mDNS lookups from command line arguments self.address_cache: AddressCache | None = None + # Cached config hash (computed lazily) + self._config_hash: int | None = None def reset(self): from esphome.pins import PIN_SCHEMA_REGISTRY @@ -636,6 +638,7 @@ class EsphomeCore: self.unique_ids = {} self.current_component = None self.address_cache = None + self._config_hash = None PIN_SCHEMA_REGISTRY.reset() @contextmanager @@ -685,6 +688,21 @@ class EsphomeCore: return None + @property + def config_hash(self) -> int: + """Get the FNV-1a 32-bit hash of the config. + + The hash is computed lazily and cached for performance. + Uses sort_keys=True to ensure deterministic ordering. + """ + if self._config_hash is None: + from esphome import yaml_util + from esphome.helpers import fnv1a_32bit_hash + + config_str = yaml_util.dump(self.config, show_secrets=True, sort_keys=True) + self._config_hash = fnv1a_32bit_hash(config_str) + return self._config_hash + @property def config_dir(self) -> Path: if self.config_path.is_dir(): diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index a85d671a07..4c9cc6b2b6 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -1,5 +1,12 @@ #include "esphome/core/application.h" +#include "esphome/core/build_info_data.h" #include "esphome/core/log.h" +#include "esphome/core/progmem.h" +#include + +#ifdef USE_ESP8266 +#include +#endif #include "esphome/core/version.h" #include "esphome/core/hal.h" #include @@ -191,7 +198,9 @@ void Application::loop() { if (this->dump_config_at_ < this->components_.size()) { if (this->dump_config_at_ == 0) { - ESP_LOGI(TAG, "ESPHome version " ESPHOME_VERSION " compiled on %s", this->compilation_time_); + char build_time_str[Application::BUILD_TIME_STR_SIZE]; + this->get_build_time_string(build_time_str); + ESP_LOGI(TAG, "ESPHome version " ESPHOME_VERSION " compiled on %s", build_time_str); #ifdef ESPHOME_PROJECT_NAME ESP_LOGI(TAG, "Project " ESPHOME_PROJECT_NAME " version " ESPHOME_PROJECT_VERSION); #endif @@ -711,4 +720,9 @@ void Application::wake_loop_threadsafe() { } #endif // defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) +void Application::get_build_time_string(std::span buffer) { + ESPHOME_strncpy_P(buffer.data(), ESPHOME_BUILD_TIME_STR, buffer.size()); + buffer[buffer.size() - 1] = '\0'; +} + } // namespace esphome diff --git a/esphome/core/application.h b/esphome/core/application.h index 8e2035b7c5..f462553a81 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -1,9 +1,12 @@ #pragma once #include +#include #include +#include #include #include +#include "esphome/core/build_info_data.h" #include "esphome/core/component.h" #include "esphome/core/defines.h" #include "esphome/core/hal.h" @@ -11,6 +14,7 @@ #include "esphome/core/preferences.h" #include "esphome/core/scheduler.h" #include "esphome/core/string_ref.h" +#include "esphome/core/version.h" #ifdef USE_DEVICES #include "esphome/core/device.h" @@ -101,7 +105,7 @@ static const uint32_t TEARDOWN_TIMEOUT_REBOOT_MS = 1000; // 1 second for quick class Application { public: void pre_setup(const std::string &name, const std::string &friendly_name, const char *comment, - const char *compilation_time, bool name_add_mac_suffix) { + bool name_add_mac_suffix) { arch_init(); this->name_add_mac_suffix_ = name_add_mac_suffix; if (name_add_mac_suffix) { @@ -121,7 +125,6 @@ class Application { this->friendly_name_ = friendly_name; } this->comment_ = comment; - this->compilation_time_ = compilation_time; } #ifdef USE_DEVICES @@ -261,9 +264,30 @@ class Application { bool is_name_add_mac_suffix_enabled() const { return this->name_add_mac_suffix_; } - std::string get_compilation_time() const { return this->compilation_time_; } - /// Get the compilation time as StringRef (for API usage) - StringRef get_compilation_time_ref() const { return StringRef(this->compilation_time_); } + /// Size of buffer required for build time string (including null terminator) + static constexpr size_t BUILD_TIME_STR_SIZE = 26; + + /// Get the config hash as a 32-bit integer + constexpr uint32_t get_config_hash() { return ESPHOME_CONFIG_HASH; } + + /// Get the config hash extended with ESPHome version + constexpr uint32_t get_config_version_hash() { return fnv1a_hash_extend(ESPHOME_CONFIG_HASH, ESPHOME_VERSION); } + + /// Get the build time as a Unix timestamp + constexpr time_t get_build_time() { return ESPHOME_BUILD_TIME; } + + /// Copy the build time string into the provided buffer + /// Buffer must be BUILD_TIME_STR_SIZE bytes (compile-time enforced) + void get_build_time_string(std::span buffer); + + /// Get the build time as a string (deprecated, use get_build_time_string() instead) + // Remove before 2026.7.0 + ESPDEPRECATED("Use get_build_time_string() instead. Removed in 2026.7.0", "2026.1.0") + std::string get_compilation_time() { + char buf[BUILD_TIME_STR_SIZE]; + this->get_build_time_string(buf); + return std::string(buf); + } /// Get the cached time in milliseconds from when the current component started its loop execution inline uint32_t IRAM_ATTR HOT get_loop_component_start_time() const { return this->loop_component_start_time_; } @@ -478,7 +502,6 @@ class Application { // Pointer-sized members first Component *current_component_{nullptr}; const char *comment_{nullptr}; - const char *compilation_time_{nullptr}; // std::vector (3 pointers each: begin, end, capacity) // Partitioned vector design for looping components diff --git a/esphome/core/build_info_data.h b/esphome/core/build_info_data.h new file mode 100644 index 0000000000..5e424ffaca --- /dev/null +++ b/esphome/core/build_info_data.h @@ -0,0 +1,10 @@ +#pragma once + +// This file is not used by the runtime, instead, a version is generated during +// compilation with the actual build info values. +// +// This file is only used by static analyzers and IDEs. + +#define ESPHOME_CONFIG_HASH 0x12345678U // NOLINT +#define ESPHOME_BUILD_TIME 1700000000 // NOLINT +static const char ESPHOME_BUILD_TIME_STR[] = "2024-01-01 00:00:00 +0000"; diff --git a/esphome/core/config.py b/esphome/core/config.py index 3adaf7eb9e..97157b6f92 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -501,7 +501,6 @@ async def to_code(config: ConfigType) -> None: config[CONF_NAME], config[CONF_FRIENDLY_NAME], config.get(CONF_COMMENT, ""), - cg.RawExpression('__DATE__ ", " __TIME__'), config[CONF_NAME_ADD_MAC_SUFFIX], ) ) diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 84079227e1..bbe59e53f1 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -155,17 +155,6 @@ uint32_t fnv1_hash(const char *str) { return hash; } -// FNV-1a hash - preferred for new code -uint32_t fnv1a_hash_extend(uint32_t hash, const char *str) { - if (str) { - while (*str) { - hash ^= *str++; - hash *= FNV1_PRIME; - } - } - return hash; -} - float random_float() { return static_cast(random_uint32()) / static_cast(UINT32_MAX); } // Strings diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index cd9efef213..f9dcfccb45 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -388,12 +388,20 @@ constexpr uint32_t FNV1_OFFSET_BASIS = 2166136261UL; constexpr uint32_t FNV1_PRIME = 16777619UL; /// Extend a FNV-1a hash with additional string data. -uint32_t fnv1a_hash_extend(uint32_t hash, const char *str); +constexpr uint32_t fnv1a_hash_extend(uint32_t hash, const char *str) { + if (str) { + while (*str) { + hash ^= *str++; + hash *= FNV1_PRIME; + } + } + return hash; +} inline uint32_t fnv1a_hash_extend(uint32_t hash, const std::string &str) { return fnv1a_hash_extend(hash, str.c_str()); } /// Calculate a FNV-1a hash of \p str. -inline uint32_t fnv1a_hash(const char *str) { return fnv1a_hash_extend(FNV1_OFFSET_BASIS, str); } +constexpr uint32_t fnv1a_hash(const char *str) { return fnv1a_hash_extend(FNV1_OFFSET_BASIS, str); } inline uint32_t fnv1a_hash(const std::string &str) { return fnv1a_hash(str.c_str()); } /// Return a random 32-bit unsigned integer. diff --git a/esphome/core/progmem.h b/esphome/core/progmem.h index f9508945e8..d1594f47e7 100644 --- a/esphome/core/progmem.h +++ b/esphome/core/progmem.h @@ -9,8 +9,10 @@ #define ESPHOME_F(string_literal) F(string_literal) #define ESPHOME_PGM_P PGM_P #define ESPHOME_strncpy_P strncpy_P +#define ESPHOME_strncat_P strncat_P #else #define ESPHOME_F(string_literal) (string_literal) #define ESPHOME_PGM_P const char * #define ESPHOME_strncpy_P strncpy +#define ESPHOME_strncat_P strncat #endif diff --git a/esphome/helpers.py b/esphome/helpers.py index ea6abff50a..d1623d1d3c 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -424,9 +424,13 @@ def write_file_if_changed(path: Path, text: str) -> bool: return True -def copy_file_if_changed(src: Path, dst: Path) -> None: +def copy_file_if_changed(src: Path, dst: Path) -> bool: + """Copy file from src to dst if contents differ. + + Returns True if file was copied, False if files already matched. + """ if file_compare(src, dst): - return + return False dst.parent.mkdir(parents=True, exist_ok=True) try: shutil.copyfile(src, dst) @@ -441,11 +445,12 @@ def copy_file_if_changed(src: Path, dst: Path) -> None: with suppress(OSError): os.unlink(dst) shutil.copyfile(src, dst) - return + return True from esphome.core import EsphomeError raise EsphomeError(f"Error copying file {src} to {dst}: {err}") from err + return True def list_starts_with(list_, sub): diff --git a/esphome/writer.py b/esphome/writer.py index 721db07f96..183fff8730 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -1,11 +1,13 @@ from collections.abc import Callable import importlib +import json import logging import os from pathlib import Path import re import shutil import stat +import time from types import TracebackType from esphome import loader @@ -23,6 +25,7 @@ from esphome.helpers import ( is_ha_addon, read_file, walk_files, + write_file, write_file_if_changed, ) from esphome.storage_json import StorageJSON, storage_path @@ -173,6 +176,7 @@ VERSION_H_FORMAT = """\ """ DEFINES_H_TARGET = "esphome/core/defines.h" VERSION_H_TARGET = "esphome/core/version.h" +BUILD_INFO_DATA_H_TARGET = "esphome/core/build_info_data.h" ESPHOME_README_TXT = """ THIS DIRECTORY IS AUTO-GENERATED, DO NOT MODIFY @@ -206,10 +210,16 @@ def copy_src_tree(): include_s = "\n".join(include_l) source_files_copy = source_files_map.copy() - ignore_targets = [Path(x) for x in (DEFINES_H_TARGET, VERSION_H_TARGET)] + ignore_targets = [ + Path(x) for x in (DEFINES_H_TARGET, VERSION_H_TARGET, BUILD_INFO_DATA_H_TARGET) + ] for t in ignore_targets: - source_files_copy.pop(t) + source_files_copy.pop(t, None) + # Files to exclude from sources_changed tracking (generated files) + generated_files = {Path("esphome/core/build_info_data.h")} + + sources_changed = False for fname in walk_files(CORE.relative_src_path("esphome")): p = Path(fname) if p.suffix not in SOURCE_FILE_EXTENSIONS: @@ -223,28 +233,80 @@ def copy_src_tree(): if target not in source_files_copy: # Source file removed, delete target p.unlink() + if target not in generated_files: + sources_changed = True else: src_file = source_files_copy.pop(target) with src_file.path() as src_path: - copy_file_if_changed(src_path, p) + if copy_file_if_changed(src_path, p) and target not in generated_files: + sources_changed = True # Now copy new files for target, src_file in source_files_copy.items(): dst_path = CORE.relative_src_path(*target.parts) with src_file.path() as src_path: - copy_file_if_changed(src_path, dst_path) + if ( + copy_file_if_changed(src_path, dst_path) + and target not in generated_files + ): + sources_changed = True # Finally copy defines - write_file_if_changed( + if write_file_if_changed( CORE.relative_src_path("esphome", "core", "defines.h"), generate_defines_h() - ) + ): + sources_changed = True write_file_if_changed(CORE.relative_build_path("README.txt"), ESPHOME_README_TXT) - write_file_if_changed( + if write_file_if_changed( CORE.relative_src_path("esphome.h"), ESPHOME_H_FORMAT.format(include_s) - ) - write_file_if_changed( + ): + sources_changed = True + if write_file_if_changed( CORE.relative_src_path("esphome", "core", "version.h"), generate_version_h() + ): + sources_changed = True + + # Generate new build_info files if needed + build_info_data_h_path = CORE.relative_src_path( + "esphome", "core", "build_info_data.h" ) + build_info_json_path = CORE.relative_build_path("build_info.json") + config_hash, build_time, build_time_str = get_build_info() + + # Defensively force a rebuild if the build_info files don't exist, or if + # there was a config change which didn't actually cause a source change + if not build_info_data_h_path.exists(): + sources_changed = True + else: + try: + existing = json.loads(build_info_json_path.read_text(encoding="utf-8")) + if ( + existing.get("config_hash") != config_hash + or existing.get("esphome_version") != __version__ + ): + sources_changed = True + except (json.JSONDecodeError, KeyError, OSError): + sources_changed = True + + # Write build_info header and JSON metadata + if sources_changed: + write_file( + build_info_data_h_path, + generate_build_info_data_h(config_hash, build_time, build_time_str), + ) + write_file( + build_info_json_path, + json.dumps( + { + "config_hash": config_hash, + "build_time": build_time, + "build_time_str": build_time_str, + "esphome_version": __version__, + }, + indent=2, + ) + + "\n", + ) platform = "esphome.components." + CORE.target_platform try: @@ -270,6 +332,35 @@ def generate_version_h(): ) +def get_build_info() -> tuple[int, int, str]: + """Calculate build_info values from current config. + + Returns: + Tuple of (config_hash, build_time, build_time_str) + """ + config_hash = CORE.config_hash + build_time = int(time.time()) + build_time_str = time.strftime("%Y-%m-%d %H:%M:%S %z", time.localtime(build_time)) + return config_hash, build_time, build_time_str + + +def generate_build_info_data_h( + config_hash: int, build_time: int, build_time_str: str +) -> str: + """Generate build_info_data.h header with config hash and build time.""" + return f"""#pragma once +// Auto-generated build_info data +#define ESPHOME_CONFIG_HASH 0x{config_hash:08x}U // NOLINT +#define ESPHOME_BUILD_TIME {build_time} // NOLINT +#ifdef USE_ESP8266 +#include +static const char ESPHOME_BUILD_TIME_STR[] PROGMEM = "{build_time_str}"; +#else +static const char ESPHOME_BUILD_TIME_STR[] = "{build_time_str}"; +#endif +""" + + def write_cpp(code_s): path = CORE.relative_src_path("main.cpp") if path.is_file(): diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index 359b72b48f..bba4bbf487 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -1,6 +1,7 @@ from __future__ import annotations from collections.abc import Callable +from contextlib import suppress import functools import inspect from io import BytesIO, TextIOBase, TextIOWrapper @@ -501,13 +502,17 @@ def _load_yaml_internal_with_type( loader.dispose() -def dump(dict_, show_secrets=False): +def dump(dict_, show_secrets=False, sort_keys=False): """Dump YAML to a string and remove null.""" if show_secrets: _SECRET_VALUES.clear() _SECRET_CACHE.clear() return yaml.dump( - dict_, default_flow_style=False, allow_unicode=True, Dumper=ESPHomeDumper + dict_, + default_flow_style=False, + allow_unicode=True, + Dumper=ESPHomeDumper, + sort_keys=sort_keys, ) @@ -543,6 +548,9 @@ class ESPHomeDumper(yaml.SafeDumper): best_style = True if hasattr(mapping, "items"): mapping = list(mapping.items()) + if self.sort_keys: + with suppress(TypeError): + mapping = sorted(mapping) for item_key, item_value in mapping: node_key = self.represent_data(item_key) node_value = self.represent_data(item_value) diff --git a/tests/dummy_main.cpp b/tests/dummy_main.cpp index afd393c095..5849f4eb95 100644 --- a/tests/dummy_main.cpp +++ b/tests/dummy_main.cpp @@ -12,7 +12,7 @@ using namespace esphome; void setup() { - App.pre_setup("livingroom", "LivingRoom", "comment", __DATE__ ", " __TIME__, false); + App.pre_setup("livingroom", "LivingRoom", "comment", false); auto *log = new logger::Logger(115200, 512); // NOLINT log->pre_setup(); log->set_uart_selection(logger::UART_SELECTION_UART0); diff --git a/tests/integration/fixtures/build_info.yaml b/tests/integration/fixtures/build_info.yaml new file mode 100644 index 0000000000..5d6101543a --- /dev/null +++ b/tests/integration/fixtures/build_info.yaml @@ -0,0 +1,31 @@ +esphome: + name: build-info-test +host: +api: +logger: + +text_sensor: + - platform: template + name: "Config Hash" + id: config_hash_sensor + update_interval: 100ms + lambda: |- + char buf[16]; + snprintf(buf, sizeof(buf), "0x%08x", App.get_config_hash()); + return std::string(buf); + - platform: template + name: "Build Time" + id: build_time_sensor + update_interval: 100ms + lambda: |- + char buf[32]; + snprintf(buf, sizeof(buf), "%ld", (long)App.get_build_time()); + return std::string(buf); + - platform: template + name: "Build Time String" + id: build_time_str_sensor + update_interval: 100ms + lambda: |- + char buf[Application::BUILD_TIME_STR_SIZE]; + App.get_build_time_string(buf); + return std::string(buf); diff --git a/tests/integration/test_build_info.py b/tests/integration/test_build_info.py new file mode 100644 index 0000000000..7079594471 --- /dev/null +++ b/tests/integration/test_build_info.py @@ -0,0 +1,117 @@ +"""Integration test for build_info values.""" + +from __future__ import annotations + +import asyncio +from datetime import datetime +import re +import time + +from aioesphomeapi import EntityState, TextSensorState +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_build_info( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that build_info values are sane.""" + async with run_compiled(yaml_config), api_client_connected() as client: + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "build-info-test" + + # Verify compilation_time from device_info is present and parseable + # The format is ISO 8601 with timezone: "YYYY-MM-DD HH:MM:SS +ZZZZ" + compilation_time = device_info.compilation_time + assert compilation_time is not None + + # Validate the ISO format: "YYYY-MM-DD HH:MM:SS +ZZZZ" + parsed = datetime.strptime(compilation_time, "%Y-%m-%d %H:%M:%S %z") + assert parsed.year >= time.localtime().tm_year + + # Get entities + entities, _ = await client.list_entities_services() + + # Find our text sensors by object_id + config_hash_entity = next( + (e for e in entities if e.object_id == "config_hash"), None + ) + build_time_entity = next( + (e for e in entities if e.object_id == "build_time"), None + ) + build_time_str_entity = next( + (e for e in entities if e.object_id == "build_time_string"), None + ) + + assert config_hash_entity is not None, "Config Hash sensor not found" + assert build_time_entity is not None, "Build Time sensor not found" + assert build_time_str_entity is not None, "Build Time String sensor not found" + + # Wait for all three text sensors to have valid states + loop = asyncio.get_running_loop() + states: dict[int, TextSensorState] = {} + all_received = loop.create_future() + expected_keys = { + config_hash_entity.key, + build_time_entity.key, + build_time_str_entity.key, + } + + def on_state(state: EntityState) -> None: + if isinstance(state, TextSensorState) and not state.missing_state: + states[state.key] = state + if expected_keys <= states.keys() and not all_received.done(): + all_received.set_result(True) + + client.subscribe_states(on_state) + + try: + await asyncio.wait_for(all_received, timeout=5.0) + except TimeoutError: + pytest.fail( + f"Timeout waiting for text sensor states. Got: {list(states.keys())}" + ) + + config_hash_state = states[config_hash_entity.key] + build_time_state = states[build_time_entity.key] + build_time_str_state = states[build_time_str_entity.key] + + # Validate config_hash format (0x followed by 8 hex digits) + config_hash = config_hash_state.state + assert re.match(r"^0x[0-9a-f]{8}$", config_hash), ( + f"config_hash should be 0x followed by 8 hex digits, got: {config_hash}" + ) + + # Validate build_time is a reasonable Unix timestamp + build_time = int(build_time_state.state) + current_time = int(time.time()) + # Build time should be within last hour and not in the future + assert build_time <= current_time, ( + f"build_time {build_time} should not be in the future (current: {current_time})" + ) + assert build_time > current_time - 3600, ( + f"build_time {build_time} should be within the last hour" + ) + + # Validate build_time_str matches the new ISO format + build_time_str = build_time_str_state.state + # Format: "YYYY-MM-DD HH:MM:SS +ZZZZ" + parsed_build_time = datetime.strptime(build_time_str, "%Y-%m-%d %H:%M:%S %z") + assert parsed_build_time.year >= time.localtime().tm_year + + # Verify build_time_str matches what we get from build_time timestamp + expected_str = time.strftime("%Y-%m-%d %H:%M:%S %z", time.localtime(build_time)) + assert build_time_str == expected_str, ( + f"build_time_str '{build_time_str}' should match timestamp '{expected_str}'" + ) + + # Verify compilation_time matches build_time_str (they should be the same) + assert compilation_time == build_time_str, ( + f"compilation_time '{compilation_time}' should match " + f"build_time_str '{build_time_str}'" + ) diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py index fc61841500..1a1bfffd03 100644 --- a/tests/unit_tests/conftest.py +++ b/tests/unit_tests/conftest.py @@ -58,6 +58,7 @@ def mock_write_file_if_changed() -> Generator[Mock, None, None]: def mock_copy_file_if_changed() -> Generator[Mock, None, None]: """Mock copy_file_if_changed for core.config.""" with patch("esphome.core.config.copy_file_if_changed") as mock: + mock.return_value = True yield mock diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index 90b2f5edba..ab7bdbb98c 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -892,3 +892,74 @@ async def test_add_includes_overwrites_existing_files( mock_copy_file_if_changed.assert_called_once_with( include_file, CORE.build_path / "src" / "header.h" ) + + +def test_config_hash_returns_int() -> None: + """Test that config_hash returns an integer.""" + CORE.reset() + CORE.config = {"esphome": {"name": "test"}} + assert isinstance(CORE.config_hash, int) + + +def test_config_hash_is_cached() -> None: + """Test that config_hash is computed once and cached.""" + CORE.reset() + CORE.config = {"esphome": {"name": "test"}} + + # First access computes the hash + hash1 = CORE.config_hash + + # Modify config (without resetting cache) + CORE.config = {"esphome": {"name": "different"}} + + # Second access returns cached value + hash2 = CORE.config_hash + + assert hash1 == hash2 + + +def test_config_hash_reset_clears_cache() -> None: + """Test that reset() clears the cached config_hash.""" + CORE.reset() + CORE.config = {"esphome": {"name": "test"}} + hash1 = CORE.config_hash + + # Reset clears the cache + CORE.reset() + CORE.config = {"esphome": {"name": "different"}} + + hash2 = CORE.config_hash + + # After reset, hash should be recomputed + assert hash1 != hash2 + + +def test_config_hash_deterministic_key_order() -> None: + """Test that config_hash is deterministic regardless of key insertion order.""" + CORE.reset() + # Create two configs with same content but different key order + config1 = {"z_key": 1, "a_key": 2, "nested": {"z_nested": "z", "a_nested": "a"}} + config2 = {"a_key": 2, "z_key": 1, "nested": {"a_nested": "a", "z_nested": "z"}} + + CORE.config = config1 + hash1 = CORE.config_hash + + CORE.reset() + CORE.config = config2 + hash2 = CORE.config_hash + + # Hashes should be equal because keys are sorted during serialization + assert hash1 == hash2 + + +def test_config_hash_different_for_different_configs() -> None: + """Test that different configs produce different hashes.""" + CORE.reset() + CORE.config = {"esphome": {"name": "test1"}} + hash1 = CORE.config_hash + + CORE.reset() + CORE.config = {"esphome": {"name": "test2"}} + hash2 = CORE.config_hash + + assert hash1 != hash2 diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index bd14395037..36a284c382 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -4,9 +4,11 @@ from __future__ import annotations from collections.abc import Generator from dataclasses import dataclass +import json import logging from pathlib import Path import re +import time from typing import Any from unittest.mock import MagicMock, Mock, patch @@ -22,6 +24,7 @@ from esphome.__main__ import ( command_rename, command_update_all, command_wizard, + compile_program, detect_external_components, get_port_type, has_ip_address, @@ -2605,3 +2608,197 @@ def test_command_analyze_memory_no_idedata( assert result == 1 assert "Failed to get IDE data for memory analysis" in caplog.text + + +@pytest.fixture +def mock_compile_build_info_run_compile() -> Generator[Mock]: + """Mock platformio_api.run_compile for build_info tests.""" + with patch("esphome.platformio_api.run_compile", return_value=0) as mock: + yield mock + + +@pytest.fixture +def mock_compile_build_info_get_idedata() -> Generator[Mock]: + """Mock platformio_api.get_idedata for build_info tests.""" + mock_idedata = MagicMock() + with patch("esphome.platformio_api.get_idedata", return_value=mock_idedata) as mock: + yield mock + + +def _setup_build_info_test( + tmp_path: Path, + *, + create_firmware: bool = True, + create_build_info: bool = True, + build_info_content: str | None = None, + firmware_first: bool = False, +) -> tuple[Path, Path]: + """Set up build directory structure for build_info tests. + + Args: + tmp_path: Temporary directory path. + create_firmware: Whether to create firmware.bin file. + create_build_info: Whether to create build_info.json file. + build_info_content: Custom content for build_info.json, or None for default. + firmware_first: If True, create firmware before build_info (makes firmware older). + + Returns: + Tuple of (build_info_path, firmware_path). + """ + setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path, name="test_device") + + build_path = tmp_path / ".esphome" / "build" / "test_device" + pioenvs_path = build_path / ".pioenvs" / "test_device" + pioenvs_path.mkdir(parents=True, exist_ok=True) + + build_info_path = build_path / "build_info.json" + firmware_path = pioenvs_path / "firmware.bin" + + default_build_info = json.dumps( + { + "config_hash": 0x12345678, + "build_time": int(time.time()), + "build_time_str": "Dec 13 2025, 12:00:00", + "esphome_version": "2025.1.0", + } + ) + + def create_build_info_file() -> None: + if create_build_info: + content = ( + build_info_content + if build_info_content is not None + else default_build_info + ) + build_info_path.write_text(content) + + def create_firmware_file() -> None: + if create_firmware: + firmware_path.write_bytes(b"fake firmware") + + if firmware_first: + create_firmware_file() + time.sleep(0.01) # Ensure different timestamps + create_build_info_file() + else: + create_build_info_file() + time.sleep(0.01) # Ensure different timestamps + create_firmware_file() + + return build_info_path, firmware_path + + +def test_compile_program_emits_build_info_when_firmware_rebuilt( + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + mock_compile_build_info_run_compile: Mock, + mock_compile_build_info_get_idedata: Mock, +) -> None: + """Test that compile_program logs build_info when firmware is rebuilt.""" + _setup_build_info_test(tmp_path, firmware_first=False) + + config: dict[str, Any] = {CONF_ESPHOME: {CONF_NAME: "test_device"}} + args = MockArgs() + + with caplog.at_level(logging.INFO): + result = compile_program(args, config) + + assert result == 0 + assert "Build Info: config_hash=0x12345678" in caplog.text + + +def test_compile_program_no_build_info_when_firmware_not_rebuilt( + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + mock_compile_build_info_run_compile: Mock, + mock_compile_build_info_get_idedata: Mock, +) -> None: + """Test that compile_program doesn't log build_info when firmware wasn't rebuilt.""" + _setup_build_info_test(tmp_path, firmware_first=True) + + config: dict[str, Any] = {CONF_ESPHOME: {CONF_NAME: "test_device"}} + args = MockArgs() + + with caplog.at_level(logging.INFO): + result = compile_program(args, config) + + assert result == 0 + assert "Build Info:" not in caplog.text + + +def test_compile_program_no_build_info_when_firmware_missing( + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + mock_compile_build_info_run_compile: Mock, + mock_compile_build_info_get_idedata: Mock, +) -> None: + """Test that compile_program doesn't log build_info when firmware.bin doesn't exist.""" + _setup_build_info_test(tmp_path, create_firmware=False) + + config: dict[str, Any] = {CONF_ESPHOME: {CONF_NAME: "test_device"}} + args = MockArgs() + + with caplog.at_level(logging.INFO): + result = compile_program(args, config) + + assert result == 0 + assert "Build Info:" not in caplog.text + + +def test_compile_program_no_build_info_when_json_missing( + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + mock_compile_build_info_run_compile: Mock, + mock_compile_build_info_get_idedata: Mock, +) -> None: + """Test that compile_program doesn't log build_info when build_info.json doesn't exist.""" + _setup_build_info_test(tmp_path, create_build_info=False) + + config: dict[str, Any] = {CONF_ESPHOME: {CONF_NAME: "test_device"}} + args = MockArgs() + + with caplog.at_level(logging.INFO): + result = compile_program(args, config) + + assert result == 0 + assert "Build Info:" not in caplog.text + + +def test_compile_program_no_build_info_when_json_invalid( + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + mock_compile_build_info_run_compile: Mock, + mock_compile_build_info_get_idedata: Mock, +) -> None: + """Test that compile_program doesn't log build_info when build_info.json is invalid.""" + _setup_build_info_test(tmp_path, build_info_content="not valid json {{{") + + config: dict[str, Any] = {CONF_ESPHOME: {CONF_NAME: "test_device"}} + args = MockArgs() + + with caplog.at_level(logging.DEBUG): + result = compile_program(args, config) + + assert result == 0 + assert "Build Info:" not in caplog.text + + +def test_compile_program_no_build_info_when_json_missing_keys( + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + mock_compile_build_info_run_compile: Mock, + mock_compile_build_info_get_idedata: Mock, +) -> None: + """Test that compile_program doesn't log build_info when build_info.json is missing required keys.""" + _setup_build_info_test( + tmp_path, build_info_content=json.dumps({"build_time": 1234567890}) + ) + + config: dict[str, Any] = {CONF_ESPHOME: {CONF_NAME: "test_device"}} + args = MockArgs() + + with caplog.at_level(logging.INFO): + result = compile_program(args, config) + + assert result == 0 + assert "Build Info:" not in caplog.text diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index 9fa60c06ec..06a7d5dbdf 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -1,6 +1,10 @@ """Test writer module functionality.""" from collections.abc import Callable +from contextlib import contextmanager +from dataclasses import dataclass +from datetime import datetime +import json import os from pathlib import Path import stat @@ -20,6 +24,9 @@ from esphome.writer import ( clean_all, clean_build, clean_cmake_cache, + copy_src_tree, + generate_build_info_data_h, + get_build_info, storage_should_clean, update_storage_json, write_cpp, @@ -1165,3 +1172,721 @@ def test_clean_build_reraises_for_other_errors( finally: # Cleanup - restore write permission so tmp_path cleanup works os.chmod(subdir, stat.S_IRWXU) + + +# Tests for get_build_info() + + +@patch("esphome.writer.CORE") +def test_get_build_info_new_build( + mock_core: MagicMock, + tmp_path: Path, +) -> None: + """Test get_build_info returns new build_time when no existing build_info.json.""" + build_info_path = tmp_path / "build_info.json" + mock_core.relative_build_path.return_value = build_info_path + mock_core.config_hash = 0x12345678 + + config_hash, build_time, build_time_str = get_build_info() + + assert config_hash == 0x12345678 + assert isinstance(build_time, int) + assert build_time > 0 + assert isinstance(build_time_str, str) + # Verify build_time_str format matches expected pattern + assert len(build_time_str) >= 19 # e.g., "2025-12-15 16:27:44 +0000" + + +@patch("esphome.writer.CORE") +def test_get_build_info_always_returns_current_time( + mock_core: MagicMock, + tmp_path: Path, +) -> None: + """Test get_build_info always returns current build_time.""" + build_info_path = tmp_path / "build_info.json" + mock_core.relative_build_path.return_value = build_info_path + mock_core.config_hash = 0x12345678 + + # Create existing build_info.json with matching config_hash and version + existing_build_time = 1700000000 + existing_build_time_str = "2023-11-14 22:13:20 +0000" + build_info_path.write_text( + json.dumps( + { + "config_hash": 0x12345678, + "build_time": existing_build_time, + "build_time_str": existing_build_time_str, + "esphome_version": "2025.1.0-dev", + } + ) + ) + + with patch("esphome.writer.__version__", "2025.1.0-dev"): + config_hash, build_time, build_time_str = get_build_info() + + assert config_hash == 0x12345678 + # get_build_info now always returns current time + assert build_time != existing_build_time + assert build_time > existing_build_time + assert build_time_str != existing_build_time_str + + +@patch("esphome.writer.CORE") +def test_get_build_info_config_changed( + mock_core: MagicMock, + tmp_path: Path, +) -> None: + """Test get_build_info returns new build_time when config hash changed.""" + build_info_path = tmp_path / "build_info.json" + mock_core.relative_build_path.return_value = build_info_path + mock_core.config_hash = 0xABCDEF00 # Different from existing + + # Create existing build_info.json with different config_hash + existing_build_time = 1700000000 + build_info_path.write_text( + json.dumps( + { + "config_hash": 0x12345678, # Different + "build_time": existing_build_time, + "build_time_str": "2023-11-14 22:13:20 +0000", + "esphome_version": "2025.1.0-dev", + } + ) + ) + + with patch("esphome.writer.__version__", "2025.1.0-dev"): + config_hash, build_time, build_time_str = get_build_info() + + assert config_hash == 0xABCDEF00 + assert build_time != existing_build_time # New time generated + assert build_time > existing_build_time + + +@patch("esphome.writer.CORE") +def test_get_build_info_version_changed( + mock_core: MagicMock, + tmp_path: Path, +) -> None: + """Test get_build_info returns new build_time when ESPHome version changed.""" + build_info_path = tmp_path / "build_info.json" + mock_core.relative_build_path.return_value = build_info_path + mock_core.config_hash = 0x12345678 + + # Create existing build_info.json with different version + existing_build_time = 1700000000 + build_info_path.write_text( + json.dumps( + { + "config_hash": 0x12345678, + "build_time": existing_build_time, + "build_time_str": "2023-11-14 22:13:20 +0000", + "esphome_version": "2024.12.0", # Old version + } + ) + ) + + with patch("esphome.writer.__version__", "2025.1.0-dev"): # New version + config_hash, build_time, build_time_str = get_build_info() + + assert config_hash == 0x12345678 + assert build_time != existing_build_time # New time generated + assert build_time > existing_build_time + + +@patch("esphome.writer.CORE") +def test_get_build_info_invalid_json( + mock_core: MagicMock, + tmp_path: Path, +) -> None: + """Test get_build_info handles invalid JSON gracefully.""" + build_info_path = tmp_path / "build_info.json" + mock_core.relative_build_path.return_value = build_info_path + mock_core.config_hash = 0x12345678 + + # Create invalid JSON file + build_info_path.write_text("not valid json {{{") + + config_hash, build_time, build_time_str = get_build_info() + + assert config_hash == 0x12345678 + assert isinstance(build_time, int) + assert build_time > 0 + + +@patch("esphome.writer.CORE") +def test_get_build_info_missing_keys( + mock_core: MagicMock, + tmp_path: Path, +) -> None: + """Test get_build_info handles missing keys gracefully.""" + build_info_path = tmp_path / "build_info.json" + mock_core.relative_build_path.return_value = build_info_path + mock_core.config_hash = 0x12345678 + + # Create JSON with missing keys + build_info_path.write_text(json.dumps({"config_hash": 0x12345678})) + + with patch("esphome.writer.__version__", "2025.1.0-dev"): + config_hash, build_time, build_time_str = get_build_info() + + assert config_hash == 0x12345678 + assert isinstance(build_time, int) + assert build_time > 0 + + +@patch("esphome.writer.CORE") +def test_get_build_info_build_time_str_format( + mock_core: MagicMock, + tmp_path: Path, +) -> None: + """Test get_build_info returns correctly formatted build_time_str.""" + build_info_path = tmp_path / "build_info.json" + mock_core.relative_build_path.return_value = build_info_path + mock_core.config_hash = 0x12345678 + + config_hash, build_time, build_time_str = get_build_info() + + # Verify the format matches "%Y-%m-%d %H:%M:%S %z" + # e.g., "2025-12-15 16:27:44 +0000" + parsed = datetime.strptime(build_time_str, "%Y-%m-%d %H:%M:%S %z") + assert parsed.year >= 2024 + + +def test_generate_build_info_data_h_format() -> None: + """Test generate_build_info_data_h produces correct header content.""" + config_hash = 0x12345678 + build_time = 1700000000 + build_time_str = "2023-11-14 22:13:20 +0000" + + result = generate_build_info_data_h(config_hash, build_time, build_time_str) + + assert "#pragma once" in result + assert "#define ESPHOME_CONFIG_HASH 0x12345678U" in result + assert "#define ESPHOME_BUILD_TIME 1700000000" in result + assert 'ESPHOME_BUILD_TIME_STR[] = "2023-11-14 22:13:20 +0000"' in result + + +def test_generate_build_info_data_h_esp8266_progmem() -> None: + """Test generate_build_info_data_h includes PROGMEM for ESP8266.""" + result = generate_build_info_data_h(0xABCDEF01, 1700000000, "test") + + # Should have ESP8266 PROGMEM conditional + assert "#ifdef USE_ESP8266" in result + assert "#include " in result + assert "PROGMEM" in result + + +def test_generate_build_info_data_h_hash_formatting() -> None: + """Test generate_build_info_data_h formats hash with leading zeros.""" + # Test with small hash value that needs leading zeros + result = generate_build_info_data_h(0x00000001, 0, "test") + assert "#define ESPHOME_CONFIG_HASH 0x00000001U" in result + + # Test with larger hash value + result = generate_build_info_data_h(0xFFFFFFFF, 0, "test") + assert "#define ESPHOME_CONFIG_HASH 0xffffffffU" in result + + +@patch("esphome.writer.CORE") +@patch("esphome.writer.iter_components") +@patch("esphome.writer.walk_files") +def test_copy_src_tree_writes_build_info_files( + mock_walk_files: MagicMock, + mock_iter_components: MagicMock, + mock_core: MagicMock, + tmp_path: Path, +) -> None: + """Test copy_src_tree writes build_info_data.h and build_info.json.""" + # Setup directory structure + src_path = tmp_path / "src" + src_path.mkdir() + esphome_core_path = src_path / "esphome" / "core" + esphome_core_path.mkdir(parents=True) + build_path = tmp_path / "build" + build_path.mkdir() + + # Create mock source files for defines.h and version.h + mock_defines_h = esphome_core_path / "defines.h" + mock_defines_h.write_text("// mock defines.h") + mock_version_h = esphome_core_path / "version.h" + mock_version_h.write_text("// mock version.h") + + # Create mock FileResource that returns our temp files + @dataclass(frozen=True) + class MockFileResource: + package: str + resource: str + _path: Path + + @contextmanager + def path(self): + yield self._path + + # Create mock resources for defines.h and version.h (required by copy_src_tree) + mock_resources = [ + MockFileResource( + package="esphome.core", + resource="defines.h", + _path=mock_defines_h, + ), + MockFileResource( + package="esphome.core", + resource="version.h", + _path=mock_version_h, + ), + ] + + # Create mock component with resources + mock_component = MagicMock() + mock_component.resources = mock_resources + + # Setup mocks + mock_core.relative_src_path.side_effect = lambda *args: src_path.joinpath(*args) + mock_core.relative_build_path.side_effect = lambda *args: build_path.joinpath(*args) + mock_core.defines = [] + mock_core.config_hash = 0xDEADBEEF + mock_core.target_platform = "test_platform" + mock_core.config = {} + mock_iter_components.return_value = [("core", mock_component)] + mock_walk_files.return_value = [] + + # Create mock module without copy_files attribute (causes AttributeError which is caught) + mock_module = MagicMock(spec=[]) # Empty spec = no copy_files attribute + + with ( + patch("esphome.writer.__version__", "2025.1.0-dev"), + patch("esphome.writer.importlib.import_module", return_value=mock_module), + ): + copy_src_tree() + + # Verify build_info_data.h was written + build_info_h_path = esphome_core_path / "build_info_data.h" + assert build_info_h_path.exists() + build_info_h_content = build_info_h_path.read_text() + assert "#define ESPHOME_CONFIG_HASH 0xdeadbeefU" in build_info_h_content + assert "#define ESPHOME_BUILD_TIME" in build_info_h_content + assert "ESPHOME_BUILD_TIME_STR" in build_info_h_content + + # Verify build_info.json was written + build_info_json_path = build_path / "build_info.json" + assert build_info_json_path.exists() + build_info_json = json.loads(build_info_json_path.read_text()) + assert build_info_json["config_hash"] == 0xDEADBEEF + assert "build_time" in build_info_json + assert "build_time_str" in build_info_json + assert build_info_json["esphome_version"] == "2025.1.0-dev" + + +@patch("esphome.writer.CORE") +@patch("esphome.writer.iter_components") +@patch("esphome.writer.walk_files") +def test_copy_src_tree_detects_config_hash_change( + mock_walk_files: MagicMock, + mock_iter_components: MagicMock, + mock_core: MagicMock, + tmp_path: Path, +) -> None: + """Test copy_src_tree detects when config_hash changes.""" + # Setup directory structure + src_path = tmp_path / "src" + src_path.mkdir() + esphome_core_path = src_path / "esphome" / "core" + esphome_core_path.mkdir(parents=True) + build_path = tmp_path / "build" + build_path.mkdir() + + # Create existing build_info.json with different config_hash + build_info_json_path = build_path / "build_info.json" + build_info_json_path.write_text( + json.dumps( + { + "config_hash": 0x12345678, # Different from current + "build_time": 1700000000, + "build_time_str": "2023-11-14 22:13:20 +0000", + "esphome_version": "2025.1.0-dev", + } + ) + ) + + # Create existing build_info_data.h + build_info_h_path = esphome_core_path / "build_info_data.h" + build_info_h_path.write_text("// old build_info_data.h") + + # Setup mocks + mock_core.relative_src_path.side_effect = lambda *args: src_path.joinpath(*args) + mock_core.relative_build_path.side_effect = lambda *args: build_path.joinpath(*args) + mock_core.defines = [] + mock_core.config_hash = 0xDEADBEEF # Different from existing + mock_core.target_platform = "test_platform" + mock_core.config = {} + mock_iter_components.return_value = [] + mock_walk_files.return_value = [] + + with ( + patch("esphome.writer.__version__", "2025.1.0-dev"), + patch("esphome.writer.importlib.import_module") as mock_import, + ): + mock_import.side_effect = AttributeError + copy_src_tree() + + # Verify build_info files were updated due to config_hash change + assert build_info_h_path.exists() + new_content = build_info_h_path.read_text() + assert "0xdeadbeef" in new_content.lower() + + new_json = json.loads(build_info_json_path.read_text()) + assert new_json["config_hash"] == 0xDEADBEEF + + +@patch("esphome.writer.CORE") +@patch("esphome.writer.iter_components") +@patch("esphome.writer.walk_files") +def test_copy_src_tree_detects_version_change( + mock_walk_files: MagicMock, + mock_iter_components: MagicMock, + mock_core: MagicMock, + tmp_path: Path, +) -> None: + """Test copy_src_tree detects when esphome_version changes.""" + # Setup directory structure + src_path = tmp_path / "src" + src_path.mkdir() + esphome_core_path = src_path / "esphome" / "core" + esphome_core_path.mkdir(parents=True) + build_path = tmp_path / "build" + build_path.mkdir() + + # Create existing build_info.json with different version + build_info_json_path = build_path / "build_info.json" + build_info_json_path.write_text( + json.dumps( + { + "config_hash": 0xDEADBEEF, + "build_time": 1700000000, + "build_time_str": "2023-11-14 22:13:20 +0000", + "esphome_version": "2024.12.0", # Old version + } + ) + ) + + # Create existing build_info_data.h + build_info_h_path = esphome_core_path / "build_info_data.h" + build_info_h_path.write_text("// old build_info_data.h") + + # Setup mocks + mock_core.relative_src_path.side_effect = lambda *args: src_path.joinpath(*args) + mock_core.relative_build_path.side_effect = lambda *args: build_path.joinpath(*args) + mock_core.defines = [] + mock_core.config_hash = 0xDEADBEEF + mock_core.target_platform = "test_platform" + mock_core.config = {} + mock_iter_components.return_value = [] + mock_walk_files.return_value = [] + + with ( + patch("esphome.writer.__version__", "2025.1.0-dev"), # New version + patch("esphome.writer.importlib.import_module") as mock_import, + ): + mock_import.side_effect = AttributeError + copy_src_tree() + + # Verify build_info files were updated due to version change + assert build_info_h_path.exists() + new_json = json.loads(build_info_json_path.read_text()) + assert new_json["esphome_version"] == "2025.1.0-dev" + + +@patch("esphome.writer.CORE") +@patch("esphome.writer.iter_components") +@patch("esphome.writer.walk_files") +def test_copy_src_tree_handles_invalid_build_info_json( + mock_walk_files: MagicMock, + mock_iter_components: MagicMock, + mock_core: MagicMock, + tmp_path: Path, +) -> None: + """Test copy_src_tree handles invalid build_info.json gracefully.""" + # Setup directory structure + src_path = tmp_path / "src" + src_path.mkdir() + esphome_core_path = src_path / "esphome" / "core" + esphome_core_path.mkdir(parents=True) + build_path = tmp_path / "build" + build_path.mkdir() + + # Create invalid build_info.json + build_info_json_path = build_path / "build_info.json" + build_info_json_path.write_text("invalid json {{{") + + # Create existing build_info_data.h + build_info_h_path = esphome_core_path / "build_info_data.h" + build_info_h_path.write_text("// old build_info_data.h") + + # Setup mocks + mock_core.relative_src_path.side_effect = lambda *args: src_path.joinpath(*args) + mock_core.relative_build_path.side_effect = lambda *args: build_path.joinpath(*args) + mock_core.defines = [] + mock_core.config_hash = 0xDEADBEEF + mock_core.target_platform = "test_platform" + mock_core.config = {} + mock_iter_components.return_value = [] + mock_walk_files.return_value = [] + + with ( + patch("esphome.writer.__version__", "2025.1.0-dev"), + patch("esphome.writer.importlib.import_module") as mock_import, + ): + mock_import.side_effect = AttributeError + copy_src_tree() + + # Verify build_info files were created despite invalid JSON + assert build_info_h_path.exists() + new_json = json.loads(build_info_json_path.read_text()) + assert new_json["config_hash"] == 0xDEADBEEF + + +@patch("esphome.writer.CORE") +@patch("esphome.writer.iter_components") +@patch("esphome.writer.walk_files") +def test_copy_src_tree_build_info_timestamp_behavior( + mock_walk_files: MagicMock, + mock_iter_components: MagicMock, + mock_core: MagicMock, + tmp_path: Path, +) -> None: + """Test build_info behaviour: regenerated on change, preserved when unchanged.""" + # Setup directory structure + src_path = tmp_path / "src" + src_path.mkdir() + esphome_core_path = src_path / "esphome" / "core" + esphome_core_path.mkdir(parents=True) + esphome_components_path = src_path / "esphome" / "components" + esphome_components_path.mkdir(parents=True) + build_path = tmp_path / "build" + build_path.mkdir() + + # Create a source file + source_file = tmp_path / "source" / "test.cpp" + source_file.parent.mkdir() + source_file.write_text("// version 1") + + # Create destination file in build tree + dest_file = esphome_components_path / "test.cpp" + + # Create mock FileResource + @dataclass(frozen=True) + class MockFileResource: + package: str + resource: str + _path: Path + + @contextmanager + def path(self): + yield self._path + + mock_resources = [ + MockFileResource( + package="esphome.components", + resource="test.cpp", + _path=source_file, + ), + ] + + mock_component = MagicMock() + mock_component.resources = mock_resources + + # Setup mocks + mock_core.relative_src_path.side_effect = lambda *args: src_path.joinpath(*args) + mock_core.relative_build_path.side_effect = lambda *args: build_path.joinpath(*args) + mock_core.defines = [] + mock_core.config_hash = 0xDEADBEEF + mock_core.target_platform = "test_platform" + mock_core.config = {} + mock_iter_components.return_value = [("test", mock_component)] + + build_info_json_path = build_path / "build_info.json" + + # First run: initial setup, should create build_info + mock_walk_files.return_value = [] + with ( + patch("esphome.writer.__version__", "2025.1.0-dev"), + patch("esphome.writer.importlib.import_module") as mock_import, + ): + mock_import.side_effect = AttributeError + copy_src_tree() + + # Manually set an old timestamp for testing + old_timestamp = 1700000000 + old_timestamp_str = "2023-11-14 22:13:20 +0000" + build_info_json_path.write_text( + json.dumps( + { + "config_hash": 0xDEADBEEF, + "build_time": old_timestamp, + "build_time_str": old_timestamp_str, + "esphome_version": "2025.1.0-dev", + } + ) + ) + + # Second run: no changes, should NOT regenerate build_info + mock_walk_files.return_value = [str(dest_file)] + with ( + patch("esphome.writer.__version__", "2025.1.0-dev"), + patch("esphome.writer.importlib.import_module") as mock_import, + ): + mock_import.side_effect = AttributeError + copy_src_tree() + + second_json = json.loads(build_info_json_path.read_text()) + second_timestamp = second_json["build_time"] + + # Verify timestamp was NOT changed + assert second_timestamp == old_timestamp, ( + f"build_info should not be regenerated when no files change: " + f"{old_timestamp} != {second_timestamp}" + ) + + # Third run: change source file, should regenerate build_info with new timestamp + source_file.write_text("// version 2") + with ( + patch("esphome.writer.__version__", "2025.1.0-dev"), + patch("esphome.writer.importlib.import_module") as mock_import, + ): + mock_import.side_effect = AttributeError + copy_src_tree() + + third_json = json.loads(build_info_json_path.read_text()) + third_timestamp = third_json["build_time"] + + # Verify timestamp WAS changed + assert third_timestamp != old_timestamp, ( + f"build_info should be regenerated when source file changes: " + f"{old_timestamp} == {third_timestamp}" + ) + assert third_timestamp > old_timestamp + + +@patch("esphome.writer.CORE") +@patch("esphome.writer.iter_components") +@patch("esphome.writer.walk_files") +def test_copy_src_tree_detects_removed_source_file( + mock_walk_files: MagicMock, + mock_iter_components: MagicMock, + mock_core: MagicMock, + tmp_path: Path, +) -> None: + """Test copy_src_tree detects when a non-generated source file is removed.""" + # Setup directory structure + src_path = tmp_path / "src" + src_path.mkdir() + esphome_components_path = src_path / "esphome" / "components" + esphome_components_path.mkdir(parents=True) + build_path = tmp_path / "build" + build_path.mkdir() + + # Create an existing source file in the build tree + existing_file = esphome_components_path / "test.cpp" + existing_file.write_text("// test file") + + # Setup mocks - no components, so the file should be removed + mock_core.relative_src_path.side_effect = lambda *args: src_path.joinpath(*args) + mock_core.relative_build_path.side_effect = lambda *args: build_path.joinpath(*args) + mock_core.defines = [] + mock_core.config_hash = 0xDEADBEEF + mock_core.target_platform = "test_platform" + mock_core.config = {} + mock_iter_components.return_value = [] # No components = file should be removed + mock_walk_files.return_value = [str(existing_file)] + + # Create existing build_info.json + build_info_json_path = build_path / "build_info.json" + old_timestamp = 1700000000 + build_info_json_path.write_text( + json.dumps( + { + "config_hash": 0xDEADBEEF, + "build_time": old_timestamp, + "build_time_str": "2023-11-14 22:13:20 +0000", + "esphome_version": "2025.1.0-dev", + } + ) + ) + + with ( + patch("esphome.writer.__version__", "2025.1.0-dev"), + patch("esphome.writer.importlib.import_module") as mock_import, + ): + mock_import.side_effect = AttributeError + copy_src_tree() + + # Verify file was removed + assert not existing_file.exists() + + # Verify build_info was regenerated due to source file removal + new_json = json.loads(build_info_json_path.read_text()) + assert new_json["build_time"] != old_timestamp + + +@patch("esphome.writer.CORE") +@patch("esphome.writer.iter_components") +@patch("esphome.writer.walk_files") +def test_copy_src_tree_ignores_removed_generated_file( + mock_walk_files: MagicMock, + mock_iter_components: MagicMock, + mock_core: MagicMock, + tmp_path: Path, +) -> None: + """Test copy_src_tree doesn't mark sources_changed when only generated file removed.""" + # Setup directory structure + src_path = tmp_path / "src" + src_path.mkdir() + esphome_core_path = src_path / "esphome" / "core" + esphome_core_path.mkdir(parents=True) + build_path = tmp_path / "build" + build_path.mkdir() + + # Create existing build_info_data.h (a generated file) + build_info_h = esphome_core_path / "build_info_data.h" + build_info_h.write_text("// old generated file") + + # Setup mocks + mock_core.relative_src_path.side_effect = lambda *args: src_path.joinpath(*args) + mock_core.relative_build_path.side_effect = lambda *args: build_path.joinpath(*args) + mock_core.defines = [] + mock_core.config_hash = 0xDEADBEEF + mock_core.target_platform = "test_platform" + mock_core.config = {} + mock_iter_components.return_value = [] + # walk_files returns the generated file, but it's not in source_files_copy + mock_walk_files.return_value = [str(build_info_h)] + + # Create existing build_info.json with old timestamp + build_info_json_path = build_path / "build_info.json" + old_timestamp = 1700000000 + build_info_json_path.write_text( + json.dumps( + { + "config_hash": 0xDEADBEEF, + "build_time": old_timestamp, + "build_time_str": "2023-11-14 22:13:20 +0000", + "esphome_version": "2025.1.0-dev", + } + ) + ) + + with ( + patch("esphome.writer.__version__", "2025.1.0-dev"), + patch("esphome.writer.importlib.import_module") as mock_import, + ): + mock_import.side_effect = AttributeError + copy_src_tree() + + # Verify build_info_data.h was regenerated (not removed) + assert build_info_h.exists() + + # Note: build_info.json will have a new timestamp because get_build_info() + # always returns current time. The key test is that the old build_info_data.h + # file was removed and regenerated, not that it triggered sources_changed. + new_json = json.loads(build_info_json_path.read_text()) + assert new_json["config_hash"] == 0xDEADBEEF diff --git a/tests/unit_tests/test_yaml_util.py b/tests/unit_tests/test_yaml_util.py index eac0ceabb8..c8cb3e144f 100644 --- a/tests/unit_tests/test_yaml_util.py +++ b/tests/unit_tests/test_yaml_util.py @@ -278,3 +278,31 @@ def test_secret_values_tracking(fixture_path: Path) -> None: assert yaml_util._SECRET_VALUES["super_secret_wifi"] == "wifi_password" assert "0123456789abcdef" in yaml_util._SECRET_VALUES assert yaml_util._SECRET_VALUES["0123456789abcdef"] == "api_key" + + +def test_dump_sort_keys() -> None: + """Test that dump with sort_keys=True produces sorted output.""" + # Create a dict with unsorted keys + data = { + "zebra": 1, + "alpha": 2, + "nested": { + "z_key": "z_value", + "a_key": "a_value", + }, + } + + # Without sort_keys, keys are in insertion order + unsorted = yaml_util.dump(data, sort_keys=False) + lines_unsorted = unsorted.strip().split("\n") + # First key should be "zebra" (insertion order) + assert lines_unsorted[0].startswith("zebra:") + + # With sort_keys, keys are alphabetically sorted + sorted_dump = yaml_util.dump(data, sort_keys=True) + lines_sorted = sorted_dump.strip().split("\n") + # First key should be "alpha" (alphabetical order) + assert lines_sorted[0].startswith("alpha:") + # nested keys should also be sorted + assert "a_key:" in sorted_dump + assert sorted_dump.index("a_key:") < sorted_dump.index("z_key:") From 1b5af7d21d500f8d03c1a505b4afef4eb5a578cd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 17 Dec 2025 15:22:19 -1000 Subject: [PATCH 02/33] Bump github/codeql-action from 4.31.8 to 4.31.9 (#12524) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index f917ecd8f8..e63c3075dd 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@1b168cd39490f61582a9beae412bb7057a6b2c4e # v4.31.8 + uses: github/codeql-action/init@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9 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@1b168cd39490f61582a9beae412bb7057a6b2c4e # v4.31.8 + uses: github/codeql-action/analyze@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9 with: category: "/language:${{matrix.language}}" From 426305836dcb1c225d52e1ca999142760208599d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 17 Dec 2025 21:16:14 -0700 Subject: [PATCH 03/33] [esp32][libretiny] Avoid duplicate snprintf when syncing preferences (#12542) --- esphome/components/esp32/preferences.cpp | 13 ++++++------- esphome/components/libretiny/preferences.cpp | 14 ++++++-------- 2 files changed, 12 insertions(+), 15 deletions(-) diff --git a/esphome/components/esp32/preferences.cpp b/esphome/components/esp32/preferences.cpp index e19a85e4e3..5e1e8734e5 100644 --- a/esphome/components/esp32/preferences.cpp +++ b/esphome/components/esp32/preferences.cpp @@ -130,10 +130,10 @@ class ESP32Preferences : public ESPPreferences { // go through vector from back to front (makes erase easier/more efficient) for (ssize_t i = s_pending_save.size() - 1; i >= 0; i--) { const auto &save = s_pending_save[i]; - ESP_LOGVV(TAG, "Checking if NVS data %" PRIu32 " has changed", save.key); - if (this->is_changed(this->nvs_handle, save)) { - char key_str[KEY_BUFFER_SIZE]; - snprintf(key_str, sizeof(key_str), "%" PRIu32, save.key); + char key_str[KEY_BUFFER_SIZE]; + snprintf(key_str, sizeof(key_str), "%" PRIu32, save.key); + ESP_LOGVV(TAG, "Checking if NVS data %s has changed", key_str); + if (this->is_changed_(this->nvs_handle, save, key_str)) { esp_err_t err = nvs_set_blob(this->nvs_handle, key_str, save.data.get(), save.len); ESP_LOGV(TAG, "sync: key: %s, len: %zu", key_str, save.len); if (err != 0) { @@ -166,10 +166,9 @@ class ESP32Preferences : public ESPPreferences { return failed == 0; } - bool is_changed(const uint32_t nvs_handle, const NVSData &to_save) { - char key_str[KEY_BUFFER_SIZE]; - snprintf(key_str, sizeof(key_str), "%" PRIu32, to_save.key); + protected: + bool is_changed_(uint32_t nvs_handle, const NVSData &to_save, const char *key_str) { size_t actual_len; esp_err_t err = nvs_get_blob(nvs_handle, key_str, nullptr, &actual_len); if (err != 0) { diff --git a/esphome/components/libretiny/preferences.cpp b/esphome/components/libretiny/preferences.cpp index c21c5813a8..e47e88c6f3 100644 --- a/esphome/components/libretiny/preferences.cpp +++ b/esphome/components/libretiny/preferences.cpp @@ -120,10 +120,10 @@ class LibreTinyPreferences : public ESPPreferences { // go through vector from back to front (makes erase easier/more efficient) for (ssize_t i = s_pending_save.size() - 1; i >= 0; i--) { const auto &save = s_pending_save[i]; - ESP_LOGVV(TAG, "Checking if FDB data %" PRIu32 " has changed", save.key); - if (this->is_changed(&this->db, save)) { - char key_str[KEY_BUFFER_SIZE]; - snprintf(key_str, sizeof(key_str), "%" PRIu32, save.key); + char key_str[KEY_BUFFER_SIZE]; + snprintf(key_str, sizeof(key_str), "%" PRIu32, save.key); + ESP_LOGVV(TAG, "Checking if FDB data %s has changed", key_str); + if (this->is_changed_(&this->db, save, key_str)) { ESP_LOGV(TAG, "sync: key: %s, len: %zu", key_str, save.len); fdb_blob_make(&this->blob, save.data.get(), save.len); fdb_err_t err = fdb_kv_set_blob(&this->db, key_str, &this->blob); @@ -150,10 +150,8 @@ class LibreTinyPreferences : public ESPPreferences { return failed == 0; } - bool is_changed(const fdb_kvdb_t db, const NVSData &to_save) { - char key_str[KEY_BUFFER_SIZE]; - snprintf(key_str, sizeof(key_str), "%" PRIu32, to_save.key); - + protected: + bool is_changed_(fdb_kvdb_t db, const NVSData &to_save, const char *key_str) { struct fdb_kv kv; fdb_kv_t kvp = fdb_kv_get_obj(db, key_str, &kv); if (kvp == nullptr) { From 4f821a6d76a27960cd1a007ac97e443d419d413a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 17 Dec 2025 21:21:46 -0700 Subject: [PATCH 04/33] [wifi] Reduce scan logging to prevent blocking loop during connection (#12544) --- esphome/components/wifi/wifi_component.cpp | 41 ++++++++++++++++------ 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index a550aa679d..7c5b001be9 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -1031,23 +1031,30 @@ template static void insertion_sort_scan_results(VectorType } } -// Helper function to log scan results - marked noinline to prevent re-inlining into loop +// Helper function to log matching scan results - marked noinline to prevent re-inlining into loop __attribute__((noinline)) static void log_scan_result(const WiFiScanResult &res) { char bssid_s[18]; auto bssid = res.get_bssid(); format_mac_addr_upper(bssid.data(), bssid_s); - if (res.get_matches()) { - ESP_LOGI(TAG, "- '%s' %s" LOG_SECRET("(%s) ") "%s", res.get_ssid().c_str(), - res.get_is_hidden() ? LOG_STR_LITERAL("(HIDDEN) ") : LOG_STR_LITERAL(""), bssid_s, - LOG_STR_ARG(get_signal_bars(res.get_rssi()))); - ESP_LOGD(TAG, " Channel: %2u, RSSI: %3d dB, Priority: %4d", res.get_channel(), res.get_rssi(), res.get_priority()); - } else { - ESP_LOGD(TAG, "- " LOG_SECRET("'%s'") " " LOG_SECRET("(%s) ") "%s", res.get_ssid().c_str(), bssid_s, - LOG_STR_ARG(get_signal_bars(res.get_rssi()))); - } + ESP_LOGI(TAG, "- '%s' %s" LOG_SECRET("(%s) ") "%s", res.get_ssid().c_str(), + res.get_is_hidden() ? LOG_STR_LITERAL("(HIDDEN) ") : LOG_STR_LITERAL(""), bssid_s, + LOG_STR_ARG(get_signal_bars(res.get_rssi()))); + ESP_LOGD(TAG, " Channel: %2u, RSSI: %3d dB, Priority: %4d", res.get_channel(), res.get_rssi(), res.get_priority()); } +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE +// Helper function to log non-matching scan results at verbose level +__attribute__((noinline)) static void log_scan_result_non_matching(const WiFiScanResult &res) { + char bssid_s[18]; + auto bssid = res.get_bssid(); + format_mac_addr_upper(bssid.data(), bssid_s); + + ESP_LOGV(TAG, "- " LOG_SECRET("'%s'") " " LOG_SECRET("(%s) ") "%s", res.get_ssid().c_str(), bssid_s, + LOG_STR_ARG(get_signal_bars(res.get_rssi()))); +} +#endif + void WiFiComponent::check_scanning_finished() { if (!this->scan_done_) { if (millis() - this->action_started_ > WIFI_SCAN_TIMEOUT_MS) { @@ -1084,8 +1091,20 @@ void WiFiComponent::check_scanning_finished() { // Sort scan results using insertion sort for better memory efficiency insertion_sort_scan_results(this->scan_result_); + size_t non_matching_count = 0; for (auto &res : this->scan_result_) { - log_scan_result(res); + if (res.get_matches()) { + log_scan_result(res); + } else { +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + log_scan_result_non_matching(res); +#else + non_matching_count++; +#endif + } + } + if (non_matching_count > 0) { + ESP_LOGD(TAG, "- %zu non-matching (VERBOSE to show)", non_matching_count); } // SYNCHRONIZATION POINT: Establish link between scan_result_[0] and selected_sta_index_ From ca47bad90ac0c3f561e53946de3832e4ea0384b9 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 17 Dec 2025 23:34:04 -0500 Subject: [PATCH 05/33] [template.alarm_control_panel] Fix compile without binary_sensor (#12548) Co-authored-by: Claude --- .../alarm_control_panel/template_alarm_control_panel.h | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/template/alarm_control_panel/template_alarm_control_panel.h b/esphome/components/template/alarm_control_panel/template_alarm_control_panel.h index bdd3747372..2038d8f1b0 100644 --- a/esphome/components/template/alarm_control_panel/template_alarm_control_panel.h +++ b/esphome/components/template/alarm_control_panel/template_alarm_control_panel.h @@ -39,6 +39,7 @@ enum TemplateAlarmControlPanelRestoreMode { ALARM_CONTROL_PANEL_RESTORE_DEFAULT_DISARMED, }; +#ifdef USE_BINARY_SENSOR struct SensorDataStore { bool last_chime_state; }; @@ -49,7 +50,6 @@ struct SensorInfo { uint8_t store_index; }; -#ifdef USE_BINARY_SENSOR struct AlarmSensor { binary_sensor::BinarySensor *sensor; SensorInfo info; @@ -139,6 +139,9 @@ class TemplateAlarmControlPanel final : public alarm_control_panel::AlarmControl FixedVector sensors_; // a list of automatically bypassed sensors std::vector bypassed_sensor_indicies_; + // Per sensor data store + std::vector sensor_data_; + uint8_t next_store_index_ = 0; #endif TemplateAlarmControlPanelRestoreMode restore_mode_{}; @@ -154,14 +157,11 @@ class TemplateAlarmControlPanel final : public alarm_control_panel::AlarmControl uint32_t trigger_time_; // a list of codes std::vector codes_; - // Per sensor data store - std::vector sensor_data_; // requires a code to arm bool requires_code_to_arm_ = false; bool supports_arm_home_ = false; bool supports_arm_night_ = false; bool sensors_ready_ = false; - uint8_t next_store_index_ = 0; // check if the code is valid bool is_code_valid_(optional code); From 663a4304e01baf6d0d03caa2e105880f8f716116 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 18 Dec 2025 07:50:31 -0500 Subject: [PATCH 06/33] [libretiny] Fix millis() ambiguity on BK72XX (#12534) Co-authored-by: Claude --- esphome/core/config.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/esphome/core/config.py b/esphome/core/config.py index 97157b6f92..507a39b401 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -382,10 +382,15 @@ def include_file(path: Path, basename: Path, is_c_header: bool = False): ARDUINO_GLUE_CODE = """\ +#undef yield #define yield() esphome::yield() +#undef millis #define millis() esphome::millis() +#undef micros #define micros() esphome::micros() +#undef delay #define delay(x) esphome::delay(x) +#undef delayMicroseconds #define delayMicroseconds(x) esphome::delayMicroseconds(x) """ @@ -536,7 +541,7 @@ async def to_code(config: ConfigType) -> None: if config[CONF_DEBUG_SCHEDULER]: cg.add_define("ESPHOME_DEBUG_SCHEDULER") - if CORE.using_arduino and not CORE.is_bk72xx: + if CORE.using_arduino: CORE.add_job(add_arduino_global_workaround) if config[CONF_INCLUDES]: From b47b7d43fd29d1837a2b1499900ba1424a6aa13d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 18 Dec 2025 09:06:16 -0700 Subject: [PATCH 07/33] [api] Remove unused force parameter from encode_message (#12551) --- esphome/components/api/api_pb2.cpp | 24 ++++++++++++------------ esphome/components/api/proto.h | 4 ++-- script/api_protobuf/api_protobuf.py | 22 ++++++++++++++-------- 3 files changed, 28 insertions(+), 22 deletions(-) diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 4a89ee78e1..52f4b495e9 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -124,12 +124,12 @@ void DeviceInfoResponse::encode(ProtoWriteBuffer buffer) const { #endif #ifdef USE_DEVICES for (const auto &it : this->devices) { - buffer.encode_message(20, it, true); + buffer.encode_message(20, it); } #endif #ifdef USE_AREAS for (const auto &it : this->areas) { - buffer.encode_message(21, it, true); + buffer.encode_message(21, it); } #endif #ifdef USE_AREAS @@ -878,13 +878,13 @@ void HomeassistantServiceMap::calculate_size(ProtoSize &size) const { void HomeassistantActionRequest::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(1, this->service_ref_); for (auto &it : this->data) { - buffer.encode_message(2, it, true); + buffer.encode_message(2, it); } for (auto &it : this->data_template) { - buffer.encode_message(3, it, true); + buffer.encode_message(3, it); } for (auto &it : this->variables) { - buffer.encode_message(4, it, true); + buffer.encode_message(4, it); } buffer.encode_bool(5, this->is_event); #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES @@ -1011,7 +1011,7 @@ void ListEntitiesServicesResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(1, this->name_ref_); buffer.encode_fixed32(2, this->key); for (auto &it : this->args) { - buffer.encode_message(3, it, true); + buffer.encode_message(3, it); } buffer.encode_uint32(4, static_cast(this->supports_response)); } @@ -1867,7 +1867,7 @@ void ListEntitiesMediaPlayerResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(7, static_cast(this->entity_category)); buffer.encode_bool(8, this->supports_pause); for (auto &it : this->supported_formats) { - buffer.encode_message(9, it, true); + buffer.encode_message(9, it); } #ifdef USE_DEVICES buffer.encode_uint32(10, this->device_id); @@ -1987,7 +1987,7 @@ void BluetoothLERawAdvertisement::calculate_size(ProtoSize &size) const { } void BluetoothLERawAdvertisementsResponse::encode(ProtoWriteBuffer buffer) const { for (uint16_t i = 0; i < this->advertisements_len; i++) { - buffer.encode_message(1, this->advertisements[i], true); + buffer.encode_message(1, this->advertisements[i]); } } void BluetoothLERawAdvertisementsResponse::calculate_size(ProtoSize &size) const { @@ -2060,7 +2060,7 @@ void BluetoothGATTCharacteristic::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint32(2, this->handle); buffer.encode_uint32(3, this->properties); for (auto &it : this->descriptors) { - buffer.encode_message(4, it, true); + buffer.encode_message(4, it); } buffer.encode_uint32(5, this->short_uuid); } @@ -2081,7 +2081,7 @@ void BluetoothGATTService::encode(ProtoWriteBuffer buffer) const { } buffer.encode_uint32(2, this->handle); for (auto &it : this->characteristics) { - buffer.encode_message(3, it, true); + buffer.encode_message(3, it); } buffer.encode_uint32(4, this->short_uuid); } @@ -2097,7 +2097,7 @@ void BluetoothGATTService::calculate_size(ProtoSize &size) const { void BluetoothGATTGetServicesResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_uint64(1, this->address); for (auto &it : this->services) { - buffer.encode_message(2, it, true); + buffer.encode_message(2, it); } } void BluetoothGATTGetServicesResponse::calculate_size(ProtoSize &size) const { @@ -2557,7 +2557,7 @@ bool VoiceAssistantConfigurationRequest::decode_length(uint32_t field_id, ProtoL } void VoiceAssistantConfigurationResponse::encode(ProtoWriteBuffer buffer) const { for (auto &it : this->available_wake_words) { - buffer.encode_message(1, it, true); + buffer.encode_message(1, it); } for (const auto &it : *this->active_wake_words) { buffer.encode_string(2, it, true); diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index 83b6922be1..efdab9341c 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -334,7 +334,7 @@ class ProtoWriteBuffer { void encode_sint64(uint32_t field_id, int64_t value, bool force = false) { this->encode_uint64(field_id, encode_zigzag64(value), force); } - void encode_message(uint32_t field_id, const ProtoMessage &value, bool force = false); + void encode_message(uint32_t field_id, const ProtoMessage &value); std::vector *get_buffer() const { return buffer_; } protected: @@ -795,7 +795,7 @@ class ProtoSize { }; // Implementation of encode_message - must be after ProtoMessage is defined -inline void ProtoWriteBuffer::encode_message(uint32_t field_id, const ProtoMessage &value, bool force) { +inline void ProtoWriteBuffer::encode_message(uint32_t field_id, const ProtoMessage &value) { this->encode_field_raw(field_id, 2); // type 2: Length-delimited message // Calculate the message size first diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 3412fac5db..cb09ef7050 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -1215,6 +1215,9 @@ class FixedArrayRepeatedType(TypeInfo): """Helper to generate encode statement for a single element.""" if isinstance(self._ti, EnumType): return f"buffer.{self._ti.encode_func}({self.number}, static_cast({element}), true);" + # MessageType.encode_message doesn't have a force parameter + if isinstance(self._ti, MessageType): + return f"buffer.{self._ti.encode_func}({self.number}, {element});" return f"buffer.{self._ti.encode_func}({self.number}, {element}, true);" @property @@ -1536,6 +1539,15 @@ class RepeatedTypeInfo(TypeInfo): # std::vector is specialized for bool, reference does not work return isinstance(self._ti, BoolType) + def _encode_element_call(self, element: str) -> str: + """Helper to generate encode call for a single element.""" + if isinstance(self._ti, EnumType): + return f"buffer.{self._ti.encode_func}({self.number}, static_cast({element}), true);" + # MessageType.encode_message doesn't have a force parameter + if isinstance(self._ti, MessageType): + return f"buffer.{self._ti.encode_func}({self.number}, {element});" + return f"buffer.{self._ti.encode_func}({self.number}, {element}, true);" + @property def encode_content(self) -> str: if self._use_pointer: @@ -1546,17 +1558,11 @@ class RepeatedTypeInfo(TypeInfo): o += f" buffer.{self._ti.encode_func}({self.number}, it, strlen(it), true);\n" else: o = f"for (const auto &it : *this->{self.field_name}) {{\n" - if isinstance(self._ti, EnumType): - o += f" buffer.{self._ti.encode_func}({self.number}, static_cast(it), true);\n" - else: - o += f" buffer.{self._ti.encode_func}({self.number}, it, true);\n" + o += f" {self._encode_element_call('it')}\n" o += "}" return o o = f"for (auto {'' if self._ti_is_bool else '&'}it : this->{self.field_name}) {{\n" - if isinstance(self._ti, EnumType): - o += f" buffer.{self._ti.encode_func}({self.number}, static_cast(it), true);\n" - else: - o += f" buffer.{self._ti.encode_func}({self.number}, it, true);\n" + o += f" {self._encode_element_call('it')}\n" o += "}" return o From 2cf6ed2af729bed731326b2eac6342e19010b8c5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 18 Dec 2025 09:07:35 -0700 Subject: [PATCH 08/33] [socket] Refactor socket implementations for memory efficiency and code quality (#12550) --- .../components/socket/bsd_sockets_impl.cpp | 106 ++++++++++-------- .../components/socket/lwip_raw_tcp_impl.cpp | 6 +- .../components/socket/lwip_sockets_impl.cpp | 106 ++++++++++-------- esphome/components/socket/socket.cpp | 28 +---- esphome/components/socket/socket.h | 13 +-- 5 files changed, 122 insertions(+), 137 deletions(-) diff --git a/esphome/components/socket/bsd_sockets_impl.cpp b/esphome/components/socket/bsd_sockets_impl.cpp index c7cca62027..09cd81752a 100644 --- a/esphome/components/socket/bsd_sockets_impl.cpp +++ b/esphome/components/socket/bsd_sockets_impl.cpp @@ -12,8 +12,7 @@ #include #endif -namespace esphome { -namespace socket { +namespace esphome::socket { std::string format_sockaddr(const struct sockaddr_storage &storage) { if (storage.ss_family == AF_INET) { @@ -44,11 +43,11 @@ class BSDSocketImpl : public Socket { BSDSocketImpl(int fd, bool monitor_loop = false) : fd_(fd) { #ifdef USE_SOCKET_SELECT_SUPPORT // Register new socket with the application for select() if monitoring requested - if (monitor_loop && fd_ >= 0) { + if (monitor_loop && this->fd_ >= 0) { // Only set loop_monitored_ to true if registration succeeds - loop_monitored_ = App.register_socket_fd(fd_); + this->loop_monitored_ = App.register_socket_fd(this->fd_); } else { - loop_monitored_ = false; + this->loop_monitored_ = false; } #else // Without select support, ignore monitor_loop parameter @@ -56,70 +55,69 @@ class BSDSocketImpl : public Socket { #endif } ~BSDSocketImpl() override { - if (!closed_) { - close(); // NOLINT(clang-analyzer-optin.cplusplus.VirtualCall) + if (!this->closed_) { + this->close(); // NOLINT(clang-analyzer-optin.cplusplus.VirtualCall) } } - int connect(const struct sockaddr *addr, socklen_t addrlen) override { return ::connect(fd_, addr, addrlen); } + int connect(const struct sockaddr *addr, socklen_t addrlen) override { return ::connect(this->fd_, addr, addrlen); } std::unique_ptr accept(struct sockaddr *addr, socklen_t *addrlen) override { - return accept_impl_(addr, addrlen, false); - } - std::unique_ptr accept_loop_monitored(struct sockaddr *addr, socklen_t *addrlen) override { - return accept_impl_(addr, addrlen, true); - } - - private: - std::unique_ptr accept_impl_(struct sockaddr *addr, socklen_t *addrlen, bool loop_monitored) { - int fd = ::accept(fd_, addr, addrlen); + int fd = ::accept(this->fd_, addr, addrlen); if (fd == -1) return {}; - return make_unique(fd, loop_monitored); + return make_unique(fd, false); + } + std::unique_ptr accept_loop_monitored(struct sockaddr *addr, socklen_t *addrlen) override { + int fd = ::accept(this->fd_, addr, addrlen); + if (fd == -1) + return {}; + return make_unique(fd, true); } - public: - int bind(const struct sockaddr *addr, socklen_t addrlen) override { return ::bind(fd_, addr, addrlen); } + int bind(const struct sockaddr *addr, socklen_t addrlen) override { return ::bind(this->fd_, addr, addrlen); } int close() override { - if (!closed_) { + if (!this->closed_) { #ifdef USE_SOCKET_SELECT_SUPPORT // Unregister from select() before closing if monitored - if (loop_monitored_) { - App.unregister_socket_fd(fd_); + if (this->loop_monitored_) { + App.unregister_socket_fd(this->fd_); } #endif - int ret = ::close(fd_); - closed_ = true; + int ret = ::close(this->fd_); + this->closed_ = true; return ret; } return 0; } - int shutdown(int how) override { return ::shutdown(fd_, how); } + int shutdown(int how) override { return ::shutdown(this->fd_, how); } - int getpeername(struct sockaddr *addr, socklen_t *addrlen) override { return ::getpeername(fd_, addr, addrlen); } + int getpeername(struct sockaddr *addr, socklen_t *addrlen) override { + return ::getpeername(this->fd_, addr, addrlen); + } std::string getpeername() override { struct sockaddr_storage storage; socklen_t len = sizeof(storage); - int err = this->getpeername((struct sockaddr *) &storage, &len); - if (err != 0) + if (::getpeername(this->fd_, (struct sockaddr *) &storage, &len) != 0) return {}; return format_sockaddr(storage); } - int getsockname(struct sockaddr *addr, socklen_t *addrlen) override { return ::getsockname(fd_, addr, addrlen); } + int getsockname(struct sockaddr *addr, socklen_t *addrlen) override { + return ::getsockname(this->fd_, addr, addrlen); + } std::string getsockname() override { struct sockaddr_storage storage; socklen_t len = sizeof(storage); - int err = this->getsockname((struct sockaddr *) &storage, &len); - if (err != 0) + if (::getsockname(this->fd_, (struct sockaddr *) &storage, &len) != 0) return {}; return format_sockaddr(storage); } int getsockopt(int level, int optname, void *optval, socklen_t *optlen) override { - return ::getsockopt(fd_, level, optname, optval, optlen); + return ::getsockopt(this->fd_, level, optname, optval, optlen); } int setsockopt(int level, int optname, const void *optval, socklen_t optlen) override { - return ::setsockopt(fd_, level, optname, optval, optlen); + return ::setsockopt(this->fd_, level, optname, optval, optlen); } - int listen(int backlog) override { return ::listen(fd_, backlog); } - ssize_t read(void *buf, size_t len) override { return ::read(fd_, buf, len); } + int listen(int backlog) override { return ::listen(this->fd_, backlog); } + ssize_t read(void *buf, size_t len) override { return ::read(this->fd_, buf, len); } ssize_t recvfrom(void *buf, size_t len, sockaddr *addr, socklen_t *addr_len) override { #if defined(USE_ESP32) || defined(USE_HOST) return ::recvfrom(this->fd_, buf, len, 0, addr, addr_len); @@ -129,41 +127,52 @@ class BSDSocketImpl : public Socket { } ssize_t readv(const struct iovec *iov, int iovcnt) override { #if defined(USE_ESP32) - return ::lwip_readv(fd_, iov, iovcnt); + return ::lwip_readv(this->fd_, iov, iovcnt); #else - return ::readv(fd_, iov, iovcnt); + return ::readv(this->fd_, iov, iovcnt); #endif } - ssize_t write(const void *buf, size_t len) override { return ::write(fd_, buf, len); } - ssize_t send(void *buf, size_t len, int flags) { return ::send(fd_, buf, len, flags); } + ssize_t write(const void *buf, size_t len) override { return ::write(this->fd_, buf, len); } + ssize_t send(void *buf, size_t len, int flags) { return ::send(this->fd_, buf, len, flags); } ssize_t writev(const struct iovec *iov, int iovcnt) override { #if defined(USE_ESP32) - return ::lwip_writev(fd_, iov, iovcnt); + return ::lwip_writev(this->fd_, iov, iovcnt); #else - return ::writev(fd_, iov, iovcnt); + return ::writev(this->fd_, iov, iovcnt); #endif } ssize_t sendto(const void *buf, size_t len, int flags, const struct sockaddr *to, socklen_t tolen) override { - return ::sendto(fd_, buf, len, flags, to, tolen); // NOLINT(readability-suspicious-call-argument) + return ::sendto(this->fd_, buf, len, flags, to, tolen); // NOLINT(readability-suspicious-call-argument) } int setblocking(bool blocking) override { - int fl = ::fcntl(fd_, F_GETFL, 0); + int fl = ::fcntl(this->fd_, F_GETFL, 0); if (blocking) { fl &= ~O_NONBLOCK; } else { fl |= O_NONBLOCK; } - ::fcntl(fd_, F_SETFL, fl); + ::fcntl(this->fd_, F_SETFL, fl); return 0; } - int get_fd() const override { return fd_; } + int get_fd() const override { return this->fd_; } + +#ifdef USE_SOCKET_SELECT_SUPPORT + bool ready() const override { + if (!this->loop_monitored_) + return true; + return App.is_socket_ready(this->fd_); + } +#endif protected: int fd_; - bool closed_ = false; + bool closed_{false}; +#ifdef USE_SOCKET_SELECT_SUPPORT + bool loop_monitored_{false}; +#endif }; // Helper to create a socket with optional monitoring @@ -182,7 +191,6 @@ std::unique_ptr socket_loop_monitored(int domain, int type, int protocol return create_socket(domain, type, protocol, true); } -} // namespace socket -} // namespace esphome +} // namespace esphome::socket #endif // USE_SOCKET_IMPL_BSD_SOCKETS diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index 328df24bdd..cb5d17d5af 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -18,8 +18,7 @@ #include // For esp_schedule() #endif -namespace esphome { -namespace socket { +namespace esphome::socket { #ifdef USE_ESP8266 // Flag to signal socket activity - checked by socket_delay() to exit early @@ -711,7 +710,6 @@ std::unique_ptr socket_loop_monitored(int domain, int type, int protocol return socket(domain, type, protocol); } -} // namespace socket -} // namespace esphome +} // namespace esphome::socket #endif // USE_SOCKET_IMPL_LWIP_TCP diff --git a/esphome/components/socket/lwip_sockets_impl.cpp b/esphome/components/socket/lwip_sockets_impl.cpp index d94c1fb2ff..23fb1a7f6f 100644 --- a/esphome/components/socket/lwip_sockets_impl.cpp +++ b/esphome/components/socket/lwip_sockets_impl.cpp @@ -7,8 +7,7 @@ #include #include "esphome/core/application.h" -namespace esphome { -namespace socket { +namespace esphome::socket { std::string format_sockaddr(const struct sockaddr_storage &storage) { if (storage.ss_family == AF_INET) { @@ -37,11 +36,11 @@ class LwIPSocketImpl : public Socket { LwIPSocketImpl(int fd, bool monitor_loop = false) : fd_(fd) { #ifdef USE_SOCKET_SELECT_SUPPORT // Register new socket with the application for select() if monitoring requested - if (monitor_loop && fd_ >= 0) { + if (monitor_loop && this->fd_ >= 0) { // Only set loop_monitored_ to true if registration succeeds - loop_monitored_ = App.register_socket_fd(fd_); + this->loop_monitored_ = App.register_socket_fd(this->fd_); } else { - loop_monitored_ = false; + this->loop_monitored_ = false; } #else // Without select support, ignore monitor_loop parameter @@ -49,96 +48,108 @@ class LwIPSocketImpl : public Socket { #endif } ~LwIPSocketImpl() override { - if (!closed_) { - close(); // NOLINT(clang-analyzer-optin.cplusplus.VirtualCall) + if (!this->closed_) { + this->close(); // NOLINT(clang-analyzer-optin.cplusplus.VirtualCall) } } - int connect(const struct sockaddr *addr, socklen_t addrlen) override { return lwip_connect(fd_, addr, addrlen); } + int connect(const struct sockaddr *addr, socklen_t addrlen) override { + return lwip_connect(this->fd_, addr, addrlen); + } std::unique_ptr accept(struct sockaddr *addr, socklen_t *addrlen) override { - return accept_impl_(addr, addrlen, false); - } - std::unique_ptr accept_loop_monitored(struct sockaddr *addr, socklen_t *addrlen) override { - return accept_impl_(addr, addrlen, true); - } - - private: - std::unique_ptr accept_impl_(struct sockaddr *addr, socklen_t *addrlen, bool loop_monitored) { - int fd = lwip_accept(fd_, addr, addrlen); + int fd = lwip_accept(this->fd_, addr, addrlen); if (fd == -1) return {}; - return make_unique(fd, loop_monitored); + return make_unique(fd, false); + } + std::unique_ptr accept_loop_monitored(struct sockaddr *addr, socklen_t *addrlen) override { + int fd = lwip_accept(this->fd_, addr, addrlen); + if (fd == -1) + return {}; + return make_unique(fd, true); } - public: - int bind(const struct sockaddr *addr, socklen_t addrlen) override { return lwip_bind(fd_, addr, addrlen); } + int bind(const struct sockaddr *addr, socklen_t addrlen) override { return lwip_bind(this->fd_, addr, addrlen); } int close() override { - if (!closed_) { + if (!this->closed_) { #ifdef USE_SOCKET_SELECT_SUPPORT // Unregister from select() before closing if monitored - if (loop_monitored_) { - App.unregister_socket_fd(fd_); + if (this->loop_monitored_) { + App.unregister_socket_fd(this->fd_); } #endif - int ret = lwip_close(fd_); - closed_ = true; + int ret = lwip_close(this->fd_); + this->closed_ = true; return ret; } return 0; } - int shutdown(int how) override { return lwip_shutdown(fd_, how); } + int shutdown(int how) override { return lwip_shutdown(this->fd_, how); } - int getpeername(struct sockaddr *addr, socklen_t *addrlen) override { return lwip_getpeername(fd_, addr, addrlen); } + int getpeername(struct sockaddr *addr, socklen_t *addrlen) override { + return lwip_getpeername(this->fd_, addr, addrlen); + } std::string getpeername() override { struct sockaddr_storage storage; socklen_t len = sizeof(storage); - int err = this->getpeername((struct sockaddr *) &storage, &len); - if (err != 0) + if (lwip_getpeername(this->fd_, (struct sockaddr *) &storage, &len) != 0) return {}; return format_sockaddr(storage); } - int getsockname(struct sockaddr *addr, socklen_t *addrlen) override { return lwip_getsockname(fd_, addr, addrlen); } + int getsockname(struct sockaddr *addr, socklen_t *addrlen) override { + return lwip_getsockname(this->fd_, addr, addrlen); + } std::string getsockname() override { struct sockaddr_storage storage; socklen_t len = sizeof(storage); - int err = this->getsockname((struct sockaddr *) &storage, &len); - if (err != 0) + if (lwip_getsockname(this->fd_, (struct sockaddr *) &storage, &len) != 0) return {}; return format_sockaddr(storage); } int getsockopt(int level, int optname, void *optval, socklen_t *optlen) override { - return lwip_getsockopt(fd_, level, optname, optval, optlen); + return lwip_getsockopt(this->fd_, level, optname, optval, optlen); } int setsockopt(int level, int optname, const void *optval, socklen_t optlen) override { - return lwip_setsockopt(fd_, level, optname, optval, optlen); + return lwip_setsockopt(this->fd_, level, optname, optval, optlen); } - int listen(int backlog) override { return lwip_listen(fd_, backlog); } - ssize_t read(void *buf, size_t len) override { return lwip_read(fd_, buf, len); } + int listen(int backlog) override { return lwip_listen(this->fd_, backlog); } + ssize_t read(void *buf, size_t len) override { return lwip_read(this->fd_, buf, len); } ssize_t recvfrom(void *buf, size_t len, sockaddr *addr, socklen_t *addr_len) override { - return lwip_recvfrom(fd_, buf, len, 0, addr, addr_len); + return lwip_recvfrom(this->fd_, buf, len, 0, addr, addr_len); } - ssize_t readv(const struct iovec *iov, int iovcnt) override { return lwip_readv(fd_, iov, iovcnt); } - ssize_t write(const void *buf, size_t len) override { return lwip_write(fd_, buf, len); } - ssize_t send(void *buf, size_t len, int flags) { return lwip_send(fd_, buf, len, flags); } - ssize_t writev(const struct iovec *iov, int iovcnt) override { return lwip_writev(fd_, iov, iovcnt); } + ssize_t readv(const struct iovec *iov, int iovcnt) override { return lwip_readv(this->fd_, iov, iovcnt); } + ssize_t write(const void *buf, size_t len) override { return lwip_write(this->fd_, buf, len); } + ssize_t send(void *buf, size_t len, int flags) { return lwip_send(this->fd_, buf, len, flags); } + ssize_t writev(const struct iovec *iov, int iovcnt) override { return lwip_writev(this->fd_, iov, iovcnt); } ssize_t sendto(const void *buf, size_t len, int flags, const struct sockaddr *to, socklen_t tolen) override { - return lwip_sendto(fd_, buf, len, flags, to, tolen); + return lwip_sendto(this->fd_, buf, len, flags, to, tolen); } int setblocking(bool blocking) override { - int fl = lwip_fcntl(fd_, F_GETFL, 0); + int fl = lwip_fcntl(this->fd_, F_GETFL, 0); if (blocking) { fl &= ~O_NONBLOCK; } else { fl |= O_NONBLOCK; } - lwip_fcntl(fd_, F_SETFL, fl); + lwip_fcntl(this->fd_, F_SETFL, fl); return 0; } - int get_fd() const override { return fd_; } + int get_fd() const override { return this->fd_; } + +#ifdef USE_SOCKET_SELECT_SUPPORT + bool ready() const override { + if (!this->loop_monitored_) + return true; + return App.is_socket_ready(this->fd_); + } +#endif protected: int fd_; - bool closed_ = false; + bool closed_{false}; +#ifdef USE_SOCKET_SELECT_SUPPORT + bool loop_monitored_{false}; +#endif }; // Helper to create a socket with optional monitoring @@ -157,7 +168,6 @@ std::unique_ptr socket_loop_monitored(int domain, int type, int protocol return create_socket(domain, type, protocol, true); } -} // namespace socket -} // namespace esphome +} // namespace esphome::socket #endif // USE_SOCKET_IMPL_LWIP_SOCKETS diff --git a/esphome/components/socket/socket.cpp b/esphome/components/socket/socket.cpp index cc9232d21a..ffe0233abc 100644 --- a/esphome/components/socket/socket.cpp +++ b/esphome/components/socket/socket.cpp @@ -6,33 +6,10 @@ #include "esphome/core/log.h" #include "esphome/core/application.h" -namespace esphome { -namespace socket { +namespace esphome::socket { Socket::~Socket() {} -bool Socket::ready() const { -#ifdef USE_SOCKET_SELECT_SUPPORT - if (!loop_monitored_) { - // Non-monitored sockets always return true (assume data may be available) - return true; - } - - // For loop-monitored sockets, check with the Application's select() results - int fd = this->get_fd(); - if (fd < 0) { - // No valid file descriptor, assume ready (fallback behavior) - return true; - } - - return App.is_socket_ready(fd); -#else - // Without select() support, we can't monitor sockets in the loop - // Always return true (assume data may be available) - return true; -#endif -} - std::unique_ptr socket_ip(int type, int protocol) { #if USE_NETWORK_IPV6 return socket(AF_INET6, type, protocol); @@ -113,6 +90,5 @@ socklen_t set_sockaddr_any(struct sockaddr *addr, socklen_t addrlen, uint16_t po return sizeof(sockaddr_in); #endif /* USE_NETWORK_IPV6 */ } -} // namespace socket -} // namespace esphome +} // namespace esphome::socket #endif diff --git a/esphome/components/socket/socket.h b/esphome/components/socket/socket.h index 8936b2cd10..75eb07de4a 100644 --- a/esphome/components/socket/socket.h +++ b/esphome/components/socket/socket.h @@ -6,8 +6,7 @@ #include "headers.h" #if defined(USE_SOCKET_IMPL_LWIP_TCP) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) || defined(USE_SOCKET_IMPL_BSD_SOCKETS) -namespace esphome { -namespace socket { +namespace esphome::socket { class Socket { public: @@ -54,12 +53,7 @@ class Socket { /// Check if socket has data ready to read /// For loop-monitored sockets, checks with the Application's select() results /// For non-monitored sockets, always returns true (assumes data may be available) - bool ready() const; - - protected: -#ifdef USE_SOCKET_SELECT_SUPPORT - bool loop_monitored_{false}; ///< Whether this socket is monitored by the event loop -#endif + virtual bool ready() const { return true; } }; /// Create a socket of the given domain, type and protocol. @@ -91,6 +85,5 @@ void socket_delay(uint32_t ms); void socket_wake(); #endif -} // namespace socket -} // namespace esphome +} // namespace esphome::socket #endif From 41fd1762e927cb63fe77e9d10b8f6fad7d5656b5 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 18 Dec 2025 11:46:16 -0500 Subject: [PATCH 09/33] [core] Deprecate custom_components folder (#12552) Co-authored-by: Claude --- esphome/loader.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/esphome/loader.py b/esphome/loader.py index 387443c032..968c8cf3e0 100644 --- a/esphome/loader.py +++ b/esphome/loader.py @@ -187,7 +187,14 @@ def install_meta_finder( def install_custom_components_meta_finder(): + # Remove before 2026.6.0 custom_components_dir = (Path(CORE.config_dir) / "custom_components").resolve() + if custom_components_dir.is_dir() and any(custom_components_dir.iterdir()): + _LOGGER.warning( + "The 'custom_components' folder is deprecated and will be removed in 2026.6.0. " + "Please use 'external_components' instead. " + "See https://esphome.io/components/external_components.html for more information." + ) install_meta_finder(custom_components_dir) From 1c50c2b672f9e70065169ebfe0082a27478bf2e7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 18 Dec 2025 11:19:19 -1000 Subject: [PATCH 10/33] Bump ruamel-yaml from 0.18.16 to 0.18.17 (#12555) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 011a2d4f0b..62352ce754 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,7 +15,7 @@ esphome-dashboard==20251013.0 aioesphomeapi==43.3.0 zeroconf==0.148.0 puremagic==1.30 -ruamel.yaml==0.18.16 # dashboard_import +ruamel.yaml==0.18.17 # dashboard_import ruamel.yaml.clib==0.2.15 # dashboard_import esphome-glyphsets==0.2.0 pillow==11.3.0 From 7ae3a11d6b4b9fc97dc84406c3fc51360fa8c293 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Thu, 18 Dec 2025 19:42:47 -0600 Subject: [PATCH 11/33] [esp32_ble, esp32_ble_tracker] Fix crash, error messages when `ble.disable` called during boot (#12560) --- esphome/components/esp32_ble/ble.cpp | 16 ++++++++++++---- esphome/components/esp32_ble/ble.h | 12 +++++++++--- .../esp32_ble_tracker/esp32_ble_tracker.cpp | 5 ++++- 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index a0ed9ee90c..a279f7d2a4 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -308,13 +308,21 @@ bool ESP32BLE::ble_setup_() { bool ESP32BLE::ble_dismantle_() { esp_err_t err = esp_bluedroid_disable(); if (err != ESP_OK) { - ESP_LOGE(TAG, "esp_bluedroid_disable failed: %d", err); - return false; + // ESP_ERR_INVALID_STATE means Bluedroid is already disabled, which is fine + if (err != ESP_ERR_INVALID_STATE) { + ESP_LOGE(TAG, "esp_bluedroid_disable failed: %d", err); + return false; + } + ESP_LOGD(TAG, "Already disabled"); } err = esp_bluedroid_deinit(); if (err != ESP_OK) { - ESP_LOGE(TAG, "esp_bluedroid_deinit failed: %d", err); - return false; + // ESP_ERR_INVALID_STATE means Bluedroid is already deinitialized, which is fine + if (err != ESP_ERR_INVALID_STATE) { + ESP_LOGE(TAG, "esp_bluedroid_deinit failed: %d", err); + return false; + } + ESP_LOGD(TAG, "Already deinitialized"); } #ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID diff --git a/esphome/components/esp32_ble/ble.h b/esphome/components/esp32_ble/ble.h index 393ec2e911..1999c870f8 100644 --- a/esphome/components/esp32_ble/ble.h +++ b/esphome/components/esp32_ble/ble.h @@ -212,17 +212,23 @@ extern ESP32BLE *global_ble; template class BLEEnabledCondition : public Condition { public: - bool check(const Ts &...x) override { return global_ble->is_active(); } + bool check(const Ts &...x) override { return global_ble != nullptr && global_ble->is_active(); } }; template class BLEEnableAction : public Action { public: - void play(const Ts &...x) override { global_ble->enable(); } + void play(const Ts &...x) override { + if (global_ble != nullptr) + global_ble->enable(); + } }; template class BLEDisableAction : public Action { public: - void play(const Ts &...x) override { global_ble->disable(); } + void play(const Ts &...x) override { + if (global_ble != nullptr) + global_ble->disable(); + } }; } // namespace esphome::esp32_ble diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index d3c5edfb94..45e343c0d2 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -185,7 +185,10 @@ void ESP32BLETracker::ble_before_disabled_event_handler() { this->stop_scan_(); void ESP32BLETracker::stop_scan_() { if (this->scanner_state_ != ScannerState::RUNNING && this->scanner_state_ != ScannerState::FAILED) { - ESP_LOGE(TAG, "Cannot stop scan: %s", this->scanner_state_to_string_(this->scanner_state_)); + // If scanner is already idle, there's nothing to stop - this is not an error + if (this->scanner_state_ != ScannerState::IDLE) { + ESP_LOGE(TAG, "Cannot stop scan: %s", this->scanner_state_to_string_(this->scanner_state_)); + } return; } // Reset timeout state machine when stopping scan From f962497db1c7466155b414eab9ccd1b7aae11cc9 Mon Sep 17 00:00:00 2001 From: pixelgrb <10325178+pixelgrb@users.noreply.github.com> Date: Thu, 18 Dec 2025 20:13:36 -0800 Subject: [PATCH 12/33] [mmc5603] enable AUTO_SR_en to compensate for temperature drift (#12556) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/mmc5603/mmc5603.cpp | 4 +++- esphome/components/mmc5603/mmc5603.h | 2 ++ esphome/components/mmc5603/sensor.py | 5 +++++ tests/components/mmc5603/common.yaml | 1 + 4 files changed, 11 insertions(+), 1 deletion(-) diff --git a/esphome/components/mmc5603/mmc5603.cpp b/esphome/components/mmc5603/mmc5603.cpp index f0d1044f3f..d6321eae8f 100644 --- a/esphome/components/mmc5603/mmc5603.cpp +++ b/esphome/components/mmc5603/mmc5603.cpp @@ -83,6 +83,7 @@ void MMC5603Component::dump_config() { ESP_LOGE(TAG, "The ID registers don't match - Is this really an MMC5603?"); } LOG_UPDATE_INTERVAL(this); + ESP_LOGCONFIG(TAG, " Auto set/reset: %s", ONOFF(this->auto_set_reset_)); LOG_SENSOR(" ", "X Axis", this->x_sensor_); LOG_SENSOR(" ", "Y Axis", this->y_sensor_); @@ -93,7 +94,8 @@ void MMC5603Component::dump_config() { float MMC5603Component::get_setup_priority() const { return setup_priority::DATA; } void MMC5603Component::update() { - if (!this->write_byte(MMC56X3_CTRL0_REG, 0x01)) { + uint8_t ctrl0 = (this->auto_set_reset_) ? 0x21 : 0x01; + if (!this->write_byte(MMC56X3_CTRL0_REG, ctrl0)) { this->status_set_warning(); return; } diff --git a/esphome/components/mmc5603/mmc5603.h b/esphome/components/mmc5603/mmc5603.h index cd0893053c..09718bd3b7 100644 --- a/esphome/components/mmc5603/mmc5603.h +++ b/esphome/components/mmc5603/mmc5603.h @@ -25,6 +25,7 @@ class MMC5603Component : public PollingComponent, public i2c::I2CDevice { void set_y_sensor(sensor::Sensor *y_sensor) { y_sensor_ = y_sensor; } void set_z_sensor(sensor::Sensor *z_sensor) { z_sensor_ = z_sensor; } void set_heading_sensor(sensor::Sensor *heading_sensor) { heading_sensor_ = heading_sensor; } + void set_auto_set_reset(bool auto_set_reset) { auto_set_reset_ = auto_set_reset; } protected: MMC5603Datarate datarate_; @@ -32,6 +33,7 @@ class MMC5603Component : public PollingComponent, public i2c::I2CDevice { sensor::Sensor *y_sensor_{nullptr}; sensor::Sensor *z_sensor_{nullptr}; sensor::Sensor *heading_sensor_{nullptr}; + bool auto_set_reset_{true}; enum ErrorCode { NONE = 0, COMMUNICATION_FAILED, diff --git a/esphome/components/mmc5603/sensor.py b/esphome/components/mmc5603/sensor.py index 3223225271..5b3982cee6 100644 --- a/esphome/components/mmc5603/sensor.py +++ b/esphome/components/mmc5603/sensor.py @@ -16,6 +16,8 @@ from esphome.const import ( UNIT_MICROTESLA, ) +CONF_AUTO_SET_RESET = "auto_set_reset" + DEPENDENCIES = ["i2c"] mmc5603_ns = cg.esphome_ns.namespace("mmc5603") @@ -54,6 +56,7 @@ CONFIG_SCHEMA = ( cv.Optional(CONF_FIELD_STRENGTH_Y): field_strength_schema, cv.Optional(CONF_FIELD_STRENGTH_Z): field_strength_schema, cv.Optional(CONF_HEADING): heading_schema, + cv.Optional(CONF_AUTO_SET_RESET, default=True): cv.boolean, } ) .extend(cv.polling_component_schema("60s")) @@ -88,3 +91,5 @@ async def to_code(config): if CONF_HEADING in config: sens = await sensor.new_sensor(config[CONF_HEADING]) cg.add(var.set_heading_sensor(sens)) + if CONF_AUTO_SET_RESET in config: + cg.add(var.set_auto_set_reset(config[CONF_AUTO_SET_RESET])) diff --git a/tests/components/mmc5603/common.yaml b/tests/components/mmc5603/common.yaml index 6f6e35e9af..ddb5c3b25e 100644 --- a/tests/components/mmc5603/common.yaml +++ b/tests/components/mmc5603/common.yaml @@ -2,6 +2,7 @@ sensor: - platform: mmc5603 i2c_id: i2c_bus address: 0x30 + auto_set_reset: true field_strength_x: name: HMC5883L Field Strength X field_strength_y: From 7e080920122246bd37863b117ccafd7c8b74a0ed Mon Sep 17 00:00:00 2001 From: Anna Oake Date: Wed, 17 Dec 2025 20:19:18 +0100 Subject: [PATCH 13/33] [cc1101] Fix default frequencies (#12539) --- esphome/components/cc1101/cc1101.cpp | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/cc1101/cc1101.cpp b/esphome/components/cc1101/cc1101.cpp index 5b6eb545bc..1fe402d6c6 100644 --- a/esphome/components/cc1101/cc1101.cpp +++ b/esphome/components/cc1101/cc1101.cpp @@ -99,11 +99,11 @@ CC1101Component::CC1101Component() { this->state_.FS_AUTOCAL = 1; // Default Settings - this->set_frequency(433920); - this->set_if_frequency(153); - this->set_filter_bandwidth(203); + this->set_frequency(433920000); + this->set_if_frequency(153000); + this->set_filter_bandwidth(203000); this->set_channel(0); - this->set_channel_spacing(200); + this->set_channel_spacing(200000); this->set_symbol_rate(5000); this->set_sync_mode(SyncMode::SYNC_MODE_NONE); this->set_carrier_sense_above_threshold(true); From 195b1c632369011fe1fe6b841a75126ce9b84758 Mon Sep 17 00:00:00 2001 From: Jack Wilsdon Date: Wed, 17 Dec 2025 20:40:31 +0000 Subject: [PATCH 14/33] [pm1006] Fix "never" update interval detection (#12529) --- esphome/components/pm1006/sensor.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/esphome/components/pm1006/sensor.py b/esphome/components/pm1006/sensor.py index c693cfea19..8ff21ab069 100644 --- a/esphome/components/pm1006/sensor.py +++ b/esphome/components/pm1006/sensor.py @@ -7,10 +7,10 @@ from esphome.const import ( CONF_UPDATE_INTERVAL, DEVICE_CLASS_PM25, ICON_BLUR, + SCHEDULER_DONT_RUN, STATE_CLASS_MEASUREMENT, UNIT_MICROGRAMS_PER_CUBIC_METER, ) -from esphome.core import TimePeriodMilliseconds CODEOWNERS = ["@habbie"] DEPENDENCIES = ["uart"] @@ -41,16 +41,12 @@ CONFIG_SCHEMA = cv.All( def validate_interval_uart(config): - require_tx = False - interval = config.get(CONF_UPDATE_INTERVAL) - - if isinstance(interval, TimePeriodMilliseconds): - # 'never' is encoded as a very large int, not as a TimePeriodMilliseconds objects - require_tx = True - uart.final_validate_device_schema( - "pm1006", baud_rate=9600, require_rx=True, require_tx=require_tx + "pm1006", + baud_rate=9600, + require_rx=True, + require_tx=interval.total_milliseconds != SCHEDULER_DONT_RUN, )(config) From 636be92c97f6c6dd7da511a6420768981253f5e2 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 17 Dec 2025 17:07:42 -0500 Subject: [PATCH 15/33] [bme68x_bsec2_i2c] Add MULTI_CONF support for multiple sensors (#12535) Co-authored-by: Claude --- esphome/components/bme68x_bsec2_i2c/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/bme68x_bsec2_i2c/__init__.py b/esphome/components/bme68x_bsec2_i2c/__init__.py index d6fb7fa9be..c8ca0ba022 100644 --- a/esphome/components/bme68x_bsec2_i2c/__init__.py +++ b/esphome/components/bme68x_bsec2_i2c/__init__.py @@ -11,6 +11,7 @@ CODEOWNERS = ["@neffs", "@kbx81"] AUTO_LOAD = ["bme68x_bsec2"] DEPENDENCIES = ["i2c"] +MULTI_CONF = True bme68x_bsec2_i2c_ns = cg.esphome_ns.namespace("bme68x_bsec2_i2c") BME68xBSEC2I2CComponent = bme68x_bsec2_i2c_ns.class_( From 3e38a5e630978ab076a34223948d22ec53922c75 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 17 Dec 2025 17:37:59 -0500 Subject: [PATCH 16/33] [esp32_camera] Fix I2C driver conflict with other components (#12533) Co-authored-by: Claude Co-authored-by: J. Nick Koston --- esphome/components/esp32_camera/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp32_camera/__init__.py b/esphome/components/esp32_camera/__init__.py index d9d9bc0a56..2ad48173f1 100644 --- a/esphome/components/esp32_camera/__init__.py +++ b/esphome/components/esp32_camera/__init__.py @@ -3,7 +3,7 @@ import logging from esphome import automation, pins import esphome.codegen as cg from esphome.components import i2c -from esphome.components.esp32 import add_idf_component +from esphome.components.esp32 import add_idf_component, add_idf_sdkconfig_option from esphome.components.psram import DOMAIN as psram_domain import esphome.config_validation as cv from esphome.const import ( @@ -352,6 +352,8 @@ async def to_code(config): cg.add_define("USE_CAMERA") add_idf_component(name="espressif/esp32-camera", ref="2.1.1") + add_idf_sdkconfig_option("CONFIG_SCCB_HARDWARE_I2C_DRIVER_NEW", True) + add_idf_sdkconfig_option("CONFIG_SCCB_HARDWARE_I2C_DRIVER_LEGACY", False) for conf in config.get(CONF_ON_STREAM_START, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) From 7ca11764abf1091d2f009599f49dcece33b0d05e Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 17 Dec 2025 23:34:04 -0500 Subject: [PATCH 17/33] [template.alarm_control_panel] Fix compile without binary_sensor (#12548) Co-authored-by: Claude --- .../alarm_control_panel/template_alarm_control_panel.h | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/template/alarm_control_panel/template_alarm_control_panel.h b/esphome/components/template/alarm_control_panel/template_alarm_control_panel.h index bdd3747372..2038d8f1b0 100644 --- a/esphome/components/template/alarm_control_panel/template_alarm_control_panel.h +++ b/esphome/components/template/alarm_control_panel/template_alarm_control_panel.h @@ -39,6 +39,7 @@ enum TemplateAlarmControlPanelRestoreMode { ALARM_CONTROL_PANEL_RESTORE_DEFAULT_DISARMED, }; +#ifdef USE_BINARY_SENSOR struct SensorDataStore { bool last_chime_state; }; @@ -49,7 +50,6 @@ struct SensorInfo { uint8_t store_index; }; -#ifdef USE_BINARY_SENSOR struct AlarmSensor { binary_sensor::BinarySensor *sensor; SensorInfo info; @@ -139,6 +139,9 @@ class TemplateAlarmControlPanel final : public alarm_control_panel::AlarmControl FixedVector sensors_; // a list of automatically bypassed sensors std::vector bypassed_sensor_indicies_; + // Per sensor data store + std::vector sensor_data_; + uint8_t next_store_index_ = 0; #endif TemplateAlarmControlPanelRestoreMode restore_mode_{}; @@ -154,14 +157,11 @@ class TemplateAlarmControlPanel final : public alarm_control_panel::AlarmControl uint32_t trigger_time_; // a list of codes std::vector codes_; - // Per sensor data store - std::vector sensor_data_; // requires a code to arm bool requires_code_to_arm_ = false; bool supports_arm_home_ = false; bool supports_arm_night_ = false; bool sensors_ready_ = false; - uint8_t next_store_index_ = 0; // check if the code is valid bool is_code_valid_(optional code); From f0d0ea60a727f020aafe26471ba22b5168b6e73a Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Thu, 18 Dec 2025 19:42:47 -0600 Subject: [PATCH 18/33] [esp32_ble, esp32_ble_tracker] Fix crash, error messages when `ble.disable` called during boot (#12560) --- esphome/components/esp32_ble/ble.cpp | 16 ++++++++++++---- esphome/components/esp32_ble/ble.h | 12 +++++++++--- .../esp32_ble_tracker/esp32_ble_tracker.cpp | 5 ++++- 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index a0ed9ee90c..a279f7d2a4 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -308,13 +308,21 @@ bool ESP32BLE::ble_setup_() { bool ESP32BLE::ble_dismantle_() { esp_err_t err = esp_bluedroid_disable(); if (err != ESP_OK) { - ESP_LOGE(TAG, "esp_bluedroid_disable failed: %d", err); - return false; + // ESP_ERR_INVALID_STATE means Bluedroid is already disabled, which is fine + if (err != ESP_ERR_INVALID_STATE) { + ESP_LOGE(TAG, "esp_bluedroid_disable failed: %d", err); + return false; + } + ESP_LOGD(TAG, "Already disabled"); } err = esp_bluedroid_deinit(); if (err != ESP_OK) { - ESP_LOGE(TAG, "esp_bluedroid_deinit failed: %d", err); - return false; + // ESP_ERR_INVALID_STATE means Bluedroid is already deinitialized, which is fine + if (err != ESP_ERR_INVALID_STATE) { + ESP_LOGE(TAG, "esp_bluedroid_deinit failed: %d", err); + return false; + } + ESP_LOGD(TAG, "Already deinitialized"); } #ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID diff --git a/esphome/components/esp32_ble/ble.h b/esphome/components/esp32_ble/ble.h index 393ec2e911..1999c870f8 100644 --- a/esphome/components/esp32_ble/ble.h +++ b/esphome/components/esp32_ble/ble.h @@ -212,17 +212,23 @@ extern ESP32BLE *global_ble; template class BLEEnabledCondition : public Condition { public: - bool check(const Ts &...x) override { return global_ble->is_active(); } + bool check(const Ts &...x) override { return global_ble != nullptr && global_ble->is_active(); } }; template class BLEEnableAction : public Action { public: - void play(const Ts &...x) override { global_ble->enable(); } + void play(const Ts &...x) override { + if (global_ble != nullptr) + global_ble->enable(); + } }; template class BLEDisableAction : public Action { public: - void play(const Ts &...x) override { global_ble->disable(); } + void play(const Ts &...x) override { + if (global_ble != nullptr) + global_ble->disable(); + } }; } // namespace esphome::esp32_ble diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index d3c5edfb94..45e343c0d2 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -185,7 +185,10 @@ void ESP32BLETracker::ble_before_disabled_event_handler() { this->stop_scan_(); void ESP32BLETracker::stop_scan_() { if (this->scanner_state_ != ScannerState::RUNNING && this->scanner_state_ != ScannerState::FAILED) { - ESP_LOGE(TAG, "Cannot stop scan: %s", this->scanner_state_to_string_(this->scanner_state_)); + // If scanner is already idle, there's nothing to stop - this is not an error + if (this->scanner_state_ != ScannerState::IDLE) { + ESP_LOGE(TAG, "Cannot stop scan: %s", this->scanner_state_to_string_(this->scanner_state_)); + } return; } // Reset timeout state machine when stopping scan From 3a888326d8056867380b8b26dffea1b24246d1af Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 19 Dec 2025 10:13:35 -0500 Subject: [PATCH 19/33] Bump version to 2025.12.1 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 7dfcbd6b6f..4c533ec87f 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2025.12.0 +PROJECT_NUMBER = 2025.12.1 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index 111396cab5..8f9a3497ff 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2025.12.0" +__version__ = "2025.12.1" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 940e6194816f25401e57ec76fd33fff54f814141 Mon Sep 17 00:00:00 2001 From: Jas Strong Date: Fri, 19 Dec 2025 10:42:11 -0800 Subject: [PATCH 20/33] [aqi, hm3301, pmsx003] Air Quality Index improvements (#12203) Co-authored-by: jas Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- CODEOWNERS | 1 + esphome/components/aqi/__init__.py | 14 ++++++++ .../{hm3301 => aqi}/abstract_aqi_calculator.h | 6 ++-- .../{hm3301 => aqi}/aqi_calculator.h | 11 +++--- .../{hm3301 => aqi}/aqi_calculator_factory.h | 14 ++++---- .../{hm3301 => aqi}/caqi_calculator.h | 6 ++-- esphome/components/hm3301/hm3301.cpp | 2 +- esphome/components/hm3301/hm3301.h | 8 ++--- esphome/components/hm3301/sensor.py | 10 ++---- esphome/components/pmsx003/pmsx003.cpp | 34 ++++++++++++++++--- esphome/components/pmsx003/pmsx003.h | 12 +++++++ esphome/components/pmsx003/sensor.py | 26 ++++++++++++++ tests/components/pmsx003/common.yaml | 3 ++ 13 files changed, 109 insertions(+), 38 deletions(-) create mode 100644 esphome/components/aqi/__init__.py rename esphome/components/{hm3301 => aqi}/abstract_aqi_calculator.h (64%) rename esphome/components/{hm3301 => aqi}/aqi_calculator.h (94%) rename esphome/components/{hm3301 => aqi}/aqi_calculator_factory.h (55%) rename esphome/components/{hm3301 => aqi}/caqi_calculator.h (94%) diff --git a/CODEOWNERS b/CODEOWNERS index fc27253d23..21be3e36d1 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -42,6 +42,7 @@ esphome/components/animation/* @syndlex esphome/components/anova/* @buxtronix esphome/components/apds9306/* @aodrenah esphome/components/api/* @esphome/core +esphome/components/aqi/* @freekode @jasstrong @ximex esphome/components/as5600/* @ammmze esphome/components/as5600/sensor/* @ammmze esphome/components/as7341/* @mrgnr diff --git a/esphome/components/aqi/__init__.py b/esphome/components/aqi/__init__.py new file mode 100644 index 0000000000..4b979ab406 --- /dev/null +++ b/esphome/components/aqi/__init__.py @@ -0,0 +1,14 @@ +import esphome.codegen as cg + +CODEOWNERS = ["@jasstrong", "@ximex", "@freekode"] + +aqi_ns = cg.esphome_ns.namespace("aqi") +AQICalculatorType = aqi_ns.enum("AQICalculatorType") + +CONF_AQI = "aqi" +CONF_CALCULATION_TYPE = "calculation_type" + +AQI_CALCULATION_TYPE = { + "CAQI": AQICalculatorType.CAQI_TYPE, + "AQI": AQICalculatorType.AQI_TYPE, +} diff --git a/esphome/components/hm3301/abstract_aqi_calculator.h b/esphome/components/aqi/abstract_aqi_calculator.h similarity index 64% rename from esphome/components/hm3301/abstract_aqi_calculator.h rename to esphome/components/aqi/abstract_aqi_calculator.h index 038828e9de..7836c76cdc 100644 --- a/esphome/components/hm3301/abstract_aqi_calculator.h +++ b/esphome/components/aqi/abstract_aqi_calculator.h @@ -2,13 +2,11 @@ #include -namespace esphome { -namespace hm3301 { +namespace esphome::aqi { class AbstractAQICalculator { public: virtual uint16_t get_aqi(uint16_t pm2_5_value, uint16_t pm10_0_value) = 0; }; -} // namespace hm3301 -} // namespace esphome +} // namespace esphome::aqi diff --git a/esphome/components/hm3301/aqi_calculator.h b/esphome/components/aqi/aqi_calculator.h similarity index 94% rename from esphome/components/hm3301/aqi_calculator.h rename to esphome/components/aqi/aqi_calculator.h index aa01060d2c..959d6a2438 100644 --- a/esphome/components/hm3301/aqi_calculator.h +++ b/esphome/components/aqi/aqi_calculator.h @@ -1,10 +1,11 @@ #pragma once + #include #include "abstract_aqi_calculator.h" + // https://document.airnow.gov/technical-assistance-document-for-the-reporting-of-daily-air-quailty.pdf -namespace esphome { -namespace hm3301 { +namespace esphome::aqi { class AQICalculator : public AbstractAQICalculator { public: @@ -28,6 +29,9 @@ class AQICalculator : public AbstractAQICalculator { int calculate_index_(uint16_t value, int array[AMOUNT_OF_LEVELS][2]) { int grid_index = get_grid_index_(value, array); + if (grid_index == -1) { + return -1; + } int aqi_lo = index_grid_[grid_index][0]; int aqi_hi = index_grid_[grid_index][1]; int conc_lo = array[grid_index][0]; @@ -46,5 +50,4 @@ class AQICalculator : public AbstractAQICalculator { } }; -} // namespace hm3301 -} // namespace esphome +} // namespace esphome::aqi diff --git a/esphome/components/hm3301/aqi_calculator_factory.h b/esphome/components/aqi/aqi_calculator_factory.h similarity index 55% rename from esphome/components/hm3301/aqi_calculator_factory.h rename to esphome/components/aqi/aqi_calculator_factory.h index 55608b6e51..db7eaab1bb 100644 --- a/esphome/components/hm3301/aqi_calculator_factory.h +++ b/esphome/components/aqi/aqi_calculator_factory.h @@ -3,8 +3,7 @@ #include "caqi_calculator.h" #include "aqi_calculator.h" -namespace esphome { -namespace hm3301 { +namespace esphome::aqi { enum AQICalculatorType { CAQI_TYPE = 0, AQI_TYPE = 1 }; @@ -12,18 +11,17 @@ class AQICalculatorFactory { public: AbstractAQICalculator *get_calculator(AQICalculatorType type) { if (type == 0) { - return caqi_calculator_; + return &this->caqi_calculator_; } else if (type == 1) { - return aqi_calculator_; + return &this->aqi_calculator_; } return nullptr; } protected: - CAQICalculator *caqi_calculator_ = new CAQICalculator(); - AQICalculator *aqi_calculator_ = new AQICalculator(); + CAQICalculator caqi_calculator_; + AQICalculator aqi_calculator_; }; -} // namespace hm3301 -} // namespace esphome +} // namespace esphome::aqi diff --git a/esphome/components/hm3301/caqi_calculator.h b/esphome/components/aqi/caqi_calculator.h similarity index 94% rename from esphome/components/hm3301/caqi_calculator.h rename to esphome/components/aqi/caqi_calculator.h index 3f338776d8..d493dcdf39 100644 --- a/esphome/components/hm3301/caqi_calculator.h +++ b/esphome/components/aqi/caqi_calculator.h @@ -3,8 +3,7 @@ #include "esphome/core/log.h" #include "abstract_aqi_calculator.h" -namespace esphome { -namespace hm3301 { +namespace esphome::aqi { class CAQICalculator : public AbstractAQICalculator { public: @@ -48,5 +47,4 @@ class CAQICalculator : public AbstractAQICalculator { } }; -} // namespace hm3301 -} // namespace esphome +} // namespace esphome::aqi diff --git a/esphome/components/hm3301/hm3301.cpp b/esphome/components/hm3301/hm3301.cpp index a19d9dd09f..00fb85397c 100644 --- a/esphome/components/hm3301/hm3301.cpp +++ b/esphome/components/hm3301/hm3301.cpp @@ -63,7 +63,7 @@ void HM3301Component::update() { int16_t aqi_value = -1; if (this->aqi_sensor_ != nullptr && pm_2_5_value != -1 && pm_10_0_value != -1) { - AbstractAQICalculator *calculator = this->aqi_calculator_factory_.get_calculator(this->aqi_calc_type_); + aqi::AbstractAQICalculator *calculator = this->aqi_calculator_factory_.get_calculator(this->aqi_calc_type_); aqi_value = calculator->get_aqi(pm_2_5_value, pm_10_0_value); } diff --git a/esphome/components/hm3301/hm3301.h b/esphome/components/hm3301/hm3301.h index 6779b4e195..e155ed6b4b 100644 --- a/esphome/components/hm3301/hm3301.h +++ b/esphome/components/hm3301/hm3301.h @@ -3,7 +3,7 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" -#include "aqi_calculator_factory.h" +#include "esphome/components/aqi/aqi_calculator_factory.h" namespace esphome { namespace hm3301 { @@ -19,7 +19,7 @@ class HM3301Component : public PollingComponent, public i2c::I2CDevice { void set_pm_10_0_sensor(sensor::Sensor *pm_10_0_sensor) { pm_10_0_sensor_ = pm_10_0_sensor; } void set_aqi_sensor(sensor::Sensor *aqi_sensor) { aqi_sensor_ = aqi_sensor; } - void set_aqi_calculation_type(AQICalculatorType aqi_calc_type) { aqi_calc_type_ = aqi_calc_type; } + void set_aqi_calculation_type(aqi::AQICalculatorType aqi_calc_type) { aqi_calc_type_ = aqi_calc_type; } void setup() override; void dump_config() override; @@ -41,8 +41,8 @@ class HM3301Component : public PollingComponent, public i2c::I2CDevice { sensor::Sensor *pm_10_0_sensor_{nullptr}; sensor::Sensor *aqi_sensor_{nullptr}; - AQICalculatorType aqi_calc_type_; - AQICalculatorFactory aqi_calculator_factory_ = AQICalculatorFactory(); + aqi::AQICalculatorType aqi_calc_type_; + aqi::AQICalculatorFactory aqi_calculator_factory_ = aqi::AQICalculatorFactory(); bool validate_checksum_(const uint8_t *data); uint16_t get_sensor_value_(const uint8_t *data, uint8_t i); diff --git a/esphome/components/hm3301/sensor.py b/esphome/components/hm3301/sensor.py index 5eb1773518..389da97b1e 100644 --- a/esphome/components/hm3301/sensor.py +++ b/esphome/components/hm3301/sensor.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import i2c, sensor +from esphome.components.aqi import AQI_CALCULATION_TYPE, CONF_AQI, CONF_CALCULATION_TYPE import esphome.config_validation as cv from esphome.const import ( CONF_ID, @@ -16,23 +17,16 @@ from esphome.const import ( ) DEPENDENCIES = ["i2c"] +AUTO_LOAD = ["aqi"] CODEOWNERS = ["@freekode"] hm3301_ns = cg.esphome_ns.namespace("hm3301") HM3301Component = hm3301_ns.class_( "HM3301Component", cg.PollingComponent, i2c.I2CDevice ) -AQICalculatorType = hm3301_ns.enum("AQICalculatorType") -CONF_AQI = "aqi" -CONF_CALCULATION_TYPE = "calculation_type" UNIT_INDEX = "index" -AQI_CALCULATION_TYPE = { - "CAQI": AQICalculatorType.CAQI_TYPE, - "AQI": AQICalculatorType.AQI_TYPE, -} - def _validate(config): if CONF_AQI in config and CONF_PM_2_5 not in config: diff --git a/esphome/components/pmsx003/pmsx003.cpp b/esphome/components/pmsx003/pmsx003.cpp index eb10d19c91..3bdb5219ed 100644 --- a/esphome/components/pmsx003/pmsx003.cpp +++ b/esphome/components/pmsx003/pmsx003.cpp @@ -18,6 +18,8 @@ static const uint16_t PMS_CMD_MEASUREMENT_MODE_ACTIVE = 0x0001; // automaticall static const uint16_t PMS_CMD_SLEEP_MODE_SLEEP = 0x0000; // go to sleep mode static const uint16_t PMS_CMD_SLEEP_MODE_WAKEUP = 0x0001; // wake up from sleep mode +void PMSX003Component::setup() {} + void PMSX003Component::dump_config() { ESP_LOGCONFIG(TAG, "PMSX003:"); LOG_SENSOR(" ", "PM1.0STD", this->pm_1_0_std_sensor_); @@ -39,21 +41,36 @@ void PMSX003Component::dump_config() { LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); LOG_SENSOR(" ", "Humidity", this->humidity_sensor_); + + if (this->update_interval_ <= PMS_STABILISING_MS) { + ESP_LOGCONFIG(TAG, " Mode: active continuous (sensor default)"); + } else { + ESP_LOGCONFIG(TAG, " Mode: passive with sleep/wake cycles"); + } + this->check_uart_settings(9600); } void PMSX003Component::loop() { const uint32_t now = App.get_loop_component_start_time(); + // Initialize sensor mode on first loop + if (this->initialised_ == 0) { + if (this->update_interval_ > PMS_STABILISING_MS) { + // Long update interval: use passive mode with sleep/wake cycles + this->send_command_(PMS_CMD_MEASUREMENT_MODE, PMS_CMD_MEASUREMENT_MODE_PASSIVE); + this->send_command_(PMS_CMD_SLEEP_MODE, PMS_CMD_SLEEP_MODE_WAKEUP); + } else { + // Short/zero update interval: use active continuous mode + this->send_command_(PMS_CMD_MEASUREMENT_MODE, PMS_CMD_MEASUREMENT_MODE_ACTIVE); + } + this->initialised_ = 1; + } + // If we update less often than it takes the device to stabilise, spin the fan down // rather than running it constantly. It does take some time to stabilise, so we // need to keep track of what state we're in. if (this->update_interval_ > PMS_STABILISING_MS) { - if (this->initialised_ == 0) { - this->send_command_(PMS_CMD_MEASUREMENT_MODE, PMS_CMD_MEASUREMENT_MODE_PASSIVE); - this->send_command_(PMS_CMD_SLEEP_MODE, PMS_CMD_SLEEP_MODE_WAKEUP); - this->initialised_ = 1; - } switch (this->state_) { case PMSX003_STATE_IDLE: // Power on the sensor now so it'll be ready when we hit the update time @@ -248,6 +265,13 @@ void PMSX003Component::parse_data_() { if (this->pm_particles_25um_sensor_ != nullptr) this->pm_particles_25um_sensor_->publish_state(pm_particles_25um); + // Calculate and publish AQI if sensor is configured + if (this->aqi_sensor_ != nullptr) { + aqi::AbstractAQICalculator *calculator = this->aqi_calculator_factory_.get_calculator(this->aqi_calc_type_); + int32_t aqi_value = calculator->get_aqi(pm_2_5_concentration, pm_10_0_concentration); + this->aqi_sensor_->publish_state(aqi_value); + } + if (this->type_ == PMSX003_TYPE_5003T) { ESP_LOGD(TAG, "Got PM0.3 Particles: %u Count/0.1L, PM0.5 Particles: %u Count/0.1L, PM1.0 Particles: %u Count/0.1L, " diff --git a/esphome/components/pmsx003/pmsx003.h b/esphome/components/pmsx003/pmsx003.h index ba607b4487..229972e2e5 100644 --- a/esphome/components/pmsx003/pmsx003.h +++ b/esphome/components/pmsx003/pmsx003.h @@ -4,6 +4,7 @@ #include "esphome/core/helpers.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/uart/uart.h" +#include "esphome/components/aqi/aqi_calculator_factory.h" namespace esphome { namespace pmsx003 { @@ -31,6 +32,7 @@ enum PMSX003State { class PMSX003Component : public uart::UARTDevice, public Component { public: PMSX003Component() = default; + void setup() override; void dump_config() override; void loop() override; @@ -72,6 +74,10 @@ class PMSX003Component : public uart::UARTDevice, public Component { void set_temperature_sensor(sensor::Sensor *temperature_sensor) { this->temperature_sensor_ = temperature_sensor; } void set_humidity_sensor(sensor::Sensor *humidity_sensor) { this->humidity_sensor_ = humidity_sensor; } + void set_aqi_sensor(sensor::Sensor *aqi_sensor) { aqi_sensor_ = aqi_sensor; } + + void set_aqi_calculation_type(aqi::AQICalculatorType aqi_calc_type) { aqi_calc_type_ = aqi_calc_type; } + protected: optional check_byte_(); void parse_data_(); @@ -115,6 +121,12 @@ class PMSX003Component : public uart::UARTDevice, public Component { // Temperature and Humidity sensor::Sensor *temperature_sensor_{nullptr}; sensor::Sensor *humidity_sensor_{nullptr}; + + // AQI + sensor::Sensor *aqi_sensor_{nullptr}; + + aqi::AQICalculatorType aqi_calc_type_; + aqi::AQICalculatorFactory aqi_calculator_factory_ = aqi::AQICalculatorFactory(); }; } // namespace pmsx003 diff --git a/esphome/components/pmsx003/sensor.py b/esphome/components/pmsx003/sensor.py index bebd3a01ee..b2d6744547 100644 --- a/esphome/components/pmsx003/sensor.py +++ b/esphome/components/pmsx003/sensor.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import sensor, uart +from esphome.components.aqi import AQI_CALCULATION_TYPE, CONF_AQI, CONF_CALCULATION_TYPE import esphome.config_validation as cv from esphome.const import ( CONF_FORMALDEHYDE, @@ -20,6 +21,7 @@ from esphome.const import ( CONF_TEMPERATURE, CONF_TYPE, CONF_UPDATE_INTERVAL, + DEVICE_CLASS_AQI, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_PM1, DEVICE_CLASS_PM10, @@ -35,11 +37,13 @@ from esphome.const import ( CODEOWNERS = ["@ximex"] DEPENDENCIES = ["uart"] +AUTO_LOAD = ["aqi"] pmsx003_ns = cg.esphome_ns.namespace("pmsx003") PMSX003Component = pmsx003_ns.class_("PMSX003Component", uart.UARTDevice, cg.Component) PMSX003Sensor = pmsx003_ns.class_("PMSX003Sensor", sensor.Sensor) +UNIT_INDEX = "index" TYPE_PMSX003 = "PMSX003" TYPE_PMS5003T = "PMS5003T" TYPE_PMS5003ST = "PMS5003ST" @@ -77,6 +81,10 @@ def validate_pmsx003_sensors(value): for key, types in SENSORS_TO_TYPE.items(): if key in value and value[CONF_TYPE] not in types: raise cv.Invalid(f"{value[CONF_TYPE]} does not have {key} sensor!") + if CONF_AQI in value and CONF_PM_2_5 not in value: + raise cv.Invalid("AQI computation requires PM 2.5 sensor") + if CONF_AQI in value and CONF_PM_10_0 not in value: + raise cv.Invalid("AQI computation requires PM 10 sensor") return value @@ -192,6 +200,19 @@ CONFIG_SCHEMA = ( device_class=DEVICE_CLASS_HUMIDITY, state_class=STATE_CLASS_MEASUREMENT, ), + cv.Optional(CONF_AQI): sensor.sensor_schema( + unit_of_measurement=UNIT_INDEX, + icon=ICON_CHEMICAL_WEAPON, + accuracy_decimals=0, + device_class=DEVICE_CLASS_AQI, + state_class=STATE_CLASS_MEASUREMENT, + ).extend( + { + cv.Required(CONF_CALCULATION_TYPE): cv.enum( + AQI_CALCULATION_TYPE, upper=True + ), + } + ), cv.Optional(CONF_UPDATE_INTERVAL, default="0s"): validate_update_interval, } ) @@ -278,4 +299,9 @@ async def to_code(config): sens = await sensor.new_sensor(config[CONF_HUMIDITY]) cg.add(var.set_humidity_sensor(sens)) + if CONF_AQI in config: + sens = await sensor.new_sensor(config[CONF_AQI]) + cg.add(var.set_aqi_sensor(sens)) + cg.add(var.set_aqi_calculation_type(config[CONF_AQI][CONF_CALCULATION_TYPE])) + cg.add(var.set_update_interval(config[CONF_UPDATE_INTERVAL])) diff --git a/tests/components/pmsx003/common.yaml b/tests/components/pmsx003/common.yaml index 3c60995804..9dd79723d1 100644 --- a/tests/components/pmsx003/common.yaml +++ b/tests/components/pmsx003/common.yaml @@ -25,4 +25,7 @@ sensor: name: Particulate Count >5.0um pm_10_0um: name: Particulate Count >10.0um + aqi: + name: AQI + calculation_type: AQI update_interval: 30s From 26c16f4ca25e1d4ac562ad484d4c1a9e7b5fedcf Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 19 Dec 2025 09:18:33 -1000 Subject: [PATCH 21/33] Bump voluptuous from 0.15.2 to 0.16.0 (#12573) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 62352ce754..5c3d82e219 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ cryptography==45.0.1 -voluptuous==0.15.2 +voluptuous==0.16.0 PyYAML==6.0.3 paho-mqtt==1.6.1 colorama==0.4.6 From 59b38d79b40a7956b9db1a42c5ceb644aee7c9b0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 19 Dec 2025 09:18:52 -1000 Subject: [PATCH 22/33] Bump docker/setup-buildx-action from 3.11.1 to 3.12.0 in the docker-actions group (#12574) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci-docker.yml | 2 +- .github/workflows/release.yml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index bf7fa0c262..84d79cda17 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -49,7 +49,7 @@ jobs: with: python-version: "3.11" - name: Set up Docker Buildx - uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - name: Set TAG run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 10194aa599..b41b118504 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -99,7 +99,7 @@ jobs: python-version: "3.11" - name: Set up Docker Buildx - uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - name: Log in to docker hub uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 @@ -178,7 +178,7 @@ jobs: merge-multiple: true - name: Set up Docker Buildx - uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - name: Log in to docker hub if: matrix.registry == 'dockerhub' From 98ed679b1995d2b96ab85cdc9f93a4d593e8331c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 19 Dec 2025 19:22:56 +0000 Subject: [PATCH 23/33] Bump ruff from 0.14.9 to 0.14.10 (#12572) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- .pre-commit-config.yaml | 2 +- requirements_test.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2f5076a6e6..de7d30cfa2 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,7 +11,7 @@ ci: repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.14.9 + rev: v0.14.10 hooks: # Run the linter. - id: ruff diff --git a/requirements_test.txt b/requirements_test.txt index bfb833e04d..f00bcd0a0d 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==4.0.4 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating -ruff==0.14.9 # also change in .pre-commit-config.yaml when updating +ruff==0.14.10 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating pre-commit From 25cebedcfcd0ff7b0fb1d9df81858f984bae9862 Mon Sep 17 00:00:00 2001 From: Rene Guca <45061891+rguca@users.noreply.github.com> Date: Fri, 19 Dec 2025 21:42:39 +0100 Subject: [PATCH 24/33] [dht] Fix "Falling edge for bit 39 failed!" for Sonoff THS01 (#9225) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/dht/dht.cpp | 38 ++++++++++++++++++---------------- esphome/components/dht/dht.h | 8 +++++-- 2 files changed, 26 insertions(+), 20 deletions(-) diff --git a/esphome/components/dht/dht.cpp b/esphome/components/dht/dht.cpp index cc0bf55a80..e0abb7c5f0 100644 --- a/esphome/components/dht/dht.cpp +++ b/esphome/components/dht/dht.cpp @@ -8,17 +8,20 @@ namespace dht { static const char *const TAG = "dht"; void DHT::setup() { - this->pin_->digital_write(true); - this->pin_->setup(); - this->pin_->digital_write(true); + this->t_pin_->digital_write(true); + this->t_pin_->setup(); +#ifdef USE_ESP32 + this->t_pin_->pin_mode(this->t_pin_->get_flags() | gpio::FLAG_OUTPUT | gpio::FLAG_OPEN_DRAIN); +#endif + this->t_pin_->digital_write(true); } void DHT::dump_config() { ESP_LOGCONFIG(TAG, "DHT:"); - LOG_PIN(" Pin: ", this->pin_); + LOG_PIN(" Pin: ", this->t_pin_); ESP_LOGCONFIG(TAG, " %sModel: %s", this->is_auto_detect_ ? "Auto-detected " : "", this->model_ == DHT_MODEL_DHT11 ? "DHT11" : "DHT22 or equivalent"); - ESP_LOGCONFIG(TAG, " Internal pull-up: %s", ONOFF(this->pin_->get_flags() & gpio::FLAG_PULLUP)); + ESP_LOGCONFIG(TAG, " Internal pull-up: %s", ONOFF(this->t_pin_->get_flags() & gpio::FLAG_PULLUP)); LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); LOG_SENSOR(" ", "Humidity", this->humidity_sensor_); @@ -72,21 +75,15 @@ bool HOT IRAM_ATTR DHT::read_sensor_(float *temperature, float *humidity, bool r int8_t i = 0; uint8_t data[5] = {0, 0, 0, 0, 0}; - this->pin_->digital_write(false); - this->pin_->pin_mode(gpio::FLAG_OUTPUT); - this->pin_->digital_write(false); +#ifndef USE_ESP32 + this->pin_.pin_mode(gpio::FLAG_OUTPUT); +#endif + this->pin_.digital_write(false); if (this->model_ == DHT_MODEL_DHT11) { delayMicroseconds(18000); } else if (this->model_ == DHT_MODEL_SI7021) { -#ifdef USE_ESP8266 delayMicroseconds(500); - this->pin_->digital_write(true); - delayMicroseconds(40); -#else - delayMicroseconds(400); - this->pin_->digital_write(true); -#endif } else if (this->model_ == DHT_MODEL_DHT22_TYPE2) { delayMicroseconds(2000); } else if (this->model_ == DHT_MODEL_AM2120 || this->model_ == DHT_MODEL_AM2302) { @@ -94,7 +91,12 @@ bool HOT IRAM_ATTR DHT::read_sensor_(float *temperature, float *humidity, bool r } else { delayMicroseconds(800); } - this->pin_->pin_mode(this->pin_->get_flags()); + +#ifdef USE_ESP32 + this->pin_.digital_write(true); +#else + this->pin_.pin_mode(this->t_pin_->get_flags()); +#endif { InterruptLock lock; @@ -110,7 +112,7 @@ bool HOT IRAM_ATTR DHT::read_sensor_(float *temperature, float *humidity, bool r uint32_t start_time = micros(); // Wait for rising edge - while (!this->pin_->digital_read()) { + while (!this->pin_.digital_read()) { if (micros() - start_time > 90) { if (i < 0) { error_code = 1; // line didn't clear @@ -127,7 +129,7 @@ bool HOT IRAM_ATTR DHT::read_sensor_(float *temperature, float *humidity, bool r uint32_t end_time = start_time; // Wait for falling edge - while (this->pin_->digital_read()) { + while (this->pin_.digital_read()) { end_time = micros(); if (end_time - start_time > 90) { if (i < 0) { diff --git a/esphome/components/dht/dht.h b/esphome/components/dht/dht.h index 327e8a4f5c..9047dd2c96 100644 --- a/esphome/components/dht/dht.h +++ b/esphome/components/dht/dht.h @@ -38,7 +38,10 @@ class DHT : public PollingComponent { */ void set_dht_model(DHTModel model); - void set_pin(InternalGPIOPin *pin) { pin_ = pin; } + void set_pin(InternalGPIOPin *pin) { + this->t_pin_ = pin; + this->pin_ = pin->to_isr(); + } void set_model(DHTModel model) { model_ = model; } void set_temperature_sensor(sensor::Sensor *temperature_sensor) { temperature_sensor_ = temperature_sensor; } void set_humidity_sensor(sensor::Sensor *humidity_sensor) { humidity_sensor_ = humidity_sensor; } @@ -54,7 +57,8 @@ class DHT : public PollingComponent { protected: bool read_sensor_(float *temperature, float *humidity, bool report_errors); - InternalGPIOPin *pin_; + InternalGPIOPin *t_pin_; + ISRInternalGPIOPin pin_; DHTModel model_{DHT_MODEL_AUTO_DETECT}; bool is_auto_detect_{false}; sensor::Sensor *temperature_sensor_{nullptr}; From ebc3d28adeeb57db9291f9c73add09e8516d08e6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 19 Dec 2025 11:18:15 -1000 Subject: [PATCH 25/33] [wifi] Replace optional with sentinel values to reduce RAM and clarify API (#12446) --- esphome/components/wifi/wifi_component.cpp | 51 ++++++++++--------- esphome/components/wifi/wifi_component.h | 24 +++++---- .../wifi/wifi_component_esp8266.cpp | 10 ++-- .../wifi/wifi_component_esp_idf.cpp | 10 ++-- .../wifi/wifi_component_libretiny.cpp | 6 +-- .../components/wifi/wifi_component_pico_w.cpp | 2 +- 6 files changed, 55 insertions(+), 48 deletions(-) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 7c5b001be9..242265344d 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -2,6 +2,7 @@ #ifdef USE_WIFI #include #include +#include #ifdef USE_ESP32 #if (ESP_IDF_VERSION_MAJOR >= 5 && ESP_IDF_VERSION_MINOR >= 1) @@ -394,7 +395,7 @@ void WiFiComponent::start() { if (this->has_sta()) { this->wifi_sta_pre_setup_(); - if (this->output_power_.has_value() && !this->wifi_apply_output_power_(*this->output_power_)) { + if (!std::isnan(this->output_power_) && !this->wifi_apply_output_power_(this->output_power_)) { ESP_LOGV(TAG, "Setting Output Power Option failed"); } @@ -441,7 +442,7 @@ void WiFiComponent::start() { #ifdef USE_WIFI_AP } else if (this->has_ap()) { this->setup_ap_config_(); - if (this->output_power_.has_value() && !this->wifi_apply_output_power_(*this->output_power_)) { + if (!std::isnan(this->output_power_) && !this->wifi_apply_output_power_(this->output_power_)) { ESP_LOGV(TAG, "Setting Output Power Option failed"); } #ifdef USE_CAPTIVE_PORTAL @@ -713,8 +714,8 @@ WiFiAP WiFiComponent::build_params_for_current_phase_() { case WiFiRetryPhase::RETRY_HIDDEN: // Hidden network mode: clear BSSID/channel to trigger probe request // (both explicit hidden and retry hidden use same behavior) - params.set_bssid(optional{}); - params.set_channel(optional{}); + params.clear_bssid(); + params.clear_channel(); break; case WiFiRetryPhase::SCAN_CONNECTING: @@ -766,21 +767,20 @@ void WiFiComponent::start_connecting(const WiFiAP &ap) { char bssid_s[18]; int8_t priority = 0; - if (ap.get_bssid().has_value()) { - format_mac_addr_upper(ap.get_bssid().value().data(), bssid_s); - priority = this->get_sta_priority(ap.get_bssid().value()); + if (ap.has_bssid()) { + format_mac_addr_upper(ap.get_bssid().data(), bssid_s); + priority = this->get_sta_priority(ap.get_bssid()); } ESP_LOGI(TAG, "Connecting to " LOG_SECRET("'%s'") " " LOG_SECRET("(%s)") " (priority %d, attempt %u/%u in phase %s)...", - ap.get_ssid().c_str(), ap.get_bssid().has_value() ? bssid_s : LOG_STR_LITERAL("any"), priority, - this->num_retried_ + 1, get_max_retries_for_phase(this->retry_phase_), - LOG_STR_ARG(retry_phase_to_log_string(this->retry_phase_))); + ap.get_ssid().c_str(), ap.has_bssid() ? bssid_s : LOG_STR_LITERAL("any"), priority, this->num_retried_ + 1, + get_max_retries_for_phase(this->retry_phase_), LOG_STR_ARG(retry_phase_to_log_string(this->retry_phase_))); #ifdef ESPHOME_LOG_HAS_VERBOSE ESP_LOGV(TAG, "Connection Params:"); ESP_LOGV(TAG, " SSID: '%s'", ap.get_ssid().c_str()); - if (ap.get_bssid().has_value()) { + if (ap.has_bssid()) { ESP_LOGV(TAG, " BSSID: %s", bssid_s); } else { ESP_LOGV(TAG, " BSSID: Not Set"); @@ -808,8 +808,8 @@ void WiFiComponent::start_connecting(const WiFiAP &ap) { #ifdef USE_WIFI_WPA2_EAP } #endif - if (ap.get_channel().has_value()) { - ESP_LOGV(TAG, " Channel: %u", *ap.get_channel()); + if (ap.has_channel()) { + ESP_LOGV(TAG, " Channel: %u", ap.get_channel()); } else { ESP_LOGV(TAG, " Channel not set"); } @@ -919,8 +919,8 @@ void WiFiComponent::print_connect_params_() { get_wifi_channel(), wifi_subnet_mask_().str().c_str(), wifi_gateway_ip_().str().c_str(), wifi_dns_ip_(0).str().c_str(), wifi_dns_ip_(1).str().c_str()); #ifdef ESPHOME_LOG_HAS_VERBOSE - if (const WiFiAP *config = this->get_selected_sta_(); config && config->get_bssid().has_value()) { - ESP_LOGV(TAG, " Priority: %d", this->get_sta_priority(*config->get_bssid())); + if (const WiFiAP *config = this->get_selected_sta_(); config && config->has_bssid()) { + ESP_LOGV(TAG, " Priority: %d", this->get_sta_priority(config->get_bssid())); } #endif #ifdef USE_WIFI_11KV_SUPPORT @@ -1514,9 +1514,9 @@ void WiFiComponent::log_and_adjust_priority_for_failed_connect_() { if (this->retry_phase_ == WiFiRetryPhase::SCAN_CONNECTING && !this->scan_result_.empty()) { // Scan-based phase: always use best result (index 0) failed_bssid = this->scan_result_[0].get_bssid(); - } else if (const WiFiAP *config = this->get_selected_sta_(); config && config->get_bssid()) { + } else if (const WiFiAP *config = this->get_selected_sta_(); config && config->has_bssid()) { // Config has specific BSSID (fast_connect or user-specified) - failed_bssid = *config->get_bssid(); + failed_bssid = config->get_bssid(); } if (!failed_bssid.has_value()) { @@ -1784,24 +1784,27 @@ void WiFiComponent::save_fast_connect_settings_() { #endif void WiFiAP::set_ssid(const std::string &ssid) { this->ssid_ = ssid; } -void WiFiAP::set_bssid(bssid_t bssid) { this->bssid_ = bssid; } -void WiFiAP::set_bssid(optional bssid) { this->bssid_ = bssid; } +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_ = password; } #ifdef USE_WIFI_WPA2_EAP void WiFiAP::set_eap(optional eap_auth) { this->eap_ = std::move(eap_auth); } #endif -void WiFiAP::set_channel(optional channel) { this->channel_ = channel; } +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 std::string &WiFiAP::get_ssid() const { return this->ssid_; } -const optional &WiFiAP::get_bssid() const { return this->bssid_; } +const bssid_t &WiFiAP::get_bssid() const { return this->bssid_; } +bool WiFiAP::has_bssid() const { return this->bssid_ != bssid_t{}; } const std::string &WiFiAP::get_password() const { return this->password_; } #ifdef USE_WIFI_WPA2_EAP const optional &WiFiAP::get_eap() const { return this->eap_; } #endif -const optional &WiFiAP::get_channel() const { return this->channel_; } +uint8_t WiFiAP::get_channel() const { return this->channel_; } +bool WiFiAP::has_channel() const { return this->channel_ != 0; } #ifdef USE_WIFI_MANUAL_IP const optional &WiFiAP::get_manual_ip() const { return this->manual_ip_; } #endif @@ -1829,7 +1832,7 @@ bool WiFiScanResult::matches(const WiFiAP &config) const { // network is configured without SSID - match other settings } // If BSSID configured, only match for correct BSSIDs - if (config.get_bssid().has_value() && *config.get_bssid() != this->bssid_) + if (config.has_bssid() && config.get_bssid() != this->bssid_) return false; #ifdef USE_WIFI_WPA2_EAP @@ -1847,7 +1850,7 @@ bool WiFiScanResult::matches(const WiFiAP &config) const { #endif // If channel configured, only match networks on that channel. - if (config.get_channel().has_value() && *config.get_channel() != this->channel_) { + if (config.has_channel() && config.get_channel() != this->channel_) { return false; } return true; diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index be94e9462b..604efa8a7e 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -151,25 +151,28 @@ template using wifi_scan_vector_t = FixedVector; class WiFiAP { public: void set_ssid(const std::string &ssid); - void set_bssid(bssid_t bssid); - void set_bssid(optional bssid); + void set_bssid(const bssid_t &bssid); + void clear_bssid(); void set_password(const std::string &password); #ifdef USE_WIFI_WPA2_EAP void set_eap(optional eap_auth); #endif // USE_WIFI_WPA2_EAP - void set_channel(optional channel); + void set_channel(uint8_t channel); + void clear_channel(); void set_priority(int8_t priority) { priority_ = priority; } #ifdef USE_WIFI_MANUAL_IP void set_manual_ip(optional manual_ip); #endif void set_hidden(bool hidden); const std::string &get_ssid() const; - const optional &get_bssid() const; + const bssid_t &get_bssid() const; + bool has_bssid() const; const std::string &get_password() const; #ifdef USE_WIFI_WPA2_EAP const optional &get_eap() const; #endif // USE_WIFI_WPA2_EAP - const optional &get_channel() const; + uint8_t get_channel() const; + bool has_channel() const; int8_t get_priority() const { return priority_; } #ifdef USE_WIFI_MANUAL_IP const optional &get_manual_ip() const; @@ -179,16 +182,17 @@ class WiFiAP { protected: std::string ssid_; std::string password_; - optional bssid_; #ifdef USE_WIFI_WPA2_EAP optional eap_; #endif // USE_WIFI_WPA2_EAP #ifdef USE_WIFI_MANUAL_IP optional manual_ip_; #endif - optional channel_; - int8_t priority_{0}; - bool hidden_{false}; + // Group small types together to minimize padding + bssid_t bssid_{}; // 6 bytes, all zeros = any/not set + uint8_t channel_{0}; // 1 byte, 0 = auto/not set + int8_t priority_{0}; // 1 byte + bool hidden_{false}; // 1 byte (+ 3 bytes end padding to 4-byte align) }; class WiFiScanResult { @@ -590,7 +594,7 @@ class WiFiComponent : public Component { #ifdef USE_WIFI_AP WiFiAP ap_; #endif - optional output_power_; + float output_power_{NAN}; #ifdef USE_WIFI_LISTENERS std::vector ip_state_listeners_; std::vector scan_results_listeners_; diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index 3b1a442bdb..1329103f98 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -257,9 +257,9 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { memcpy(reinterpret_cast(conf.ssid), ap.get_ssid().c_str(), ap.get_ssid().size()); memcpy(reinterpret_cast(conf.password), ap.get_password().c_str(), ap.get_password().size()); - if (ap.get_bssid().has_value()) { + if (ap.has_bssid()) { conf.bssid_set = 1; - memcpy(conf.bssid, ap.get_bssid()->data(), 6); + memcpy(conf.bssid, ap.get_bssid().data(), 6); } else { conf.bssid_set = 0; } @@ -381,8 +381,8 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { } #endif /* USE_NETWORK_IPV6 */ - if (ap.get_channel().has_value()) { - ret = wifi_set_channel(*ap.get_channel()); + if (ap.has_channel()) { + ret = wifi_set_channel(ap.get_channel()); if (!ret) { ESP_LOGV(TAG, "wifi_set_channel failed"); return false; @@ -845,7 +845,7 @@ bool WiFiComponent::wifi_start_ap_(const WiFiAP &ap) { } memcpy(reinterpret_cast(conf.ssid), ap.get_ssid().c_str(), ap.get_ssid().size()); conf.ssid_len = static_cast(ap.get_ssid().size()); - conf.channel = ap.get_channel().value_or(1); + conf.channel = ap.has_channel() ? ap.get_channel() : 1; conf.ssid_hidden = ap.get_hidden(); conf.max_connection = 5; conf.beacon_interval = 100; diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index 4a3c40a119..f9e117f468 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -339,14 +339,14 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { conf.sta.rm_enabled = this->rrm_; #endif - if (ap.get_bssid().has_value()) { + if (ap.has_bssid()) { conf.sta.bssid_set = true; - memcpy(conf.sta.bssid, ap.get_bssid()->data(), 6); + memcpy(conf.sta.bssid, ap.get_bssid().data(), 6); } else { conf.sta.bssid_set = false; } - if (ap.get_channel().has_value()) { - conf.sta.channel = *ap.get_channel(); + if (ap.has_channel()) { + conf.sta.channel = ap.get_channel(); conf.sta.scan_method = WIFI_FAST_SCAN; } else { conf.sta.scan_method = WIFI_ALL_CHANNEL_SCAN; @@ -1003,7 +1003,7 @@ bool WiFiComponent::wifi_start_ap_(const WiFiAP &ap) { return false; } memcpy(reinterpret_cast(conf.ap.ssid), ap.get_ssid().c_str(), ap.get_ssid().size()); - conf.ap.channel = ap.get_channel().value_or(1); + conf.ap.channel = ap.has_channel() ? ap.get_channel() : 1; conf.ap.ssid_hidden = ap.get_ssid().size(); conf.ap.max_connection = 5; conf.ap.beacon_interval = 100; diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index 36003a6eb4..ffc6b21359 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -139,8 +139,8 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { s_sta_connecting = true; WiFiStatus status = WiFi.begin(ap.get_ssid().c_str(), ap.get_password().empty() ? NULL : ap.get_password().c_str(), - ap.get_channel().has_value() ? *ap.get_channel() : 0, - ap.get_bssid().has_value() ? ap.get_bssid()->data() : NULL); + ap.get_channel(), // 0 = auto + ap.has_bssid() ? ap.get_bssid().data() : NULL); if (status != WL_CONNECTED) { ESP_LOGW(TAG, "esp_wifi_connect failed: %d", status); return false; @@ -522,7 +522,7 @@ bool WiFiComponent::wifi_start_ap_(const WiFiAP &ap) { yield(); return WiFi.softAP(ap.get_ssid().c_str(), ap.get_password().empty() ? NULL : ap.get_password().c_str(), - ap.get_channel().value_or(1), ap.get_hidden()); + ap.has_channel() ? ap.get_channel() : 1, ap.get_hidden()); } network::IPAddress WiFiComponent::wifi_soft_ap_ip() { return {WiFi.softAPIP()}; } diff --git a/esphome/components/wifi/wifi_component_pico_w.cpp b/esphome/components/wifi/wifi_component_pico_w.cpp index 0228755432..4e763a9e22 100644 --- a/esphome/components/wifi/wifi_component_pico_w.cpp +++ b/esphome/components/wifi/wifi_component_pico_w.cpp @@ -192,7 +192,7 @@ bool WiFiComponent::wifi_start_ap_(const WiFiAP &ap) { } #endif - WiFi.beginAP(ap.get_ssid().c_str(), ap.get_password().c_str(), ap.get_channel().value_or(1)); + WiFi.beginAP(ap.get_ssid().c_str(), ap.get_password().c_str(), ap.has_channel() ? ap.get_channel() : 1); return true; } From 81e91c2a8f05a0192d6c1cda0e1b13cb465312f5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 19 Dec 2025 11:18:32 -1000 Subject: [PATCH 26/33] [esp32_ble] Add stack-based UUID formatting to avoid heap allocations (#12510) --- esphome/components/esp32_ble/ble_uuid.cpp | 17 +++++++++------- esphome/components/esp32_ble/ble_uuid.h | 5 +++++ .../esp32_ble_client/ble_characteristic.cpp | 6 +++++- .../esp32_ble_client/ble_client_base.cpp | 7 +++++-- .../esp32_ble_client/ble_service.cpp | 7 +++++-- .../esp32_ble_server/ble_characteristic.cpp | 6 +++++- .../esp32_ble_server/ble_descriptor.cpp | 6 +++++- .../esp32_ble_server/ble_server.cpp | 20 +++++++++++++++---- .../esp32_ble_tracker/esp32_ble_tracker.cpp | 17 +++++++++++----- 9 files changed, 68 insertions(+), 23 deletions(-) diff --git a/esphome/components/esp32_ble/ble_uuid.cpp b/esphome/components/esp32_ble/ble_uuid.cpp index dcbb285e07..c6b27f3bb9 100644 --- a/esphome/components/esp32_ble/ble_uuid.cpp +++ b/esphome/components/esp32_ble/ble_uuid.cpp @@ -143,9 +143,8 @@ bool ESPBTUUID::operator==(const ESPBTUUID &uuid) const { return this->as_128bit() == uuid.as_128bit(); } esp_bt_uuid_t ESPBTUUID::get_uuid() const { return this->uuid_; } -std::string ESPBTUUID::to_string() const { - char buf[40]; // Enough for 128-bit UUID with dashes - char *pos = buf; +void ESPBTUUID::to_str(std::span output) const { + char *pos = output.data(); switch (this->uuid_.len) { case ESP_UUID_LEN_16: @@ -156,7 +155,7 @@ std::string ESPBTUUID::to_string() const { *pos++ = format_hex_pretty_char((this->uuid_.uuid.uuid16 >> 4) & 0x0F); *pos++ = format_hex_pretty_char(this->uuid_.uuid.uuid16 & 0x0F); *pos = '\0'; - return std::string(buf); + return; case ESP_UUID_LEN_32: *pos++ = '0'; @@ -165,7 +164,7 @@ std::string ESPBTUUID::to_string() const { *pos++ = format_hex_pretty_char((this->uuid_.uuid.uuid32 >> shift) & 0x0F); } *pos = '\0'; - return std::string(buf); + return; default: case ESP_UUID_LEN_128: @@ -179,9 +178,13 @@ std::string ESPBTUUID::to_string() const { } } *pos = '\0'; - return std::string(buf); + return; } - return ""; +} +std::string ESPBTUUID::to_string() const { + char buf[UUID_STR_LEN]; + this->to_str(buf); + return std::string(buf); } } // namespace esphome::esp32_ble diff --git a/esphome/components/esp32_ble/ble_uuid.h b/esphome/components/esp32_ble/ble_uuid.h index 4cf2d10abd..ed561d70e4 100644 --- a/esphome/components/esp32_ble/ble_uuid.h +++ b/esphome/components/esp32_ble/ble_uuid.h @@ -7,11 +7,15 @@ #ifdef USE_ESP32 #ifdef USE_ESP32_BLE_UUID +#include #include #include namespace esphome::esp32_ble { +/// Buffer size for UUID string: "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX\0" +static constexpr size_t UUID_STR_LEN = 37; + class ESPBTUUID { public: ESPBTUUID(); @@ -37,6 +41,7 @@ class ESPBTUUID { esp_bt_uuid_t get_uuid() const; std::string to_string() const; + void to_str(std::span output) const; protected: esp_bt_uuid_t uuid_; diff --git a/esphome/components/esp32_ble_client/ble_characteristic.cpp b/esphome/components/esp32_ble_client/ble_characteristic.cpp index e0d0174c57..e830702f11 100644 --- a/esphome/components/esp32_ble_client/ble_characteristic.cpp +++ b/esphome/components/esp32_ble_client/ble_characteristic.cpp @@ -50,8 +50,12 @@ void BLECharacteristic::parse_descriptors() { desc->handle = result.handle; desc->characteristic = this; this->descriptors.push_back(desc); +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + char uuid_buf[espbt::UUID_STR_LEN]; + desc->uuid.to_str(uuid_buf); ESP_LOGV(TAG, "[%d] [%s] descriptor %s, handle 0x%x", this->service->client->get_connection_index(), - this->service->client->address_str(), desc->uuid.to_string().c_str(), desc->handle); + this->service->client->address_str(), uuid_buf, desc->handle); +#endif offset++; } } diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index a09390c747..8017b577f4 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -411,12 +411,15 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ this->update_conn_params_(MEDIUM_MIN_CONN_INTERVAL, MEDIUM_MAX_CONN_INTERVAL, 0, MEDIUM_CONN_TIMEOUT, "medium"); } else if (this->connection_type_ != espbt::ConnectionType::V3_WITH_CACHE) { #ifdef USE_ESP32_BLE_DEVICE +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE for (auto &svc : this->services_) { - ESP_LOGV(TAG, "[%d] [%s] Service UUID: %s", this->connection_index_, this->address_str_, - svc->uuid.to_string().c_str()); + char uuid_buf[espbt::UUID_STR_LEN]; + svc->uuid.to_str(uuid_buf); + ESP_LOGV(TAG, "[%d] [%s] Service UUID: %s", this->connection_index_, this->address_str_, uuid_buf); ESP_LOGV(TAG, "[%d] [%s] start_handle: 0x%x end_handle: 0x%x", this->connection_index_, this->address_str_, svc->start_handle, svc->end_handle); } +#endif #endif } ESP_LOGI(TAG, "[%d] [%s] Service discovery complete", this->connection_index_, this->address_str_); diff --git a/esphome/components/esp32_ble_client/ble_service.cpp b/esphome/components/esp32_ble_client/ble_service.cpp index deaaa3de02..695f468c5b 100644 --- a/esphome/components/esp32_ble_client/ble_service.cpp +++ b/esphome/components/esp32_ble_client/ble_service.cpp @@ -64,9 +64,12 @@ void BLEService::parse_characteristics() { characteristic->handle = result.char_handle; characteristic->service = this; this->characteristics.push_back(characteristic); +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + char uuid_buf[espbt::UUID_STR_LEN]; + characteristic->uuid.to_str(uuid_buf); ESP_LOGV(TAG, "[%d] [%s] characteristic %s, handle 0x%x, properties 0x%x", this->client->get_connection_index(), - this->client->address_str(), characteristic->uuid.to_string().c_str(), characteristic->handle, - characteristic->properties); + this->client->address_str(), uuid_buf, characteristic->handle, characteristic->properties); +#endif offset++; } } diff --git a/esphome/components/esp32_ble_server/ble_characteristic.cpp b/esphome/components/esp32_ble_server/ble_characteristic.cpp index 7627a58338..0482848ea0 100644 --- a/esphome/components/esp32_ble_server/ble_characteristic.cpp +++ b/esphome/components/esp32_ble_server/ble_characteristic.cpp @@ -109,7 +109,11 @@ void BLECharacteristic::do_create(BLEService *service) { esp_attr_control_t control; control.auto_rsp = ESP_GATT_RSP_BY_APP; - ESP_LOGV(TAG, "Creating characteristic - %s", this->uuid_.to_string().c_str()); +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + char uuid_buf[esp32_ble::UUID_STR_LEN]; + this->uuid_.to_str(uuid_buf); + ESP_LOGV(TAG, "Creating characteristic - %s", uuid_buf); +#endif esp_bt_uuid_t uuid = this->uuid_.get_uuid(); esp_err_t err = esp_ble_gatts_add_char(service->get_handle(), &uuid, static_cast(this->permissions_), diff --git a/esphome/components/esp32_ble_server/ble_descriptor.cpp b/esphome/components/esp32_ble_server/ble_descriptor.cpp index 2d053c09bd..4ffca7312b 100644 --- a/esphome/components/esp32_ble_server/ble_descriptor.cpp +++ b/esphome/components/esp32_ble_server/ble_descriptor.cpp @@ -34,7 +34,11 @@ void BLEDescriptor::do_create(BLECharacteristic *characteristic) { esp_attr_control_t control; control.auto_rsp = ESP_GATT_AUTO_RSP; - ESP_LOGV(TAG, "Creating descriptor - %s", this->uuid_.to_string().c_str()); +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + char uuid_buf[esp32_ble::UUID_STR_LEN]; + this->uuid_.to_str(uuid_buf); + ESP_LOGV(TAG, "Creating descriptor - %s", uuid_buf); +#endif esp_bt_uuid_t uuid = this->uuid_.get_uuid(); esp_err_t err = esp_ble_gatts_add_char_descr(this->characteristic_->get_service()->get_handle(), &uuid, this->permissions_, &this->value_, &control); diff --git a/esphome/components/esp32_ble_server/ble_server.cpp b/esphome/components/esp32_ble_server/ble_server.cpp index 0e58224a5a..2c13a8ac36 100644 --- a/esphome/components/esp32_ble_server/ble_server.cpp +++ b/esphome/components/esp32_ble_server/ble_server.cpp @@ -106,7 +106,11 @@ void BLEServer::restart_advertising_() { } BLEService *BLEServer::create_service(ESPBTUUID uuid, bool advertise, uint16_t num_handles) { - ESP_LOGV(TAG, "Creating BLE service - %s", uuid.to_string().c_str()); +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + char uuid_buf[esp32_ble::UUID_STR_LEN]; + uuid.to_str(uuid_buf); + ESP_LOGV(TAG, "Creating BLE service - %s", uuid_buf); +#endif // Calculate the inst_id for the service uint8_t inst_id = 0; for (; inst_id < 0xFF; inst_id++) { @@ -115,7 +119,9 @@ BLEService *BLEServer::create_service(ESPBTUUID uuid, bool advertise, uint16_t n } } if (inst_id == 0xFF) { - ESP_LOGW(TAG, "Could not create BLE service %s, too many instances", uuid.to_string().c_str()); + char warn_uuid_buf[esp32_ble::UUID_STR_LEN]; + uuid.to_str(warn_uuid_buf); + ESP_LOGW(TAG, "Could not create BLE service %s, too many instances", warn_uuid_buf); return nullptr; } BLEService *service = // NOLINT(cppcoreguidelines-owning-memory) @@ -128,7 +134,11 @@ BLEService *BLEServer::create_service(ESPBTUUID uuid, bool advertise, uint16_t n } void BLEServer::remove_service(ESPBTUUID uuid, uint8_t inst_id) { - ESP_LOGV(TAG, "Removing BLE service - %s %d", uuid.to_string().c_str(), inst_id); +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + char uuid_buf[esp32_ble::UUID_STR_LEN]; + uuid.to_str(uuid_buf); + ESP_LOGV(TAG, "Removing BLE service - %s %d", uuid_buf, inst_id); +#endif for (auto it = this->services_.begin(); it != this->services_.end(); ++it) { if (it->uuid == uuid && it->inst_id == inst_id) { it->service->do_delete(); @@ -137,7 +147,9 @@ void BLEServer::remove_service(ESPBTUUID uuid, uint8_t inst_id) { return; } } - ESP_LOGW(TAG, "BLE service %s %d does not exist", uuid.to_string().c_str(), inst_id); + char warn_uuid_buf[esp32_ble::UUID_STR_LEN]; + uuid.to_str(warn_uuid_buf); + ESP_LOGW(TAG, "BLE service %s %d does not exist", warn_uuid_buf, inst_id); } BLEService *BLEServer::get_service(ESPBTUUID uuid, uint8_t inst_id) { diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index 45e343c0d2..cb83eb5a0d 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -438,24 +438,31 @@ void ESPBTDevice::parse_scan_rst(const BLEScanResult &scan_result) { ESP_LOGVV(TAG, " Ad Flag: %u", *this->ad_flag_); } for (auto &uuid : this->service_uuids_) { - ESP_LOGVV(TAG, " Service UUID: %s", uuid.to_string().c_str()); + char uuid_buf[esp32_ble::UUID_STR_LEN]; + uuid.to_str(uuid_buf); + ESP_LOGVV(TAG, " Service UUID: %s", uuid_buf); } for (auto &data : this->manufacturer_datas_) { auto ibeacon = ESPBLEiBeacon::from_manufacturer_data(data); if (ibeacon.has_value()) { ESP_LOGVV(TAG, " Manufacturer iBeacon:"); - ESP_LOGVV(TAG, " UUID: %s", ibeacon.value().get_uuid().to_string().c_str()); + char uuid_buf[esp32_ble::UUID_STR_LEN]; + ibeacon.value().get_uuid().to_str(uuid_buf); + ESP_LOGVV(TAG, " UUID: %s", uuid_buf); ESP_LOGVV(TAG, " Major: %u", ibeacon.value().get_major()); ESP_LOGVV(TAG, " Minor: %u", ibeacon.value().get_minor()); ESP_LOGVV(TAG, " TXPower: %d", ibeacon.value().get_signal_power()); } else { - ESP_LOGVV(TAG, " Manufacturer ID: %s, data: %s", data.uuid.to_string().c_str(), - format_hex_pretty(data.data).c_str()); + char uuid_buf[esp32_ble::UUID_STR_LEN]; + data.uuid.to_str(uuid_buf); + ESP_LOGVV(TAG, " Manufacturer ID: %s, data: %s", uuid_buf, format_hex_pretty(data.data).c_str()); } } for (auto &data : this->service_datas_) { ESP_LOGVV(TAG, " Service data:"); - ESP_LOGVV(TAG, " UUID: %s", data.uuid.to_string().c_str()); + char uuid_buf[esp32_ble::UUID_STR_LEN]; + data.uuid.to_str(uuid_buf); + ESP_LOGVV(TAG, " UUID: %s", uuid_buf); ESP_LOGVV(TAG, " Data: %s", format_hex_pretty(data.data).c_str()); } From 940afdbb12e212299edc1ad67481ef9a6d5f568e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 19 Dec 2025 11:18:50 -1000 Subject: [PATCH 27/33] [climate] Add zero-copy support for API custom fan mode and preset commands (#12402) --- esphome/components/api/api.proto | 4 +- esphome/components/api/api_connection.cpp | 4 +- esphome/components/api/api_pb2.cpp | 14 +++++-- esphome/components/api/api_pb2.h | 8 ++-- esphome/components/api/api_pb2_dump.cpp | 8 +++- esphome/components/climate/climate.cpp | 45 +++++++++++++++------ esphome/components/climate/climate.h | 6 +++ esphome/components/climate/climate_traits.h | 22 +++++++--- 8 files changed, 80 insertions(+), 31 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 50af5061c0..dd8320bebb 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -1091,11 +1091,11 @@ message ClimateCommandRequest { bool has_swing_mode = 14; ClimateSwingMode swing_mode = 15; bool has_custom_fan_mode = 16; - string custom_fan_mode = 17; + string custom_fan_mode = 17 [(pointer_to_buffer) = true]; bool has_preset = 18; ClimatePreset preset = 19; bool has_custom_preset = 20; - string custom_preset = 21; + string custom_preset = 21 [(pointer_to_buffer) = true]; bool has_target_humidity = 22; float target_humidity = 23; uint32 device_id = 24 [(field_ifdef) = "USE_DEVICES"]; diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 85f4566f3c..686fdcba41 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -712,11 +712,11 @@ void APIConnection::climate_command(const ClimateCommandRequest &msg) { if (msg.has_fan_mode) call.set_fan_mode(static_cast(msg.fan_mode)); if (msg.has_custom_fan_mode) - call.set_fan_mode(msg.custom_fan_mode); + call.set_fan_mode(reinterpret_cast(msg.custom_fan_mode), msg.custom_fan_mode_len); if (msg.has_preset) call.set_preset(static_cast(msg.preset)); if (msg.has_custom_preset) - call.set_preset(msg.custom_preset); + call.set_preset(reinterpret_cast(msg.custom_preset), msg.custom_preset_len); if (msg.has_swing_mode) call.set_swing_mode(static_cast(msg.swing_mode)); call.perform(); diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 52f4b495e9..211f856e3b 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -1392,12 +1392,18 @@ bool ClimateCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) } bool ClimateCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { - case 17: - this->custom_fan_mode = value.as_string(); + case 17: { + // Use raw data directly to avoid allocation + this->custom_fan_mode = value.data(); + this->custom_fan_mode_len = value.size(); break; - case 21: - this->custom_preset = value.as_string(); + } + case 21: { + // Use raw data directly to avoid allocation + this->custom_preset = value.data(); + this->custom_preset_len = value.size(); break; + } default: return false; } diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index f23a62fc3c..4e10c63881 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -1475,7 +1475,7 @@ class ClimateStateResponse final : public StateResponseProtoMessage { class ClimateCommandRequest final : public CommandProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 48; - static constexpr uint8_t ESTIMATED_SIZE = 84; + static constexpr uint8_t ESTIMATED_SIZE = 104; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "climate_command_request"; } #endif @@ -1492,11 +1492,13 @@ class ClimateCommandRequest final : public CommandProtoMessage { bool has_swing_mode{false}; enums::ClimateSwingMode swing_mode{}; bool has_custom_fan_mode{false}; - std::string custom_fan_mode{}; + const uint8_t *custom_fan_mode{nullptr}; + uint16_t custom_fan_mode_len{0}; bool has_preset{false}; enums::ClimatePreset preset{}; bool has_custom_preset{false}; - std::string custom_preset{}; + const uint8_t *custom_preset{nullptr}; + uint16_t custom_preset_len{0}; bool has_target_humidity{false}; float target_humidity{0.0f}; #ifdef HAS_PROTO_MESSAGE_DUMP diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 5e271f41cb..90e8e75c93 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -1374,11 +1374,15 @@ void ClimateCommandRequest::dump_to(std::string &out) const { dump_field(out, "has_swing_mode", this->has_swing_mode); dump_field(out, "swing_mode", static_cast(this->swing_mode)); dump_field(out, "has_custom_fan_mode", this->has_custom_fan_mode); - dump_field(out, "custom_fan_mode", this->custom_fan_mode); + out.append(" custom_fan_mode: "); + out.append(format_hex_pretty(this->custom_fan_mode, this->custom_fan_mode_len)); + out.append("\n"); dump_field(out, "has_preset", this->has_preset); dump_field(out, "preset", static_cast(this->preset)); dump_field(out, "has_custom_preset", this->has_custom_preset); - dump_field(out, "custom_preset", this->custom_preset); + out.append(" custom_preset: "); + out.append(format_hex_pretty(this->custom_preset, this->custom_preset_len)); + out.append("\n"); dump_field(out, "has_target_humidity", this->has_target_humidity); dump_field(out, "target_humidity", this->target_humidity); #ifdef USE_DEVICES diff --git a/esphome/components/climate/climate.cpp b/esphome/components/climate/climate.cpp index 3bc20a17c6..229862ce01 100644 --- a/esphome/components/climate/climate.cpp +++ b/esphome/components/climate/climate.cpp @@ -2,6 +2,7 @@ #include "esphome/core/defines.h" #include "esphome/core/controller_registry.h" #include "esphome/core/macros.h" +#include namespace esphome::climate { @@ -190,24 +191,30 @@ ClimateCall &ClimateCall::set_fan_mode(ClimateFanMode fan_mode) { } ClimateCall &ClimateCall::set_fan_mode(const char *custom_fan_mode) { + return this->set_fan_mode(custom_fan_mode, strlen(custom_fan_mode)); +} + +ClimateCall &ClimateCall::set_fan_mode(const std::string &fan_mode) { + return this->set_fan_mode(fan_mode.data(), fan_mode.size()); +} + +ClimateCall &ClimateCall::set_fan_mode(const char *custom_fan_mode, size_t len) { // Check if it's a standard enum mode first for (const auto &mode_entry : CLIMATE_FAN_MODES_BY_STR) { - if (str_equals_case_insensitive(custom_fan_mode, mode_entry.str)) { + if (strncasecmp(custom_fan_mode, mode_entry.str, len) == 0 && mode_entry.str[len] == '\0') { return this->set_fan_mode(static_cast(mode_entry.value)); } } // Find the matching pointer from parent climate device - if (const char *mode_ptr = this->parent_->find_custom_fan_mode_(custom_fan_mode)) { + if (const char *mode_ptr = this->parent_->find_custom_fan_mode_(custom_fan_mode, len)) { this->custom_fan_mode_ = mode_ptr; this->fan_mode_.reset(); return *this; } - ESP_LOGW(TAG, "'%s' - Unrecognized fan mode %s", this->parent_->get_name().c_str(), custom_fan_mode); + ESP_LOGW(TAG, "'%s' - Unrecognized fan mode %.*s", this->parent_->get_name().c_str(), (int) len, custom_fan_mode); return *this; } -ClimateCall &ClimateCall::set_fan_mode(const std::string &fan_mode) { return this->set_fan_mode(fan_mode.c_str()); } - ClimateCall &ClimateCall::set_fan_mode(optional fan_mode) { if (fan_mode.has_value()) { this->set_fan_mode(fan_mode.value()); @@ -222,24 +229,30 @@ ClimateCall &ClimateCall::set_preset(ClimatePreset preset) { } ClimateCall &ClimateCall::set_preset(const char *custom_preset) { + return this->set_preset(custom_preset, strlen(custom_preset)); +} + +ClimateCall &ClimateCall::set_preset(const std::string &preset) { + return this->set_preset(preset.data(), preset.size()); +} + +ClimateCall &ClimateCall::set_preset(const char *custom_preset, size_t len) { // Check if it's a standard enum preset first for (const auto &preset_entry : CLIMATE_PRESETS_BY_STR) { - if (str_equals_case_insensitive(custom_preset, preset_entry.str)) { + if (strncasecmp(custom_preset, preset_entry.str, len) == 0 && preset_entry.str[len] == '\0') { return this->set_preset(static_cast(preset_entry.value)); } } // Find the matching pointer from parent climate device - if (const char *preset_ptr = this->parent_->find_custom_preset_(custom_preset)) { + if (const char *preset_ptr = this->parent_->find_custom_preset_(custom_preset, len)) { this->custom_preset_ = preset_ptr; this->preset_.reset(); return *this; } - ESP_LOGW(TAG, "'%s' - Unrecognized preset %s", this->parent_->get_name().c_str(), custom_preset); + ESP_LOGW(TAG, "'%s' - Unrecognized preset %.*s", this->parent_->get_name().c_str(), (int) len, custom_preset); return *this; } -ClimateCall &ClimateCall::set_preset(const std::string &preset) { return this->set_preset(preset.c_str()); } - ClimateCall &ClimateCall::set_preset(optional preset) { if (preset.has_value()) { this->set_preset(preset.value()); @@ -688,11 +701,19 @@ bool Climate::set_custom_preset_(const char *preset) { void Climate::clear_custom_preset_() { this->custom_preset_ = nullptr; } const char *Climate::find_custom_fan_mode_(const char *custom_fan_mode) { - return this->get_traits().find_custom_fan_mode_(custom_fan_mode); + return this->find_custom_fan_mode_(custom_fan_mode, strlen(custom_fan_mode)); +} + +const char *Climate::find_custom_fan_mode_(const char *custom_fan_mode, size_t len) { + return this->get_traits().find_custom_fan_mode_(custom_fan_mode, len); } const char *Climate::find_custom_preset_(const char *custom_preset) { - return this->get_traits().find_custom_preset_(custom_preset); + return this->find_custom_preset_(custom_preset, strlen(custom_preset)); +} + +const char *Climate::find_custom_preset_(const char *custom_preset, size_t len) { + return this->get_traits().find_custom_preset_(custom_preset, len); } void Climate::dump_traits_(const char *tag) { diff --git a/esphome/components/climate/climate.h b/esphome/components/climate/climate.h index 82df4b815f..0bae28df5a 100644 --- a/esphome/components/climate/climate.h +++ b/esphome/components/climate/climate.h @@ -78,6 +78,8 @@ class ClimateCall { ClimateCall &set_fan_mode(optional fan_mode); /// Set the custom fan mode of the climate device. ClimateCall &set_fan_mode(const char *custom_fan_mode); + /// Set the custom fan mode of the climate device (zero-copy API path). + ClimateCall &set_fan_mode(const char *custom_fan_mode, size_t len); /// Set the swing mode of the climate device. ClimateCall &set_swing_mode(ClimateSwingMode swing_mode); /// Set the swing mode of the climate device. @@ -94,6 +96,8 @@ class ClimateCall { ClimateCall &set_preset(optional preset); /// Set the custom preset of the climate device. ClimateCall &set_preset(const char *custom_preset); + /// Set the custom preset of the climate device (zero-copy API path). + ClimateCall &set_preset(const char *custom_preset, size_t len); void perform(); @@ -290,9 +294,11 @@ class Climate : public EntityBase { /// Find and return the matching custom fan mode pointer from traits, or nullptr if not found. const char *find_custom_fan_mode_(const char *custom_fan_mode); + const char *find_custom_fan_mode_(const char *custom_fan_mode, size_t len); /// Find and return the matching custom preset pointer from traits, or nullptr if not found. const char *find_custom_preset_(const char *custom_preset); + const char *find_custom_preset_(const char *custom_preset, size_t len); /** Get the default traits of this climate device. * diff --git a/esphome/components/climate/climate_traits.h b/esphome/components/climate/climate_traits.h index d358293475..80ef0854d5 100644 --- a/esphome/components/climate/climate_traits.h +++ b/esphome/components/climate/climate_traits.h @@ -20,18 +20,22 @@ using ClimatePresetMask = FiniteSetMask &vec, const char *value) { +inline bool vector_contains(const std::vector &vec, const char *value, size_t len) { for (const char *item : vec) { - if (strcmp(item, value) == 0) + if (strncmp(item, value, len) == 0 && item[len] == '\0') return true; } return false; } +inline bool vector_contains(const std::vector &vec, const char *value) { + return vector_contains(vec, value, strlen(value)); +} + // Find and return matching pointer from vector, or nullptr if not found -inline const char *vector_find(const std::vector &vec, const char *value) { +inline const char *vector_find(const std::vector &vec, const char *value, size_t len) { for (const char *item : vec) { - if (strcmp(item, value) == 0) + if (strncmp(item, value, len) == 0 && item[len] == '\0') return item; } return nullptr; @@ -257,13 +261,19 @@ class ClimateTraits { /// Find and return the matching custom fan mode pointer from supported modes, or nullptr if not found /// This is protected as it's an implementation detail - use Climate::find_custom_fan_mode_() instead const char *find_custom_fan_mode_(const char *custom_fan_mode) const { - return vector_find(this->supported_custom_fan_modes_, custom_fan_mode); + return this->find_custom_fan_mode_(custom_fan_mode, strlen(custom_fan_mode)); + } + const char *find_custom_fan_mode_(const char *custom_fan_mode, size_t len) const { + return vector_find(this->supported_custom_fan_modes_, custom_fan_mode, len); } /// Find and return the matching custom preset pointer from supported presets, or nullptr if not found /// This is protected as it's an implementation detail - use Climate::find_custom_preset_() instead const char *find_custom_preset_(const char *custom_preset) const { - return vector_find(this->supported_custom_presets_, custom_preset); + return this->find_custom_preset_(custom_preset, strlen(custom_preset)); + } + const char *find_custom_preset_(const char *custom_preset, size_t len) const { + return vector_find(this->supported_custom_presets_, custom_preset, len); } uint32_t feature_flags_{0}; From 988b888c6308d3b819918a6ec50a31aca76b8e43 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 19 Dec 2025 11:19:07 -1000 Subject: [PATCH 28/33] [ota] Replace std::function callbacks with listener interface (#12167) --- .../components/esp32_ble_tracker/__init__.py | 4 +- .../esp32_ble_tracker/esp32_ble_tracker.cpp | 27 +++--- .../esp32_ble_tracker/esp32_ble_tracker.h | 11 +++ .../components/esphome/ota/ota_esphome.cpp | 20 ++-- .../http_request/ota/ota_http_request.cpp | 22 ++--- .../http_request/ota/ota_http_request.h | 1 - .../http_request/update/__init__.py | 4 +- .../update/http_request_update.cpp | 26 +++--- .../http_request/update/http_request_update.h | 4 +- .../components/micro_wake_word/__init__.py | 4 +- .../micro_wake_word/micro_wake_word.cpp | 21 +++-- .../micro_wake_word/micro_wake_word.h | 16 +++- esphome/components/ota/__init__.py | 22 ++++- esphome/components/ota/automation.h | 92 +++++++++---------- esphome/components/ota/ota_backend.cpp | 9 +- esphome/components/ota/ota_backend.h | 91 ++++++++++-------- .../speaker/media_player/__init__.py | 4 +- .../media_player/speaker_media_player.cpp | 42 +++++---- .../media_player/speaker_media_player.h | 18 +++- .../web_server/ota/ota_web_server.cpp | 40 ++++---- esphome/core/defines.h | 2 +- 21 files changed, 274 insertions(+), 206 deletions(-) diff --git a/esphome/components/esp32_ble_tracker/__init__.py b/esphome/components/esp32_ble_tracker/__init__.py index 4e25434aad..37e74672ed 100644 --- a/esphome/components/esp32_ble_tracker/__init__.py +++ b/esphome/components/esp32_ble_tracker/__init__.py @@ -5,7 +5,7 @@ import logging from esphome import automation import esphome.codegen as cg -from esphome.components import esp32_ble +from esphome.components import esp32_ble, ota from esphome.components.esp32 import add_idf_sdkconfig_option from esphome.components.esp32_ble import ( IDF_MAX_CONNECTIONS, @@ -328,7 +328,7 @@ async def to_code(config): # Note: CONFIG_BT_ACL_CONNECTIONS and CONFIG_BTDM_CTRL_BLE_MAX_CONN are now # configured in esp32_ble component based on max_connections setting - cg.add_define("USE_OTA_STATE_CALLBACK") # To be notified when an OTA update starts + ota.request_ota_state_listeners() # To be notified when an OTA update starts cg.add_define("USE_ESP32_BLE_CLIENT") CORE.add_job(_add_ble_features) diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index cb83eb5a0d..47da2e3570 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -71,21 +71,24 @@ void ESP32BLETracker::setup() { global_esp32_ble_tracker = this; -#ifdef USE_OTA - ota::get_global_ota_callback()->add_on_state_callback( - [this](ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) { - if (state == ota::OTA_STARTED) { - this->stop_scan(); -#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT - for (auto *client : this->clients_) { - client->disconnect(); - } -#endif - } - }); +#ifdef USE_OTA_STATE_LISTENER + ota::get_global_ota_callback()->add_global_state_listener(this); #endif } +#ifdef USE_OTA_STATE_LISTENER +void ESP32BLETracker::on_ota_global_state(ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) { + if (state == ota::OTA_STARTED) { + this->stop_scan(); +#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT + for (auto *client : this->clients_) { + client->disconnect(); + } +#endif + } +} +#endif + void ESP32BLETracker::loop() { if (!this->parent_->is_active()) { this->ble_was_disabled_ = true; diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index 92d13a62ad..b64e36279c 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -22,6 +22,10 @@ #include "esphome/components/esp32_ble/ble_uuid.h" #include "esphome/components/esp32_ble/ble_scan_result.h" +#ifdef USE_OTA_STATE_LISTENER +#include "esphome/components/ota/ota_backend.h" +#endif + namespace esphome::esp32_ble_tracker { using namespace esp32_ble; @@ -241,6 +245,9 @@ class ESP32BLETracker : public Component, public GAPScanEventHandler, public GATTcEventHandler, public BLEStatusEventHandler, +#ifdef USE_OTA_STATE_LISTENER + public ota::OTAGlobalStateListener, +#endif public Parented { public: void set_scan_duration(uint32_t scan_duration) { scan_duration_ = scan_duration; } @@ -274,6 +281,10 @@ class ESP32BLETracker : public Component, void gap_scan_event_handler(const BLEScanResult &scan_result) override; void ble_before_disabled_event_handler() override; +#ifdef USE_OTA_STATE_LISTENER + void on_ota_global_state(ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) override; +#endif + /// Add a listener for scanner state changes void add_scanner_state_listener(BLEScannerStateListener *listener) { this->scanner_state_listeners_.push_back(listener); diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 6cfd543553..b589a6119f 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -41,10 +41,6 @@ static constexpr size_t SHA256_HEX_SIZE = 64; // SHA256 hash as hex string (32 #endif // USE_OTA_PASSWORD void ESPHomeOTAComponent::setup() { -#ifdef USE_OTA_STATE_CALLBACK - ota::register_ota_platform(this); -#endif - this->server_ = socket::socket_ip_loop_monitored(SOCK_STREAM, 0); // monitored for incoming connections if (this->server_ == nullptr) { this->log_socket_error_(LOG_STR("creation")); @@ -297,8 +293,8 @@ void ESPHomeOTAComponent::handle_data_() { // accidentally trigger the update process. this->log_start_(LOG_STR("update")); this->status_set_warning(); -#ifdef USE_OTA_STATE_CALLBACK - this->state_callback_.call(ota::OTA_STARTED, 0.0f, 0); +#ifdef USE_OTA_STATE_LISTENER + this->notify_state_(ota::OTA_STARTED, 0.0f, 0); #endif // This will block for a few seconds as it locks flash @@ -357,8 +353,8 @@ void ESPHomeOTAComponent::handle_data_() { last_progress = now; float percentage = (total * 100.0f) / ota_size; ESP_LOGD(TAG, "Progress: %0.1f%%", percentage); -#ifdef USE_OTA_STATE_CALLBACK - this->state_callback_.call(ota::OTA_IN_PROGRESS, percentage, 0); +#ifdef USE_OTA_STATE_LISTENER + this->notify_state_(ota::OTA_IN_PROGRESS, percentage, 0); #endif // feed watchdog and give other tasks a chance to run this->yield_and_feed_watchdog_(); @@ -387,8 +383,8 @@ void ESPHomeOTAComponent::handle_data_() { delay(10); ESP_LOGI(TAG, "Update complete"); this->status_clear_warning(); -#ifdef USE_OTA_STATE_CALLBACK - this->state_callback_.call(ota::OTA_COMPLETED, 100.0f, 0); +#ifdef USE_OTA_STATE_LISTENER + this->notify_state_(ota::OTA_COMPLETED, 100.0f, 0); #endif delay(100); // NOLINT App.safe_reboot(); @@ -402,8 +398,8 @@ error: } this->status_momentary_error("err", 5000); -#ifdef USE_OTA_STATE_CALLBACK - this->state_callback_.call(ota::OTA_ERROR, 0.0f, static_cast(error_code)); +#ifdef USE_OTA_STATE_LISTENER + this->notify_state_(ota::OTA_ERROR, 0.0f, static_cast(error_code)); #endif } diff --git a/esphome/components/http_request/ota/ota_http_request.cpp b/esphome/components/http_request/ota/ota_http_request.cpp index b257518e06..058579752e 100644 --- a/esphome/components/http_request/ota/ota_http_request.cpp +++ b/esphome/components/http_request/ota/ota_http_request.cpp @@ -16,12 +16,6 @@ namespace http_request { static const char *const TAG = "http_request.ota"; -void OtaHttpRequestComponent::setup() { -#ifdef USE_OTA_STATE_CALLBACK - ota::register_ota_platform(this); -#endif -} - void OtaHttpRequestComponent::dump_config() { ESP_LOGCONFIG(TAG, "Over-The-Air updates via HTTP request"); }; void OtaHttpRequestComponent::set_md5_url(const std::string &url) { @@ -48,24 +42,24 @@ void OtaHttpRequestComponent::flash() { } ESP_LOGI(TAG, "Starting update"); -#ifdef USE_OTA_STATE_CALLBACK - this->state_callback_.call(ota::OTA_STARTED, 0.0f, 0); +#ifdef USE_OTA_STATE_LISTENER + this->notify_state_(ota::OTA_STARTED, 0.0f, 0); #endif auto ota_status = this->do_ota_(); switch (ota_status) { case ota::OTA_RESPONSE_OK: -#ifdef USE_OTA_STATE_CALLBACK - this->state_callback_.call(ota::OTA_COMPLETED, 100.0f, ota_status); +#ifdef USE_OTA_STATE_LISTENER + this->notify_state_(ota::OTA_COMPLETED, 100.0f, ota_status); #endif delay(10); App.safe_reboot(); break; default: -#ifdef USE_OTA_STATE_CALLBACK - this->state_callback_.call(ota::OTA_ERROR, 0.0f, ota_status); +#ifdef USE_OTA_STATE_LISTENER + this->notify_state_(ota::OTA_ERROR, 0.0f, ota_status); #endif this->md5_computed_.clear(); // will be reset at next attempt this->md5_expected_.clear(); // will be reset at next attempt @@ -165,8 +159,8 @@ uint8_t OtaHttpRequestComponent::do_ota_() { last_progress = now; float percentage = container->get_bytes_read() * 100.0f / container->content_length; ESP_LOGD(TAG, "Progress: %0.1f%%", percentage); -#ifdef USE_OTA_STATE_CALLBACK - this->state_callback_.call(ota::OTA_IN_PROGRESS, percentage, 0); +#ifdef USE_OTA_STATE_LISTENER + this->notify_state_(ota::OTA_IN_PROGRESS, percentage, 0); #endif } } // while diff --git a/esphome/components/http_request/ota/ota_http_request.h b/esphome/components/http_request/ota/ota_http_request.h index 6a86b4ab43..8735189e99 100644 --- a/esphome/components/http_request/ota/ota_http_request.h +++ b/esphome/components/http_request/ota/ota_http_request.h @@ -24,7 +24,6 @@ enum OtaHttpRequestError : uint8_t { class OtaHttpRequestComponent : public ota::OTAComponent, public Parented { public: - void setup() override; void dump_config() override; float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } diff --git a/esphome/components/http_request/update/__init__.py b/esphome/components/http_request/update/__init__.py index abb4b2a430..d84d80109a 100644 --- a/esphome/components/http_request/update/__init__.py +++ b/esphome/components/http_request/update/__init__.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import update +from esphome.components import ota, update import esphome.config_validation as cv from esphome.const import CONF_SOURCE @@ -38,6 +38,6 @@ async def to_code(config): cg.add(var.set_source_url(config[CONF_SOURCE])) - cg.add_define("USE_OTA_STATE_CALLBACK") + ota.request_ota_state_listeners() await cg.register_component(var, config) diff --git a/esphome/components/http_request/update/http_request_update.cpp b/esphome/components/http_request/update/http_request_update.cpp index 22cad625d1..a9392ad736 100644 --- a/esphome/components/http_request/update/http_request_update.cpp +++ b/esphome/components/http_request/update/http_request_update.cpp @@ -20,19 +20,19 @@ static const char *const TAG = "http_request.update"; static const size_t MAX_READ_SIZE = 256; -void HttpRequestUpdate::setup() { - this->ota_parent_->add_on_state_callback([this](ota::OTAState state, float progress, uint8_t err) { - if (state == ota::OTAState::OTA_IN_PROGRESS) { - this->state_ = update::UPDATE_STATE_INSTALLING; - this->update_info_.has_progress = true; - this->update_info_.progress = progress; - this->publish_state(); - } else if (state == ota::OTAState::OTA_ABORT || state == ota::OTAState::OTA_ERROR) { - this->state_ = update::UPDATE_STATE_AVAILABLE; - this->status_set_error(LOG_STR("Failed to install firmware")); - this->publish_state(); - } - }); +void HttpRequestUpdate::setup() { this->ota_parent_->add_state_listener(this); } + +void HttpRequestUpdate::on_ota_state(ota::OTAState state, float progress, uint8_t error) { + if (state == ota::OTAState::OTA_IN_PROGRESS) { + this->state_ = update::UPDATE_STATE_INSTALLING; + this->update_info_.has_progress = true; + this->update_info_.progress = progress; + this->publish_state(); + } else if (state == ota::OTAState::OTA_ABORT || state == ota::OTAState::OTA_ERROR) { + this->state_ = update::UPDATE_STATE_AVAILABLE; + this->status_set_error(LOG_STR("Failed to install firmware")); + this->publish_state(); + } } void HttpRequestUpdate::update() { diff --git a/esphome/components/http_request/update/http_request_update.h b/esphome/components/http_request/update/http_request_update.h index e05fdb0cc2..cf34ace18e 100644 --- a/esphome/components/http_request/update/http_request_update.h +++ b/esphome/components/http_request/update/http_request_update.h @@ -14,7 +14,7 @@ namespace esphome { namespace http_request { -class HttpRequestUpdate : public update::UpdateEntity, public PollingComponent { +class HttpRequestUpdate final : public update::UpdateEntity, public PollingComponent, public ota::OTAStateListener { public: void setup() override; void update() override; @@ -29,6 +29,8 @@ class HttpRequestUpdate : public update::UpdateEntity, public PollingComponent { float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } + void on_ota_state(ota::OTAState state, float progress, uint8_t error) override; + protected: HttpRequestComponent *request_parent_; OtaHttpRequestComponent *ota_parent_; diff --git a/esphome/components/micro_wake_word/__init__.py b/esphome/components/micro_wake_word/__init__.py index 575fb97799..0d478f749b 100644 --- a/esphome/components/micro_wake_word/__init__.py +++ b/esphome/components/micro_wake_word/__init__.py @@ -7,7 +7,7 @@ from urllib.parse import urljoin from esphome import automation, external_files, git from esphome.automation import register_action, register_condition import esphome.codegen as cg -from esphome.components import esp32, microphone, socket +from esphome.components import esp32, microphone, ota, socket import esphome.config_validation as cv from esphome.const import ( CONF_FILE, @@ -452,7 +452,7 @@ async def to_code(config): cg.add(var.set_microphone_source(mic_source)) cg.add_define("USE_MICRO_WAKE_WORD") - cg.add_define("USE_OTA_STATE_CALLBACK") + ota.request_ota_state_listeners() esp32.add_idf_component(name="espressif/esp-tflite-micro", ref="1.3.3~1") diff --git a/esphome/components/micro_wake_word/micro_wake_word.cpp b/esphome/components/micro_wake_word/micro_wake_word.cpp index ec8fa34da4..b8377ead38 100644 --- a/esphome/components/micro_wake_word/micro_wake_word.cpp +++ b/esphome/components/micro_wake_word/micro_wake_word.cpp @@ -119,18 +119,21 @@ void MicroWakeWord::setup() { } }); -#ifdef USE_OTA - ota::get_global_ota_callback()->add_on_state_callback( - [this](ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) { - if (state == ota::OTA_STARTED) { - this->suspend_task_(); - } else if (state == ota::OTA_ERROR) { - this->resume_task_(); - } - }); +#ifdef USE_OTA_STATE_LISTENER + ota::get_global_ota_callback()->add_global_state_listener(this); #endif } +#ifdef USE_OTA_STATE_LISTENER +void MicroWakeWord::on_ota_global_state(ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) { + if (state == ota::OTA_STARTED) { + this->suspend_task_(); + } else if (state == ota::OTA_ERROR) { + this->resume_task_(); + } +} +#endif + void MicroWakeWord::inference_task(void *params) { MicroWakeWord *this_mww = (MicroWakeWord *) params; diff --git a/esphome/components/micro_wake_word/micro_wake_word.h b/esphome/components/micro_wake_word/micro_wake_word.h index d46c40e48b..84261eaa5b 100644 --- a/esphome/components/micro_wake_word/micro_wake_word.h +++ b/esphome/components/micro_wake_word/micro_wake_word.h @@ -9,8 +9,13 @@ #include "esphome/core/automation.h" #include "esphome/core/component.h" +#include "esphome/core/defines.h" #include "esphome/core/ring_buffer.h" +#ifdef USE_OTA_STATE_LISTENER +#include "esphome/components/ota/ota_backend.h" +#endif + #include #include @@ -26,13 +31,22 @@ enum State { STOPPED, }; -class MicroWakeWord : public Component { +class MicroWakeWord : public Component +#ifdef USE_OTA_STATE_LISTENER + , + public ota::OTAGlobalStateListener +#endif +{ public: void setup() override; void loop() override; float get_setup_priority() const override; void dump_config() override; +#ifdef USE_OTA_STATE_LISTENER + void on_ota_global_state(ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) override; +#endif + void start(); void stop(); diff --git a/esphome/components/ota/__init__.py b/esphome/components/ota/__init__.py index be1b6da241..8bed9cee42 100644 --- a/esphome/components/ota/__init__.py +++ b/esphome/components/ota/__init__.py @@ -13,6 +13,8 @@ from esphome.const import ( from esphome.core import CORE, coroutine_with_priority from esphome.coroutine import CoroPriority +OTA_STATE_LISTENER_KEY = "ota_state_listener" + CODEOWNERS = ["@esphome/core"] AUTO_LOAD = ["md5", "safe_mode"] @@ -86,6 +88,7 @@ BASE_OTA_SCHEMA = cv.Schema( @coroutine_with_priority(CoroPriority.OTA_UPDATES) async def to_code(config): cg.add_define("USE_OTA") + CORE.add_job(final_step) if CORE.is_rp2040 and CORE.using_arduino: cg.add_library("Updater", None) @@ -119,7 +122,24 @@ async def ota_to_code(var, config): await automation.build_automation(trigger, [(cg.uint8, "x")], conf) use_state_callback = True if use_state_callback: - cg.add_define("USE_OTA_STATE_CALLBACK") + request_ota_state_listeners() + + +def request_ota_state_listeners() -> None: + """Request that OTA state listeners be compiled in. + + Components that need to be notified about OTA state changes (start, progress, + complete, error) should call this function during their code generation. + This enables the add_state_listener() API on OTAComponent. + """ + CORE.data[OTA_STATE_LISTENER_KEY] = True + + +@coroutine_with_priority(CoroPriority.FINAL) +async def final_step(): + """Final code generation step to configure optional OTA features.""" + if CORE.data.get(OTA_STATE_LISTENER_KEY, False): + cg.add_define("USE_OTA_STATE_LISTENER") FILTER_SOURCE_FILES = filter_source_files_from_platform( diff --git a/esphome/components/ota/automation.h b/esphome/components/ota/automation.h index 7e1a60f3ce..92c0050ba0 100644 --- a/esphome/components/ota/automation.h +++ b/esphome/components/ota/automation.h @@ -1,5 +1,5 @@ #pragma once -#ifdef USE_OTA_STATE_CALLBACK +#ifdef USE_OTA_STATE_LISTENER #include "ota_backend.h" #include "esphome/core/automation.h" @@ -7,70 +7,64 @@ namespace esphome { namespace ota { -class OTAStateChangeTrigger : public Trigger { +class OTAStateChangeTrigger final : public Trigger, public OTAStateListener { public: - explicit OTAStateChangeTrigger(OTAComponent *parent) { - parent->add_on_state_callback([this, parent](OTAState state, float progress, uint8_t error) { - if (!parent->is_failed()) { - trigger(state); - } - }); + explicit OTAStateChangeTrigger(OTAComponent *parent) : parent_(parent) { parent->add_state_listener(this); } + + void on_ota_state(OTAState state, float progress, uint8_t error) override { + if (!this->parent_->is_failed()) { + this->trigger(state); + } } + + protected: + OTAComponent *parent_; }; -class OTAStartTrigger : public Trigger<> { +template class OTAStateTrigger final : public Trigger<>, public OTAStateListener { public: - explicit OTAStartTrigger(OTAComponent *parent) { - parent->add_on_state_callback([this, parent](OTAState state, float progress, uint8_t error) { - if (state == OTA_STARTED && !parent->is_failed()) { - trigger(); - } - }); + explicit OTAStateTrigger(OTAComponent *parent) : parent_(parent) { parent->add_state_listener(this); } + + void on_ota_state(OTAState state, float progress, uint8_t error) override { + if (state == State && !this->parent_->is_failed()) { + this->trigger(); + } } + + protected: + OTAComponent *parent_; }; -class OTAProgressTrigger : public Trigger { +using OTAStartTrigger = OTAStateTrigger; +using OTAEndTrigger = OTAStateTrigger; +using OTAAbortTrigger = OTAStateTrigger; + +class OTAProgressTrigger final : public Trigger, public OTAStateListener { public: - explicit OTAProgressTrigger(OTAComponent *parent) { - parent->add_on_state_callback([this, parent](OTAState state, float progress, uint8_t error) { - if (state == OTA_IN_PROGRESS && !parent->is_failed()) { - trigger(progress); - } - }); + explicit OTAProgressTrigger(OTAComponent *parent) : parent_(parent) { parent->add_state_listener(this); } + + void on_ota_state(OTAState state, float progress, uint8_t error) override { + if (state == OTA_IN_PROGRESS && !this->parent_->is_failed()) { + this->trigger(progress); + } } + + protected: + OTAComponent *parent_; }; -class OTAEndTrigger : public Trigger<> { +class OTAErrorTrigger final : public Trigger, public OTAStateListener { public: - explicit OTAEndTrigger(OTAComponent *parent) { - parent->add_on_state_callback([this, parent](OTAState state, float progress, uint8_t error) { - if (state == OTA_COMPLETED && !parent->is_failed()) { - trigger(); - } - }); - } -}; + explicit OTAErrorTrigger(OTAComponent *parent) : parent_(parent) { parent->add_state_listener(this); } -class OTAAbortTrigger : public Trigger<> { - public: - explicit OTAAbortTrigger(OTAComponent *parent) { - parent->add_on_state_callback([this, parent](OTAState state, float progress, uint8_t error) { - if (state == OTA_ABORT && !parent->is_failed()) { - trigger(); - } - }); + void on_ota_state(OTAState state, float progress, uint8_t error) override { + if (state == OTA_ERROR && !this->parent_->is_failed()) { + this->trigger(error); + } } -}; -class OTAErrorTrigger : public Trigger { - public: - explicit OTAErrorTrigger(OTAComponent *parent) { - parent->add_on_state_callback([this, parent](OTAState state, float progress, uint8_t error) { - if (state == OTA_ERROR && !parent->is_failed()) { - trigger(error); - } - }); - } + protected: + OTAComponent *parent_; }; } // namespace ota diff --git a/esphome/components/ota/ota_backend.cpp b/esphome/components/ota/ota_backend.cpp index 30de4ec4b3..8fb9f67214 100644 --- a/esphome/components/ota/ota_backend.cpp +++ b/esphome/components/ota/ota_backend.cpp @@ -3,7 +3,7 @@ namespace esphome { namespace ota { -#ifdef USE_OTA_STATE_CALLBACK +#ifdef USE_OTA_STATE_LISTENER OTAGlobalCallback *global_ota_callback{nullptr}; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) OTAGlobalCallback *get_global_ota_callback() { @@ -13,7 +13,12 @@ OTAGlobalCallback *get_global_ota_callback() { return global_ota_callback; } -void register_ota_platform(OTAComponent *ota_caller) { get_global_ota_callback()->register_ota(ota_caller); } +void OTAComponent::notify_state_(OTAState state, float progress, uint8_t error) { + for (auto *listener : this->state_listeners_) { + listener->on_ota_state(state, progress, error); + } + get_global_ota_callback()->notify_ota_state(state, progress, error, this); +} #endif } // namespace ota diff --git a/esphome/components/ota/ota_backend.h b/esphome/components/ota/ota_backend.h index 64ee0b9f7c..e03afd4fc6 100644 --- a/esphome/components/ota/ota_backend.h +++ b/esphome/components/ota/ota_backend.h @@ -4,8 +4,8 @@ #include "esphome/core/defines.h" #include "esphome/core/helpers.h" -#ifdef USE_OTA_STATE_CALLBACK -#include "esphome/core/automation.h" +#ifdef USE_OTA_STATE_LISTENER +#include #endif namespace esphome { @@ -60,62 +60,75 @@ class OTABackend { virtual bool supports_compression() = 0; }; -class OTAComponent : public Component { -#ifdef USE_OTA_STATE_CALLBACK +/** Listener interface for OTA state changes. + * + * Components can implement this interface to receive OTA state updates + * without the overhead of std::function callbacks. + */ +class OTAStateListener { public: - void add_on_state_callback(std::function &&callback) { - this->state_callback_.add(std::move(callback)); - } + virtual ~OTAStateListener() = default; + virtual void on_ota_state(OTAState state, float progress, uint8_t error) = 0; +}; + +class OTAComponent : public Component { +#ifdef USE_OTA_STATE_LISTENER + public: + void add_state_listener(OTAStateListener *listener) { this->state_listeners_.push_back(listener); } protected: - /** Extended callback manager with deferred call support. + void notify_state_(OTAState state, float progress, uint8_t error); + + /** Notify state with deferral to main loop (for thread safety). * - * This adds a call_deferred() method for thread-safe execution from other tasks. + * This should be used by OTA implementations that run in separate tasks + * (like web_server OTA) to ensure listeners execute in the main loop. */ - class StateCallbackManager : public CallbackManager { - public: - StateCallbackManager(OTAComponent *component) : component_(component) {} + void notify_state_deferred_(OTAState state, float progress, uint8_t error) { + this->defer([this, state, progress, error]() { this->notify_state_(state, progress, error); }); + } - /** Call callbacks with deferral to main loop (for thread safety). - * - * This should be used by OTA implementations that run in separate tasks - * (like web_server OTA) to ensure callbacks execute in the main loop. - */ - void call_deferred(ota::OTAState state, float progress, uint8_t error) { - component_->defer([this, state, progress, error]() { this->call(state, progress, error); }); - } - - private: - OTAComponent *component_; - }; - - StateCallbackManager state_callback_{this}; + std::vector state_listeners_; #endif }; -#ifdef USE_OTA_STATE_CALLBACK +#ifdef USE_OTA_STATE_LISTENER + +/** Listener interface for global OTA state changes (includes OTA component pointer). + * + * Used by OTAGlobalCallback to aggregate state from multiple OTA components. + */ +class OTAGlobalStateListener { + public: + virtual ~OTAGlobalStateListener() = default; + virtual void on_ota_global_state(OTAState state, float progress, uint8_t error, OTAComponent *component) = 0; +}; + +/** Global callback that aggregates OTA state from all OTA components. + * + * OTA components call notify_ota_state() directly with their pointer, + * which forwards the event to all registered global listeners. + */ class OTAGlobalCallback { public: - void register_ota(OTAComponent *ota_caller) { - ota_caller->add_on_state_callback([this, ota_caller](OTAState state, float progress, uint8_t error) { - this->state_callback_.call(state, progress, error, ota_caller); - }); - } - void add_on_state_callback(std::function &&callback) { - this->state_callback_.add(std::move(callback)); + void add_global_state_listener(OTAGlobalStateListener *listener) { this->global_listeners_.push_back(listener); } + + void notify_ota_state(OTAState state, float progress, uint8_t error, OTAComponent *component) { + for (auto *listener : this->global_listeners_) { + listener->on_ota_global_state(state, progress, error, component); + } } protected: - CallbackManager state_callback_{}; + std::vector global_listeners_; }; OTAGlobalCallback *get_global_ota_callback(); -void register_ota_platform(OTAComponent *ota_caller); // OTA implementations should use: -// - state_callback_.call() when already in main loop (e.g., esphome OTA) -// - state_callback_.call_deferred() when in separate task (e.g., web_server OTA) -// This ensures proper callback execution in all contexts. +// - notify_state_() when already in main loop (e.g., esphome OTA) +// - notify_state_deferred_() when in separate task (e.g., web_server OTA) +// This ensures proper listener execution in all contexts. #endif std::unique_ptr make_ota_backend(); diff --git a/esphome/components/speaker/media_player/__init__.py b/esphome/components/speaker/media_player/__init__.py index 062bff92f8..4ca57f2c4a 100644 --- a/esphome/components/speaker/media_player/__init__.py +++ b/esphome/components/speaker/media_player/__init__.py @@ -6,7 +6,7 @@ from pathlib import Path from esphome import automation, external_files import esphome.codegen as cg -from esphome.components import audio, esp32, media_player, network, psram, speaker +from esphome.components import audio, esp32, media_player, network, ota, psram, speaker import esphome.config_validation as cv from esphome.const import ( CONF_BUFFER_SIZE, @@ -342,7 +342,7 @@ async def to_code(config): var = await media_player.new_media_player(config) await cg.register_component(var, config) - cg.add_define("USE_OTA_STATE_CALLBACK") + ota.request_ota_state_listeners() cg.add(var.set_buffer_size(config[CONF_BUFFER_SIZE])) diff --git a/esphome/components/speaker/media_player/speaker_media_player.cpp b/esphome/components/speaker/media_player/speaker_media_player.cpp index b45a78010a..5722aab195 100644 --- a/esphome/components/speaker/media_player/speaker_media_player.cpp +++ b/esphome/components/speaker/media_player/speaker_media_player.cpp @@ -66,25 +66,8 @@ void SpeakerMediaPlayer::setup() { this->set_mute_state_(false); } -#ifdef USE_OTA - ota::get_global_ota_callback()->add_on_state_callback( - [this](ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) { - if (state == ota::OTA_STARTED) { - if (this->media_pipeline_ != nullptr) { - this->media_pipeline_->suspend_tasks(); - } - if (this->announcement_pipeline_ != nullptr) { - this->announcement_pipeline_->suspend_tasks(); - } - } else if (state == ota::OTA_ERROR) { - if (this->media_pipeline_ != nullptr) { - this->media_pipeline_->resume_tasks(); - } - if (this->announcement_pipeline_ != nullptr) { - this->announcement_pipeline_->resume_tasks(); - } - } - }); +#ifdef USE_OTA_STATE_LISTENER + ota::get_global_ota_callback()->add_global_state_listener(this); #endif this->announcement_pipeline_ = @@ -300,6 +283,27 @@ void SpeakerMediaPlayer::watch_media_commands_() { } } +#ifdef USE_OTA_STATE_LISTENER +void SpeakerMediaPlayer::on_ota_global_state(ota::OTAState state, float progress, uint8_t error, + ota::OTAComponent *comp) { + if (state == ota::OTA_STARTED) { + if (this->media_pipeline_ != nullptr) { + this->media_pipeline_->suspend_tasks(); + } + if (this->announcement_pipeline_ != nullptr) { + this->announcement_pipeline_->suspend_tasks(); + } + } else if (state == ota::OTA_ERROR) { + if (this->media_pipeline_ != nullptr) { + this->media_pipeline_->resume_tasks(); + } + if (this->announcement_pipeline_ != nullptr) { + this->announcement_pipeline_->resume_tasks(); + } + } +} +#endif + void SpeakerMediaPlayer::loop() { this->watch_media_commands_(); diff --git a/esphome/components/speaker/media_player/speaker_media_player.h b/esphome/components/speaker/media_player/speaker_media_player.h index 967772d1a5..f1c564b63d 100644 --- a/esphome/components/speaker/media_player/speaker_media_player.h +++ b/esphome/components/speaker/media_player/speaker_media_player.h @@ -5,14 +5,18 @@ #include "audio_pipeline.h" #include "esphome/components/audio/audio.h" - #include "esphome/components/media_player/media_player.h" #include "esphome/components/speaker/speaker.h" #include "esphome/core/automation.h" #include "esphome/core/component.h" +#include "esphome/core/defines.h" #include "esphome/core/preferences.h" +#ifdef USE_OTA_STATE_LISTENER +#include "esphome/components/ota/ota_backend.h" +#endif + #include #include #include @@ -39,12 +43,22 @@ struct VolumeRestoreState { bool is_muted; }; -class SpeakerMediaPlayer : public Component, public media_player::MediaPlayer { +class SpeakerMediaPlayer : public Component, + public media_player::MediaPlayer +#ifdef USE_OTA_STATE_LISTENER + , + public ota::OTAGlobalStateListener +#endif +{ public: float get_setup_priority() const override { return esphome::setup_priority::PROCESSOR; } void setup() override; void loop() override; +#ifdef USE_OTA_STATE_LISTENER + void on_ota_global_state(ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) override; +#endif + // MediaPlayer implementations media_player::MediaPlayerTraits get_traits() override; bool is_muted() const override { return this->is_muted_; } diff --git a/esphome/components/web_server/ota/ota_web_server.cpp b/esphome/components/web_server/ota/ota_web_server.cpp index 7929f3647f..f612aa056c 100644 --- a/esphome/components/web_server/ota/ota_web_server.cpp +++ b/esphome/components/web_server/ota/ota_web_server.cpp @@ -84,9 +84,9 @@ void OTARequestHandler::report_ota_progress_(AsyncWebServerRequest *request) { } else { ESP_LOGD(TAG, "OTA in progress: %" PRIu32 " bytes read", this->ota_read_length_); } -#ifdef USE_OTA_STATE_CALLBACK - // Report progress - use call_deferred since we're in web server task - this->parent_->state_callback_.call_deferred(ota::OTA_IN_PROGRESS, percentage, 0); +#ifdef USE_OTA_STATE_LISTENER + // Report progress - use notify_state_deferred_ since we're in web server task + this->parent_->notify_state_deferred_(ota::OTA_IN_PROGRESS, percentage, 0); #endif this->last_ota_progress_ = now; } @@ -114,9 +114,9 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf // Initialize OTA on first call this->ota_init_(filename.c_str()); -#ifdef USE_OTA_STATE_CALLBACK - // Notify OTA started - use call_deferred since we're in web server task - this->parent_->state_callback_.call_deferred(ota::OTA_STARTED, 0.0f, 0); +#ifdef USE_OTA_STATE_LISTENER + // Notify OTA started - use notify_state_deferred_ since we're in web server task + this->parent_->notify_state_deferred_(ota::OTA_STARTED, 0.0f, 0); #endif // Platform-specific pre-initialization @@ -134,9 +134,9 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf this->ota_backend_ = ota::make_ota_backend(); if (!this->ota_backend_) { ESP_LOGE(TAG, "Failed to create OTA backend"); -#ifdef USE_OTA_STATE_CALLBACK - this->parent_->state_callback_.call_deferred(ota::OTA_ERROR, 0.0f, - static_cast(ota::OTA_RESPONSE_ERROR_UNKNOWN)); +#ifdef USE_OTA_STATE_LISTENER + this->parent_->notify_state_deferred_(ota::OTA_ERROR, 0.0f, + static_cast(ota::OTA_RESPONSE_ERROR_UNKNOWN)); #endif return; } @@ -148,8 +148,8 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf if (error_code != ota::OTA_RESPONSE_OK) { ESP_LOGE(TAG, "OTA begin failed: %d", error_code); this->ota_backend_.reset(); -#ifdef USE_OTA_STATE_CALLBACK - this->parent_->state_callback_.call_deferred(ota::OTA_ERROR, 0.0f, static_cast(error_code)); +#ifdef USE_OTA_STATE_LISTENER + this->parent_->notify_state_deferred_(ota::OTA_ERROR, 0.0f, static_cast(error_code)); #endif return; } @@ -166,8 +166,8 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf ESP_LOGE(TAG, "OTA write failed: %d", error_code); this->ota_backend_->abort(); this->ota_backend_.reset(); -#ifdef USE_OTA_STATE_CALLBACK - this->parent_->state_callback_.call_deferred(ota::OTA_ERROR, 0.0f, static_cast(error_code)); +#ifdef USE_OTA_STATE_LISTENER + this->parent_->notify_state_deferred_(ota::OTA_ERROR, 0.0f, static_cast(error_code)); #endif return; } @@ -186,15 +186,15 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf error_code = this->ota_backend_->end(); if (error_code == ota::OTA_RESPONSE_OK) { this->ota_success_ = true; -#ifdef USE_OTA_STATE_CALLBACK - // Report completion before reboot - use call_deferred since we're in web server task - this->parent_->state_callback_.call_deferred(ota::OTA_COMPLETED, 100.0f, 0); +#ifdef USE_OTA_STATE_LISTENER + // Report completion before reboot - use notify_state_deferred_ since we're in web server task + this->parent_->notify_state_deferred_(ota::OTA_COMPLETED, 100.0f, 0); #endif this->schedule_ota_reboot_(); } else { ESP_LOGE(TAG, "OTA end failed: %d", error_code); -#ifdef USE_OTA_STATE_CALLBACK - this->parent_->state_callback_.call_deferred(ota::OTA_ERROR, 0.0f, static_cast(error_code)); +#ifdef USE_OTA_STATE_LISTENER + this->parent_->notify_state_deferred_(ota::OTA_ERROR, 0.0f, static_cast(error_code)); #endif } this->ota_backend_.reset(); @@ -232,10 +232,6 @@ void WebServerOTAComponent::setup() { // AsyncWebServer takes ownership of the handler and will delete it when the server is destroyed base->add_handler(new OTARequestHandler(this)); // NOLINT -#ifdef USE_OTA_STATE_CALLBACK - // Register with global OTA callback system - ota::register_ota_platform(this); -#endif } void WebServerOTAComponent::dump_config() { ESP_LOGCONFIG(TAG, "Web Server OTA"); } diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 4cbe683723..0c12b29eb7 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -146,7 +146,7 @@ #define USE_OTA_PASSWORD #define USE_OTA_SHA256 #define ALLOW_OTA_DOWNGRADE_MD5 -#define USE_OTA_STATE_CALLBACK +#define USE_OTA_STATE_LISTENER #define USE_OTA_VERSION 2 #define USE_TIME_TIMEZONE #define USE_WIFI From ada6c42f3f021ec57f7864185c635feee28d7dd4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 19 Dec 2025 11:48:14 -1000 Subject: [PATCH 29/33] [alarm_control_panel] Remove redundant per-state callbacks (#12171) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../alarm_control_panel.cpp | 46 +-- .../alarm_control_panel/alarm_control_panel.h | 63 +--- .../alarm_control_panel/automation.h | 66 ++-- ...alarm_control_panel_state_transitions.yaml | 106 ++++++ ...t_alarm_control_panel_state_transitions.py | 319 ++++++++++++++++++ 5 files changed, 453 insertions(+), 147 deletions(-) create mode 100644 tests/integration/fixtures/alarm_control_panel_state_transitions.yaml create mode 100644 tests/integration/test_alarm_control_panel_state_transitions.py diff --git a/esphome/components/alarm_control_panel/alarm_control_panel.cpp b/esphome/components/alarm_control_panel/alarm_control_panel.cpp index c29e02c8ef..f938155dd3 100644 --- a/esphome/components/alarm_control_panel/alarm_control_panel.cpp +++ b/esphome/components/alarm_control_panel/alarm_control_panel.cpp @@ -35,26 +35,12 @@ void AlarmControlPanel::publish_state(AlarmControlPanelState state) { ESP_LOGD(TAG, "Set state to: %s, previous: %s", LOG_STR_ARG(alarm_control_panel_state_to_string(state)), LOG_STR_ARG(alarm_control_panel_state_to_string(prev_state))); this->current_state_ = state; + // Single state callback - triggers check get_state() for specific states this->state_callback_.call(); #if defined(USE_ALARM_CONTROL_PANEL) && defined(USE_CONTROLLER_REGISTRY) ControllerRegistry::notify_alarm_control_panel_update(this); #endif - if (state == ACP_STATE_TRIGGERED) { - this->triggered_callback_.call(); - } else if (state == ACP_STATE_ARMING) { - this->arming_callback_.call(); - } else if (state == ACP_STATE_PENDING) { - this->pending_callback_.call(); - } else if (state == ACP_STATE_ARMED_HOME) { - this->armed_home_callback_.call(); - } else if (state == ACP_STATE_ARMED_NIGHT) { - this->armed_night_callback_.call(); - } else if (state == ACP_STATE_ARMED_AWAY) { - this->armed_away_callback_.call(); - } else if (state == ACP_STATE_DISARMED) { - this->disarmed_callback_.call(); - } - + // Cleared fires when leaving TRIGGERED state if (prev_state == ACP_STATE_TRIGGERED) { this->cleared_callback_.call(); } @@ -69,34 +55,6 @@ void AlarmControlPanel::add_on_state_callback(std::function &&callback) this->state_callback_.add(std::move(callback)); } -void AlarmControlPanel::add_on_triggered_callback(std::function &&callback) { - this->triggered_callback_.add(std::move(callback)); -} - -void AlarmControlPanel::add_on_arming_callback(std::function &&callback) { - this->arming_callback_.add(std::move(callback)); -} - -void AlarmControlPanel::add_on_armed_home_callback(std::function &&callback) { - this->armed_home_callback_.add(std::move(callback)); -} - -void AlarmControlPanel::add_on_armed_night_callback(std::function &&callback) { - this->armed_night_callback_.add(std::move(callback)); -} - -void AlarmControlPanel::add_on_armed_away_callback(std::function &&callback) { - this->armed_away_callback_.add(std::move(callback)); -} - -void AlarmControlPanel::add_on_pending_callback(std::function &&callback) { - this->pending_callback_.add(std::move(callback)); -} - -void AlarmControlPanel::add_on_disarmed_callback(std::function &&callback) { - this->disarmed_callback_.add(std::move(callback)); -} - void AlarmControlPanel::add_on_cleared_callback(std::function &&callback) { this->cleared_callback_.add(std::move(callback)); } diff --git a/esphome/components/alarm_control_panel/alarm_control_panel.h b/esphome/components/alarm_control_panel/alarm_control_panel.h index 85c2b2148e..c46edc11c2 100644 --- a/esphome/components/alarm_control_panel/alarm_control_panel.h +++ b/esphome/components/alarm_control_panel/alarm_control_panel.h @@ -35,54 +35,13 @@ class AlarmControlPanel : public EntityBase { */ void publish_state(AlarmControlPanelState state); - /** Add a callback for when the state of the alarm_control_panel changes + /** Add a callback for when the state of the alarm_control_panel changes. + * Triggers can check get_state() to determine the new state. * * @param callback The callback function */ void add_on_state_callback(std::function &&callback); - /** Add a callback for when the state of the alarm_control_panel chanes to triggered - * - * @param callback The callback function - */ - void add_on_triggered_callback(std::function &&callback); - - /** Add a callback for when the state of the alarm_control_panel chanes to arming - * - * @param callback The callback function - */ - void add_on_arming_callback(std::function &&callback); - - /** Add a callback for when the state of the alarm_control_panel changes to pending - * - * @param callback The callback function - */ - void add_on_pending_callback(std::function &&callback); - - /** Add a callback for when the state of the alarm_control_panel changes to armed_home - * - * @param callback The callback function - */ - void add_on_armed_home_callback(std::function &&callback); - - /** Add a callback for when the state of the alarm_control_panel changes to armed_night - * - * @param callback The callback function - */ - void add_on_armed_night_callback(std::function &&callback); - - /** Add a callback for when the state of the alarm_control_panel changes to armed_away - * - * @param callback The callback function - */ - void add_on_armed_away_callback(std::function &&callback); - - /** Add a callback for when the state of the alarm_control_panel changes to disarmed - * - * @param callback The callback function - */ - void add_on_disarmed_callback(std::function &&callback); - /** Add a callback for when the state of the alarm_control_panel clears from triggered * * @param callback The callback function @@ -172,23 +131,9 @@ class AlarmControlPanel : public EntityBase { uint32_t last_update_; // the call control function virtual void control(const AlarmControlPanelCall &call) = 0; - // state callback + // state callback - triggers check get_state() for specific state CallbackManager state_callback_{}; - // trigger callback - CallbackManager triggered_callback_{}; - // arming callback - CallbackManager arming_callback_{}; - // pending callback - CallbackManager pending_callback_{}; - // armed_home callback - CallbackManager armed_home_callback_{}; - // armed_night callback - CallbackManager armed_night_callback_{}; - // armed_away callback - CallbackManager armed_away_callback_{}; - // disarmed callback - CallbackManager disarmed_callback_{}; - // clear callback + // clear callback - fires when leaving TRIGGERED state CallbackManager cleared_callback_{}; // chime callback CallbackManager chime_callback_{}; diff --git a/esphome/components/alarm_control_panel/automation.h b/esphome/components/alarm_control_panel/automation.h index db2ef78158..af4a14e27a 100644 --- a/esphome/components/alarm_control_panel/automation.h +++ b/esphome/components/alarm_control_panel/automation.h @@ -6,6 +6,7 @@ namespace esphome { namespace alarm_control_panel { +/// Trigger on any state change class StateTrigger : public Trigger<> { public: explicit StateTrigger(AlarmControlPanel *alarm_control_panel) { @@ -13,55 +14,30 @@ class StateTrigger : public Trigger<> { } }; -class TriggeredTrigger : public Trigger<> { +/// Template trigger that fires when entering a specific state +template class StateEnterTrigger : public Trigger<> { public: - explicit TriggeredTrigger(AlarmControlPanel *alarm_control_panel) { - alarm_control_panel->add_on_triggered_callback([this]() { this->trigger(); }); + explicit StateEnterTrigger(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) { + alarm_control_panel->add_on_state_callback([this]() { + if (this->alarm_control_panel_->get_state() == State) + this->trigger(); + }); } + + protected: + AlarmControlPanel *alarm_control_panel_; }; -class ArmingTrigger : public Trigger<> { - public: - explicit ArmingTrigger(AlarmControlPanel *alarm_control_panel) { - alarm_control_panel->add_on_arming_callback([this]() { this->trigger(); }); - } -}; - -class PendingTrigger : public Trigger<> { - public: - explicit PendingTrigger(AlarmControlPanel *alarm_control_panel) { - alarm_control_panel->add_on_pending_callback([this]() { this->trigger(); }); - } -}; - -class ArmedHomeTrigger : public Trigger<> { - public: - explicit ArmedHomeTrigger(AlarmControlPanel *alarm_control_panel) { - alarm_control_panel->add_on_armed_home_callback([this]() { this->trigger(); }); - } -}; - -class ArmedNightTrigger : public Trigger<> { - public: - explicit ArmedNightTrigger(AlarmControlPanel *alarm_control_panel) { - alarm_control_panel->add_on_armed_night_callback([this]() { this->trigger(); }); - } -}; - -class ArmedAwayTrigger : public Trigger<> { - public: - explicit ArmedAwayTrigger(AlarmControlPanel *alarm_control_panel) { - alarm_control_panel->add_on_armed_away_callback([this]() { this->trigger(); }); - } -}; - -class DisarmedTrigger : public Trigger<> { - public: - explicit DisarmedTrigger(AlarmControlPanel *alarm_control_panel) { - alarm_control_panel->add_on_disarmed_callback([this]() { this->trigger(); }); - } -}; +// Type aliases for state-specific triggers +using TriggeredTrigger = StateEnterTrigger; +using ArmingTrigger = StateEnterTrigger; +using PendingTrigger = StateEnterTrigger; +using ArmedHomeTrigger = StateEnterTrigger; +using ArmedNightTrigger = StateEnterTrigger; +using ArmedAwayTrigger = StateEnterTrigger; +using DisarmedTrigger = StateEnterTrigger; +/// Trigger when leaving TRIGGERED state (alarm cleared) class ClearedTrigger : public Trigger<> { public: explicit ClearedTrigger(AlarmControlPanel *alarm_control_panel) { @@ -69,6 +45,7 @@ class ClearedTrigger : public Trigger<> { } }; +/// Trigger on chime event (zone opened while disarmed) class ChimeTrigger : public Trigger<> { public: explicit ChimeTrigger(AlarmControlPanel *alarm_control_panel) { @@ -76,6 +53,7 @@ class ChimeTrigger : public Trigger<> { } }; +/// Trigger on ready state change class ReadyTrigger : public Trigger<> { public: explicit ReadyTrigger(AlarmControlPanel *alarm_control_panel) { diff --git a/tests/integration/fixtures/alarm_control_panel_state_transitions.yaml b/tests/integration/fixtures/alarm_control_panel_state_transitions.yaml new file mode 100644 index 0000000000..1edb401a0d --- /dev/null +++ b/tests/integration/fixtures/alarm_control_panel_state_transitions.yaml @@ -0,0 +1,106 @@ +esphome: + name: alarm-state-transitions + friendly_name: "Alarm Control Panel State Transitions Test" + +logger: + +host: + +globals: + - id: door_sensor_state + type: bool + initial_value: "false" + - id: chime_sensor_state + type: bool + initial_value: "false" + +switch: + # Switch to control the door sensor state + - platform: template + id: door_sensor_switch + name: "Door Sensor Switch" + optimistic: true + turn_on_action: + - globals.set: + id: door_sensor_state + value: "true" + turn_off_action: + - globals.set: + id: door_sensor_state + value: "false" + # Switch to control the chime sensor state + - platform: template + id: chime_sensor_switch + name: "Chime Sensor Switch" + optimistic: true + turn_on_action: + - globals.set: + id: chime_sensor_state + value: "true" + turn_off_action: + - globals.set: + id: chime_sensor_state + value: "false" + +binary_sensor: + - platform: template + id: door_sensor + name: "Door Sensor" + lambda: |- + return id(door_sensor_state); + - platform: template + id: chime_sensor + name: "Chime Sensor" + lambda: |- + return id(chime_sensor_state); + +alarm_control_panel: + - platform: template + id: test_alarm + name: "Test Alarm" + codes: + - "1234" + requires_code_to_arm: true + # Short timeouts for faster testing + arming_away_time: 50ms + arming_home_time: 50ms + arming_night_time: 50ms + pending_time: 50ms + trigger_time: 100ms + restore_mode: ALWAYS_DISARMED + binary_sensors: + - input: door_sensor + bypass_armed_home: false + bypass_armed_night: false + chime: false + trigger_mode: DELAYED + - input: chime_sensor + bypass_armed_home: true + bypass_armed_night: true + chime: true + trigger_mode: DELAYED + on_state: + - logger.log: "State changed" + on_disarmed: + - logger.log: "Alarm disarmed" + on_arming: + - logger.log: "Alarm arming" + on_armed_away: + - logger.log: "Alarm armed away" + on_armed_home: + - logger.log: "Alarm armed home" + on_armed_night: + - logger.log: "Alarm armed night" + on_pending: + - logger.log: "Alarm pending" + on_triggered: + - logger.log: "Alarm triggered" + on_cleared: + - logger.log: "Alarm cleared" + on_chime: + - logger.log: "Chime activated" + on_ready: + - logger.log: "Sensors ready state changed" + +api: + batch_delay: 0ms diff --git a/tests/integration/test_alarm_control_panel_state_transitions.py b/tests/integration/test_alarm_control_panel_state_transitions.py new file mode 100644 index 0000000000..2977ff56c2 --- /dev/null +++ b/tests/integration/test_alarm_control_panel_state_transitions.py @@ -0,0 +1,319 @@ +"""Integration test for alarm control panel state transitions.""" + +from __future__ import annotations + +import asyncio +import re + +import aioesphomeapi +from aioesphomeapi import ( + AlarmControlPanelCommand, + AlarmControlPanelEntityState, + AlarmControlPanelInfo, + AlarmControlPanelState, + SwitchInfo, +) +import pytest + +from .state_utils import InitialStateHelper +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_alarm_control_panel_state_transitions( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test alarm control panel state transitions. + + This comprehensive test verifies all state transitions and listener callbacks: + + 1. Basic arm/disarm sequences: + - DISARMED -> ARMING -> ARMED_AWAY -> DISARMED + - DISARMED -> ARMING -> ARMED_HOME -> DISARMED + - DISARMED -> ARMING -> ARMED_NIGHT -> DISARMED + + 2. Wrong code rejection + + 3. Sensor triggering while armed: + - ARMED_AWAY -> PENDING -> TRIGGERED (delayed sensor) + - TRIGGERED -> ARMED_AWAY (auto-reset after trigger_time, fires on_cleared) + + 4. Chime functionality: + - Sensor open while DISARMED triggers on_chime + + 5. Ready state: + - Sensor state changes trigger on_ready + """ + loop = asyncio.get_running_loop() + + # Track log messages for callback verification + log_lines: list[str] = [] + chime_future: asyncio.Future[bool] = loop.create_future() + ready_futures: list[asyncio.Future[bool]] = [] + cleared_future: asyncio.Future[bool] = loop.create_future() + + # Patterns to match log output from callbacks + chime_pattern = re.compile(r"Chime activated") + ready_pattern = re.compile(r"Sensors ready state changed") + cleared_pattern = re.compile(r"Alarm cleared") + + def on_log_line(line: str) -> None: + log_lines.append(line) + if not chime_future.done() and chime_pattern.search(line): + chime_future.set_result(True) + if ready_pattern.search(line): + # Create new future for each ready event + for fut in ready_futures: + if not fut.done(): + fut.set_result(True) + break + if not cleared_future.done() and cleared_pattern.search(line): + cleared_future.set_result(True) + + async with ( + run_compiled(yaml_config, line_callback=on_log_line), + api_client_connected() as client, + ): + entities, _ = await client.list_entities_services() + + # Find entities + alarm_info: AlarmControlPanelInfo | None = None + door_switch_info: SwitchInfo | None = None + chime_switch_info: SwitchInfo | None = None + + for entity in entities: + if isinstance(entity, AlarmControlPanelInfo): + alarm_info = entity + elif isinstance(entity, SwitchInfo): + if entity.name == "Door Sensor Switch": + door_switch_info = entity + elif entity.name == "Chime Sensor Switch": + chime_switch_info = entity + + assert alarm_info is not None, "Alarm control panel not found" + assert door_switch_info is not None, "Door sensor switch not found" + assert chime_switch_info is not None, "Chime sensor switch not found" + + # Track state changes + states_received: list[AlarmControlPanelState] = [] + state_event = asyncio.Event() + + def on_state(state: aioesphomeapi.EntityState) -> None: + if ( + isinstance(state, AlarmControlPanelEntityState) + and state.key == alarm_info.key + ): + states_received.append(state.state) + state_event.set() + + # Use InitialStateHelper to handle initial state broadcast + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + + # Wait for initial states from all entities + await initial_state_helper.wait_for_initial_states() + + # Verify alarm panel started in DISARMED state + initial_alarm_state = initial_state_helper.initial_states.get(alarm_info.key) + assert initial_alarm_state is not None, "No initial alarm state received" + assert isinstance(initial_alarm_state, AlarmControlPanelEntityState) + assert initial_alarm_state.state == AlarmControlPanelState.DISARMED + + # Helper to wait for specific state + async def wait_for_state( + expected: AlarmControlPanelState, timeout: float = 5.0 + ) -> None: + deadline = loop.time() + timeout + while True: + remaining = deadline - loop.time() + if remaining <= 0: + raise TimeoutError( + f"Timeout waiting for state {expected}, " + f"last state: {states_received[-1] if states_received else 'none'}" + ) + await asyncio.wait_for(state_event.wait(), timeout=remaining) + state_event.clear() + if states_received[-1] == expected: + return + + # ===== Test wrong code rejection ===== + client.alarm_control_panel_command( + alarm_info.key, + AlarmControlPanelCommand.ARM_AWAY, + code="0000", # Wrong code + ) + + # Should NOT transition - wait a bit and verify no state changes + with pytest.raises(asyncio.TimeoutError): + await asyncio.wait_for(state_event.wait(), timeout=0.5) + # No state changes should have occurred (list is empty) + assert len(states_received) == 0, f"Unexpected state changes: {states_received}" + + # ===== Test ARM_AWAY sequence ===== + client.alarm_control_panel_command( + alarm_info.key, + AlarmControlPanelCommand.ARM_AWAY, + code="1234", + ) + await wait_for_state(AlarmControlPanelState.ARMING) + await wait_for_state(AlarmControlPanelState.ARMED_AWAY) + + # Disarm + client.alarm_control_panel_command( + alarm_info.key, + AlarmControlPanelCommand.DISARM, + code="1234", + ) + await wait_for_state(AlarmControlPanelState.DISARMED) + + # ===== Test ARM_HOME sequence ===== + client.alarm_control_panel_command( + alarm_info.key, + AlarmControlPanelCommand.ARM_HOME, + code="1234", + ) + await wait_for_state(AlarmControlPanelState.ARMING) + await wait_for_state(AlarmControlPanelState.ARMED_HOME) + + # Disarm + client.alarm_control_panel_command( + alarm_info.key, + AlarmControlPanelCommand.DISARM, + code="1234", + ) + await wait_for_state(AlarmControlPanelState.DISARMED) + + # ===== Test ARM_NIGHT sequence ===== + client.alarm_control_panel_command( + alarm_info.key, + AlarmControlPanelCommand.ARM_NIGHT, + code="1234", + ) + await wait_for_state(AlarmControlPanelState.ARMING) + await wait_for_state(AlarmControlPanelState.ARMED_NIGHT) + + # Disarm + client.alarm_control_panel_command( + alarm_info.key, + AlarmControlPanelCommand.DISARM, + code="1234", + ) + await wait_for_state(AlarmControlPanelState.DISARMED) + + # Verify basic state sequence (initial DISARMED is handled by InitialStateHelper) + expected_states = [ + AlarmControlPanelState.ARMING, # Arm away + AlarmControlPanelState.ARMED_AWAY, + AlarmControlPanelState.DISARMED, + AlarmControlPanelState.ARMING, # Arm home + AlarmControlPanelState.ARMED_HOME, + AlarmControlPanelState.DISARMED, + AlarmControlPanelState.ARMING, # Arm night + AlarmControlPanelState.ARMED_NIGHT, + AlarmControlPanelState.DISARMED, + ] + assert states_received == expected_states, ( + f"State sequence mismatch.\nExpected: {expected_states}\n" + f"Got: {states_received}" + ) + + # ===== Test PENDING -> TRIGGERED -> CLEARED sequence ===== + # This tests on_pending, on_triggered, and on_cleared callbacks + + # Arm away first + client.alarm_control_panel_command( + alarm_info.key, + AlarmControlPanelCommand.ARM_AWAY, + code="1234", + ) + await wait_for_state(AlarmControlPanelState.ARMING) + await wait_for_state(AlarmControlPanelState.ARMED_AWAY) + + # Trip the door sensor (delayed mode triggers PENDING first) + client.switch_command(door_switch_info.key, True) + + # Should go to PENDING (delayed sensor) + await wait_for_state(AlarmControlPanelState.PENDING) + + # Should go to TRIGGERED after pending_time (50ms) + await wait_for_state(AlarmControlPanelState.TRIGGERED) + + # Close the sensor + client.switch_command(door_switch_info.key, False) + + # Wait for trigger_time to expire and auto-reset (100ms) + # The alarm should go back to ARMED_AWAY after trigger_time + # This transition FROM TRIGGERED fires on_cleared + await wait_for_state(AlarmControlPanelState.ARMED_AWAY, timeout=2.0) + + # Verify on_cleared was logged + try: + await asyncio.wait_for(cleared_future, timeout=1.0) + except TimeoutError: + pytest.fail(f"on_cleared callback not fired. Log lines: {log_lines[-20:]}") + + # Disarm + client.alarm_control_panel_command( + alarm_info.key, + AlarmControlPanelCommand.DISARM, + code="1234", + ) + await wait_for_state(AlarmControlPanelState.DISARMED) + + # Verify trigger sequence was added + assert AlarmControlPanelState.PENDING in states_received + assert AlarmControlPanelState.TRIGGERED in states_received + + # ===== Test chime (sensor open while disarmed) ===== + # The chime_sensor has chime: true, so opening it while disarmed + # should trigger on_chime callback + + # We're currently DISARMED - open the chime sensor + client.switch_command(chime_switch_info.key, True) + + # Wait for chime callback to be logged + try: + await asyncio.wait_for(chime_future, timeout=2.0) + except TimeoutError: + pytest.fail(f"on_chime callback not fired. Log lines: {log_lines[-20:]}") + + # Close the chime sensor + client.switch_command(chime_switch_info.key, False) + + # ===== Test ready state changes ===== + # Opening/closing sensors while disarmed affects ready state + # The on_ready callback fires when sensors_ready changes + + # Set up futures for ready state changes + ready_future_1: asyncio.Future[bool] = loop.create_future() + ready_future_2: asyncio.Future[bool] = loop.create_future() + ready_futures.extend([ready_future_1, ready_future_2]) + + # Open door sensor (makes alarm not ready) + client.switch_command(door_switch_info.key, True) + + # Wait for first on_ready callback (not ready) + try: + await asyncio.wait_for(ready_future_1, timeout=2.0) + except TimeoutError: + pytest.fail( + f"on_ready callback not fired when sensor opened. " + f"Log lines: {log_lines[-20:]}" + ) + + # Close door sensor (makes alarm ready again) + client.switch_command(door_switch_info.key, False) + + # Wait for second on_ready callback (ready) + try: + await asyncio.wait_for(ready_future_2, timeout=2.0) + except TimeoutError: + pytest.fail( + f"on_ready callback not fired when sensor closed. " + f"Log lines: {log_lines[-20:]}" + ) + + # Final state should still be DISARMED + assert states_received[-1] == AlarmControlPanelState.DISARMED From c9fccdff251ae4a796d1123c656afe1ac9490631 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 19 Dec 2025 12:05:52 -1000 Subject: [PATCH 30/33] [fan] Add zero-copy support for API preset mode commands (#12404) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/components/api/api.proto | 2 +- esphome/components/api/api_connection.cpp | 2 +- esphome/components/api/api_pb2.cpp | 7 +++++-- esphome/components/api/api_pb2.h | 5 +++-- esphome/components/api/api_pb2_dump.cpp | 4 +++- esphome/components/fan/fan.cpp | 22 +++++++++++++++++----- esphome/components/fan/fan.h | 2 ++ esphome/components/fan/fan_traits.h | 7 +++++-- 8 files changed, 37 insertions(+), 14 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index dd8320bebb..e8c900df26 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -477,7 +477,7 @@ message FanCommandRequest { bool has_speed_level = 10; int32 speed_level = 11; bool has_preset_mode = 12; - string preset_mode = 13; + string preset_mode = 13 [(pointer_to_buffer) = true]; uint32 device_id = 14 [(field_ifdef) = "USE_DEVICES"]; } diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 686fdcba41..126d3cb220 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -447,7 +447,7 @@ void APIConnection::fan_command(const FanCommandRequest &msg) { if (msg.has_direction) call.set_direction(static_cast(msg.direction)); if (msg.has_preset_mode) - call.set_preset_mode(msg.preset_mode); + call.set_preset_mode(reinterpret_cast(msg.preset_mode), msg.preset_mode_len); call.perform(); } #endif diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 211f856e3b..8bba13a4de 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -447,9 +447,12 @@ bool FanCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { } bool FanCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { - case 13: - this->preset_mode = value.as_string(); + case 13: { + // Use raw data directly to avoid allocation + this->preset_mode = value.data(); + this->preset_mode_len = value.size(); break; + } default: return false; } diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 4e10c63881..d3b91ac56b 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -765,7 +765,7 @@ class FanStateResponse final : public StateResponseProtoMessage { class FanCommandRequest final : public CommandProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 31; - static constexpr uint8_t ESTIMATED_SIZE = 38; + static constexpr uint8_t ESTIMATED_SIZE = 48; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "fan_command_request"; } #endif @@ -778,7 +778,8 @@ class FanCommandRequest final : public CommandProtoMessage { bool has_speed_level{false}; int32_t speed_level{0}; bool has_preset_mode{false}; - std::string preset_mode{}; + const uint8_t *preset_mode{nullptr}; + uint16_t preset_mode_len{0}; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 90e8e75c93..d733e66a6d 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -923,7 +923,9 @@ void FanCommandRequest::dump_to(std::string &out) const { dump_field(out, "has_speed_level", this->has_speed_level); dump_field(out, "speed_level", this->speed_level); dump_field(out, "has_preset_mode", this->has_preset_mode); - dump_field(out, "preset_mode", this->preset_mode); + out.append(" preset_mode: "); + out.append(format_hex_pretty(this->preset_mode, this->preset_mode_len)); + out.append("\n"); #ifdef USE_DEVICES dump_field(out, "device_id", this->device_id); #endif diff --git a/esphome/components/fan/fan.cpp b/esphome/components/fan/fan.cpp index d37825a651..bf5506da4b 100644 --- a/esphome/components/fan/fan.cpp +++ b/esphome/components/fan/fan.cpp @@ -19,22 +19,28 @@ const LogString *fan_direction_to_string(FanDirection direction) { } } -FanCall &FanCall::set_preset_mode(const std::string &preset_mode) { return this->set_preset_mode(preset_mode.c_str()); } +FanCall &FanCall::set_preset_mode(const std::string &preset_mode) { + return this->set_preset_mode(preset_mode.data(), preset_mode.size()); +} FanCall &FanCall::set_preset_mode(const char *preset_mode) { - if (preset_mode == nullptr || strlen(preset_mode) == 0) { + return this->set_preset_mode(preset_mode, preset_mode ? strlen(preset_mode) : 0); +} + +FanCall &FanCall::set_preset_mode(const char *preset_mode, size_t len) { + if (preset_mode == nullptr || len == 0) { this->preset_mode_ = nullptr; return *this; } // Find and validate pointer from traits immediately auto traits = this->parent_.get_traits(); - const char *validated_mode = traits.find_preset_mode(preset_mode); + const char *validated_mode = traits.find_preset_mode(preset_mode, len); if (validated_mode != nullptr) { this->preset_mode_ = validated_mode; // Store pointer from traits } else { // Preset mode not found in traits - log warning and don't set - ESP_LOGW(TAG, "%s: Preset mode '%s' not supported", this->parent_.get_name().c_str(), preset_mode); + ESP_LOGW(TAG, "%s: Preset mode '%.*s' not supported", this->parent_.get_name().c_str(), (int) len, preset_mode); this->preset_mode_ = nullptr; } return *this; @@ -140,7 +146,13 @@ FanCall Fan::turn_off() { return this->make_call().set_state(false); } FanCall Fan::toggle() { return this->make_call().set_state(!this->state); } FanCall Fan::make_call() { return FanCall(*this); } -const char *Fan::find_preset_mode_(const char *preset_mode) { return this->get_traits().find_preset_mode(preset_mode); } +const char *Fan::find_preset_mode_(const char *preset_mode) { + return this->find_preset_mode_(preset_mode, preset_mode ? strlen(preset_mode) : 0); +} + +const char *Fan::find_preset_mode_(const char *preset_mode, size_t len) { + return this->get_traits().find_preset_mode(preset_mode, len); +} bool Fan::set_preset_mode_(const char *preset_mode) { if (preset_mode == nullptr) { diff --git a/esphome/components/fan/fan.h b/esphome/components/fan/fan.h index e38a80dbbe..70c4dab940 100644 --- a/esphome/components/fan/fan.h +++ b/esphome/components/fan/fan.h @@ -72,6 +72,7 @@ class FanCall { optional get_direction() const { return this->direction_; } FanCall &set_preset_mode(const std::string &preset_mode); FanCall &set_preset_mode(const char *preset_mode); + FanCall &set_preset_mode(const char *preset_mode, size_t len); const char *get_preset_mode() const { return this->preset_mode_; } bool has_preset_mode() const { return this->preset_mode_ != nullptr; } @@ -152,6 +153,7 @@ class Fan : public EntityBase { void clear_preset_mode_(); /// Find and return the matching preset mode pointer from traits, or nullptr if not found. const char *find_preset_mode_(const char *preset_mode); + const char *find_preset_mode_(const char *preset_mode, size_t len); CallbackManager state_callback_{}; ESPPreferenceObject rtc_; diff --git a/esphome/components/fan/fan_traits.h b/esphome/components/fan/fan_traits.h index 24987fe984..c0c5f34c50 100644 --- a/esphome/components/fan/fan_traits.h +++ b/esphome/components/fan/fan_traits.h @@ -47,10 +47,13 @@ class FanTraits { bool supports_preset_modes() const { return !this->preset_modes_.empty(); } /// Find and return the matching preset mode pointer from supported modes, or nullptr if not found. const char *find_preset_mode(const char *preset_mode) const { - if (preset_mode == nullptr) + return this->find_preset_mode(preset_mode, preset_mode ? strlen(preset_mode) : 0); + } + const char *find_preset_mode(const char *preset_mode, size_t len) const { + if (preset_mode == nullptr || len == 0) return nullptr; for (const char *mode : this->preset_modes_) { - if (strcmp(mode, preset_mode) == 0) { + if (strncmp(mode, preset_mode, len) == 0 && mode[len] == '\0') { return mode; // Return pointer from traits } } From 730bf206de6842aa4c65485801b03319957cbdf4 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Fri, 19 Dec 2025 20:25:16 -0600 Subject: [PATCH 31/33] [wifi] Fix for `wifi_info` when static IP is configured (#12576) --- esphome/components/wifi/wifi_component_esp8266.cpp | 10 ++++++++++ esphome/components/wifi/wifi_component_esp_idf.cpp | 8 ++++++++ esphome/components/wifi/wifi_component_libretiny.cpp | 8 ++++++++ esphome/components/wifi/wifi_component_pico_w.cpp | 9 +++++++++ 4 files changed, 35 insertions(+) diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index 1329103f98..550b5579ff 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -528,6 +528,16 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) { for (auto *listener : global_wifi_component->connect_state_listeners_) { listener->on_wifi_connect_state(global_wifi_component->wifi_ssid(), global_wifi_component->wifi_bssid()); } + // For static IP configurations, GOT_IP event may not fire, so notify IP listeners here +#ifdef USE_WIFI_MANUAL_IP + if (const WiFiAP *config = global_wifi_component->get_selected_sta_(); + config && config->get_manual_ip().has_value()) { + for (auto *listener : global_wifi_component->ip_state_listeners_) { + listener->on_ip_state(global_wifi_component->wifi_sta_ip_addresses(), + global_wifi_component->get_dns_address(0), global_wifi_component->get_dns_address(1)); + } + } +#endif #endif break; } diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index f9e117f468..212514af93 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -739,6 +739,14 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { for (auto *listener : this->connect_state_listeners_) { listener->on_wifi_connect_state(this->wifi_ssid(), this->wifi_bssid()); } + // For static IP configurations, GOT_IP event may not fire, so notify IP listeners here +#ifdef USE_WIFI_MANUAL_IP + if (const WiFiAP *config = this->get_selected_sta_(); config && config->get_manual_ip().has_value()) { + for (auto *listener : this->ip_state_listeners_) { + listener->on_ip_state(this->wifi_sta_ip_addresses(), this->get_dns_address(0), this->get_dns_address(1)); + } + } +#endif #endif } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_STA_DISCONNECTED) { diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index ffc6b21359..340537b228 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -305,6 +305,14 @@ void WiFiComponent::wifi_event_callback_(esphome_wifi_event_id_t event, esphome_ for (auto *listener : this->connect_state_listeners_) { listener->on_wifi_connect_state(this->wifi_ssid(), this->wifi_bssid()); } + // For static IP configurations, GOT_IP event may not fire, so notify IP listeners here +#ifdef USE_WIFI_MANUAL_IP + if (const WiFiAP *config = this->get_selected_sta_(); config && config->get_manual_ip().has_value()) { + for (auto *listener : this->ip_state_listeners_) { + listener->on_ip_state(this->wifi_sta_ip_addresses(), this->get_dns_address(0), this->get_dns_address(1)); + } + } +#endif #endif break; } diff --git a/esphome/components/wifi/wifi_component_pico_w.cpp b/esphome/components/wifi/wifi_component_pico_w.cpp index 4e763a9e22..61709852ff 100644 --- a/esphome/components/wifi/wifi_component_pico_w.cpp +++ b/esphome/components/wifi/wifi_component_pico_w.cpp @@ -259,6 +259,15 @@ void WiFiComponent::wifi_loop_() { for (auto *listener : this->connect_state_listeners_) { listener->on_wifi_connect_state(this->wifi_ssid(), this->wifi_bssid()); } + // For static IP configurations, notify IP listeners immediately as the IP is already configured +#ifdef USE_WIFI_MANUAL_IP + if (const WiFiAP *config = this->get_selected_sta_(); config && config->get_manual_ip().has_value()) { + s_sta_had_ip = true; + for (auto *listener : this->ip_state_listeners_) { + listener->on_ip_state(this->wifi_sta_ip_addresses(), this->get_dns_address(0), this->get_dns_address(1)); + } + } +#endif #endif } else if (!is_connected && s_sta_was_connected) { // Just disconnected From be6c1e4ec00b1d53972a471109ba223667b430b2 Mon Sep 17 00:00:00 2001 From: Martin Ebner <185941678+mebner86@users.noreply.github.com> Date: Sat, 20 Dec 2025 07:59:02 +0530 Subject: [PATCH 32/33] [sen5x][sgp4x] Move configuration keys from SEN5x and SGP4x to const.py (#12567) Co-authored-by: Martin Ebner --- esphome/components/sen5x/sensor.py | 22 +++++++++++----------- esphome/components/sgp4x/sensor.py | 18 +++++++++--------- esphome/const.py | 11 +++++++++++ 3 files changed, 31 insertions(+), 20 deletions(-) diff --git a/esphome/components/sen5x/sensor.py b/esphome/components/sen5x/sensor.py index 9668a253c0..9c3114b9e2 100644 --- a/esphome/components/sen5x/sensor.py +++ b/esphome/components/sen5x/sensor.py @@ -4,17 +4,28 @@ import esphome.codegen as cg from esphome.components import i2c, sensirion_common, sensor import esphome.config_validation as cv from esphome.const import ( + CONF_ALGORITHM_TUNING, CONF_GAIN_FACTOR, + CONF_GATING_MAX_DURATION_MINUTES, CONF_HUMIDITY, CONF_ID, + CONF_INDEX_OFFSET, + CONF_LEARNING_TIME_GAIN_HOURS, + CONF_LEARNING_TIME_OFFSET_HOURS, + CONF_NORMALIZED_OFFSET_SLOPE, + CONF_NOX, CONF_OFFSET, CONF_PM_1_0, CONF_PM_2_5, CONF_PM_4_0, CONF_PM_10_0, + CONF_STD_INITIAL, CONF_STORE_BASELINE, CONF_TEMPERATURE, CONF_TEMPERATURE_COMPENSATION, + CONF_TIME_CONSTANT, + CONF_VOC, + CONF_VOC_BASELINE, DEVICE_CLASS_AQI, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_PM1, @@ -42,18 +53,7 @@ SEN5XComponent = sen5x_ns.class_( RhtAccelerationMode = sen5x_ns.enum("RhtAccelerationMode") CONF_ACCELERATION_MODE = "acceleration_mode" -CONF_ALGORITHM_TUNING = "algorithm_tuning" CONF_AUTO_CLEANING_INTERVAL = "auto_cleaning_interval" -CONF_GATING_MAX_DURATION_MINUTES = "gating_max_duration_minutes" -CONF_INDEX_OFFSET = "index_offset" -CONF_LEARNING_TIME_GAIN_HOURS = "learning_time_gain_hours" -CONF_LEARNING_TIME_OFFSET_HOURS = "learning_time_offset_hours" -CONF_NORMALIZED_OFFSET_SLOPE = "normalized_offset_slope" -CONF_NOX = "nox" -CONF_STD_INITIAL = "std_initial" -CONF_TIME_CONSTANT = "time_constant" -CONF_VOC = "voc" -CONF_VOC_BASELINE = "voc_baseline" # Actions diff --git a/esphome/components/sgp4x/sensor.py b/esphome/components/sgp4x/sensor.py index 7c6fe580b2..ab78ab59d9 100644 --- a/esphome/components/sgp4x/sensor.py +++ b/esphome/components/sgp4x/sensor.py @@ -2,11 +2,20 @@ import esphome.codegen as cg from esphome.components import i2c, sensirion_common, sensor import esphome.config_validation as cv from esphome.const import ( + CONF_ALGORITHM_TUNING, CONF_COMPENSATION, CONF_GAIN_FACTOR, + CONF_GATING_MAX_DURATION_MINUTES, CONF_ID, + CONF_INDEX_OFFSET, + CONF_LEARNING_TIME_GAIN_HOURS, + CONF_LEARNING_TIME_OFFSET_HOURS, + CONF_NOX, + CONF_STD_INITIAL, CONF_STORE_BASELINE, CONF_TEMPERATURE_SOURCE, + CONF_VOC, + CONF_VOC_BASELINE, DEVICE_CLASS_AQI, ICON_RADIATOR, STATE_CLASS_MEASUREMENT, @@ -24,16 +33,7 @@ SGP4xComponent = sgp4x_ns.class_( sensirion_common.SensirionI2CDevice, ) -CONF_ALGORITHM_TUNING = "algorithm_tuning" -CONF_GATING_MAX_DURATION_MINUTES = "gating_max_duration_minutes" CONF_HUMIDITY_SOURCE = "humidity_source" -CONF_INDEX_OFFSET = "index_offset" -CONF_LEARNING_TIME_GAIN_HOURS = "learning_time_gain_hours" -CONF_LEARNING_TIME_OFFSET_HOURS = "learning_time_offset_hours" -CONF_NOX = "nox" -CONF_STD_INITIAL = "std_initial" -CONF_VOC = "voc" -CONF_VOC_BASELINE = "voc_baseline" def validate_sensors(config): diff --git a/esphome/const.py b/esphome/const.py index c94ead0be4..075679d177 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -123,6 +123,7 @@ CONF_ADDRESS = "address" CONF_ADDRESSABLE_LIGHT_ID = "addressable_light_id" CONF_ADVANCED = "advanced" CONF_AFTER = "after" +CONF_ALGORITHM_TUNING = "algorithm_tuning" CONF_ALL = "all" CONF_ALLOW_OTHER_USES = "allow_other_uses" CONF_ALPHA = "alpha" @@ -435,6 +436,7 @@ CONF_GAIN_FACTOR = "gain_factor" CONF_GAMMA_CORRECT = "gamma_correct" CONF_GAS_RESISTANCE = "gas_resistance" CONF_GATEWAY = "gateway" +CONF_GATING_MAX_DURATION_MINUTES = "gating_max_duration_minutes" CONF_GLASS_ATTENUATION_FACTOR = "glass_attenuation_factor" CONF_GLYPHS = "glyphs" CONF_GPIO = "gpio" @@ -497,6 +499,7 @@ CONF_INCLUDE_INTERNAL = "include_internal" CONF_INCLUDES = "includes" CONF_INCLUDES_C = "includes_c" CONF_INDEX = "index" +CONF_INDEX_OFFSET = "index_offset" CONF_INDOOR = "indoor" CONF_INFRARED = "infrared" CONF_INIT_SEQUENCE = "init_sequence" @@ -534,6 +537,8 @@ CONF_LAMBDA = "lambda" CONF_LAST_CONFIDENCE = "last_confidence" CONF_LAST_FINGER_ID = "last_finger_id" CONF_LATITUDE = "latitude" +CONF_LEARNING_TIME_GAIN_HOURS = "learning_time_gain_hours" +CONF_LEARNING_TIME_OFFSET_HOURS = "learning_time_offset_hours" CONF_LED = "led" CONF_LEGEND = "legend" CONF_LENGTH = "length" @@ -645,7 +650,9 @@ CONF_NEVER = "never" CONF_NEW_PASSWORD = "new_password" CONF_NITROGEN_DIOXIDE = "nitrogen_dioxide" CONF_NOISE_LEVEL = "noise_level" +CONF_NORMALIZED_OFFSET_SLOPE = "normalized_offset_slope" CONF_NOTIFY = "notify" +CONF_NOX = "nox" CONF_NUM_ATTEMPTS = "num_attempts" CONF_NUM_CHANNELS = "num_channels" CONF_NUM_CHIPS = "num_chips" @@ -939,6 +946,7 @@ CONF_STATE_TOPIC = "state_topic" CONF_STATIC_IP = "static_ip" CONF_STATUS = "status" CONF_STB_PIN = "stb_pin" +CONF_STD_INITIAL = "std_initial" CONF_STEP = "step" CONF_STEP_DELAY = "step_delay" CONF_STEP_MODE = "step_mode" @@ -1006,6 +1014,7 @@ CONF_TILT_COMMAND_TOPIC = "tilt_command_topic" CONF_TILT_LAMBDA = "tilt_lambda" CONF_TILT_STATE_TOPIC = "tilt_state_topic" CONF_TIME = "time" +CONF_TIME_CONSTANT = "time_constant" CONF_TIME_ID = "time_id" CONF_TIMEOUT = "timeout" CONF_TIMES = "times" @@ -1060,6 +1069,8 @@ CONF_VERSION = "version" CONF_VIBRATIONS = "vibrations" CONF_VISIBLE = "visible" CONF_VISUAL = "visual" +CONF_VOC = "voc" +CONF_VOC_BASELINE = "voc_baseline" CONF_VOLTAGE = "voltage" CONF_VOLTAGE_ATTENUATION = "voltage_attenuation" CONF_VOLTAGE_DIVIDER = "voltage_divider" From 3e313014e12c94b68f70974b205242fb2af6ddef Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 19 Dec 2025 19:04:21 -1000 Subject: [PATCH 33/33] [core] Migrate entities to use lazy callbacks (#12580) --- .../alarm_control_panel/alarm_control_panel.h | 8 ++-- esphome/components/button/button.h | 2 +- esphome/components/climate/climate.h | 4 +- esphome/components/cover/cover.h | 2 +- esphome/components/datetime/datetime_base.h | 2 +- esphome/components/event/event.h | 2 +- esphome/components/fan/fan.h | 2 +- esphome/components/lock/lock.h | 2 +- .../components/media_player/media_player.h | 2 +- esphome/components/number/number.h | 2 +- esphome/components/select/select.h | 2 +- esphome/components/sensor/sensor.cpp | 9 +--- esphome/components/sensor/sensor.h | 4 +- esphome/components/switch/switch.h | 4 +- esphome/components/text/text.h | 2 +- .../components/text_sensor/text_sensor.cpp | 9 +--- esphome/components/text_sensor/text_sensor.h | 5 +-- esphome/components/update/update_entity.h | 2 +- esphome/components/valve/valve.h | 2 +- esphome/core/helpers.h | 44 +++++++++++++++++++ 20 files changed, 72 insertions(+), 39 deletions(-) diff --git a/esphome/components/alarm_control_panel/alarm_control_panel.h b/esphome/components/alarm_control_panel/alarm_control_panel.h index c46edc11c2..59ccf0e484 100644 --- a/esphome/components/alarm_control_panel/alarm_control_panel.h +++ b/esphome/components/alarm_control_panel/alarm_control_panel.h @@ -132,13 +132,13 @@ class AlarmControlPanel : public EntityBase { // the call control function virtual void control(const AlarmControlPanelCall &call) = 0; // state callback - triggers check get_state() for specific state - CallbackManager state_callback_{}; + LazyCallbackManager state_callback_{}; // clear callback - fires when leaving TRIGGERED state - CallbackManager cleared_callback_{}; + LazyCallbackManager cleared_callback_{}; // chime callback - CallbackManager chime_callback_{}; + LazyCallbackManager chime_callback_{}; // ready callback - CallbackManager ready_callback_{}; + LazyCallbackManager ready_callback_{}; }; } // namespace alarm_control_panel diff --git a/esphome/components/button/button.h b/esphome/components/button/button.h index 18122f6f2f..be6e080917 100644 --- a/esphome/components/button/button.h +++ b/esphome/components/button/button.h @@ -41,7 +41,7 @@ class Button : public EntityBase, public EntityBase_DeviceClass { */ virtual void press_action() = 0; - CallbackManager press_callback_{}; + LazyCallbackManager press_callback_{}; }; } // namespace esphome::button diff --git a/esphome/components/climate/climate.h b/esphome/components/climate/climate.h index 0bae28df5a..06adb580cf 100644 --- a/esphome/components/climate/climate.h +++ b/esphome/components/climate/climate.h @@ -326,8 +326,8 @@ class Climate : public EntityBase { void dump_traits_(const char *tag); - CallbackManager state_callback_{}; - CallbackManager control_callback_{}; + LazyCallbackManager state_callback_{}; + LazyCallbackManager control_callback_{}; ESPPreferenceObject rtc_; #ifdef USE_CLIMATE_VISUAL_OVERRIDES float visual_min_temperature_override_{NAN}; diff --git a/esphome/components/cover/cover.h b/esphome/components/cover/cover.h index d8c45ab2bd..e710915a0e 100644 --- a/esphome/components/cover/cover.h +++ b/esphome/components/cover/cover.h @@ -152,7 +152,7 @@ class Cover : public EntityBase, public EntityBase_DeviceClass { optional restore_state_(); - CallbackManager state_callback_{}; + LazyCallbackManager state_callback_{}; ESPPreferenceObject rtc_; }; diff --git a/esphome/components/datetime/datetime_base.h b/esphome/components/datetime/datetime_base.h index 7b9b281ea4..1b0b3d5463 100644 --- a/esphome/components/datetime/datetime_base.h +++ b/esphome/components/datetime/datetime_base.h @@ -22,7 +22,7 @@ class DateTimeBase : public EntityBase { #endif protected: - CallbackManager state_callback_; + LazyCallbackManager state_callback_; #ifdef USE_TIME time::RealTimeClock *rtc_; diff --git a/esphome/components/event/event.h b/esphome/components/event/event.h index e4b2e0b845..0d5850d339 100644 --- a/esphome/components/event/event.h +++ b/esphome/components/event/event.h @@ -50,7 +50,7 @@ class Event : public EntityBase, public EntityBase_DeviceClass { void add_on_event_callback(std::function &&callback); protected: - CallbackManager event_callback_; + LazyCallbackManager event_callback_; FixedVector types_; private: diff --git a/esphome/components/fan/fan.h b/esphome/components/fan/fan.h index 70c4dab940..7c79fda83e 100644 --- a/esphome/components/fan/fan.h +++ b/esphome/components/fan/fan.h @@ -155,7 +155,7 @@ class Fan : public EntityBase { const char *find_preset_mode_(const char *preset_mode); const char *find_preset_mode_(const char *preset_mode, size_t len); - CallbackManager state_callback_{}; + LazyCallbackManager state_callback_{}; ESPPreferenceObject rtc_; FanRestoreMode restore_mode_; diff --git a/esphome/components/lock/lock.h b/esphome/components/lock/lock.h index 4001a182b8..f77b11b145 100644 --- a/esphome/components/lock/lock.h +++ b/esphome/components/lock/lock.h @@ -174,7 +174,7 @@ class Lock : public EntityBase { */ virtual void control(const LockCall &call) = 0; - CallbackManager state_callback_{}; + LazyCallbackManager state_callback_{}; Deduplicator publish_dedup_; ESPPreferenceObject rtc_; }; diff --git a/esphome/components/media_player/media_player.h b/esphome/components/media_player/media_player.h index 2f1c99115f..b753e2d088 100644 --- a/esphome/components/media_player/media_player.h +++ b/esphome/components/media_player/media_player.h @@ -157,7 +157,7 @@ class MediaPlayer : public EntityBase { virtual void control(const MediaPlayerCall &call) = 0; - CallbackManager state_callback_{}; + LazyCallbackManager state_callback_{}; }; } // namespace media_player diff --git a/esphome/components/number/number.h b/esphome/components/number/number.h index 472e06ad61..0425714702 100644 --- a/esphome/components/number/number.h +++ b/esphome/components/number/number.h @@ -49,7 +49,7 @@ class Number : public EntityBase { */ virtual void control(float value) = 0; - CallbackManager state_callback_; + LazyCallbackManager state_callback_; }; } // namespace esphome::number diff --git a/esphome/components/select/select.h b/esphome/components/select/select.h index 854fdcf252..330d18ce6f 100644 --- a/esphome/components/select/select.h +++ b/esphome/components/select/select.h @@ -111,7 +111,7 @@ class Select : public EntityBase { } } - CallbackManager state_callback_; + LazyCallbackManager state_callback_; }; } // namespace esphome::select diff --git a/esphome/components/sensor/sensor.cpp b/esphome/components/sensor/sensor.cpp index 49dc56edaa..c1d28bf260 100644 --- a/esphome/components/sensor/sensor.cpp +++ b/esphome/components/sensor/sensor.cpp @@ -76,9 +76,7 @@ StateClass Sensor::get_state_class() { void Sensor::publish_state(float state) { this->raw_state = state; - if (this->raw_callback_) { - this->raw_callback_->call(state); - } + this->raw_callback_.call(state); ESP_LOGV(TAG, "'%s': Received new state %f", this->name_.c_str(), state); @@ -91,10 +89,7 @@ void Sensor::publish_state(float state) { void Sensor::add_on_state_callback(std::function &&callback) { this->callback_.add(std::move(callback)); } void Sensor::add_on_raw_state_callback(std::function &&callback) { - if (!this->raw_callback_) { - this->raw_callback_ = make_unique>(); - } - this->raw_callback_->add(std::move(callback)); + this->raw_callback_.add(std::move(callback)); } void Sensor::add_filter(Filter *filter) { diff --git a/esphome/components/sensor/sensor.h b/esphome/components/sensor/sensor.h index 5d387a1ad7..a792c0d3fd 100644 --- a/esphome/components/sensor/sensor.h +++ b/esphome/components/sensor/sensor.h @@ -125,8 +125,8 @@ class Sensor : public EntityBase, public EntityBase_DeviceClass, public EntityBa void internal_send_state_to_frontend(float state); protected: - std::unique_ptr> raw_callback_; ///< Storage for raw state callbacks (lazy allocated). - CallbackManager callback_; ///< Storage for filtered state callbacks. + LazyCallbackManager raw_callback_; ///< Storage for raw state callbacks. + LazyCallbackManager callback_; ///< Storage for filtered state callbacks. Filter *filter_list_{nullptr}; ///< Store all active filters. diff --git a/esphome/components/switch/switch.h b/esphome/components/switch/switch.h index 6371e35292..9319adf9ed 100644 --- a/esphome/components/switch/switch.h +++ b/esphome/components/switch/switch.h @@ -134,8 +134,8 @@ class Switch : public EntityBase, public EntityBase_DeviceClass { // Pointer first (4 bytes) ESPPreferenceObject rtc_; - // CallbackManager (12 bytes on 32-bit - contains vector) - CallbackManager state_callback_{}; + // LazyCallbackManager (4 bytes on 32-bit - nullptr when empty) + LazyCallbackManager state_callback_{}; // Small types grouped together Deduplicator publish_dedup_; // 2 bytes (bool has_value_ + bool last_value_) diff --git a/esphome/components/text/text.h b/esphome/components/text/text.h index f24464cb20..b8881c59e6 100644 --- a/esphome/components/text/text.h +++ b/esphome/components/text/text.h @@ -44,7 +44,7 @@ class Text : public EntityBase { */ virtual void control(const std::string &value) = 0; - CallbackManager state_callback_; + LazyCallbackManager state_callback_; }; } // namespace text diff --git a/esphome/components/text_sensor/text_sensor.cpp b/esphome/components/text_sensor/text_sensor.cpp index 51923ebd96..76c1acf56c 100644 --- a/esphome/components/text_sensor/text_sensor.cpp +++ b/esphome/components/text_sensor/text_sensor.cpp @@ -30,9 +30,7 @@ void TextSensor::publish_state(const std::string &state) { #pragma GCC diagnostic ignored "-Wdeprecated-declarations" this->raw_state = state; #pragma GCC diagnostic pop - if (this->raw_callback_) { - this->raw_callback_->call(state); - } + this->raw_callback_.call(state); ESP_LOGV(TAG, "'%s': Received new state %s", this->name_.c_str(), state.c_str()); @@ -77,10 +75,7 @@ void TextSensor::add_on_state_callback(std::function callback this->callback_.add(std::move(callback)); } void TextSensor::add_on_raw_state_callback(std::function callback) { - if (!this->raw_callback_) { - this->raw_callback_ = make_unique>(); - } - this->raw_callback_->add(std::move(callback)); + this->raw_callback_.add(std::move(callback)); } std::string TextSensor::get_state() const { return this->state; } diff --git a/esphome/components/text_sensor/text_sensor.h b/esphome/components/text_sensor/text_sensor.h index e411f57d67..f926f171a7 100644 --- a/esphome/components/text_sensor/text_sensor.h +++ b/esphome/components/text_sensor/text_sensor.h @@ -65,9 +65,8 @@ class TextSensor : public EntityBase, public EntityBase_DeviceClass { void internal_send_state_to_frontend(const std::string &state); protected: - std::unique_ptr> - raw_callback_; ///< Storage for raw state callbacks (lazy allocated). - CallbackManager callback_; ///< Storage for filtered state callbacks. + LazyCallbackManager raw_callback_; ///< Storage for raw state callbacks. + LazyCallbackManager callback_; ///< Storage for filtered state callbacks. Filter *filter_list_{nullptr}; ///< Store all active filters. }; diff --git a/esphome/components/update/update_entity.h b/esphome/components/update/update_entity.h index 9424e80b9f..8eba78b44b 100644 --- a/esphome/components/update/update_entity.h +++ b/esphome/components/update/update_entity.h @@ -50,7 +50,7 @@ class UpdateEntity : public EntityBase, public EntityBase_DeviceClass { UpdateState state_{UPDATE_STATE_UNKNOWN}; UpdateInfo update_info_; - CallbackManager state_callback_{}; + LazyCallbackManager state_callback_{}; std::unique_ptr> update_available_trigger_{nullptr}; }; diff --git a/esphome/components/valve/valve.h b/esphome/components/valve/valve.h index 2cb28e4b2f..2b3419b67a 100644 --- a/esphome/components/valve/valve.h +++ b/esphome/components/valve/valve.h @@ -144,7 +144,7 @@ class Valve : public EntityBase, public EntityBase_DeviceClass { optional restore_state_(); - CallbackManager state_callback_{}; + LazyCallbackManager state_callback_{}; ESPPreferenceObject rtc_; }; diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index f9dcfccb45..9ff2458a74 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -934,6 +934,50 @@ template class CallbackManager { std::vector> callbacks_; }; +template class LazyCallbackManager; + +/** Lazy-allocating callback manager that only allocates memory when callbacks are registered. + * + * This is a drop-in replacement for CallbackManager that saves memory when no callbacks + * are registered (common case after the Controller Registry eliminated per-entity callbacks + * from API and web_server components). + * + * Memory overhead comparison (32-bit systems): + * - CallbackManager: 12 bytes (empty std::vector) + * - LazyCallbackManager: 4 bytes (nullptr unique_ptr) + * + * @tparam Ts The arguments for the callbacks, wrapped in void(). + */ +template class LazyCallbackManager { + public: + /// Add a callback to the list. Allocates the underlying CallbackManager on first use. + void add(std::function &&callback) { + if (!this->callbacks_) { + this->callbacks_ = make_unique>(); + } + this->callbacks_->add(std::move(callback)); + } + + /// Call all callbacks in this manager. No-op if no callbacks registered. + void call(Ts... args) { + if (this->callbacks_) { + this->callbacks_->call(args...); + } + } + + /// Return the number of registered callbacks. + size_t size() const { return this->callbacks_ ? this->callbacks_->size() : 0; } + + /// Check if any callbacks are registered. + bool empty() const { return !this->callbacks_ || this->callbacks_->size() == 0; } + + /// Call all callbacks in this manager. + void operator()(Ts... args) { this->call(args...); } + + protected: + std::unique_ptr> callbacks_; +}; + /// Helper class to deduplicate items in a series of values. template class Deduplicator { public: