From edc320fef820d0f9572c8b4ff4fd317efebb4ff1 Mon Sep 17 00:00:00 2001 From: David Woodhouse Date: Thu, 11 Dec 2025 13:04:07 +0900 Subject: [PATCH 001/111] Add buildinfo system with config hash and build time To allow for more selective managed updates, allow the config hash and build time to be built into the image itself. To avoid triggering unneeded rebuilds, do this through a linker script so that the new config hash and timestamp are included only if the firmware is actually relinked. Add a _check_and_emit_buildinfo() step after building, which prints the information after the firmware was rebuilt. A subsequent commit will emit a manifest here, or at least the HMAC-MD5 for signing OTA updates using the hmac_key configured in this image. --- esphome/__main__.py | 48 ++++++++++++++++++++++++++++++++++++++ esphome/core/buildinfo.cpp | 38 ++++++++++++++++++++++++++++++ esphome/core/buildinfo.h | 18 ++++++++++++++ esphome/writer.py | 37 +++++++++++++++++++++++++++++ 4 files changed, 141 insertions(+) create mode 100644 esphome/core/buildinfo.cpp create mode 100644 esphome/core/buildinfo.h diff --git a/esphome/__main__.py b/esphome/__main__.py index 55fbbc6c8a..38efe58b95 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -518,10 +518,58 @@ 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 buildinfo + create manifest + _check_and_emit_buildinfo() + idedata = platformio_api.get_idedata(config) return 0 if idedata is not None else 1 +def _check_and_emit_buildinfo(): + """Check if firmware was rebuilt and emit buildinfo.""" + + firmware_path = CORE.firmware_bin + buildinfo_script_path = CORE.relative_build_path("buildinfo.ld") + + # Check if both files exist + if not firmware_path.exists() or not buildinfo_script_path.exists(): + return + + # Check if firmware is newer than buildinfo script (indicating a relink occurred) + if firmware_path.stat().st_mtime <= buildinfo_script_path.stat().st_mtime: + return + + # Read buildinfo values from linker script + try: + with open(buildinfo_script_path, encoding="utf-8") as f: + content = f.read() + + config_hash_match = re.search( + r"ESPHOME_CONFIG_HASH = 0x([0-9a-fA-F]+);", content + ) + build_time_match = re.search(r"ESPHOME_BUILD_TIME = (\d+);", content) + + if not config_hash_match or not build_time_match: + return + + config_hash = config_hash_match.group(1) + build_time = int(build_time_match.group(1)) + + # Emit buildinfo + print("=== ESPHome Build Info ===") + print(f"Config Hash: 0x{config_hash}") + print( + f"Build Time: {build_time} ({time.strftime('%Y-%m-%d %H:%M:%S %z', time.localtime(build_time))})" + ) + print("===========================") + + # TODO: Future commit will create JSON manifest with OTA metadata here + + except OSError as e: + _LOGGER.debug("Failed to emit buildinfo: %s", e) + + def upload_using_esptool( config: ConfigType, port: str, file: str, speed: int ) -> str | int: diff --git a/esphome/core/buildinfo.cpp b/esphome/core/buildinfo.cpp new file mode 100644 index 0000000000..d17bf01124 --- /dev/null +++ b/esphome/core/buildinfo.cpp @@ -0,0 +1,38 @@ +// Build information using linker-provided symbols +// +// Including build information into the build is fun, because we *don't* +// want the mere fact of changing the build time to *itself* cause a +// rebuild if nothing else had changed. If we do the naïve thing of +// just putting #defines in a header like version.h, we'll cause exactly +// that. +// +// So instead we provide the config hash and build time in a linker +// script, so they get pulled in only if the firmware is already being +// rebuilt. +#include "esphome/core/buildinfo.h" +#include + +// Linker-provided symbols - declare as extern variables, not functions +extern "C" { +extern const char ESPHOME_CONFIG_HASH[]; +extern const char ESPHOME_BUILD_TIME[]; +} + +namespace esphome { +namespace buildinfo { + +// Reference the linker symbols as uintptr_t from the *data* section to +// avoid issues with pc-relative relocations on 64-bit platforms. +static const uintptr_t config_hash = (uintptr_t) &ESPHOME_CONFIG_HASH; +static const uintptr_t build_time = (uintptr_t) &ESPHOME_BUILD_TIME; + +const char *get_config_hash() { + static char hash_str[9]; + snprintf(hash_str, sizeof(hash_str), "%08x", (uint32_t) config_hash); + return hash_str; +} + +time_t get_build_time() { return (time_t) build_time; } + +} // namespace buildinfo +} // namespace esphome diff --git a/esphome/core/buildinfo.h b/esphome/core/buildinfo.h new file mode 100644 index 0000000000..cc4656f4dc --- /dev/null +++ b/esphome/core/buildinfo.h @@ -0,0 +1,18 @@ +#pragma once +#include +#include + +// Build information functions that provide config hash and build time. +// The actual values are provided by linker-defined symbols to avoid +// unnecessary rebuilds when only the build time changes. +// This is kept in its own file so that only files that need build-specific +// information have to include it explicitly. + +namespace esphome { +namespace buildinfo { + +const char *get_config_hash(); +time_t get_build_time(); + +} // namespace buildinfo +} // namespace esphome diff --git a/esphome/writer.py b/esphome/writer.py index 721db07f96..f955b22b79 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -1,4 +1,5 @@ from collections.abc import Callable +import hashlib import importlib import logging import os @@ -6,6 +7,7 @@ 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" +BUILDINFO_H_TARGET = "esphome/core/buildinfo.h" ESPHOME_README_TXT = """ THIS DIRECTORY IS AUTO-GENERATED, DO NOT MODIFY @@ -245,6 +249,12 @@ def copy_src_tree(): write_file_if_changed( CORE.relative_src_path("esphome", "core", "version.h"), generate_version_h() ) + # Write buildinfo generation script + write_file( + CORE.relative_build_path("generate_buildinfo.py"), generate_buildinfo_script() + ) + # Add buildinfo script to platformio extra_scripts + CORE.add_platformio_option("extra_scripts", ["pre:generate_buildinfo.py"]) platform = "esphome.components." + CORE.target_platform try: @@ -270,6 +280,33 @@ def generate_version_h(): ) +def generate_buildinfo_script(): + from esphome import yaml_util + + # Use the same clean YAML representation as 'esphome config' command + config_str = yaml_util.dump(CORE.config, show_secrets=True) + + config_hash = hashlib.md5(config_str.encode("utf-8")).hexdigest()[:8] + config_hash_int = int(config_hash, 16) + build_time = int(time.time()) + + # Generate linker script content + linker_script = f"""/* Auto-generated buildinfo symbols */ +ESPHOME_CONFIG_HASH = 0x{config_hash_int:08x}; +ESPHOME_BUILD_TIME = {build_time}; +""" + + # Write linker script file + with open(CORE.relative_build_path("buildinfo.ld"), "w", encoding="utf-8") as f: + f.write(linker_script) + + return """#!/usr/bin/env python3 +# Buildinfo linker script already generated +Import("env") +env.Append(LINKFLAGS=["buildinfo.ld"]) +""" + + def write_cpp(code_s): path = CORE.relative_src_path("main.cpp") if path.is_file(): From cfdb5a82e2d85e64953737c7d7b8c7e3fef50472 Mon Sep 17 00:00:00 2001 From: David Woodhouse Date: Thu, 11 Dec 2025 17:45:48 +0900 Subject: [PATCH 002/111] Replace __DATE__/__TIME__ with buildinfo functions - Add get_build_time_string() function to format build time consistently - Replace __DATE__ ", " __TIME__ in App.pre_setup() with buildinfo call - Eliminates dependency on compiler-provided date/time macros - Ensures consistent build time across all build information displays --- esphome/core/buildinfo.cpp | 8 ++++++++ esphome/core/buildinfo.h | 1 + esphome/core/config.py | 2 +- 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/esphome/core/buildinfo.cpp b/esphome/core/buildinfo.cpp index d17bf01124..4726f789a1 100644 --- a/esphome/core/buildinfo.cpp +++ b/esphome/core/buildinfo.cpp @@ -34,5 +34,13 @@ const char *get_config_hash() { time_t get_build_time() { return (time_t) build_time; } +const char *get_build_time_string() { + static char time_str[32]; + time_t bt = get_build_time(); + struct tm *tm_info = localtime(&bt); + strftime(time_str, sizeof(time_str), "%b %d %Y, %H:%M:%S", tm_info); + return time_str; +} + } // namespace buildinfo } // namespace esphome diff --git a/esphome/core/buildinfo.h b/esphome/core/buildinfo.h index cc4656f4dc..664b7985dd 100644 --- a/esphome/core/buildinfo.h +++ b/esphome/core/buildinfo.h @@ -13,6 +13,7 @@ namespace buildinfo { const char *get_config_hash(); time_t get_build_time(); +const char *get_build_time_string(); } // namespace buildinfo } // namespace esphome diff --git a/esphome/core/config.py b/esphome/core/config.py index 3adaf7eb9e..f7a5305144 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -501,7 +501,7 @@ async def to_code(config: ConfigType) -> None: config[CONF_NAME], config[CONF_FRIENDLY_NAME], config.get(CONF_COMMENT, ""), - cg.RawExpression('__DATE__ ", " __TIME__'), + cg.RawExpression("esphome::buildinfo::get_build_time_string()"), config[CONF_NAME_ADD_MAC_SUFFIX], ) ) From 478f12f75e8446b5e83debc5512e93a7a5483913 Mon Sep 17 00:00:00 2001 From: David Woodhouse Date: Thu, 11 Dec 2025 22:44:04 +0900 Subject: [PATCH 003/111] Remove const from buildinfo static variables The const qualifier allows compiler optimization that bypasses our indirection workaround, causing PC-relative relocations that fail on some platforms. Keep variables non-const to force data section relocations. --- esphome/core/buildinfo.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/core/buildinfo.cpp b/esphome/core/buildinfo.cpp index 4726f789a1..28d1479240 100644 --- a/esphome/core/buildinfo.cpp +++ b/esphome/core/buildinfo.cpp @@ -23,8 +23,8 @@ namespace buildinfo { // Reference the linker symbols as uintptr_t from the *data* section to // avoid issues with pc-relative relocations on 64-bit platforms. -static const uintptr_t config_hash = (uintptr_t) &ESPHOME_CONFIG_HASH; -static const uintptr_t build_time = (uintptr_t) &ESPHOME_BUILD_TIME; +static uintptr_t config_hash = (uintptr_t) &ESPHOME_CONFIG_HASH; +static uintptr_t build_time = (uintptr_t) &ESPHOME_BUILD_TIME; const char *get_config_hash() { static char hash_str[9]; From 0b1ea8f2ca26ffc84f7f6e97c371a51a7ff30732 Mon Sep 17 00:00:00 2001 From: David Woodhouse Date: Thu, 11 Dec 2025 22:48:02 +0900 Subject: [PATCH 004/111] Add nolint for non-const buildinfo variables Variables must remain non-const to prevent compiler optimization that would bypass the indirection workaround for PC-relative relocation issues. --- esphome/core/buildinfo.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/esphome/core/buildinfo.cpp b/esphome/core/buildinfo.cpp index 28d1479240..912b506790 100644 --- a/esphome/core/buildinfo.cpp +++ b/esphome/core/buildinfo.cpp @@ -23,8 +23,10 @@ namespace buildinfo { // Reference the linker symbols as uintptr_t from the *data* section to // avoid issues with pc-relative relocations on 64-bit platforms. +// NOLINTBEGIN(cppcoreguidelines-avoid-non-const-global-variables) static uintptr_t config_hash = (uintptr_t) &ESPHOME_CONFIG_HASH; static uintptr_t build_time = (uintptr_t) &ESPHOME_BUILD_TIME; +// NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables) const char *get_config_hash() { static char hash_str[9]; From 54ed6154eb40201442bc6be36ed53b569cd7ae67 Mon Sep 17 00:00:00 2001 From: David Woodhouse Date: Thu, 11 Dec 2025 22:49:32 +0900 Subject: [PATCH 005/111] Expand non-const comment --- esphome/core/buildinfo.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/esphome/core/buildinfo.cpp b/esphome/core/buildinfo.cpp index 912b506790..839bee9857 100644 --- a/esphome/core/buildinfo.cpp +++ b/esphome/core/buildinfo.cpp @@ -22,7 +22,11 @@ namespace esphome { namespace buildinfo { // Reference the linker symbols as uintptr_t from the *data* section to -// avoid issues with pc-relative relocations on 64-bit platforms. +// avoid issues with pc-relative relocations on 64-bit platforms. And +// don't let the compiler know they're const or it'll optimise away the +// whole thing and emit a relocation to the ESPHOME_XXX symbols above +// directly, which defaults the whole point! +// // NOLINTBEGIN(cppcoreguidelines-avoid-non-const-global-variables) static uintptr_t config_hash = (uintptr_t) &ESPHOME_CONFIG_HASH; static uintptr_t build_time = (uintptr_t) &ESPHOME_BUILD_TIME; From 58fddeb74f9215a7d7c8ff8dd935727cbdf0e5d8 Mon Sep 17 00:00:00 2001 From: David Woodhouse Date: Thu, 11 Dec 2025 22:53:03 +0900 Subject: [PATCH 006/111] Optimize get_config_hash to avoid repeated snprintf calls Check if hash string is already formatted before calling snprintf, since static variables in BSS are zero-initialized. --- esphome/core/buildinfo.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/esphome/core/buildinfo.cpp b/esphome/core/buildinfo.cpp index 839bee9857..dd07fa6f6c 100644 --- a/esphome/core/buildinfo.cpp +++ b/esphome/core/buildinfo.cpp @@ -34,7 +34,9 @@ static uintptr_t build_time = (uintptr_t) &ESPHOME_BUILD_TIME; const char *get_config_hash() { static char hash_str[9]; - snprintf(hash_str, sizeof(hash_str), "%08x", (uint32_t) config_hash); + if (!hash_str[0]) { + snprintf(hash_str, sizeof(hash_str), "%08x", (uint32_t) config_hash); + } return hash_str; } From 295b31780949bce44e19db16831cf77252b64eda Mon Sep 17 00:00:00 2001 From: David Woodhouse Date: Thu, 11 Dec 2025 22:54:19 +0900 Subject: [PATCH 007/111] Optimize get_build_time_string to avoid repeated formatting Apply same concurrency fix as get_config_hash to prevent race conditions when multiple threads access the function. --- esphome/core/buildinfo.cpp | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/esphome/core/buildinfo.cpp b/esphome/core/buildinfo.cpp index dd07fa6f6c..58129c43b5 100644 --- a/esphome/core/buildinfo.cpp +++ b/esphome/core/buildinfo.cpp @@ -44,9 +44,11 @@ time_t get_build_time() { return (time_t) build_time; } const char *get_build_time_string() { static char time_str[32]; - time_t bt = get_build_time(); - struct tm *tm_info = localtime(&bt); - strftime(time_str, sizeof(time_str), "%b %d %Y, %H:%M:%S", tm_info); + if (!time_str[0]) { + time_t bt = get_build_time(); + struct tm *tm_info = localtime(&bt); + strftime(time_str, sizeof(time_str), "%b %d %Y, %H:%M:%S", tm_info); + } return time_str; } From ccebe613e23b2452e245f0b84927adf8ec7a5479 Mon Sep 17 00:00:00 2001 From: David Woodhouse Date: Thu, 11 Dec 2025 23:35:54 +0900 Subject: [PATCH 008/111] Optimize buildinfo RAM usage on 32-bit platforms Use direct symbol access on 32-bit platforms to avoid 8 bytes of RAM overhead. Keep indirection workaround only on 64-bit platforms where PC-relative relocations cause linking issues. --- esphome/core/buildinfo.cpp | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/esphome/core/buildinfo.cpp b/esphome/core/buildinfo.cpp index 58129c43b5..dfeb073578 100644 --- a/esphome/core/buildinfo.cpp +++ b/esphome/core/buildinfo.cpp @@ -21,16 +21,21 @@ extern const char ESPHOME_BUILD_TIME[]; namespace esphome { namespace buildinfo { -// Reference the linker symbols as uintptr_t from the *data* section to -// avoid issues with pc-relative relocations on 64-bit platforms. And -// don't let the compiler know they're const or it'll optimise away the -// whole thing and emit a relocation to the ESPHOME_XXX symbols above -// directly, which defaults the whole point! -// +#if __SIZEOF_POINTER__ > 4 +// On 64-bit platforms, reference the linker symbols as uintptr_t from the *data* section to +// avoid issues with pc-relative relocations. Don't let the compiler know they're const or +// it'll optimise away the whole thing and emit a relocation to the ESPHOME_XXX symbols +// directly, which defeats the whole point! // NOLINTBEGIN(cppcoreguidelines-avoid-non-const-global-variables) -static uintptr_t config_hash = (uintptr_t) &ESPHOME_CONFIG_HASH; -static uintptr_t build_time = (uintptr_t) &ESPHOME_BUILD_TIME; +static uintptr_t config_hash_ptr = (uintptr_t) &ESPHOME_CONFIG_HASH; +static uintptr_t build_time_ptr = (uintptr_t) &ESPHOME_BUILD_TIME; // NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables) +#define config_hash config_hash_ptr +#define build_time build_time_ptr +#else +#define config_hash ((uintptr_t) &ESPHOME_CONFIG_HASH) +#define build_time ((uintptr_t) &ESPHOME_BUILD_TIME) +#endif const char *get_config_hash() { static char hash_str[9]; From 07d784b0bfbae0e2af8f84c841268ff99544a563 Mon Sep 17 00:00:00 2001 From: David Woodhouse Date: Sat, 13 Dec 2025 01:38:07 +0900 Subject: [PATCH 009/111] Pass config hash and build date in as strings via linker symbols This saves the RAM we were using to build it at runtime. --- esphome/core/buildinfo.cpp | 115 ++++++++++++++++++++++--------------- esphome/core/buildinfo.h | 12 +++- esphome/writer.py | 78 ++++++++++++++++++++----- 3 files changed, 143 insertions(+), 62 deletions(-) diff --git a/esphome/core/buildinfo.cpp b/esphome/core/buildinfo.cpp index dfeb073578..4947f7fe7f 100644 --- a/esphome/core/buildinfo.cpp +++ b/esphome/core/buildinfo.cpp @@ -1,61 +1,84 @@ -// Build information using linker-provided symbols -// -// Including build information into the build is fun, because we *don't* -// want the mere fact of changing the build time to *itself* cause a -// rebuild if nothing else had changed. If we do the naïve thing of -// just putting #defines in a header like version.h, we'll cause exactly -// that. -// -// So instead we provide the config hash and build time in a linker -// script, so they get pulled in only if the firmware is already being -// rebuilt. -#include "esphome/core/buildinfo.h" -#include +#include + +// Build information is passed in via symbols defined in a linker script +// as that is the simplest way to include build timestamps without the +// changed timestamp itself causing a rebuild through dependencies, as +// it would if it were in a header file like version.h. +// +// It's passed in in *string* form so that it can go directly into the +// flash as .rodate instead of using precious RAM to build a date string +// from a time_t at runtime. +// +// Determining the target endianness and word size from the generation +// side is problematic, so it emits *four* sets of symbols into the +// linker script, for each of little-endian and big-endiand, 32-bit and +// 64-bit targets. +// +// The LINKERSYM macro gymnastics select the correct symbol for the +// target, named e.g. 'ESPHOME_BUILD_TIME_STR_32LE_0'. + +// Not all targets have (e.g. LibreTiny on BK72xx). +// Use the compiler built-in macros but defensively default to +// little-endian and 32-bit. +#if !defined(__BYTE_ORDER__) || __BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__ +#define BO LE +#else +#define BO BE +#endif + +#if defined(__SIZEOF_POINTER__) && __SIZEOF_POINTER__ == 8 +#define WS 64 +#else +#define WS 32 +#define USE_32BIT +#endif + +// If you have to ask, you don't want to know... +#define LINKERSYM2(name, ws, bo, us, num) ESPHOME_##name##_##ws##bo##us##num +#define LINKERSYM1(name, ws, bo, us, num) LINKERSYM2(name, ws, bo, us, num) +#define LINKERSYM(name, num) LINKERSYM1(name, WS, BO, _, num) -// Linker-provided symbols - declare as extern variables, not functions extern "C" { -extern const char ESPHOME_CONFIG_HASH[]; extern const char ESPHOME_BUILD_TIME[]; +extern const char LINKERSYM(CONFIG_HASH_STR, 0)[]; +extern const char LINKERSYM(CONFIG_HASH_STR, 1)[]; +extern const char LINKERSYM(BUILD_TIME_STR, 0)[]; +extern const char LINKERSYM(BUILD_TIME_STR, 1)[]; +extern const char LINKERSYM(BUILD_TIME_STR, 2)[]; +extern const char LINKERSYM(BUILD_TIME_STR, 3)[]; +extern const char LINKERSYM(BUILD_TIME_STR, 4)[]; +extern const char LINKERSYM(BUILD_TIME_STR, 5)[]; } namespace esphome { namespace buildinfo { -#if __SIZEOF_POINTER__ > 4 -// On 64-bit platforms, reference the linker symbols as uintptr_t from the *data* section to -// avoid issues with pc-relative relocations. Don't let the compiler know they're const or -// it'll optimise away the whole thing and emit a relocation to the ESPHOME_XXX symbols -// directly, which defeats the whole point! -// NOLINTBEGIN(cppcoreguidelines-avoid-non-const-global-variables) -static uintptr_t config_hash_ptr = (uintptr_t) &ESPHOME_CONFIG_HASH; -static uintptr_t build_time_ptr = (uintptr_t) &ESPHOME_BUILD_TIME; -// NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables) -#define config_hash config_hash_ptr -#define build_time build_time_ptr -#else -#define config_hash ((uintptr_t) &ESPHOME_CONFIG_HASH) -#define build_time ((uintptr_t) &ESPHOME_BUILD_TIME) +// An 8-byte string plus terminating NUL. +struct config_hash_struct { + uintptr_t data0; +#ifdef USE_32BIT + uintptr_t data1; #endif + char nul; +} __attribute__((packed)); -const char *get_config_hash() { - static char hash_str[9]; - if (!hash_str[0]) { - snprintf(hash_str, sizeof(hash_str), "%08x", (uint32_t) config_hash); - } - return hash_str; -} +extern const config_hash_struct CONFIG_HASH_STR = {(uintptr_t) &LINKERSYM(CONFIG_HASH_STR, 0), +#ifdef USE_32BIT + (uintptr_t) &LINKERSYM(CONFIG_HASH_STR, 1), +#endif + 0}; -time_t get_build_time() { return (time_t) build_time; } +// A 21-byte string plus terminating NUL, in 24 bytes +extern const uintptr_t BUILD_TIME_STR[] = { + (uintptr_t) &LINKERSYM(BUILD_TIME_STR, 0), (uintptr_t) &LINKERSYM(BUILD_TIME_STR, 1), + (uintptr_t) &LINKERSYM(BUILD_TIME_STR, 2), +#ifdef USE_32BIT + (uintptr_t) &LINKERSYM(BUILD_TIME_STR, 3), (uintptr_t) &LINKERSYM(BUILD_TIME_STR, 4), + (uintptr_t) &LINKERSYM(BUILD_TIME_STR, 5), +#endif +}; -const char *get_build_time_string() { - static char time_str[32]; - if (!time_str[0]) { - time_t bt = get_build_time(); - struct tm *tm_info = localtime(&bt); - strftime(time_str, sizeof(time_str), "%b %d %Y, %H:%M:%S", tm_info); - } - return time_str; -} +extern const uintptr_t BUILD_TIME = (uintptr_t) &ESPHOME_BUILD_TIME; } // namespace buildinfo } // namespace esphome diff --git a/esphome/core/buildinfo.h b/esphome/core/buildinfo.h index 664b7985dd..2217b8bc57 100644 --- a/esphome/core/buildinfo.h +++ b/esphome/core/buildinfo.h @@ -11,9 +11,15 @@ namespace esphome { namespace buildinfo { -const char *get_config_hash(); -time_t get_build_time(); -const char *get_build_time_string(); +extern const char CONFIG_HASH_STR[]; +extern const char BUILD_TIME_STR[]; +extern const uintptr_t BUILD_TIME; + +static inline const char *get_config_hash() { return CONFIG_HASH_STR; } + +static inline time_t get_build_time() { return (time_t) BUILD_TIME; } + +static inline const char *get_build_time_string() { return BUILD_TIME_STR; } } // namespace buildinfo } // namespace esphome diff --git a/esphome/writer.py b/esphome/writer.py index f955b22b79..b6bcfaeab4 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -176,7 +176,6 @@ VERSION_H_FORMAT = """\ """ DEFINES_H_TARGET = "esphome/core/defines.h" VERSION_H_TARGET = "esphome/core/version.h" -BUILDINFO_H_TARGET = "esphome/core/buildinfo.h" ESPHOME_README_TXT = """ THIS DIRECTORY IS AUTO-GENERATED, DO NOT MODIFY @@ -287,24 +286,77 @@ def generate_buildinfo_script(): config_str = yaml_util.dump(CORE.config, show_secrets=True) config_hash = hashlib.md5(config_str.encode("utf-8")).hexdigest()[:8] - config_hash_int = int(config_hash, 16) build_time = int(time.time()) - # Generate linker script content - linker_script = f"""/* Auto-generated buildinfo symbols */ -ESPHOME_CONFIG_HASH = 0x{config_hash_int:08x}; -ESPHOME_BUILD_TIME = {build_time}; -""" + # Generate build time string + build_time_str = time.strftime("%b %d %Y, %H:%M:%S", time.localtime(build_time)) - # Write linker script file - with open(CORE.relative_build_path("buildinfo.ld"), "w", encoding="utf-8") as f: - f.write(linker_script) - - return """#!/usr/bin/env python3 -# Buildinfo linker script already generated + return ( + """#!/usr/bin/env python3 +# Generate buildinfo with target-specific encoding Import("env") +import struct +import subprocess +import tempfile +import os + +# Generate all four variants of both config hash and build time strings +# to be handled by esphome/core/buildinfo.cpp +build_time_str = \"""" + + build_time_str + + """\" +config_hash_str = \"""" + + config_hash + + """\" + +# Generate symbols for all 4 variants: 32LE, 32BE, 64LE, 64BE +all_variants = [] + +for bits, bit_suffix in [(4, "32"), (8, "64")]: + for endian, endian_suffix in [("<", "LE"), (">", "BE")]: + # Config hash string (8 hex chars) + config_padded = config_hash_str + while len(config_padded) % bits != 0: + config_padded += '\\0' + + for i in range(0, len(config_padded), bits): + chunk = config_padded[i:i+bits].encode('utf-8') + if bits == 8: + value = struct.unpack(endian + "Q", chunk)[0] + all_variants.append(f"ESPHOME_CONFIG_HASH_STR_{bit_suffix}{endian_suffix}_{i//bits} = 0x{value:016x};") + else: + value = struct.unpack(endian + "I", chunk)[0] + all_variants.append(f"ESPHOME_CONFIG_HASH_STR_{bit_suffix}{endian_suffix}_{i//bits} = 0x{value:08x};") + + # Build time string + build_padded = build_time_str + '\\0' + while len(build_padded) % bits != 0: + build_padded += '\\0' + + for i in range(0, len(build_padded), bits): + chunk = build_padded[i:i+bits].encode('utf-8') + if bits == 8: + value = struct.unpack(endian + "Q", chunk)[0] + all_variants.append(f"ESPHOME_BUILD_TIME_STR_{bit_suffix}{endian_suffix}_{i//bits} = 0x{value:016x};") + else: + value = struct.unpack(endian + "I", chunk)[0] + all_variants.append(f"ESPHOME_BUILD_TIME_STR_{bit_suffix}{endian_suffix}_{i//bits} = 0x{value:08x};") + +# Write linker script with all variants +linker_script = f'''/* Auto-generated buildinfo symbols */ +ESPHOME_BUILD_TIME = """ + + str(build_time) + + """; +{chr(10).join(all_variants)} +''' + +with open("buildinfo.ld", "w") as f: + f.write(linker_script) + +# Compile and link env.Append(LINKFLAGS=["buildinfo.ld"]) """ + ) def write_cpp(code_s): From b5703523f907fc30bab50789c937e377ea4fbca1 Mon Sep 17 00:00:00 2001 From: David Woodhouse Date: Sat, 13 Dec 2025 01:44:25 +0900 Subject: [PATCH 010/111] nolint for the macros that have to be macros --- esphome/core/buildinfo.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/core/buildinfo.cpp b/esphome/core/buildinfo.cpp index 4947f7fe7f..2913b1f898 100644 --- a/esphome/core/buildinfo.cpp +++ b/esphome/core/buildinfo.cpp @@ -27,9 +27,9 @@ #endif #if defined(__SIZEOF_POINTER__) && __SIZEOF_POINTER__ == 8 -#define WS 64 +#define WS 64 // NOLINT #else -#define WS 32 +#define WS 32 // NOLINT #define USE_32BIT #endif From e728e8ed0cef838e395cc5d1ab79b8d95268bead Mon Sep 17 00:00:00 2001 From: David Woodhouse Date: Sat, 13 Dec 2025 06:16:34 +0900 Subject: [PATCH 011/111] Apply clang-format suggestions to buildinfo.cpp - Use instead of - Rename config_hash_struct to ConfigHashStruct for naming consistency --- esphome/core/buildinfo.cpp | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/esphome/core/buildinfo.cpp b/esphome/core/buildinfo.cpp index 2913b1f898..5a7a4f485c 100644 --- a/esphome/core/buildinfo.cpp +++ b/esphome/core/buildinfo.cpp @@ -1,4 +1,4 @@ -#include +#include // Build information is passed in via symbols defined in a linker script // as that is the simplest way to include build timestamps without the @@ -54,7 +54,7 @@ namespace esphome { namespace buildinfo { // An 8-byte string plus terminating NUL. -struct config_hash_struct { +struct ConfigHashStruct { uintptr_t data0; #ifdef USE_32BIT uintptr_t data1; @@ -62,11 +62,11 @@ struct config_hash_struct { char nul; } __attribute__((packed)); -extern const config_hash_struct CONFIG_HASH_STR = {(uintptr_t) &LINKERSYM(CONFIG_HASH_STR, 0), +extern const ConfigHashStruct CONFIG_HASH_STR = {(uintptr_t) &LINKERSYM(CONFIG_HASH_STR, 0), #ifdef USE_32BIT - (uintptr_t) &LINKERSYM(CONFIG_HASH_STR, 1), + (uintptr_t) &LINKERSYM(CONFIG_HASH_STR, 1), #endif - 0}; + 0}; // A 21-byte string plus terminating NUL, in 24 bytes extern const uintptr_t BUILD_TIME_STR[] = { From 32797fbe005ffb60a62d12dd88df5f79e3602107 Mon Sep 17 00:00:00 2001 From: David Woodhouse Date: Sat, 13 Dec 2025 11:11:59 +0900 Subject: [PATCH 012/111] Generate buildinfo.ld directly, use fnv1a_32bit_hash() Co-authored-by: J. Nick Koston --- esphome/__main__.py | 10 +-- esphome/core/buildinfo.py.script | 2 + esphome/writer.py | 120 +++++++++++++------------------ 3 files changed, 58 insertions(+), 74 deletions(-) create mode 100644 esphome/core/buildinfo.py.script diff --git a/esphome/__main__.py b/esphome/__main__.py index 38efe58b95..2275ab41df 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -530,19 +530,19 @@ def _check_and_emit_buildinfo(): """Check if firmware was rebuilt and emit buildinfo.""" firmware_path = CORE.firmware_bin - buildinfo_script_path = CORE.relative_build_path("buildinfo.ld") + buildinfo_ld_path = CORE.relative_build_path("buildinfo.ld") # Check if both files exist - if not firmware_path.exists() or not buildinfo_script_path.exists(): + if not firmware_path.exists() or not buildinfo_ld_path.exists(): return - # Check if firmware is newer than buildinfo script (indicating a relink occurred) - if firmware_path.stat().st_mtime <= buildinfo_script_path.stat().st_mtime: + # Check if firmware is newer than buildinfo linker script (indicating a relink occurred) + if firmware_path.stat().st_mtime <= buildinfo_ld_path.stat().st_mtime: return # Read buildinfo values from linker script try: - with open(buildinfo_script_path, encoding="utf-8") as f: + with open(buildinfo_ld_path, encoding="utf-8") as f: content = f.read() config_hash_match = re.search( diff --git a/esphome/core/buildinfo.py.script b/esphome/core/buildinfo.py.script new file mode 100644 index 0000000000..9a4da86537 --- /dev/null +++ b/esphome/core/buildinfo.py.script @@ -0,0 +1,2 @@ +Import("env") # noqa: F821 +env.Append(LINKFLAGS=["buildinfo.ld"]) # noqa: F821 diff --git a/esphome/writer.py b/esphome/writer.py index b6bcfaeab4..33c77a7e12 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -1,5 +1,4 @@ from collections.abc import Callable -import hashlib import importlib import logging import os @@ -7,6 +6,7 @@ from pathlib import Path import re import shutil import stat +import struct import time from types import TracebackType @@ -21,6 +21,7 @@ from esphome.const import ( from esphome.core import CORE, EsphomeError from esphome.helpers import ( copy_file_if_changed, + fnv1a_32bit_hash, get_str_env, is_ha_addon, read_file, @@ -248,12 +249,13 @@ def copy_src_tree(): write_file_if_changed( CORE.relative_src_path("esphome", "core", "version.h"), generate_version_h() ) - # Write buildinfo generation script - write_file( - CORE.relative_build_path("generate_buildinfo.py"), generate_buildinfo_script() + # Write buildinfo linker script and copy the PlatformIO script + write_file(CORE.relative_build_path("buildinfo.ld"), generate_buildinfo_ld()) + copy_file_if_changed( + Path(__file__).parent / "core" / "buildinfo.py.script", + CORE.relative_build_path("buildinfo.py"), ) - # Add buildinfo script to platformio extra_scripts - CORE.add_platformio_option("extra_scripts", ["pre:generate_buildinfo.py"]) + CORE.add_platformio_option("extra_scripts", ["pre:buildinfo.py"]) platform = "esphome.components." + CORE.target_platform try: @@ -279,84 +281,64 @@ def generate_version_h(): ) -def generate_buildinfo_script(): +def generate_buildinfo_ld() -> str: + """Generate buildinfo linker script with config hash and build time.""" from esphome import yaml_util # Use the same clean YAML representation as 'esphome config' command config_str = yaml_util.dump(CORE.config, show_secrets=True) + config_hash = fnv1a_32bit_hash(config_str) + config_hash_str = f"{config_hash:08x}" - config_hash = hashlib.md5(config_str.encode("utf-8")).hexdigest()[:8] build_time = int(time.time()) - - # Generate build time string build_time_str = time.strftime("%b %d %Y, %H:%M:%S", time.localtime(build_time)) - return ( - """#!/usr/bin/env python3 -# Generate buildinfo with target-specific encoding -Import("env") -import struct -import subprocess -import tempfile -import os + # Generate symbols for all 4 variants: 32LE, 32BE, 64LE, 64BE + all_variants: list[str] = [] -# Generate all four variants of both config hash and build time strings -# to be handled by esphome/core/buildinfo.cpp -build_time_str = \"""" - + build_time_str - + """\" -config_hash_str = \"""" - + config_hash - + """\" + for bits, bit_suffix in [(4, "32"), (8, "64")]: + for endian, endian_suffix in [("<", "LE"), (">", "BE")]: + # Config hash string (8 hex chars) + config_padded = config_hash_str + while len(config_padded) % bits != 0: + config_padded += "\0" -# Generate symbols for all 4 variants: 32LE, 32BE, 64LE, 64BE -all_variants = [] + for i in range(0, len(config_padded), bits): + chunk = config_padded[i : i + bits].encode("utf-8") + if bits == 8: + value = struct.unpack(endian + "Q", chunk)[0] + all_variants.append( + f"ESPHOME_CONFIG_HASH_STR_{bit_suffix}{endian_suffix}_{i // bits} = 0x{value:016x};" + ) + else: + value = struct.unpack(endian + "I", chunk)[0] + all_variants.append( + f"ESPHOME_CONFIG_HASH_STR_{bit_suffix}{endian_suffix}_{i // bits} = 0x{value:08x};" + ) -for bits, bit_suffix in [(4, "32"), (8, "64")]: - for endian, endian_suffix in [("<", "LE"), (">", "BE")]: - # Config hash string (8 hex chars) - config_padded = config_hash_str - while len(config_padded) % bits != 0: - config_padded += '\\0' + # Build time string (pad to word boundary with NUL) + build_padded = build_time_str + "\0" + while len(build_padded) % bits != 0: + build_padded += "\0" - for i in range(0, len(config_padded), bits): - chunk = config_padded[i:i+bits].encode('utf-8') - if bits == 8: - value = struct.unpack(endian + "Q", chunk)[0] - all_variants.append(f"ESPHOME_CONFIG_HASH_STR_{bit_suffix}{endian_suffix}_{i//bits} = 0x{value:016x};") - else: - value = struct.unpack(endian + "I", chunk)[0] - all_variants.append(f"ESPHOME_CONFIG_HASH_STR_{bit_suffix}{endian_suffix}_{i//bits} = 0x{value:08x};") + for i in range(0, len(build_padded), bits): + chunk = build_padded[i : i + bits].encode("utf-8") + if bits == 8: + value = struct.unpack(endian + "Q", chunk)[0] + all_variants.append( + f"ESPHOME_BUILD_TIME_STR_{bit_suffix}{endian_suffix}_{i // bits} = 0x{value:016x};" + ) + else: + value = struct.unpack(endian + "I", chunk)[0] + all_variants.append( + f"ESPHOME_BUILD_TIME_STR_{bit_suffix}{endian_suffix}_{i // bits} = 0x{value:08x};" + ) - # Build time string - build_padded = build_time_str + '\\0' - while len(build_padded) % bits != 0: - build_padded += '\\0' - - for i in range(0, len(build_padded), bits): - chunk = build_padded[i:i+bits].encode('utf-8') - if bits == 8: - value = struct.unpack(endian + "Q", chunk)[0] - all_variants.append(f"ESPHOME_BUILD_TIME_STR_{bit_suffix}{endian_suffix}_{i//bits} = 0x{value:016x};") - else: - value = struct.unpack(endian + "I", chunk)[0] - all_variants.append(f"ESPHOME_BUILD_TIME_STR_{bit_suffix}{endian_suffix}_{i//bits} = 0x{value:08x};") - -# Write linker script with all variants -linker_script = f'''/* Auto-generated buildinfo symbols */ -ESPHOME_BUILD_TIME = """ - + str(build_time) - + """; + return f"""/* Auto-generated buildinfo symbols */ +ESPHOME_BUILD_TIME = {build_time}; +ESPHOME_CONFIG_HASH = 0x{config_hash:08x}; {chr(10).join(all_variants)} -''' - -with open("buildinfo.ld", "w") as f: - f.write(linker_script) - -# Compile and link -env.Append(LINKFLAGS=["buildinfo.ld"]) """ - ) def write_cpp(code_s): From da96ffb92306ac4cac400837c854faddf0970fba Mon Sep 17 00:00:00 2001 From: David Woodhouse Date: Sat, 13 Dec 2025 11:23:45 +0900 Subject: [PATCH 013/111] Convert buildinfo to C++17 nested namespace syntax --- esphome/core/buildinfo.cpp | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/esphome/core/buildinfo.cpp b/esphome/core/buildinfo.cpp index 5a7a4f485c..03a2ac55ad 100644 --- a/esphome/core/buildinfo.cpp +++ b/esphome/core/buildinfo.cpp @@ -50,8 +50,7 @@ extern const char LINKERSYM(BUILD_TIME_STR, 4)[]; extern const char LINKERSYM(BUILD_TIME_STR, 5)[]; } -namespace esphome { -namespace buildinfo { +namespace esphome::buildinfo { // An 8-byte string plus terminating NUL. struct ConfigHashStruct { @@ -80,5 +79,4 @@ extern const uintptr_t BUILD_TIME_STR[] = { extern const uintptr_t BUILD_TIME = (uintptr_t) &ESPHOME_BUILD_TIME; -} // namespace buildinfo -} // namespace esphome +} // namespace esphome::buildinfo From 94fefb140549ee6ed6eda348b8433330832fd3f8 Mon Sep 17 00:00:00 2001 From: David Woodhouse Date: Sat, 13 Dec 2025 11:24:37 +0900 Subject: [PATCH 014/111] Limit OSError exception catch to file open operation only --- esphome/__main__.py | 44 +++++++++++++++++++++----------------------- 1 file changed, 21 insertions(+), 23 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 2275ab41df..93d222a850 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -544,30 +544,28 @@ def _check_and_emit_buildinfo(): try: with open(buildinfo_ld_path, encoding="utf-8") as f: content = f.read() - - config_hash_match = re.search( - r"ESPHOME_CONFIG_HASH = 0x([0-9a-fA-F]+);", content - ) - build_time_match = re.search(r"ESPHOME_BUILD_TIME = (\d+);", content) - - if not config_hash_match or not build_time_match: - return - - config_hash = config_hash_match.group(1) - build_time = int(build_time_match.group(1)) - - # Emit buildinfo - print("=== ESPHome Build Info ===") - print(f"Config Hash: 0x{config_hash}") - print( - f"Build Time: {build_time} ({time.strftime('%Y-%m-%d %H:%M:%S %z', time.localtime(build_time))})" - ) - print("===========================") - - # TODO: Future commit will create JSON manifest with OTA metadata here - except OSError as e: - _LOGGER.debug("Failed to emit buildinfo: %s", e) + _LOGGER.debug("Failed to read buildinfo: %s", e) + return + + config_hash_match = re.search(r"ESPHOME_CONFIG_HASH = 0x([0-9a-fA-F]+);", content) + build_time_match = re.search(r"ESPHOME_BUILD_TIME = (\d+);", content) + + if not config_hash_match or not build_time_match: + return + + config_hash = config_hash_match.group(1) + build_time = int(build_time_match.group(1)) + + # Emit buildinfo + print("=== ESPHome Build Info ===") + print(f"Config Hash: 0x{config_hash}") + print( + f"Build Time: {build_time} ({time.strftime('%Y-%m-%d %H:%M:%S %z', time.localtime(build_time))})" + ) + print("===========================") + + # TODO: Future commit will create JSON manifest with OTA metadata here def upload_using_esptool( From eda0a391ca78bc429d34fc103dc40a625d8db4f1 Mon Sep 17 00:00:00 2001 From: David Woodhouse Date: Sat, 13 Dec 2025 11:25:47 +0900 Subject: [PATCH 015/111] Extract duplicate string encoding logic into helper function --- esphome/writer.py | 78 ++++++++++++++++++++++++++--------------------- 1 file changed, 43 insertions(+), 35 deletions(-) diff --git a/esphome/writer.py b/esphome/writer.py index 33c77a7e12..4785ad5f72 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -281,6 +281,29 @@ def generate_version_h(): ) +def _encode_string_symbols(text, prefix, bits, bit_suffix, endian, endian_suffix): + """Encode a string as linker symbols for given word size and endianness.""" + symbols = [] + # Pad to word boundary with NUL (build time strings need trailing NUL) + padded = text if prefix == "CONFIG_HASH_STR" else text + "\0" + while len(padded) % bits != 0: + padded += "\0" + + for i in range(0, len(padded), bits): + chunk = padded[i : i + bits].encode("utf-8") + if bits == 8: + value = struct.unpack(endian + "Q", chunk)[0] + symbols.append( + f"ESPHOME_{prefix}_{bit_suffix}{endian_suffix}_{i // bits} = 0x{value:016x};" + ) + else: + value = struct.unpack(endian + "I", chunk)[0] + symbols.append( + f"ESPHOME_{prefix}_{bit_suffix}{endian_suffix}_{i // bits} = 0x{value:08x};" + ) + return symbols + + def generate_buildinfo_ld() -> str: """Generate buildinfo linker script with config hash and build time.""" from esphome import yaml_util @@ -298,41 +321,26 @@ def generate_buildinfo_ld() -> str: for bits, bit_suffix in [(4, "32"), (8, "64")]: for endian, endian_suffix in [("<", "LE"), (">", "BE")]: - # Config hash string (8 hex chars) - config_padded = config_hash_str - while len(config_padded) % bits != 0: - config_padded += "\0" - - for i in range(0, len(config_padded), bits): - chunk = config_padded[i : i + bits].encode("utf-8") - if bits == 8: - value = struct.unpack(endian + "Q", chunk)[0] - all_variants.append( - f"ESPHOME_CONFIG_HASH_STR_{bit_suffix}{endian_suffix}_{i // bits} = 0x{value:016x};" - ) - else: - value = struct.unpack(endian + "I", chunk)[0] - all_variants.append( - f"ESPHOME_CONFIG_HASH_STR_{bit_suffix}{endian_suffix}_{i // bits} = 0x{value:08x};" - ) - - # Build time string (pad to word boundary with NUL) - build_padded = build_time_str + "\0" - while len(build_padded) % bits != 0: - build_padded += "\0" - - for i in range(0, len(build_padded), bits): - chunk = build_padded[i : i + bits].encode("utf-8") - if bits == 8: - value = struct.unpack(endian + "Q", chunk)[0] - all_variants.append( - f"ESPHOME_BUILD_TIME_STR_{bit_suffix}{endian_suffix}_{i // bits} = 0x{value:016x};" - ) - else: - value = struct.unpack(endian + "I", chunk)[0] - all_variants.append( - f"ESPHOME_BUILD_TIME_STR_{bit_suffix}{endian_suffix}_{i // bits} = 0x{value:08x};" - ) + all_variants.extend( + _encode_string_symbols( + config_hash_str, + "CONFIG_HASH_STR", + bits, + bit_suffix, + endian, + endian_suffix, + ) + ) + all_variants.extend( + _encode_string_symbols( + build_time_str, + "BUILD_TIME_STR", + bits, + bit_suffix, + endian, + endian_suffix, + ) + ) return f"""/* Auto-generated buildinfo symbols */ ESPHOME_BUILD_TIME = {build_time}; From d8c52297abc28bb007bfa86065a2675b4c45c854 Mon Sep 17 00:00:00 2001 From: David Woodhouse Date: Sat, 13 Dec 2025 11:54:24 +0900 Subject: [PATCH 016/111] Add type hints to _encode_string_symbols function --- esphome/writer.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/esphome/writer.py b/esphome/writer.py index 4785ad5f72..c94fb8e304 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -281,7 +281,9 @@ def generate_version_h(): ) -def _encode_string_symbols(text, prefix, bits, bit_suffix, endian, endian_suffix): +def _encode_string_symbols( + text: str, prefix: str, bits: int, bit_suffix: str, endian: str, endian_suffix: str +) -> list[str]: """Encode a string as linker symbols for given word size and endianness.""" symbols = [] # Pad to word boundary with NUL (build time strings need trailing NUL) From 12e0d6bdcca53583bfddfafa4cbe2e5a000b8b2f Mon Sep 17 00:00:00 2001 From: David Woodhouse Date: Sat, 13 Dec 2025 11:57:04 +0900 Subject: [PATCH 017/111] Create and use buildinfo.json instead of parsing linker script Co-authored-by: J. Nick Koston --- esphome/__main__.py | 35 ++++++++++++++--------------------- esphome/writer.py | 31 +++++++++++++++++++++++++------ 2 files changed, 39 insertions(+), 27 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 93d222a850..75f06fb9fe 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -528,44 +528,37 @@ def compile_program(args: ArgsProtocol, config: ConfigType) -> int: def _check_and_emit_buildinfo(): """Check if firmware was rebuilt and emit buildinfo.""" + import json firmware_path = CORE.firmware_bin - buildinfo_ld_path = CORE.relative_build_path("buildinfo.ld") + buildinfo_json_path = CORE.relative_build_path("buildinfo.json") # Check if both files exist - if not firmware_path.exists() or not buildinfo_ld_path.exists(): + if not firmware_path.exists() or not buildinfo_json_path.exists(): return - # Check if firmware is newer than buildinfo linker script (indicating a relink occurred) - if firmware_path.stat().st_mtime <= buildinfo_ld_path.stat().st_mtime: + # Check if firmware is newer than buildinfo (indicating a relink occurred) + if firmware_path.stat().st_mtime <= buildinfo_json_path.stat().st_mtime: return - # Read buildinfo values from linker script + # Read buildinfo from JSON try: - with open(buildinfo_ld_path, encoding="utf-8") as f: - content = f.read() - except OSError as e: + with open(buildinfo_json_path, encoding="utf-8") as f: + buildinfo = json.load(f) + except (OSError, json.JSONDecodeError) as e: _LOGGER.debug("Failed to read buildinfo: %s", e) return - config_hash_match = re.search(r"ESPHOME_CONFIG_HASH = 0x([0-9a-fA-F]+);", content) - build_time_match = re.search(r"ESPHOME_BUILD_TIME = (\d+);", content) + config_hash = buildinfo.get("config_hash") + build_time = buildinfo.get("build_time") - if not config_hash_match or not build_time_match: + if config_hash is None or build_time is None: return - config_hash = config_hash_match.group(1) - build_time = int(build_time_match.group(1)) - # Emit buildinfo - print("=== ESPHome Build Info ===") - print(f"Config Hash: 0x{config_hash}") - print( - f"Build Time: {build_time} ({time.strftime('%Y-%m-%d %H:%M:%S %z', time.localtime(build_time))})" + _LOGGER.info( + "Build Info: config_hash=0x%08x build_time=%s", config_hash, build_time ) - print("===========================") - - # TODO: Future commit will create JSON manifest with OTA metadata here def upload_using_esptool( diff --git a/esphome/writer.py b/esphome/writer.py index c94fb8e304..798f921fd2 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -1,5 +1,6 @@ from collections.abc import Callable import importlib +import json import logging import os from pathlib import Path @@ -249,8 +250,16 @@ def copy_src_tree(): write_file_if_changed( CORE.relative_src_path("esphome", "core", "version.h"), generate_version_h() ) - # Write buildinfo linker script and copy the PlatformIO script - write_file(CORE.relative_build_path("buildinfo.ld"), generate_buildinfo_ld()) + # Write buildinfo linker script, JSON metadata, and copy the PlatformIO script + config_hash, build_time, build_time_str = get_buildinfo() + write_file( + CORE.relative_build_path("buildinfo.ld"), + generate_buildinfo_ld(config_hash, build_time, build_time_str), + ) + write_file( + CORE.relative_build_path("buildinfo.json"), + json.dumps({"config_hash": config_hash, "build_time": build_time}), + ) copy_file_if_changed( Path(__file__).parent / "core" / "buildinfo.py.script", CORE.relative_build_path("buildinfo.py"), @@ -306,17 +315,27 @@ def _encode_string_symbols( return symbols -def generate_buildinfo_ld() -> str: - """Generate buildinfo linker script with config hash and build time.""" +def get_buildinfo() -> tuple[int, int, str]: + """Calculate buildinfo values from current config. + + Returns: + Tuple of (config_hash, build_time, build_time_str) + """ from esphome import yaml_util # Use the same clean YAML representation as 'esphome config' command config_str = yaml_util.dump(CORE.config, show_secrets=True) config_hash = fnv1a_32bit_hash(config_str) - config_hash_str = f"{config_hash:08x}" - build_time = int(time.time()) build_time_str = time.strftime("%b %d %Y, %H:%M:%S", time.localtime(build_time)) + return config_hash, build_time, build_time_str + + +def generate_buildinfo_ld( + config_hash: int, build_time: int, build_time_str: str +) -> str: + """Generate buildinfo linker script with config hash and build time.""" + config_hash_str = f"{config_hash:08x}" # Generate symbols for all 4 variants: 32LE, 32BE, 64LE, 64BE all_variants: list[str] = [] From 17db6bee3c0034c2412109621211f8c78ebf8cf0 Mon Sep 17 00:00:00 2001 From: David Woodhouse Date: Sat, 13 Dec 2025 12:03:12 +0900 Subject: [PATCH 018/111] Update esphome/__main__.py Co-authored-by: J. Nick Koston --- esphome/__main__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 75f06fb9fe..b8e1055b70 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -526,7 +526,7 @@ def compile_program(args: ArgsProtocol, config: ConfigType) -> int: return 0 if idedata is not None else 1 -def _check_and_emit_buildinfo(): +def _check_and_emit_buildinfo() -> None: """Check if firmware was rebuilt and emit buildinfo.""" import json From fe798dff817794a1012a6bab1db69828dc6696b0 Mon Sep 17 00:00:00 2001 From: David Woodhouse Date: Sat, 13 Dec 2025 12:03:43 +0900 Subject: [PATCH 019/111] Update esphome/writer.py Co-authored-by: J. Nick Koston --- esphome/writer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/writer.py b/esphome/writer.py index 798f921fd2..96dcbf3860 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -294,7 +294,7 @@ def _encode_string_symbols( text: str, prefix: str, bits: int, bit_suffix: str, endian: str, endian_suffix: str ) -> list[str]: """Encode a string as linker symbols for given word size and endianness.""" - symbols = [] + symbols: list[str] = [] # Pad to word boundary with NUL (build time strings need trailing NUL) padded = text if prefix == "CONFIG_HASH_STR" else text + "\0" while len(padded) % bits != 0: From d016302e36bb0832fd908ad9a3c7fa61017bbaab Mon Sep 17 00:00:00 2001 From: David Woodhouse Date: Sat, 13 Dec 2025 12:05:05 +0900 Subject: [PATCH 020/111] Convert buildinfo.h to C++17 nested namespace syntax --- esphome/core/buildinfo.h | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/esphome/core/buildinfo.h b/esphome/core/buildinfo.h index 2217b8bc57..d86b2af067 100644 --- a/esphome/core/buildinfo.h +++ b/esphome/core/buildinfo.h @@ -8,8 +8,7 @@ // This is kept in its own file so that only files that need build-specific // information have to include it explicitly. -namespace esphome { -namespace buildinfo { +namespace esphome::buildinfo { extern const char CONFIG_HASH_STR[]; extern const char BUILD_TIME_STR[]; @@ -21,5 +20,4 @@ static inline time_t get_build_time() { return (time_t) BUILD_TIME; } static inline const char *get_build_time_string() { return BUILD_TIME_STR; } -} // namespace buildinfo -} // namespace esphome +} // namespace esphome::buildinfo From 15d2d3ff968f66e6fa44a143de00dc77251789ff Mon Sep 17 00:00:00 2001 From: David Woodhouse Date: Sat, 13 Dec 2025 12:13:01 +0900 Subject: [PATCH 021/111] Update esphome/core/buildinfo.cpp Co-authored-by: J. Nick Koston --- esphome/core/buildinfo.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/core/buildinfo.cpp b/esphome/core/buildinfo.cpp index 03a2ac55ad..edfff44b18 100644 --- a/esphome/core/buildinfo.cpp +++ b/esphome/core/buildinfo.cpp @@ -6,7 +6,7 @@ // it would if it were in a header file like version.h. // // It's passed in in *string* form so that it can go directly into the -// flash as .rodate instead of using precious RAM to build a date string +// flash as .rodata instead of using precious RAM to build a date string // from a time_t at runtime. // // Determining the target endianness and word size from the generation From 1543f56f707138c0459c182bad2603b57090fe9e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 13 Dec 2025 08:53:51 -0600 Subject: [PATCH 022/111] simplify approach --- esphome/__main__.py | 30 +++--- esphome/components/api/api_connection.cpp | 6 +- esphome/components/mqtt/mqtt_component.cpp | 6 +- esphome/components/sen5x/sen5x.cpp | 5 +- esphome/components/sgp30/sgp30.cpp | 6 +- esphome/components/sgp4x/sgp4x.cpp | 5 +- .../version/version_text_sensor.cpp | 6 +- esphome/components/wifi/wifi_component.cpp | 3 +- esphome/core/application.cpp | 5 +- esphome/core/application.h | 8 +- esphome/core/build_info.cpp | 24 +++++ esphome/core/build_info.h | 21 +++++ esphome/core/build_info_data.h | 10 ++ esphome/core/buildinfo.cpp | 82 ---------------- esphome/core/buildinfo.h | 23 ----- esphome/core/buildinfo.py.script | 2 - esphome/core/config.py | 1 - esphome/writer.py | 93 ++++--------------- 18 files changed, 119 insertions(+), 217 deletions(-) create mode 100644 esphome/core/build_info.cpp create mode 100644 esphome/core/build_info.h create mode 100644 esphome/core/build_info_data.h delete mode 100644 esphome/core/buildinfo.cpp delete mode 100644 esphome/core/buildinfo.h delete mode 100644 esphome/core/buildinfo.py.script diff --git a/esphome/__main__.py b/esphome/__main__.py index b8e1055b70..5a58abcc78 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -519,43 +519,43 @@ def compile_program(args: ArgsProtocol, config: ConfigType) -> int: if rc != 0: return rc - # Check if firmware was rebuilt and emit buildinfo + create manifest - _check_and_emit_buildinfo() + # 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_buildinfo() -> None: - """Check if firmware was rebuilt and emit buildinfo.""" +def _check_and_emit_build_info() -> None: + """Check if firmware was rebuilt and emit build_info.""" import json firmware_path = CORE.firmware_bin - buildinfo_json_path = CORE.relative_build_path("buildinfo.json") + build_info_json_path = CORE.relative_build_path("build_info.json") # Check if both files exist - if not firmware_path.exists() or not buildinfo_json_path.exists(): + if not firmware_path.exists() or not build_info_json_path.exists(): return - # Check if firmware is newer than buildinfo (indicating a relink occurred) - if firmware_path.stat().st_mtime <= buildinfo_json_path.stat().st_mtime: + # 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 buildinfo from JSON + # Read build_info from JSON try: - with open(buildinfo_json_path, encoding="utf-8") as f: - buildinfo = json.load(f) + 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 buildinfo: %s", e) + _LOGGER.debug("Failed to read build_info: %s", e) return - config_hash = buildinfo.get("config_hash") - build_time = buildinfo.get("build_time") + config_hash = build_info.get("config_hash") + build_time = build_info.get("build_time") if config_hash is None or build_time is None: return - # Emit buildinfo + # Emit build_info _LOGGER.info( "Build Info: config_hash=0x%08x build_time=%s", config_hash, build_time ) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 5186e5afda..992cae028c 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -19,6 +19,7 @@ #endif #include "esphome/components/network/util.h" #include "esphome/core/application.h" +#include "esphome/core/build_info.h" #include "esphome/core/entity_base.h" #include "esphome/core/hal.h" #include "esphome/core/log.h" @@ -1472,7 +1473,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[BUILD_TIME_STR_SIZE]; + 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..f06013fb7e 100644 --- a/esphome/components/mqtt/mqtt_component.cpp +++ b/esphome/components/mqtt/mqtt_component.cpp @@ -2,7 +2,7 @@ #ifdef USE_MQTT -#include "esphome/core/application.h" +#include "esphome/core/build_info.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/version.h" @@ -154,7 +154,9 @@ 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() + ")"; + char build_time_str[BUILD_TIME_STR_SIZE]; + get_build_time_string(build_time_str); + device_info[MQTT_DEVICE_SW_VERSION] = str_sprintf(ESPHOME_VERSION " (%s)", build_time_str); 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..01e7b76101 100644 --- a/esphome/components/sen5x/sen5x.cpp +++ b/esphome/components/sen5x/sen5x.cpp @@ -1,4 +1,5 @@ #include "sen5x.h" +#include "esphome/core/build_info.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 build time 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)); + uint32_t hash = static_cast(get_build_time()) ^ 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..5174281ad5 100644 --- a/esphome/components/sgp30/sgp30.cpp +++ b/esphome/components/sgp30/sgp30.cpp @@ -1,5 +1,5 @@ #include "sgp30.h" -#include "esphome/core/application.h" +#include "esphome/core/build_info.h" #include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -72,10 +72,10 @@ void SGP30Component::setup() { return; } - // Hash with compilation time and serial number + // Hash with build time 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_)); + uint32_t hash = static_cast(get_build_time()) ^ static_cast(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..ec54e6d8f6 100644 --- a/esphome/components/sgp4x/sgp4x.cpp +++ b/esphome/components/sgp4x/sgp4x.cpp @@ -1,4 +1,5 @@ #include "sgp4x.h" +#include "esphome/core/build_info.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 build time 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_)); + uint32_t hash = static_cast(get_build_time()) ^ static_cast(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..1b9bde7f81 100644 --- a/esphome/components/version/version_text_sensor.cpp +++ b/esphome/components/version/version_text_sensor.cpp @@ -1,6 +1,6 @@ #include "version_text_sensor.h" +#include "esphome/core/build_info.h" #include "esphome/core/log.h" -#include "esphome/core/application.h" #include "esphome/core/version.h" #include "esphome/core/helpers.h" @@ -13,7 +13,9 @@ 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())); + char build_time_str[BUILD_TIME_STR_SIZE]; + get_build_time_string(build_time_str); + this->publish_state(str_sprintf(ESPHOME_VERSION " %s", build_time_str)); } } float VersionTextSensor::get_setup_priority() const { return setup_priority::DATA; } diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index d46916bfd9..fbc5fefb00 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 "esphome/core/build_info.h" #ifdef USE_ESP32 #if (ESP_IDF_VERSION_MAJOR >= 5 && ESP_IDF_VERSION_MINOR >= 1) @@ -360,7 +361,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() ? static_cast(get_build_time()) : 88491487UL; this->pref_ = global_preferences->make_preference(hash, true); #ifdef USE_WIFI_FAST_CONNECT diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index a85d671a07..2fee5a6fe8 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -1,4 +1,5 @@ #include "esphome/core/application.h" +#include "esphome/core/build_info.h" #include "esphome/core/log.h" #include "esphome/core/version.h" #include "esphome/core/hal.h" @@ -191,7 +192,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[BUILD_TIME_STR_SIZE]; + 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 diff --git a/esphome/core/application.h b/esphome/core/application.h index 8e2035b7c5..09cd7e5bbc 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -101,7 +101,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 +121,6 @@ class Application { this->friendly_name_ = friendly_name; } this->comment_ = comment; - this->compilation_time_ = compilation_time; } #ifdef USE_DEVICES @@ -261,10 +260,6 @@ 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_); } - /// 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 +473,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.cpp b/esphome/core/build_info.cpp new file mode 100644 index 0000000000..85d07ce020 --- /dev/null +++ b/esphome/core/build_info.cpp @@ -0,0 +1,24 @@ +#include "build_info.h" +#include "build_info_data.h" +#include + +#ifdef USE_ESP8266 +#include +#endif + +namespace esphome { + +uint32_t get_config_hash() { return ESPHOME_CONFIG_HASH; } + +time_t get_build_time() { return ESPHOME_BUILD_TIME; } + +void get_build_time_string(std::span buffer) { +#ifdef USE_ESP8266 + strncpy_P(buffer.data(), ESPHOME_BUILD_TIME_STR, buffer.size()); +#else + strncpy(buffer.data(), ESPHOME_BUILD_TIME_STR, buffer.size()); +#endif + buffer[buffer.size() - 1] = '\0'; +} + +} // namespace esphome diff --git a/esphome/core/build_info.h b/esphome/core/build_info.h new file mode 100644 index 0000000000..6a427ea511 --- /dev/null +++ b/esphome/core/build_info.h @@ -0,0 +1,21 @@ +#pragma once +#include +#include +#include + +namespace esphome { + +/// Size of buffer required for build time string (including null terminator) +static constexpr size_t BUILD_TIME_STR_SIZE = 24; + +/// Get the config hash as a 32-bit integer +uint32_t get_config_hash(); + +/// Get the build time as a Unix timestamp +time_t get_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); + +} // namespace esphome diff --git a/esphome/core/build_info_data.h b/esphome/core/build_info_data.h new file mode 100644 index 0000000000..e81645a3da --- /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 +#define ESPHOME_BUILD_TIME 1700000000 +static const char ESPHOME_BUILD_TIME_STR[] = "Jan 01 2024, 00:00:00"; diff --git a/esphome/core/buildinfo.cpp b/esphome/core/buildinfo.cpp deleted file mode 100644 index edfff44b18..0000000000 --- a/esphome/core/buildinfo.cpp +++ /dev/null @@ -1,82 +0,0 @@ -#include - -// Build information is passed in via symbols defined in a linker script -// as that is the simplest way to include build timestamps without the -// changed timestamp itself causing a rebuild through dependencies, as -// it would if it were in a header file like version.h. -// -// It's passed in in *string* form so that it can go directly into the -// flash as .rodata instead of using precious RAM to build a date string -// from a time_t at runtime. -// -// Determining the target endianness and word size from the generation -// side is problematic, so it emits *four* sets of symbols into the -// linker script, for each of little-endian and big-endiand, 32-bit and -// 64-bit targets. -// -// The LINKERSYM macro gymnastics select the correct symbol for the -// target, named e.g. 'ESPHOME_BUILD_TIME_STR_32LE_0'. - -// Not all targets have (e.g. LibreTiny on BK72xx). -// Use the compiler built-in macros but defensively default to -// little-endian and 32-bit. -#if !defined(__BYTE_ORDER__) || __BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__ -#define BO LE -#else -#define BO BE -#endif - -#if defined(__SIZEOF_POINTER__) && __SIZEOF_POINTER__ == 8 -#define WS 64 // NOLINT -#else -#define WS 32 // NOLINT -#define USE_32BIT -#endif - -// If you have to ask, you don't want to know... -#define LINKERSYM2(name, ws, bo, us, num) ESPHOME_##name##_##ws##bo##us##num -#define LINKERSYM1(name, ws, bo, us, num) LINKERSYM2(name, ws, bo, us, num) -#define LINKERSYM(name, num) LINKERSYM1(name, WS, BO, _, num) - -extern "C" { -extern const char ESPHOME_BUILD_TIME[]; -extern const char LINKERSYM(CONFIG_HASH_STR, 0)[]; -extern const char LINKERSYM(CONFIG_HASH_STR, 1)[]; -extern const char LINKERSYM(BUILD_TIME_STR, 0)[]; -extern const char LINKERSYM(BUILD_TIME_STR, 1)[]; -extern const char LINKERSYM(BUILD_TIME_STR, 2)[]; -extern const char LINKERSYM(BUILD_TIME_STR, 3)[]; -extern const char LINKERSYM(BUILD_TIME_STR, 4)[]; -extern const char LINKERSYM(BUILD_TIME_STR, 5)[]; -} - -namespace esphome::buildinfo { - -// An 8-byte string plus terminating NUL. -struct ConfigHashStruct { - uintptr_t data0; -#ifdef USE_32BIT - uintptr_t data1; -#endif - char nul; -} __attribute__((packed)); - -extern const ConfigHashStruct CONFIG_HASH_STR = {(uintptr_t) &LINKERSYM(CONFIG_HASH_STR, 0), -#ifdef USE_32BIT - (uintptr_t) &LINKERSYM(CONFIG_HASH_STR, 1), -#endif - 0}; - -// A 21-byte string plus terminating NUL, in 24 bytes -extern const uintptr_t BUILD_TIME_STR[] = { - (uintptr_t) &LINKERSYM(BUILD_TIME_STR, 0), (uintptr_t) &LINKERSYM(BUILD_TIME_STR, 1), - (uintptr_t) &LINKERSYM(BUILD_TIME_STR, 2), -#ifdef USE_32BIT - (uintptr_t) &LINKERSYM(BUILD_TIME_STR, 3), (uintptr_t) &LINKERSYM(BUILD_TIME_STR, 4), - (uintptr_t) &LINKERSYM(BUILD_TIME_STR, 5), -#endif -}; - -extern const uintptr_t BUILD_TIME = (uintptr_t) &ESPHOME_BUILD_TIME; - -} // namespace esphome::buildinfo diff --git a/esphome/core/buildinfo.h b/esphome/core/buildinfo.h deleted file mode 100644 index d86b2af067..0000000000 --- a/esphome/core/buildinfo.h +++ /dev/null @@ -1,23 +0,0 @@ -#pragma once -#include -#include - -// Build information functions that provide config hash and build time. -// The actual values are provided by linker-defined symbols to avoid -// unnecessary rebuilds when only the build time changes. -// This is kept in its own file so that only files that need build-specific -// information have to include it explicitly. - -namespace esphome::buildinfo { - -extern const char CONFIG_HASH_STR[]; -extern const char BUILD_TIME_STR[]; -extern const uintptr_t BUILD_TIME; - -static inline const char *get_config_hash() { return CONFIG_HASH_STR; } - -static inline time_t get_build_time() { return (time_t) BUILD_TIME; } - -static inline const char *get_build_time_string() { return BUILD_TIME_STR; } - -} // namespace esphome::buildinfo diff --git a/esphome/core/buildinfo.py.script b/esphome/core/buildinfo.py.script deleted file mode 100644 index 9a4da86537..0000000000 --- a/esphome/core/buildinfo.py.script +++ /dev/null @@ -1,2 +0,0 @@ -Import("env") # noqa: F821 -env.Append(LINKFLAGS=["buildinfo.ld"]) # noqa: F821 diff --git a/esphome/core/config.py b/esphome/core/config.py index f7a5305144..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("esphome::buildinfo::get_build_time_string()"), config[CONF_NAME_ADD_MAC_SUFFIX], ) ) diff --git a/esphome/writer.py b/esphome/writer.py index 96dcbf3860..5960dc0af5 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -7,7 +7,6 @@ from pathlib import Path import re import shutil import stat -import struct import time from types import TracebackType @@ -250,21 +249,16 @@ def copy_src_tree(): write_file_if_changed( CORE.relative_src_path("esphome", "core", "version.h"), generate_version_h() ) - # Write buildinfo linker script, JSON metadata, and copy the PlatformIO script - config_hash, build_time, build_time_str = get_buildinfo() - write_file( - CORE.relative_build_path("buildinfo.ld"), - generate_buildinfo_ld(config_hash, build_time, build_time_str), + # Write build_info header and JSON metadata + config_hash, build_time, build_time_str = get_build_info() + write_file_if_changed( + CORE.relative_src_path("esphome", "core", "build_info_data.h"), + generate_build_info_data_h(config_hash, build_time, build_time_str), ) write_file( - CORE.relative_build_path("buildinfo.json"), + CORE.relative_build_path("build_info.json"), json.dumps({"config_hash": config_hash, "build_time": build_time}), ) - copy_file_if_changed( - Path(__file__).parent / "core" / "buildinfo.py.script", - CORE.relative_build_path("buildinfo.py"), - ) - CORE.add_platformio_option("extra_scripts", ["pre:buildinfo.py"]) platform = "esphome.components." + CORE.target_platform try: @@ -290,33 +284,8 @@ def generate_version_h(): ) -def _encode_string_symbols( - text: str, prefix: str, bits: int, bit_suffix: str, endian: str, endian_suffix: str -) -> list[str]: - """Encode a string as linker symbols for given word size and endianness.""" - symbols: list[str] = [] - # Pad to word boundary with NUL (build time strings need trailing NUL) - padded = text if prefix == "CONFIG_HASH_STR" else text + "\0" - while len(padded) % bits != 0: - padded += "\0" - - for i in range(0, len(padded), bits): - chunk = padded[i : i + bits].encode("utf-8") - if bits == 8: - value = struct.unpack(endian + "Q", chunk)[0] - symbols.append( - f"ESPHOME_{prefix}_{bit_suffix}{endian_suffix}_{i // bits} = 0x{value:016x};" - ) - else: - value = struct.unpack(endian + "I", chunk)[0] - symbols.append( - f"ESPHOME_{prefix}_{bit_suffix}{endian_suffix}_{i // bits} = 0x{value:08x};" - ) - return symbols - - -def get_buildinfo() -> tuple[int, int, str]: - """Calculate buildinfo values from current config. +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) @@ -331,42 +300,20 @@ def get_buildinfo() -> tuple[int, int, str]: return config_hash, build_time, build_time_str -def generate_buildinfo_ld( +def generate_build_info_data_h( config_hash: int, build_time: int, build_time_str: str ) -> str: - """Generate buildinfo linker script with config hash and build time.""" - config_hash_str = f"{config_hash:08x}" - - # Generate symbols for all 4 variants: 32LE, 32BE, 64LE, 64BE - all_variants: list[str] = [] - - for bits, bit_suffix in [(4, "32"), (8, "64")]: - for endian, endian_suffix in [("<", "LE"), (">", "BE")]: - all_variants.extend( - _encode_string_symbols( - config_hash_str, - "CONFIG_HASH_STR", - bits, - bit_suffix, - endian, - endian_suffix, - ) - ) - all_variants.extend( - _encode_string_symbols( - build_time_str, - "BUILD_TIME_STR", - bits, - bit_suffix, - endian, - endian_suffix, - ) - ) - - return f"""/* Auto-generated buildinfo symbols */ -ESPHOME_BUILD_TIME = {build_time}; -ESPHOME_CONFIG_HASH = 0x{config_hash:08x}; -{chr(10).join(all_variants)} + """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 +#define ESPHOME_BUILD_TIME {build_time} +#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 """ From 67937aeda43f892ebc1245195d2a87a963e52aaa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 13 Dec 2025 08:55:26 -0600 Subject: [PATCH 023/111] tests --- tests/integration/fixtures/build_info.yaml | 5 +++ tests/integration/test_build_info.py | 51 ++++++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 tests/integration/fixtures/build_info.yaml create mode 100644 tests/integration/test_build_info.py diff --git a/tests/integration/fixtures/build_info.yaml b/tests/integration/fixtures/build_info.yaml new file mode 100644 index 0000000000..cb3c437b0c --- /dev/null +++ b/tests/integration/fixtures/build_info.yaml @@ -0,0 +1,5 @@ +esphome: + name: build-info-test +host: +api: +logger: diff --git a/tests/integration/test_build_info.py b/tests/integration/test_build_info.py new file mode 100644 index 0000000000..7a829935fd --- /dev/null +++ b/tests/integration/test_build_info.py @@ -0,0 +1,51 @@ +"""Integration test for build_info values.""" + +from __future__ import annotations + +import time + +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 is present and reasonable + # The format is "Mon DD YYYY, HH:MM:SS" (e.g., "Dec 13 2024, 15:30:00") + compilation_time = device_info.compilation_time + assert compilation_time is not None + assert len(compilation_time) > 0, "compilation_time should not be empty" + + # Verify it looks like a date string (contains comma and colon) + assert "," in compilation_time, ( + f"compilation_time should contain comma: {compilation_time}" + ) + assert ":" in compilation_time, ( + f"compilation_time should contain colon: {compilation_time}" + ) + + # Verify it contains a year (4 digits) + import re + + year_match = re.search(r"\b(20\d{2})\b", compilation_time) + assert year_match is not None, ( + f"compilation_time should contain a year: {compilation_time}" + ) + + # Verify the year is reasonable (within last year to next year) + year = int(year_match.group(1)) + current_year = time.localtime().tm_year + assert current_year - 1 <= year <= current_year + 1, ( + f"Year {year} should be close to current year {current_year}" + ) From 6d91f1cd7761f095429fc7d5820a4999cb347246 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 13 Dec 2025 08:56:12 -0600 Subject: [PATCH 024/111] tests --- tests/integration/test_build_info.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/tests/integration/test_build_info.py b/tests/integration/test_build_info.py index 7a829935fd..4c3844fd38 100644 --- a/tests/integration/test_build_info.py +++ b/tests/integration/test_build_info.py @@ -2,6 +2,7 @@ from __future__ import annotations +import re import time import pytest @@ -35,17 +36,14 @@ async def test_build_info( f"compilation_time should contain colon: {compilation_time}" ) - # Verify it contains a year (4 digits) - import re - + # Verify it contains a year (4 digits) that is >= current year year_match = re.search(r"\b(20\d{2})\b", compilation_time) assert year_match is not None, ( f"compilation_time should contain a year: {compilation_time}" ) - # Verify the year is reasonable (within last year to next year) year = int(year_match.group(1)) current_year = time.localtime().tm_year - assert current_year - 1 <= year <= current_year + 1, ( - f"Year {year} should be close to current year {current_year}" + assert year >= current_year, ( + f"Year {year} should be >= current year {current_year}" ) From dce5face4e302b92a2a9925c87fd8f3794518505 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 13 Dec 2025 09:01:39 -0600 Subject: [PATCH 025/111] simplify more --- esphome/__main__.py | 5 ++-- esphome/components/api/api_connection.cpp | 5 ++-- esphome/components/mqtt/mqtt_component.cpp | 6 ++--- esphome/components/sen5x/sen5x.cpp | 4 ++-- esphome/components/sgp30/sgp30.cpp | 4 ++-- esphome/components/sgp4x/sgp4x.cpp | 4 ++-- .../version/version_text_sensor.cpp | 6 ++--- esphome/components/wifi/wifi_component.cpp | 4 ++-- esphome/core/application.cpp | 24 ++++++++++++++++--- esphome/core/application.h | 15 ++++++++++++ esphome/core/build_info.cpp | 24 ------------------- esphome/core/build_info.h | 21 ---------------- esphome/core/build_info_data.h | 4 ++-- esphome/writer.py | 4 ++-- 14 files changed, 59 insertions(+), 71 deletions(-) delete mode 100644 esphome/core/build_info.cpp delete mode 100644 esphome/core/build_info.h diff --git a/esphome/__main__.py b/esphome/__main__.py index 5a58abcc78..942f533038 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -555,9 +555,10 @@ def _check_and_emit_build_info() -> None: if config_hash is None or build_time is None: return - # Emit build_info + # Emit build_info with human-readable time + build_time_str = time.strftime("%b %d %Y, %H:%M:%S", time.localtime(build_time)) _LOGGER.info( - "Build Info: config_hash=0x%08x build_time=%s", config_hash, build_time + "Build Info: config_hash=0x%08x build_time=%s", config_hash, build_time_str ) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 992cae028c..c7afd72bf3 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -19,7 +19,6 @@ #endif #include "esphome/components/network/util.h" #include "esphome/core/application.h" -#include "esphome/core/build_info.h" #include "esphome/core/entity_base.h" #include "esphome/core/hal.h" #include "esphome/core/log.h" @@ -1474,8 +1473,8 @@ bool APIConnection::send_device_info_response(const DeviceInfoRequest &msg) { resp.set_esphome_version(ESPHOME_VERSION_REF); // Stack buffer for build time string - char build_time_str[BUILD_TIME_STR_SIZE]; - get_build_time_string(build_time_str); + char build_time_str[App.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 diff --git a/esphome/components/mqtt/mqtt_component.cpp b/esphome/components/mqtt/mqtt_component.cpp index f06013fb7e..6f5cf5edad 100644 --- a/esphome/components/mqtt/mqtt_component.cpp +++ b/esphome/components/mqtt/mqtt_component.cpp @@ -2,7 +2,7 @@ #ifdef USE_MQTT -#include "esphome/core/build_info.h" +#include "esphome/core/application.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/version.h" @@ -154,8 +154,8 @@ bool MQTTComponent::send_discovery_() { device_info[MQTT_DEVICE_MANUFACTURER] = model == nullptr ? ESPHOME_PROJECT_NAME : std::string(ESPHOME_PROJECT_NAME, model - ESPHOME_PROJECT_NAME); #else - char build_time_str[BUILD_TIME_STR_SIZE]; - get_build_time_string(build_time_str); + char build_time_str[App.BUILD_TIME_STR_SIZE]; + App.get_build_time_string(build_time_str); device_info[MQTT_DEVICE_SW_VERSION] = str_sprintf(ESPHOME_VERSION " (%s)", build_time_str); device_info[MQTT_DEVICE_MODEL] = ESPHOME_BOARD; #if defined(USE_ESP8266) || defined(USE_ESP32) diff --git a/esphome/components/sen5x/sen5x.cpp b/esphome/components/sen5x/sen5x.cpp index 01e7b76101..82145d0b22 100644 --- a/esphome/components/sen5x/sen5x.cpp +++ b/esphome/components/sen5x/sen5x.cpp @@ -1,5 +1,5 @@ #include "sen5x.h" -#include "esphome/core/build_info.h" +#include "esphome/core/application.h" #include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -158,7 +158,7 @@ void SEN5XComponent::setup() { // Hash with build time 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 = static_cast(get_build_time()) ^ combined_serial; + uint32_t hash = static_cast(App.get_build_time()) ^ 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 5174281ad5..0645d2faf9 100644 --- a/esphome/components/sgp30/sgp30.cpp +++ b/esphome/components/sgp30/sgp30.cpp @@ -1,5 +1,5 @@ #include "sgp30.h" -#include "esphome/core/build_info.h" +#include "esphome/core/application.h" #include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -75,7 +75,7 @@ void SGP30Component::setup() { // Hash with build time 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 = static_cast(get_build_time()) ^ static_cast(this->serial_number_); + uint32_t hash = static_cast(App.get_build_time()) ^ static_cast(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 ec54e6d8f6..fa984ba418 100644 --- a/esphome/components/sgp4x/sgp4x.cpp +++ b/esphome/components/sgp4x/sgp4x.cpp @@ -1,5 +1,5 @@ #include "sgp4x.h" -#include "esphome/core/build_info.h" +#include "esphome/core/application.h" #include "esphome/core/log.h" #include "esphome/core/hal.h" #include @@ -60,7 +60,7 @@ void SGP4xComponent::setup() { // Hash with build time 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 = static_cast(get_build_time()) ^ static_cast(this->serial_number_); + uint32_t hash = static_cast(App.get_build_time()) ^ static_cast(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 1b9bde7f81..7cec62a10a 100644 --- a/esphome/components/version/version_text_sensor.cpp +++ b/esphome/components/version/version_text_sensor.cpp @@ -1,5 +1,5 @@ #include "version_text_sensor.h" -#include "esphome/core/build_info.h" +#include "esphome/core/application.h" #include "esphome/core/log.h" #include "esphome/core/version.h" #include "esphome/core/helpers.h" @@ -13,8 +13,8 @@ void VersionTextSensor::setup() { if (this->hide_timestamp_) { this->publish_state(ESPHOME_VERSION); } else { - char build_time_str[BUILD_TIME_STR_SIZE]; - get_build_time_string(build_time_str); + char build_time_str[App.BUILD_TIME_STR_SIZE]; + App.get_build_time_string(build_time_str); this->publish_state(str_sprintf(ESPHOME_VERSION " %s", build_time_str)); } } diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index fbc5fefb00..9eaa5fcfb5 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -2,7 +2,7 @@ #ifdef USE_WIFI #include #include -#include "esphome/core/build_info.h" +#include "esphome/core/application.h" #ifdef USE_ESP32 #if (ESP_IDF_VERSION_MAJOR >= 5 && ESP_IDF_VERSION_MINOR >= 1) @@ -361,7 +361,7 @@ void WiFiComponent::start() { get_mac_address_pretty_into_buffer(mac_s)); this->last_connected_ = millis(); - uint32_t hash = this->has_sta() ? static_cast(get_build_time()) : 88491487UL; + uint32_t hash = this->has_sta() ? static_cast(App.get_build_time()) : 88491487UL; this->pref_ = global_preferences->make_preference(hash, true); #ifdef USE_WIFI_FAST_CONNECT diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 2fee5a6fe8..376ea3c200 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -1,6 +1,11 @@ #include "esphome/core/application.h" -#include "esphome/core/build_info.h" +#include "esphome/core/build_info_data.h" #include "esphome/core/log.h" +#include + +#ifdef USE_ESP8266 +#include +#endif #include "esphome/core/version.h" #include "esphome/core/hal.h" #include @@ -192,8 +197,8 @@ void Application::loop() { if (this->dump_config_at_ < this->components_.size()) { if (this->dump_config_at_ == 0) { - char build_time_str[BUILD_TIME_STR_SIZE]; - get_build_time_string(build_time_str); + 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); @@ -714,4 +719,17 @@ void Application::wake_loop_threadsafe() { } #endif // defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) +uint32_t Application::get_config_hash() { return ESPHOME_CONFIG_HASH; } + +time_t Application::get_build_time() { return ESPHOME_BUILD_TIME; } + +void Application::get_build_time_string(std::span buffer) { +#ifdef USE_ESP8266 + strncpy_P(buffer.data(), ESPHOME_BUILD_TIME_STR, buffer.size()); +#else + strncpy(buffer.data(), ESPHOME_BUILD_TIME_STR, buffer.size()); +#endif + buffer[buffer.size() - 1] = '\0'; +} + } // namespace esphome diff --git a/esphome/core/application.h b/esphome/core/application.h index 09cd7e5bbc..93f409b6cb 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -1,7 +1,9 @@ #pragma once #include +#include #include +#include #include #include #include "esphome/core/component.h" @@ -260,6 +262,19 @@ class Application { bool is_name_add_mac_suffix_enabled() const { return this->name_add_mac_suffix_; } + /// Size of buffer required for build time string (including null terminator) + static constexpr size_t BUILD_TIME_STR_SIZE = 24; + + /// Get the config hash as a 32-bit integer + uint32_t get_config_hash(); + + /// Get the build time as a Unix timestamp + time_t get_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 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_; } diff --git a/esphome/core/build_info.cpp b/esphome/core/build_info.cpp deleted file mode 100644 index 85d07ce020..0000000000 --- a/esphome/core/build_info.cpp +++ /dev/null @@ -1,24 +0,0 @@ -#include "build_info.h" -#include "build_info_data.h" -#include - -#ifdef USE_ESP8266 -#include -#endif - -namespace esphome { - -uint32_t get_config_hash() { return ESPHOME_CONFIG_HASH; } - -time_t get_build_time() { return ESPHOME_BUILD_TIME; } - -void get_build_time_string(std::span buffer) { -#ifdef USE_ESP8266 - strncpy_P(buffer.data(), ESPHOME_BUILD_TIME_STR, buffer.size()); -#else - strncpy(buffer.data(), ESPHOME_BUILD_TIME_STR, buffer.size()); -#endif - buffer[buffer.size() - 1] = '\0'; -} - -} // namespace esphome diff --git a/esphome/core/build_info.h b/esphome/core/build_info.h deleted file mode 100644 index 6a427ea511..0000000000 --- a/esphome/core/build_info.h +++ /dev/null @@ -1,21 +0,0 @@ -#pragma once -#include -#include -#include - -namespace esphome { - -/// Size of buffer required for build time string (including null terminator) -static constexpr size_t BUILD_TIME_STR_SIZE = 24; - -/// Get the config hash as a 32-bit integer -uint32_t get_config_hash(); - -/// Get the build time as a Unix timestamp -time_t get_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); - -} // namespace esphome diff --git a/esphome/core/build_info_data.h b/esphome/core/build_info_data.h index e81645a3da..81c24e0fb5 100644 --- a/esphome/core/build_info_data.h +++ b/esphome/core/build_info_data.h @@ -5,6 +5,6 @@ // // This file is only used by static analyzers and IDEs. -#define ESPHOME_CONFIG_HASH 0x12345678U -#define ESPHOME_BUILD_TIME 1700000000 +#define ESPHOME_CONFIG_HASH 0x12345678U // NOLINT +#define ESPHOME_BUILD_TIME 1700000000 // NOLINT static const char ESPHOME_BUILD_TIME_STR[] = "Jan 01 2024, 00:00:00"; diff --git a/esphome/writer.py b/esphome/writer.py index 5960dc0af5..4a87c576c3 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -306,8 +306,8 @@ def generate_build_info_data_h( """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 -#define ESPHOME_BUILD_TIME {build_time} +#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}"; From d31be6ed9da3fcf673961eb0075f6c645964b333 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 13 Dec 2025 09:10:40 -0600 Subject: [PATCH 026/111] check version as well --- esphome/writer.py | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/esphome/writer.py b/esphome/writer.py index 4a87c576c3..3ceadd3f10 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -257,7 +257,14 @@ def copy_src_tree(): ) write_file( CORE.relative_build_path("build_info.json"), - json.dumps({"config_hash": config_hash, "build_time": build_time}), + json.dumps( + { + "config_hash": config_hash, + "build_time": build_time, + "build_time_str": build_time_str, + "esphome_version": __version__, + } + ), ) platform = "esphome.components." + CORE.target_platform @@ -287,6 +294,9 @@ def generate_version_h(): def get_build_info() -> tuple[int, int, str]: """Calculate build_info values from current config. + Only updates build_time when config_hash or ESPHome version changes. + This prevents unnecessary preference invalidation on simple recompiles. + Returns: Tuple of (config_hash, build_time, build_time_str) """ @@ -295,6 +305,26 @@ def get_build_info() -> tuple[int, int, str]: # Use the same clean YAML representation as 'esphome config' command config_str = yaml_util.dump(CORE.config, show_secrets=True) config_hash = fnv1a_32bit_hash(config_str) + + # Check if config_hash and version are unchanged - keep existing build_time + build_info_path = CORE.relative_build_path("build_info.json") + if build_info_path.exists(): + try: + existing = json.loads(build_info_path.read_text(encoding="utf-8")) + if ( + existing.get("config_hash") == config_hash + and existing.get("esphome_version") == __version__ + ): + # Config and version unchanged - keep existing build_time + return ( + config_hash, + existing["build_time"], + existing["build_time_str"], + ) + except (json.JSONDecodeError, KeyError, OSError): + pass + + # Config or version changed - use current time build_time = int(time.time()) build_time_str = time.strftime("%b %d %Y, %H:%M:%S", time.localtime(build_time)) return config_hash, build_time, build_time_str From 0c7c1d3c57e8648178bb04ce3400ea371a2af390 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 13 Dec 2025 09:13:28 -0600 Subject: [PATCH 027/111] check version as well --- esphome/writer.py | 33 +++++++++++++++++---------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/esphome/writer.py b/esphome/writer.py index 3ceadd3f10..11e10d2fd4 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -308,23 +308,24 @@ def get_build_info() -> tuple[int, int, str]: # Check if config_hash and version are unchanged - keep existing build_time build_info_path = CORE.relative_build_path("build_info.json") - if build_info_path.exists(): - try: - existing = json.loads(build_info_path.read_text(encoding="utf-8")) - if ( - existing.get("config_hash") == config_hash - and existing.get("esphome_version") == __version__ - ): - # Config and version unchanged - keep existing build_time - return ( - config_hash, - existing["build_time"], - existing["build_time_str"], - ) - except (json.JSONDecodeError, KeyError, OSError): - pass + existing: dict[str, int | str] | None = None + try: + existing = json.loads(build_info_path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, KeyError, OSError, FileNotFoundError): + pass + else: + if ( + existing.get("config_hash") == config_hash + and existing.get("esphome_version") == __version__ + ): + # Config and version unchanged - keep existing build_time + return ( + config_hash, + existing["build_time"], + existing["build_time_str"], + ) - # Config or version changed - use current time + # Config or version changed, or no existing build_info - use current time build_time = int(time.time()) build_time_str = time.strftime("%b %d %Y, %H:%M:%S", time.localtime(build_time)) return config_hash, build_time, build_time_str From 4b937b5228f9524ad112d07744d17250d61e5a8a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 13 Dec 2025 09:21:53 -0600 Subject: [PATCH 028/111] some coverage --- tests/unit_tests/test_main.py | 197 ++++++++++++++++++++++++++++++++++ 1 file changed, 197 insertions(+) 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 From 4bf810fcd1418799d9c95f21ec60d2b6603063bb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 13 Dec 2025 09:25:21 -0600 Subject: [PATCH 029/111] a bit of future proofing to avoid many dumps if it gets reused --- esphome/core/__init__.py | 17 +++++++++++++++++ esphome/writer.py | 7 +------ 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 721cd5787d..5ce968f20d 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,20 @@ 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. + """ + 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) + 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/writer.py b/esphome/writer.py index 11e10d2fd4..629329cbb1 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -21,7 +21,6 @@ from esphome.const import ( from esphome.core import CORE, EsphomeError from esphome.helpers import ( copy_file_if_changed, - fnv1a_32bit_hash, get_str_env, is_ha_addon, read_file, @@ -300,11 +299,7 @@ def get_build_info() -> tuple[int, int, str]: Returns: Tuple of (config_hash, build_time, build_time_str) """ - from esphome import yaml_util - - # Use the same clean YAML representation as 'esphome config' command - config_str = yaml_util.dump(CORE.config, show_secrets=True) - config_hash = fnv1a_32bit_hash(config_str) + config_hash = CORE.config_hash # Check if config_hash and version are unchanged - keep existing build_time build_info_path = CORE.relative_build_path("build_info.json") From cf8708b8882369a47a430601e6fc68ba441ddc02 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 13 Dec 2025 09:28:52 -0600 Subject: [PATCH 030/111] writer coverage --- tests/unit_tests/test_writer.py | 178 ++++++++++++++++++++++++++++++++ 1 file changed, 178 insertions(+) diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index 9fa60c06ec..b39f59d291 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -1,6 +1,8 @@ """Test writer module functionality.""" from collections.abc import Callable +from datetime import datetime +import json import os from pathlib import Path import stat @@ -20,6 +22,7 @@ from esphome.writer import ( clean_all, clean_build, clean_cmake_cache, + get_build_info, storage_should_clean, update_storage_json, write_cpp, @@ -1165,3 +1168,178 @@ 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) > 10 # e.g., "Dec 13 2025, 12:00:00" + + +@patch("esphome.writer.CORE") +def test_get_build_info_config_unchanged_version_unchanged( + mock_core: MagicMock, + tmp_path: Path, +) -> None: + """Test get_build_info keeps existing build_time when config and version unchanged.""" + 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 = "Nov 14 2023, 22:13:20" + 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 + 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": "Nov 14 2023, 22:13:20", + "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": "Nov 14 2023, 22:13:20", + "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 "%b %d %Y, %H:%M:%S" (e.g., "Dec 13 2025, 14:30:45") + parsed = datetime.strptime(build_time_str, "%b %d %Y, %H:%M:%S") + assert parsed.year >= 2024 From b4a54f2df14dbd686383531ca4543f60727a11f7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 13 Dec 2025 09:40:26 -0600 Subject: [PATCH 031/111] sort so config hash does not change --- esphome/core/__init__.py | 3 ++- esphome/yaml_util.py | 12 ++++++++++-- tests/unit_tests/test_yaml_util.py | 28 ++++++++++++++++++++++++++++ 3 files changed, 40 insertions(+), 3 deletions(-) diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 5ce968f20d..ad9844a3bf 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -693,12 +693,13 @@ class EsphomeCore: """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) + 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 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/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 de500450d9dd0ba1a8869eb97bac13bb98023b09 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 13 Dec 2025 09:45:17 -0600 Subject: [PATCH 032/111] coverage for hash order change --- tests/unit_tests/core/test_config.py | 71 ++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) 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 From bb35ed5f5316a4c80de12ad3ec6b953903adecf1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 13 Dec 2025 09:54:07 -0600 Subject: [PATCH 033/111] tidy --- esphome/components/api/api_connection.cpp | 2 +- esphome/components/wifi/wifi_component.cpp | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index c7afd72bf3..85f4566f3c 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1473,7 +1473,7 @@ bool APIConnection::send_device_info_response(const DeviceInfoRequest &msg) { resp.set_esphome_version(ESPHOME_VERSION_REF); // Stack buffer for build time string - char build_time_str[App.BUILD_TIME_STR_SIZE]; + 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)); diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 9eaa5fcfb5..4f68a33461 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -24,7 +24,6 @@ #include "lwip/dns.h" #include "lwip/err.h" -#include "esphome/core/application.h" #include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" From 0539c5d4d248d1fd0fa148e2b51ba1333bd4a4dd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 13 Dec 2025 10:02:54 -0600 Subject: [PATCH 034/111] cover --- tests/unit_tests/test_writer.py | 129 ++++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index b39f59d291..cd29efc850 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -1,6 +1,8 @@ """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 @@ -22,6 +24,8 @@ 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, @@ -1343,3 +1347,128 @@ def test_get_build_info_build_time_str_format( # Verify the format matches "%b %d %Y, %H:%M:%S" (e.g., "Dec 13 2025, 14:30:45") parsed = datetime.strptime(build_time_str, "%b %d %Y, %H:%M:%S") 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 = "Nov 14 2023, 22:13:20" + + 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[] = "Nov 14 2023, 22:13:20"' 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" From ba0f559856b064bceee79dfd295c7c930c4e06d0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 13 Dec 2025 10:10:24 -0600 Subject: [PATCH 035/111] better cover --- tests/integration/fixtures/build_info.yaml | 26 ++++++ tests/integration/test_build_info.py | 97 ++++++++++++++++++---- 2 files changed, 108 insertions(+), 15 deletions(-) diff --git a/tests/integration/fixtures/build_info.yaml b/tests/integration/fixtures/build_info.yaml index cb3c437b0c..5d6101543a 100644 --- a/tests/integration/fixtures/build_info.yaml +++ b/tests/integration/fixtures/build_info.yaml @@ -3,3 +3,29 @@ esphome: 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 index 4c3844fd38..3c3a89b3ab 100644 --- a/tests/integration/test_build_info.py +++ b/tests/integration/test_build_info.py @@ -2,9 +2,12 @@ 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 @@ -22,28 +25,92 @@ async def test_build_info( assert device_info is not None assert device_info.name == "build-info-test" - # Verify compilation_time is present and reasonable + # Verify compilation_time from device_info is present and parseable # The format is "Mon DD YYYY, HH:MM:SS" (e.g., "Dec 13 2024, 15:30:00") compilation_time = device_info.compilation_time assert compilation_time is not None - assert len(compilation_time) > 0, "compilation_time should not be empty" - # Verify it looks like a date string (contains comma and colon) - assert "," in compilation_time, ( - f"compilation_time should contain comma: {compilation_time}" + # Parse the date string - raises ValueError if format is wrong + parsed = datetime.strptime(compilation_time, "%b %d %Y, %H:%M:%S") + 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 ) - assert ":" in compilation_time, ( - f"compilation_time should contain colon: {compilation_time}" + 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 ) - # Verify it contains a year (4 digits) that is >= current year - year_match = re.search(r"\b(20\d{2})\b", compilation_time) - assert year_match is not None, ( - f"compilation_time should contain a year: {compilation_time}" + 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}" ) - year = int(year_match.group(1)) - current_year = time.localtime().tm_year - assert year >= current_year, ( - f"Year {year} should be >= current year {current_year}" + # 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 same format as compilation_time + build_time_str = build_time_str_state.state + parsed_build_time = datetime.strptime(build_time_str, "%b %d %Y, %H:%M:%S") + 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("%b %d %Y, %H:%M:%S", 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}'" ) From 6198618044b202649af43a6a280a61237771f123 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 13 Dec 2025 10:32:25 -0600 Subject: [PATCH 036/111] Update esphome/components/sen5x/sen5x.cpp Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/components/sen5x/sen5x.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/sen5x/sen5x.cpp b/esphome/components/sen5x/sen5x.cpp index 82145d0b22..f2b99dc9bf 100644 --- a/esphome/components/sen5x/sen5x.cpp +++ b/esphome/components/sen5x/sen5x.cpp @@ -157,7 +157,7 @@ void SEN5XComponent::setup() { encode_uint24(this->serial_number_[0], this->serial_number_[1], this->serial_number_[2]); // Hash with build time 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 + // Serial numbers are unique to each sensor, so multiple sensors can be used without conflict uint32_t hash = static_cast(App.get_build_time()) ^ combined_serial; this->pref_ = global_preferences->make_preference(hash, true); From 184ac0c1e7620bb99dd11f07dfd348b09e06b78c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 13 Dec 2025 10:32:33 -0600 Subject: [PATCH 037/111] Update esphome/components/sgp30/sgp30.cpp Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/components/sgp30/sgp30.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/sgp30/sgp30.cpp b/esphome/components/sgp30/sgp30.cpp index 0645d2faf9..d70c47a938 100644 --- a/esphome/components/sgp30/sgp30.cpp +++ b/esphome/components/sgp30/sgp30.cpp @@ -74,7 +74,7 @@ void SGP30Component::setup() { // Hash with build time 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 + // Serial numbers are unique to each sensor, so multiple sensors can be used without conflict uint32_t hash = static_cast(App.get_build_time()) ^ static_cast(this->serial_number_); this->pref_ = global_preferences->make_preference(hash, true); From 8299656375a4ef3e292336d8aba6126af1d82782 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 13 Dec 2025 10:32:39 -0600 Subject: [PATCH 038/111] Update esphome/components/sgp4x/sgp4x.cpp Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/components/sgp4x/sgp4x.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/sgp4x/sgp4x.cpp b/esphome/components/sgp4x/sgp4x.cpp index fa984ba418..4854b9ec43 100644 --- a/esphome/components/sgp4x/sgp4x.cpp +++ b/esphome/components/sgp4x/sgp4x.cpp @@ -59,7 +59,7 @@ void SGP4xComponent::setup() { if (this->store_baseline_) { // Hash with build time 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 + // Serial numbers are unique to each sensor, so multiple sensors can be used without conflict uint32_t hash = static_cast(App.get_build_time()) ^ static_cast(this->serial_number_); this->pref_ = global_preferences->make_preference(hash, true); From 16107ad788462c5a20164f6204b895cef1615450 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 13 Dec 2025 10:34:09 -0600 Subject: [PATCH 039/111] bot comments --- esphome/components/sgp30/sgp30.cpp | 3 ++- esphome/components/sgp4x/sgp4x.cpp | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/esphome/components/sgp30/sgp30.cpp b/esphome/components/sgp30/sgp30.cpp index d70c47a938..1d23e3eab0 100644 --- a/esphome/components/sgp30/sgp30.cpp +++ b/esphome/components/sgp30/sgp30.cpp @@ -75,7 +75,8 @@ void SGP30Component::setup() { // Hash with build time and serial number // This ensures the baseline storage is cleared after OTA // Serial numbers are unique to each sensor, so multiple sensors can be used without conflict - uint32_t hash = static_cast(App.get_build_time()) ^ static_cast(this->serial_number_); + uint32_t hash = static_cast(App.get_build_time()) ^ static_cast(this->serial_number_) ^ + static_cast(this->serial_number_ >> 32); 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 4854b9ec43..6f21a10877 100644 --- a/esphome/components/sgp4x/sgp4x.cpp +++ b/esphome/components/sgp4x/sgp4x.cpp @@ -60,7 +60,8 @@ void SGP4xComponent::setup() { // Hash with build time and serial number // This ensures the baseline storage is cleared after OTA // Serial numbers are unique to each sensor, so multiple sensors can be used without conflict - uint32_t hash = static_cast(App.get_build_time()) ^ static_cast(this->serial_number_); + uint32_t hash = static_cast(App.get_build_time()) ^ static_cast(this->serial_number_) ^ + static_cast(this->serial_number_ >> 32); this->pref_ = global_preferences->make_preference(hash, true); if (this->pref_.load(&this->voc_baselines_storage_)) { From 841d9664d3a405849e6353c5d7db2a0f7368be44 Mon Sep 17 00:00:00 2001 From: David Woodhouse Date: Sun, 14 Dec 2025 08:51:59 +0900 Subject: [PATCH 040/111] Fix build system to relink when source files change - Make copy_file_if_changed() return bool indicating if file was copied - Track sources_changed in copy_src_tree() to detect when source files change - Only update build_info timestamp when sources/config/version change - Exclude generated files (build_info_data.h) from sources_changed tracking - Add build_info_data.h to ignore_targets to prevent copying from resources - Track changes to generated headers (defines.h, esphome.h, version.h) - Check for config_hash or version changes to trigger rebuild - Pretty-print build_info.json with indentation and trailing newline - Update mock_copy_file_if_changed to return True by default This fixes the issue where changing a source file would recompile the .o file but not relink the final program executable. --- esphome/helpers.py | 11 +++- esphome/writer.py | 114 ++++++++++++++++++++--------------- tests/unit_tests/conftest.py | 1 + 3 files changed, 75 insertions(+), 51 deletions(-) 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 629329cbb1..300839cc4a 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -176,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 @@ -209,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) + # 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: @@ -226,45 +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" ) - # Write build_info header and JSON metadata + build_info_json_path = CORE.relative_build_path("build_info.json") config_hash, build_time, build_time_str = get_build_info() - write_file_if_changed( - CORE.relative_src_path("esphome", "core", "build_info_data.h"), - generate_build_info_data_h(config_hash, build_time, build_time_str), - ) - write_file( - CORE.relative_build_path("build_info.json"), - json.dumps( - { - "config_hash": config_hash, - "build_time": build_time, - "build_time_str": build_time_str, - "esphome_version": __version__, - } - ), - ) + + # 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: @@ -293,34 +335,10 @@ def generate_version_h(): def get_build_info() -> tuple[int, int, str]: """Calculate build_info values from current config. - Only updates build_time when config_hash or ESPHome version changes. - This prevents unnecessary preference invalidation on simple recompiles. - Returns: Tuple of (config_hash, build_time, build_time_str) """ config_hash = CORE.config_hash - - # Check if config_hash and version are unchanged - keep existing build_time - build_info_path = CORE.relative_build_path("build_info.json") - existing: dict[str, int | str] | None = None - try: - existing = json.loads(build_info_path.read_text(encoding="utf-8")) - except (json.JSONDecodeError, KeyError, OSError, FileNotFoundError): - pass - else: - if ( - existing.get("config_hash") == config_hash - and existing.get("esphome_version") == __version__ - ): - # Config and version unchanged - keep existing build_time - return ( - config_hash, - existing["build_time"], - existing["build_time_str"], - ) - - # Config or version changed, or no existing build_info - use current time build_time = int(time.time()) build_time_str = time.strftime("%b %d %Y, %H:%M:%S", time.localtime(build_time)) return config_hash, build_time, 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 From 4bde4dbdc83cbcb3b330996b6bd3adedf3d686e8 Mon Sep 17 00:00:00 2001 From: David Woodhouse Date: Sun, 14 Dec 2025 08:55:25 +0900 Subject: [PATCH 041/111] Fix KeyError when build_info_data.h not in source_files_copy Use pop(t, None) instead of pop(t) to handle case where build_info_data.h might not be in the component resources. --- esphome/writer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/writer.py b/esphome/writer.py index 300839cc4a..a2cc0dc446 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -214,7 +214,7 @@ def copy_src_tree(): 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")} From 1ebfd5b4ebe967c75b3db03d1072eba53ed9024d Mon Sep 17 00:00:00 2001 From: David Woodhouse Date: Sun, 14 Dec 2025 09:07:00 +0900 Subject: [PATCH 042/111] Update test for new get_build_info behaviour get_build_info() now always returns current time instead of preserving the existing build_time. The timestamp preservation logic is now handled in copy_src_tree() based on sources_changed flag. --- tests/unit_tests/test_writer.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index cd29efc850..d74919dc3e 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -1198,11 +1198,11 @@ def test_get_build_info_new_build( @patch("esphome.writer.CORE") -def test_get_build_info_config_unchanged_version_unchanged( +def test_get_build_info_always_returns_current_time( mock_core: MagicMock, tmp_path: Path, ) -> None: - """Test get_build_info keeps existing build_time when config and version unchanged.""" + """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 @@ -1225,8 +1225,10 @@ def test_get_build_info_config_unchanged_version_unchanged( config_hash, build_time, build_time_str = get_build_info() assert config_hash == 0x12345678 - assert build_time == existing_build_time - assert build_time_str == existing_build_time_str + # 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") From 0f22b23d9a70ae7f786fbe06717989515f30cbf2 Mon Sep 17 00:00:00 2001 From: David Woodhouse Date: Mon, 15 Dec 2025 17:10:12 +0900 Subject: [PATCH 043/111] clang-tidy CI fix ...but this is weird. Why are we copying into a local buffer at all instead of just using the original string? --- esphome/components/version/version_text_sensor.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/version/version_text_sensor.cpp b/esphome/components/version/version_text_sensor.cpp index 7cec62a10a..88774b4b3a 100644 --- a/esphome/components/version/version_text_sensor.cpp +++ b/esphome/components/version/version_text_sensor.cpp @@ -13,7 +13,7 @@ void VersionTextSensor::setup() { if (this->hide_timestamp_) { this->publish_state(ESPHOME_VERSION); } else { - char build_time_str[App.BUILD_TIME_STR_SIZE]; + char build_time_str[esphome::Application::BUILD_TIME_STR_SIZE]; App.get_build_time_string(build_time_str); this->publish_state(str_sprintf(ESPHOME_VERSION " %s", build_time_str)); } From 5eab42441e9bcdbed8952a506d047ca362fb3144 Mon Sep 17 00:00:00 2001 From: David Woodhouse Date: Mon, 15 Dec 2025 09:06:49 +0000 Subject: [PATCH 044/111] Fix dummy_main.cpp to match new pre_setup signature Remove compilation timestamp argument as build time is now handled through build_info_data.h instead of being passed to pre_setup(). --- tests/dummy_main.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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); From d83fd263b09f4ff118f44084d89491ef21b34267 Mon Sep 17 00:00:00 2001 From: David Woodhouse Date: Mon, 15 Dec 2025 15:44:12 +0000 Subject: [PATCH 045/111] Don't rebuild build_time_str It's already in the JSON now --- esphome/__main__.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 942f533038..119ab957a3 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -550,15 +550,14 @@ def _check_and_emit_build_info() -> None: return config_hash = build_info.get("config_hash") - build_time = build_info.get("build_time") + build_time_str = build_info.get("build_time_str") - if config_hash is None or build_time is None: + if config_hash is None or build_time_str is None: return # Emit build_info with human-readable time - build_time_str = time.strftime("%b %d %Y, %H:%M:%S", time.localtime(build_time)) _LOGGER.info( - "Build Info: config_hash=0x%08x build_time=%s", config_hash, build_time_str + "Build Info: config_hash=0x%08x build_time_str=%s", config_hash, build_time_str ) From c451fbd697242841a62af5eed17eb2a6a8b47e04 Mon Sep 17 00:00:00 2001 From: David Woodhouse Date: Mon, 15 Dec 2025 16:24:54 +0000 Subject: [PATCH 046/111] Postpone breaking changes for another PR I think we need to put a little more thought into whether we really want the build time in each of these, or whether it should be just the config_hash (perhaps extended with version, and in some cases the component's own serial number or other identifier). So put the old compilation_time_ and its access methods back, so this PR only adds the *new* fields. We can migrate users over and then remove the compilation_time_ separately. --- esphome/components/api/api_connection.cpp | 5 +---- esphome/components/mqtt/mqtt_component.cpp | 4 +--- esphome/components/sen5x/sen5x.cpp | 7 +++---- esphome/components/sgp30/sgp30.cpp | 7 +++---- esphome/components/sgp4x/sgp4x.cpp | 8 +++----- esphome/components/version/version_text_sensor.cpp | 6 ++---- esphome/components/wifi/wifi_component.cpp | 4 ++-- esphome/core/application.h | 9 ++++++++- esphome/core/config.py | 1 + tests/dummy_main.cpp | 2 +- 10 files changed, 25 insertions(+), 28 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 85f4566f3c..5186e5afda 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1472,10 +1472,7 @@ bool APIConnection::send_device_info_response(const DeviceInfoRequest &msg) { resp.set_esphome_version(ESPHOME_VERSION_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)); + resp.set_compilation_time(App.get_compilation_time_ref()); // 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 6f5cf5edad..5d2bedae79 100644 --- a/esphome/components/mqtt/mqtt_component.cpp +++ b/esphome/components/mqtt/mqtt_component.cpp @@ -154,9 +154,7 @@ bool MQTTComponent::send_discovery_() { device_info[MQTT_DEVICE_MANUFACTURER] = model == nullptr ? ESPHOME_PROJECT_NAME : std::string(ESPHOME_PROJECT_NAME, model - ESPHOME_PROJECT_NAME); #else - char build_time_str[App.BUILD_TIME_STR_SIZE]; - App.get_build_time_string(build_time_str); - device_info[MQTT_DEVICE_SW_VERSION] = str_sprintf(ESPHOME_VERSION " (%s)", build_time_str); + device_info[MQTT_DEVICE_SW_VERSION] = ESPHOME_VERSION " (" + App.get_compilation_time_ref() + ")"; 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 f2b99dc9bf..ffb9e2bc02 100644 --- a/esphome/components/sen5x/sen5x.cpp +++ b/esphome/components/sen5x/sen5x.cpp @@ -1,5 +1,4 @@ #include "sen5x.h" -#include "esphome/core/application.h" #include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -155,10 +154,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 build time and serial number + // Hash with compilation time and serial number // This ensures the baseline storage is cleared after OTA - // Serial numbers are unique to each sensor, so multiple sensors can be used without conflict - uint32_t hash = static_cast(App.get_build_time()) ^ combined_serial; + // 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)); 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 1d23e3eab0..fa548ce94e 100644 --- a/esphome/components/sgp30/sgp30.cpp +++ b/esphome/components/sgp30/sgp30.cpp @@ -72,11 +72,10 @@ void SGP30Component::setup() { return; } - // Hash with build time and serial number + // Hash with compilation time and serial number // This ensures the baseline storage is cleared after OTA - // Serial numbers are unique to each sensor, so multiple sensors can be used without conflict - uint32_t hash = static_cast(App.get_build_time()) ^ static_cast(this->serial_number_) ^ - static_cast(this->serial_number_ >> 32); + // 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_)); 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 6f21a10877..a0c957d608 100644 --- a/esphome/components/sgp4x/sgp4x.cpp +++ b/esphome/components/sgp4x/sgp4x.cpp @@ -1,5 +1,4 @@ #include "sgp4x.h" -#include "esphome/core/application.h" #include "esphome/core/log.h" #include "esphome/core/hal.h" #include @@ -57,11 +56,10 @@ void SGP4xComponent::setup() { ESP_LOGD(TAG, "Version 0x%0X", featureset); if (this->store_baseline_) { - // Hash with build time and serial number + // Hash with compilation time and serial number // This ensures the baseline storage is cleared after OTA - // Serial numbers are unique to each sensor, so multiple sensors can be used without conflict - uint32_t hash = static_cast(App.get_build_time()) ^ static_cast(this->serial_number_) ^ - static_cast(this->serial_number_ >> 32); + // 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_)); 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 88774b4b3a..78d0fb501b 100644 --- a/esphome/components/version/version_text_sensor.cpp +++ b/esphome/components/version/version_text_sensor.cpp @@ -1,6 +1,6 @@ #include "version_text_sensor.h" -#include "esphome/core/application.h" #include "esphome/core/log.h" +#include "esphome/core/application.h" #include "esphome/core/version.h" #include "esphome/core/helpers.h" @@ -13,9 +13,7 @@ void VersionTextSensor::setup() { if (this->hide_timestamp_) { this->publish_state(ESPHOME_VERSION); } else { - char build_time_str[esphome::Application::BUILD_TIME_STR_SIZE]; - App.get_build_time_string(build_time_str); - this->publish_state(str_sprintf(ESPHOME_VERSION " %s", build_time_str)); + this->publish_state(str_sprintf(ESPHOME_VERSION " %s", App.get_compilation_time_ref().c_str())); } } float VersionTextSensor::get_setup_priority() const { return setup_priority::DATA; } diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index c1335dd697..a5e8c4a59d 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -2,7 +2,6 @@ #ifdef USE_WIFI #include #include -#include "esphome/core/application.h" #ifdef USE_ESP32 #if (ESP_IDF_VERSION_MAJOR >= 5 && ESP_IDF_VERSION_MINOR >= 1) @@ -24,6 +23,7 @@ #include "lwip/dns.h" #include "lwip/err.h" +#include "esphome/core/application.h" #include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -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() ? static_cast(App.get_build_time()) : 88491487UL; + uint32_t hash = this->has_sta() ? fnv1_hash(App.get_compilation_time_ref().c_str()) : 88491487UL; this->pref_ = global_preferences->make_preference(hash, true); #ifdef USE_WIFI_FAST_CONNECT diff --git a/esphome/core/application.h b/esphome/core/application.h index 93f409b6cb..e16041c070 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -103,7 +103,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, - bool name_add_mac_suffix) { + const char *compilation_time, bool name_add_mac_suffix) { arch_init(); this->name_add_mac_suffix_ = name_add_mac_suffix; if (name_add_mac_suffix) { @@ -123,6 +123,7 @@ class Application { this->friendly_name_ = friendly_name; } this->comment_ = comment; + this->compilation_time_ = compilation_time; } #ifdef USE_DEVICES @@ -262,6 +263,11 @@ class Application { bool is_name_add_mac_suffix_enabled() const { return this->name_add_mac_suffix_; } + /// deprecated: use get_build_time() or get_build_time_string() instead. + 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 = 24; @@ -488,6 +494,7 @@ 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/config.py b/esphome/core/config.py index 97157b6f92..3adaf7eb9e 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -501,6 +501,7 @@ 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/tests/dummy_main.cpp b/tests/dummy_main.cpp index 5849f4eb95..afd393c095 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", false); + App.pre_setup("livingroom", "LivingRoom", "comment", __DATE__ ", " __TIME__, false); auto *log = new logger::Logger(115200, 512); // NOLINT log->pre_setup(); log->set_uart_selection(logger::UART_SELECTION_UART0); From 09e9b58eb64a14a62bd03b4777d943af3064f747 Mon Sep 17 00:00:00 2001 From: David Woodhouse Date: Mon, 15 Dec 2025 16:38:44 +0000 Subject: [PATCH 047/111] Change build_time_str format to ISO 8601 with timezone Use YYYY-MM-DD HH:MM:SS +ZZZZ format instead of the locale-dependent '%b %d %Y, %H:%M:%S' format. This provides: - Unambiguous date format (YYYY-MM-DD) - Timezone information - Locale-independent formatting - Better sortability and parseability Example: "2025-12-15 16:30:27 +0000" instead of "Dec 15 2025, 16:30:27" Tests validate the format using strptime with '%Y-%m-%d %H:%M:%S %z'. --- esphome/writer.py | 2 +- tests/integration/test_build_info.py | 11 ++++++----- tests/unit_tests/test_writer.py | 17 +++++++++-------- 3 files changed, 16 insertions(+), 14 deletions(-) diff --git a/esphome/writer.py b/esphome/writer.py index a2cc0dc446..183fff8730 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -340,7 +340,7 @@ def get_build_info() -> tuple[int, int, str]: """ config_hash = CORE.config_hash build_time = int(time.time()) - build_time_str = time.strftime("%b %d %Y, %H:%M:%S", time.localtime(build_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 diff --git a/tests/integration/test_build_info.py b/tests/integration/test_build_info.py index 3c3a89b3ab..c1c655c664 100644 --- a/tests/integration/test_build_info.py +++ b/tests/integration/test_build_info.py @@ -30,8 +30,8 @@ async def test_build_info( compilation_time = device_info.compilation_time assert compilation_time is not None - # Parse the date string - raises ValueError if format is wrong - parsed = datetime.strptime(compilation_time, "%b %d %Y, %H:%M:%S") + # 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 @@ -98,13 +98,14 @@ async def test_build_info( f"build_time {build_time} should be within the last hour" ) - # Validate build_time_str matches the same format as compilation_time + # Validate build_time_str matches the new ISO format build_time_str = build_time_str_state.state - parsed_build_time = datetime.strptime(build_time_str, "%b %d %Y, %H:%M:%S") + # 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("%b %d %Y, %H:%M:%S", time.localtime(build_time)) + 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}'" ) diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index d74919dc3e..858101026e 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -1194,7 +1194,7 @@ def test_get_build_info_new_build( assert build_time > 0 assert isinstance(build_time_str, str) # Verify build_time_str format matches expected pattern - assert len(build_time_str) > 10 # e.g., "Dec 13 2025, 12:00:00" + assert len(build_time_str) >= 19 # e.g., "2025-12-15 16:27:44 +0000" @patch("esphome.writer.CORE") @@ -1209,7 +1209,7 @@ def test_get_build_info_always_returns_current_time( # Create existing build_info.json with matching config_hash and version existing_build_time = 1700000000 - existing_build_time_str = "Nov 14 2023, 22:13:20" + existing_build_time_str = "2023-11-14 22:13:20 +0000" build_info_path.write_text( json.dumps( { @@ -1248,7 +1248,7 @@ def test_get_build_info_config_changed( { "config_hash": 0x12345678, # Different "build_time": existing_build_time, - "build_time_str": "Nov 14 2023, 22:13:20", + "build_time_str": "2023-11-14 22:13:20 +0000", "esphome_version": "2025.1.0-dev", } ) @@ -1279,7 +1279,7 @@ def test_get_build_info_version_changed( { "config_hash": 0x12345678, "build_time": existing_build_time, - "build_time_str": "Nov 14 2023, 22:13:20", + "build_time_str": "2023-11-14 22:13:20 +0000", "esphome_version": "2024.12.0", # Old version } ) @@ -1346,8 +1346,9 @@ def test_get_build_info_build_time_str_format( config_hash, build_time, build_time_str = get_build_info() - # Verify the format matches "%b %d %Y, %H:%M:%S" (e.g., "Dec 13 2025, 14:30:45") - parsed = datetime.strptime(build_time_str, "%b %d %Y, %H:%M:%S") + # 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 @@ -1355,14 +1356,14 @@ 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 = "Nov 14 2023, 22:13:20" + 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[] = "Nov 14 2023, 22:13:20"' 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: From 87a125f303d1e46b98d1c0e0e039a6061a6a8b65 Mon Sep 17 00:00:00 2001 From: David Woodhouse Date: Mon, 15 Dec 2025 16:45:15 +0000 Subject: [PATCH 048/111] Add test coverage for build_info.json change detection Add tests to cover: - Detection of config_hash changes in existing build_info.json - Detection of esphome_version changes in existing build_info.json - Handling of invalid/corrupted build_info.json files These tests cover the exception handling and change detection logic in copy_src_tree() that checks the existing build_info.json. --- tests/unit_tests/test_writer.py | 168 ++++++++++++++++++++++++++++++++ 1 file changed, 168 insertions(+) diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index 858101026e..e5849f1f68 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -1475,3 +1475,171 @@ def test_copy_src_tree_writes_build_info_files( 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 From 0a63c50e1eb1756fa944cade22780bb0d8ddd01e Mon Sep 17 00:00:00 2001 From: David Woodhouse Date: Mon, 15 Dec 2025 16:59:51 +0000 Subject: [PATCH 049/111] Add test for build_info regeneration behaviour Test verifies that: - When source files change, build_info is regenerated with new timestamp - When no files change, build_info is preserved with same timestamp The test runs copy_src_tree() three times in the same environment: 1. Initial run creates build_info 2. Second run with no changes preserves the timestamp 3. Third run with changed source file regenerates with new timestamp --- tests/unit_tests/test_writer.py | 122 ++++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index e5849f1f68..c8c6ea6523 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -1643,3 +1643,125 @@ def test_copy_src_tree_handles_invalid_build_info_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 From fd32139d896ffdcb3a7cc42bac20ebcc58911bcf Mon Sep 17 00:00:00 2001 From: David Woodhouse Date: Mon, 15 Dec 2025 17:44:16 +0000 Subject: [PATCH 050/111] Use new ISO format for compilation_time in API DeviceInfo Change the API's DeviceInfo response to use the new ISO 8601 format with timezone for compilation_time field by calling get_build_time_string() instead of get_compilation_time_ref(). Update the placeholder build_info_data.h to match the new format. Update integration test to expect the new format for compilation_time. --- esphome/components/api/api_connection.cpp | 5 ++++- esphome/core/build_info_data.h | 2 +- tests/integration/test_build_info.py | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) 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/core/build_info_data.h b/esphome/core/build_info_data.h index 81c24e0fb5..5e424ffaca 100644 --- a/esphome/core/build_info_data.h +++ b/esphome/core/build_info_data.h @@ -7,4 +7,4 @@ #define ESPHOME_CONFIG_HASH 0x12345678U // NOLINT #define ESPHOME_BUILD_TIME 1700000000 // NOLINT -static const char ESPHOME_BUILD_TIME_STR[] = "Jan 01 2024, 00:00:00"; +static const char ESPHOME_BUILD_TIME_STR[] = "2024-01-01 00:00:00 +0000"; diff --git a/tests/integration/test_build_info.py b/tests/integration/test_build_info.py index c1c655c664..7079594471 100644 --- a/tests/integration/test_build_info.py +++ b/tests/integration/test_build_info.py @@ -26,7 +26,7 @@ async def test_build_info( assert device_info.name == "build-info-test" # Verify compilation_time from device_info is present and parseable - # The format is "Mon DD YYYY, HH:MM:SS" (e.g., "Dec 13 2024, 15:30:00") + # 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 From d911ae94fee0fa26465a16937703e7126de50680 Mon Sep 17 00:00:00 2001 From: David Woodhouse Date: Mon, 15 Dec 2025 18:53:39 +0000 Subject: [PATCH 051/111] Fix BUILD_TIME_STR_SIZE for ISO 8601 format Increase buffer from 24 to 26 bytes to accommodate the ISO 8601 format with timezone: "YYYY-MM-DD HH:MM:SS +ZZZZ" (25 chars + null terminator). The old format "Dec 15 2025, 18:14:59" was 20 chars, but the new format needs 25 chars. The 24-byte buffer was truncating the timezone to "+00" instead of "+0000". --- esphome/core/application.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/core/application.h b/esphome/core/application.h index e16041c070..7915780946 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -269,7 +269,7 @@ class Application { 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 = 24; + static constexpr size_t BUILD_TIME_STR_SIZE = 26; /// Get the config hash as a 32-bit integer uint32_t get_config_hash(); From d2b5398fadf6e09d2d1b6e04b91b8940e4425ea8 Mon Sep 17 00:00:00 2001 From: David Woodhouse Date: Mon, 15 Dec 2025 21:01:15 +0000 Subject: [PATCH 052/111] Revert API compilation_time to old locale-dependent format Keep the API DeviceInfo compilation_time field using the old get_compilation_time_ref() format for backward compatibility. The text sensor build_time_str continues to use the new ISO 8601 format. --- esphome/components/api/api_connection.cpp | 5 +---- tests/integration/test_build_info.py | 14 +++++--------- 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 85f4566f3c..5186e5afda 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1472,10 +1472,7 @@ bool APIConnection::send_device_info_response(const DeviceInfoRequest &msg) { resp.set_esphome_version(ESPHOME_VERSION_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)); + resp.set_compilation_time(App.get_compilation_time_ref()); // Manufacturer string - define once, handle ESP8266 PROGMEM separately #if defined(USE_ESP8266) || defined(USE_ESP32) diff --git a/tests/integration/test_build_info.py b/tests/integration/test_build_info.py index 7079594471..7934472b12 100644 --- a/tests/integration/test_build_info.py +++ b/tests/integration/test_build_info.py @@ -26,13 +26,12 @@ async def test_build_info( 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" + # The format is locale-dependent: "Dec 15 2025, 17:44:16" 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 + # Validate the format (locale-dependent, so just check it's not empty) + assert len(compilation_time) > 0 # Get entities entities, _ = await client.list_entities_services() @@ -110,8 +109,5 @@ async def test_build_info( 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}'" - ) + # Note: compilation_time (from API) uses old locale-dependent format, + # while build_time_str (text sensor) uses new ISO format From 803bb742c9358245e97aed967b962f8aed34ffdc Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 15 Dec 2025 09:29:51 -0500 Subject: [PATCH 053/111] [remote_base] Fix crash when ABBWelcome action has no data field (#12493) Co-authored-by: Claude --- esphome/components/remote_base/abbwelcome_protocol.h | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/remote_base/abbwelcome_protocol.h b/esphome/components/remote_base/abbwelcome_protocol.h index 4b922eb2f1..b8d9293c11 100644 --- a/esphome/components/remote_base/abbwelcome_protocol.h +++ b/esphome/components/remote_base/abbwelcome_protocol.h @@ -232,10 +232,10 @@ template class ABBWelcomeAction : public RemoteTransmitterAction data.set_message_id(this->message_id_.value(x...)); data.auto_message_id = this->auto_message_id_.value(x...); std::vector data_vec; - if (this->len_ >= 0) { + if (this->len_ > 0) { // Static mode: copy from flash to vector data_vec.assign(this->data_.data, this->data_.data + this->len_); - } else { + } else if (this->len_ < 0) { // Template mode: call function data_vec = this->data_.func(x...); } @@ -245,7 +245,7 @@ template class ABBWelcomeAction : public RemoteTransmitterAction } protected: - ssize_t len_{-1}; // -1 = template mode, >=0 = static mode with length + ssize_t len_{0}; // <0 = template mode, >=0 = static mode with length union Data { std::vector (*func)(Ts...); // Function pointer (stateless lambdas) const uint8_t *data; // Pointer to static data in flash From 8dff7ee746fdb76b5c09176e96e5749310a19e61 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 15 Dec 2025 12:07:02 -0500 Subject: [PATCH 054/111] [esp32] Support all IDF component version operators in shorthand syntax (#12499) Co-authored-by: Claude --- esphome/components/esp32/__init__.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 3dc5e4bbaa..0142fd4841 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -4,6 +4,7 @@ import itertools import logging import os from pathlib import Path +import re from esphome import yaml_util import esphome.codegen as cg @@ -616,10 +617,13 @@ def require_vfs_dir() -> None: def _parse_idf_component(value: str) -> ConfigType: """Parse IDF component shorthand syntax like 'owner/component^version'""" - if "^" not in value: - raise cv.Invalid(f"Invalid IDF component shorthand '{value}'") - name, ref = value.split("^", 1) - return {CONF_NAME: name, CONF_REF: ref} + # Match operator followed by version-like string (digit or *) + if match := re.search(r"(~=|>=|<=|==|!=|>|<|\^|~)(\d|\*)", value): + return {CONF_NAME: value[: match.start()], CONF_REF: value[match.start() :]} + raise cv.Invalid( + f"Invalid IDF component shorthand '{value}'. " + f"Expected format: 'owner/componentversion' where is one of: ^, ~, ~=, ==, !=, >=, >, <=, <" + ) def _validate_idf_component(config: ConfigType) -> ConfigType: From 57634b612ac73e291522026ed6188f404f7d8b48 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Mon, 15 Dec 2025 18:54:12 +0100 Subject: [PATCH 055/111] [http_request] Fix infinite loop when server doesn't send Content-Length header (#12480) Co-authored-by: Claude Sonnet 4.5 --- .../components/http_request/http_request.h | 3 +++ .../http_request/ota/ota_http_request.cpp | 20 ++++++++++++++----- .../update/http_request_update.cpp | 5 +++++ 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/esphome/components/http_request/http_request.h b/esphome/components/http_request/http_request.h index 8a82a44d7d..8adf13b954 100644 --- a/esphome/components/http_request/http_request.h +++ b/esphome/components/http_request/http_request.h @@ -255,6 +255,9 @@ template class HttpRequestSendAction : public Action { size_t read_index = 0; while (container->get_bytes_read() < max_length) { int read = container->read(buf + read_index, std::min(max_length - read_index, 512)); + if (read <= 0) { + break; + } App.feed_wdt(); yield(); read_index += read; diff --git a/esphome/components/http_request/ota/ota_http_request.cpp b/esphome/components/http_request/ota/ota_http_request.cpp index 4552fcc9df..b257518e06 100644 --- a/esphome/components/http_request/ota/ota_http_request.cpp +++ b/esphome/components/http_request/ota/ota_http_request.cpp @@ -132,11 +132,18 @@ uint8_t OtaHttpRequestComponent::do_ota_() { App.feed_wdt(); yield(); - if (bufsize < 0) { - ESP_LOGE(TAG, "Stream closed"); - this->cleanup_(std::move(backend), container); - return OTA_CONNECTION_ERROR; - } else if (bufsize > 0 && bufsize <= OtaHttpRequestComponent::HTTP_RECV_BUFFER) { + // Exit loop if no data available (stream closed or end of data) + if (bufsize <= 0) { + if (bufsize < 0) { + ESP_LOGE(TAG, "Stream closed with error"); + this->cleanup_(std::move(backend), container); + return OTA_CONNECTION_ERROR; + } + // bufsize == 0: no more data available, exit loop + break; + } + + if (bufsize <= OtaHttpRequestComponent::HTTP_RECV_BUFFER) { // add read bytes to MD5 md5_receive.add(buf, bufsize); @@ -247,6 +254,9 @@ bool OtaHttpRequestComponent::http_get_md5_() { int read_len = 0; while (container->get_bytes_read() < MD5_SIZE) { read_len = container->read((uint8_t *) this->md5_expected_.data(), MD5_SIZE); + if (read_len <= 0) { + break; + } App.feed_wdt(); yield(); } diff --git a/esphome/components/http_request/update/http_request_update.cpp b/esphome/components/http_request/update/http_request_update.cpp index 26af754e69..22cad625d1 100644 --- a/esphome/components/http_request/update/http_request_update.cpp +++ b/esphome/components/http_request/update/http_request_update.cpp @@ -76,6 +76,11 @@ void HttpRequestUpdate::update_task(void *params) { yield(); + if (read_bytes <= 0) { + // Network error or connection closed - break to avoid infinite loop + break; + } + read_index += read_bytes; } From 4c926cca60128b4335b4146e98667b01fd678b25 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 15 Dec 2025 18:09:42 -0500 Subject: [PATCH 056/111] Bump version to 2025.12.0b4 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 532e207788..039bba2136 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.0b3 +PROJECT_NUMBER = 2025.12.0b4 # 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 916136a69c..0aae3e2b17 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.0b3" +__version__ = "2025.12.0b4" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From ffbbf37fc28ef1d736c18e2c52ba3a3779ed25d3 Mon Sep 17 00:00:00 2001 From: David Woodhouse Date: Mon, 15 Dec 2025 23:12:21 +0000 Subject: [PATCH 057/111] Revert "Revert API compilation_time to old locale-dependent format" This reverts commit d2b5398fadf6e09d2d1b6e04b91b8940e4425ea8. --- esphome/components/api/api_connection.cpp | 5 ++++- tests/integration/test_build_info.py | 14 +++++++++----- 2 files changed, 13 insertions(+), 6 deletions(-) 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/tests/integration/test_build_info.py b/tests/integration/test_build_info.py index 7934472b12..7079594471 100644 --- a/tests/integration/test_build_info.py +++ b/tests/integration/test_build_info.py @@ -26,12 +26,13 @@ async def test_build_info( assert device_info.name == "build-info-test" # Verify compilation_time from device_info is present and parseable - # The format is locale-dependent: "Dec 15 2025, 17:44:16" + # 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 format (locale-dependent, so just check it's not empty) - assert len(compilation_time) > 0 + # 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() @@ -109,5 +110,8 @@ async def test_build_info( f"build_time_str '{build_time_str}' should match timestamp '{expected_str}'" ) - # Note: compilation_time (from API) uses old locale-dependent format, - # while build_time_str (text sensor) uses new ISO format + # 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}'" + ) From 4a58ab6310b58a819553848f338390dcd41434e8 Mon Sep 17 00:00:00 2001 From: David Woodhouse Date: Mon, 15 Dec 2025 23:13:11 +0000 Subject: [PATCH 058/111] Restore switch to build_time_str in mqtt sw_version and version sensor --- esphome/components/mqtt/mqtt_component.cpp | 4 +++- esphome/components/version/version_text_sensor.cpp | 6 ++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/esphome/components/mqtt/mqtt_component.cpp b/esphome/components/mqtt/mqtt_component.cpp index 5d2bedae79..6f5cf5edad 100644 --- a/esphome/components/mqtt/mqtt_component.cpp +++ b/esphome/components/mqtt/mqtt_component.cpp @@ -154,7 +154,9 @@ 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() + ")"; + char build_time_str[App.BUILD_TIME_STR_SIZE]; + App.get_build_time_string(build_time_str); + device_info[MQTT_DEVICE_SW_VERSION] = str_sprintf(ESPHOME_VERSION " (%s)", build_time_str); 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/version/version_text_sensor.cpp b/esphome/components/version/version_text_sensor.cpp index 78d0fb501b..88774b4b3a 100644 --- a/esphome/components/version/version_text_sensor.cpp +++ b/esphome/components/version/version_text_sensor.cpp @@ -1,6 +1,6 @@ #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" @@ -13,7 +13,9 @@ 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())); + char build_time_str[esphome::Application::BUILD_TIME_STR_SIZE]; + App.get_build_time_string(build_time_str); + this->publish_state(str_sprintf(ESPHOME_VERSION " %s", build_time_str)); } } float VersionTextSensor::get_setup_priority() const { return setup_priority::DATA; } From f5592595bccf12ebc89777959b389fd4a2949575 Mon Sep 17 00:00:00 2001 From: David Woodhouse Date: Mon, 15 Dec 2025 23:22:23 +0000 Subject: [PATCH 059/111] Use fnv1a_hash_extend with config_hash and version for sensor baselines Change sen5x, sgp30, and sgp4x components to use fnv1a_hash_extend() starting with config_hash and ESPHOME_VERSION, then extending with the sensor serial number. This replaces the previous use of fnv1_hash with compilation_time. This ensures baseline storage is invalidated on config or version changes, not just on recompilation. --- esphome/components/sen5x/sen5x.cpp | 8 +++++--- esphome/components/sgp30/sgp30.cpp | 7 ++++--- esphome/components/sgp4x/sgp4x.cpp | 8 +++++--- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/esphome/components/sen5x/sen5x.cpp b/esphome/components/sen5x/sen5x.cpp index ffb9e2bc02..1a09cc6bc1 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,11 @@ 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_hash(), ESPHOME_VERSION); + hash = fnv1a_hash_extend(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..83ffbda457 100644 --- a/esphome/components/sgp30/sgp30.cpp +++ b/esphome/components/sgp30/sgp30.cpp @@ -72,10 +72,11 @@ 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_hash(), ESPHOME_VERSION); + hash = fnv1a_hash_extend(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..9929986eb8 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,11 @@ 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_hash(), ESPHOME_VERSION); + hash = fnv1a_hash_extend(hash, std::to_string(this->serial_number_)); this->pref_ = global_preferences->make_preference(hash, true); if (this->pref_.load(&this->voc_baselines_storage_)) { From 69fa5020d26a9253d8fe349328ac12525979aa90 Mon Sep 17 00:00:00 2001 From: David Woodhouse Date: Mon, 15 Dec 2025 23:23:46 +0000 Subject: [PATCH 060/111] Re-remove compilation_time_ from the app --- esphome/core/application.h | 9 +-------- esphome/core/config.py | 1 - tests/dummy_main.cpp | 2 +- 3 files changed, 2 insertions(+), 10 deletions(-) diff --git a/esphome/core/application.h b/esphome/core/application.h index 7915780946..dfc7f23f51 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -103,7 +103,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) { @@ -123,7 +123,6 @@ class Application { this->friendly_name_ = friendly_name; } this->comment_ = comment; - this->compilation_time_ = compilation_time; } #ifdef USE_DEVICES @@ -263,11 +262,6 @@ class Application { bool is_name_add_mac_suffix_enabled() const { return this->name_add_mac_suffix_; } - /// deprecated: use get_build_time() or get_build_time_string() instead. - 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; @@ -494,7 +488,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/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/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); From f231fc856b16ab2497275aac4df3e04dc7622a70 Mon Sep 17 00:00:00 2001 From: David Woodhouse Date: Tue, 16 Dec 2025 00:05:43 +0000 Subject: [PATCH 061/111] Use fnv1a_hash_extend with config_hash and version for wifi preferences Change wifi component to use fnv1a_hash_extend(config_hash, ESPHOME_VERSION) instead of fnv1_hash(compilation_time) for the preferences hash. This ensures wifi settings are invalidated on config or version changes, not just on recompilation. --- esphome/components/wifi/wifi_component.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index a5e8c4a59d..1560a0dc58 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -28,6 +28,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/util.h" +#include "esphome/core/version.h" #ifdef USE_CAPTIVE_PORTAL #include "esphome/components/captive_portal/captive_portal.h" @@ -375,7 +376,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() ? fnv1a_hash_extend(App.get_config_hash(), ESPHOME_VERSION) : 88491487UL; this->pref_ = global_preferences->make_preference(hash, true); #ifdef USE_WIFI_FAST_CONNECT From 305a58cb8435d90254181e1fe18ad3ddc8b44f50 Mon Sep 17 00:00:00 2001 From: David Woodhouse Date: Tue, 16 Dec 2025 00:07:28 +0000 Subject: [PATCH 062/111] Use config_hash in MQTT and version sensor Change MQTT sw_version and version text sensor to display config_hash instead of build_time_str. Format: "(config hash 0xXXXXXXXX)" Version sensor with hide_timestamp=false also includes build time: "(config hash 0xXXXXXXXX, built: YYYY-MM-DD HH:MM:SS +ZZZZ)" --- esphome/components/mqtt/mqtt_component.cpp | 8 ++++---- esphome/components/version/version_text_sensor.cpp | 5 +++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/esphome/components/mqtt/mqtt_component.cpp b/esphome/components/mqtt/mqtt_component.cpp index 6f5cf5edad..44fa570850 100644 --- a/esphome/components/mqtt/mqtt_component.cpp +++ b/esphome/components/mqtt/mqtt_component.cpp @@ -68,7 +68,8 @@ bool MQTTComponent::send_discovery_() { return global_mqtt_client->publish(this->get_discovery_topic_(discovery_info), "", 0, this->qos_, true); } - ESP_LOGV(TAG, "'%s': Sending discovery", this->friendly_name_().c_str()); + ESP_LOGI(TAG, "'%s': Sending discovery to %s", this->friendly_name_().c_str(), + this->get_discovery_topic_(discovery_info)); // NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson return global_mqtt_client->publish_json( @@ -154,9 +155,8 @@ bool MQTTComponent::send_discovery_() { device_info[MQTT_DEVICE_MANUFACTURER] = model == nullptr ? ESPHOME_PROJECT_NAME : std::string(ESPHOME_PROJECT_NAME, model - ESPHOME_PROJECT_NAME); #else - char build_time_str[App.BUILD_TIME_STR_SIZE]; - App.get_build_time_string(build_time_str); - device_info[MQTT_DEVICE_SW_VERSION] = str_sprintf(ESPHOME_VERSION " (%s)", build_time_str); + device_info[MQTT_DEVICE_SW_VERSION] = + str_sprintf(ESPHOME_VERSION " (config hash 0x%08" PRIx32 ")", 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/version/version_text_sensor.cpp b/esphome/components/version/version_text_sensor.cpp index 88774b4b3a..f03c91e5f5 100644 --- a/esphome/components/version/version_text_sensor.cpp +++ b/esphome/components/version/version_text_sensor.cpp @@ -11,11 +11,12 @@ static const char *const TAG = "version.text_sensor"; void VersionTextSensor::setup() { if (this->hide_timestamp_) { - this->publish_state(ESPHOME_VERSION); + this->publish_state(str_sprintf(ESPHOME_VERSION " (config hash 0x%08" PRIx32 ")", App.get_config_hash())); } else { char build_time_str[esphome::Application::BUILD_TIME_STR_SIZE]; App.get_build_time_string(build_time_str); - this->publish_state(str_sprintf(ESPHOME_VERSION " %s", build_time_str)); + this->publish_state(str_sprintf(ESPHOME_VERSION " (config hash 0x%08" PRIx32 ", built: %s)", App.get_config_hash(), + build_time_str)); } } float VersionTextSensor::get_setup_priority() const { return setup_priority::DATA; } From e8a3a8380d099cd0d1fbaae5d954cb203d347d13 Mon Sep 17 00:00:00 2001 From: David Woodhouse Date: Tue, 16 Dec 2025 00:18:56 +0000 Subject: [PATCH 063/111] Remove stray debug --- esphome/components/mqtt/mqtt_component.cpp | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/esphome/components/mqtt/mqtt_component.cpp b/esphome/components/mqtt/mqtt_component.cpp index 44fa570850..200f1f99a3 100644 --- a/esphome/components/mqtt/mqtt_component.cpp +++ b/esphome/components/mqtt/mqtt_component.cpp @@ -68,8 +68,7 @@ bool MQTTComponent::send_discovery_() { return global_mqtt_client->publish(this->get_discovery_topic_(discovery_info), "", 0, this->qos_, true); } - ESP_LOGI(TAG, "'%s': Sending discovery to %s", this->friendly_name_().c_str(), - this->get_discovery_topic_(discovery_info)); + ESP_LOGV(TAG, "'%s': Sending discovery", this->friendly_name_().c_str()); // NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson return global_mqtt_client->publish_json( From 87f88b8a9a4ca27ede85c8a427eda875aa3c27fb Mon Sep 17 00:00:00 2001 From: David Woodhouse Date: Tue, 16 Dec 2025 00:32:30 +0000 Subject: [PATCH 064/111] Add version.h includes to sensor components Add missing version.h includes to sen5x, sgp30, and sgp4x components for ESPHOME_VERSION definition. --- esphome/components/sen5x/sen5x.cpp | 1 + esphome/components/sgp30/sgp30.cpp | 1 + esphome/components/sgp4x/sgp4x.cpp | 1 + 3 files changed, 3 insertions(+) diff --git a/esphome/components/sen5x/sen5x.cpp b/esphome/components/sen5x/sen5x.cpp index 1a09cc6bc1..a1cdeab55e 100644 --- a/esphome/components/sen5x/sen5x.cpp +++ b/esphome/components/sen5x/sen5x.cpp @@ -3,6 +3,7 @@ #include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include "esphome/core/version.h" #include namespace esphome { diff --git a/esphome/components/sgp30/sgp30.cpp b/esphome/components/sgp30/sgp30.cpp index 83ffbda457..20bb914ef9 100644 --- a/esphome/components/sgp30/sgp30.cpp +++ b/esphome/components/sgp30/sgp30.cpp @@ -3,6 +3,7 @@ #include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include "esphome/core/version.h" #include diff --git a/esphome/components/sgp4x/sgp4x.cpp b/esphome/components/sgp4x/sgp4x.cpp index 9929986eb8..94212a18ef 100644 --- a/esphome/components/sgp4x/sgp4x.cpp +++ b/esphome/components/sgp4x/sgp4x.cpp @@ -2,6 +2,7 @@ #include "esphome/core/application.h" #include "esphome/core/log.h" #include "esphome/core/hal.h" +#include "esphome/core/version.h" #include namespace esphome { From ead60bc5c47e535b12787526f5a42cb91b972fac Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 16 Dec 2025 00:48:30 -0600 Subject: [PATCH 065/111] [socket] Fix getpeername() returning local address instead of remote in LWIP raw TCP (#12475) --- esphome/components/socket/lwip_raw_tcp_impl.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index 5538206058..328df24bdd 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -188,7 +188,7 @@ class LWIPRawImpl : public Socket { errno = EINVAL; return -1; } - return this->ip2sockaddr_(&pcb_->local_ip, pcb_->local_port, name, addrlen); + return this->ip2sockaddr_(&pcb_->remote_ip, pcb_->remote_port, name, addrlen); } std::string getpeername() override { if (pcb_ == nullptr) { From 1897551b2874f2f4fc92407dc39abfb5bdf97866 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 16 Dec 2025 10:17:17 -0500 Subject: [PATCH 066/111] [uart] Fix UART on default UART0 pins for ESP-IDF (#12519) Co-authored-by: Claude --- .../uart/uart_component_esp_idf.cpp | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/esphome/components/uart/uart_component_esp_idf.cpp b/esphome/components/uart/uart_component_esp_idf.cpp index b438e4f7a6..b4f6eedf91 100644 --- a/esphome/components/uart/uart_component_esp_idf.cpp +++ b/esphome/components/uart/uart_component_esp_idf.cpp @@ -9,6 +9,7 @@ #include "esphome/core/gpio.h" #include "driver/gpio.h" #include "soc/gpio_num.h" +#include "soc/uart_pins.h" #ifdef USE_LOGGER #include "esphome/components/logger/logger.h" @@ -139,6 +140,22 @@ void IDFUARTComponent::load_settings(bool dump_config) { return; } + int8_t tx = this->tx_pin_ != nullptr ? this->tx_pin_->get_pin() : -1; + int8_t rx = this->rx_pin_ != nullptr ? this->rx_pin_->get_pin() : -1; + int8_t flow_control = this->flow_control_pin_ != nullptr ? this->flow_control_pin_->get_pin() : -1; + + // Workaround for ESP-IDF issue: https://github.com/espressif/esp-idf/issues/17459 + // Commit 9ed617fb17 removed gpio_func_sel() calls from uart_set_pin(), which breaks + // UART on default UART0 pins that may have residual state from boot console. + // Reset these pins before configuring UART to ensure they're in a clean state. + if (tx == U0TXD_GPIO_NUM || tx == U0RXD_GPIO_NUM) { + gpio_reset_pin(static_cast(tx)); + } + if (rx == U0TXD_GPIO_NUM || rx == U0RXD_GPIO_NUM) { + gpio_reset_pin(static_cast(rx)); + } + + // Setup pins after reset to preserve open drain/pullup/pulldown flags auto setup_pin_if_needed = [](InternalGPIOPin *pin) { if (!pin) { return; @@ -154,10 +171,6 @@ void IDFUARTComponent::load_settings(bool dump_config) { setup_pin_if_needed(this->tx_pin_); } - int8_t tx = this->tx_pin_ != nullptr ? this->tx_pin_->get_pin() : -1; - int8_t rx = this->rx_pin_ != nullptr ? this->rx_pin_->get_pin() : -1; - int8_t flow_control = this->flow_control_pin_ != nullptr ? this->flow_control_pin_->get_pin() : -1; - uint32_t invert = 0; if (this->tx_pin_ != nullptr && this->tx_pin_->is_inverted()) { invert |= UART_SIGNAL_TXD_INV; From 7216120bfd12f590b6cb84891cc5a14dce908c11 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 16 Dec 2025 00:48:30 -0600 Subject: [PATCH 067/111] [socket] Fix getpeername() returning local address instead of remote in LWIP raw TCP (#12475) --- esphome/components/socket/lwip_raw_tcp_impl.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index 5538206058..328df24bdd 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -188,7 +188,7 @@ class LWIPRawImpl : public Socket { errno = EINVAL; return -1; } - return this->ip2sockaddr_(&pcb_->local_ip, pcb_->local_port, name, addrlen); + return this->ip2sockaddr_(&pcb_->remote_ip, pcb_->remote_port, name, addrlen); } std::string getpeername() override { if (pcb_ == nullptr) { From 4d6a93f92de0aa16a0a61ac750d5f42076333a9d Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 16 Dec 2025 10:17:17 -0500 Subject: [PATCH 068/111] [uart] Fix UART on default UART0 pins for ESP-IDF (#12519) Co-authored-by: Claude --- .../uart/uart_component_esp_idf.cpp | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/esphome/components/uart/uart_component_esp_idf.cpp b/esphome/components/uart/uart_component_esp_idf.cpp index b438e4f7a6..b4f6eedf91 100644 --- a/esphome/components/uart/uart_component_esp_idf.cpp +++ b/esphome/components/uart/uart_component_esp_idf.cpp @@ -9,6 +9,7 @@ #include "esphome/core/gpio.h" #include "driver/gpio.h" #include "soc/gpio_num.h" +#include "soc/uart_pins.h" #ifdef USE_LOGGER #include "esphome/components/logger/logger.h" @@ -139,6 +140,22 @@ void IDFUARTComponent::load_settings(bool dump_config) { return; } + int8_t tx = this->tx_pin_ != nullptr ? this->tx_pin_->get_pin() : -1; + int8_t rx = this->rx_pin_ != nullptr ? this->rx_pin_->get_pin() : -1; + int8_t flow_control = this->flow_control_pin_ != nullptr ? this->flow_control_pin_->get_pin() : -1; + + // Workaround for ESP-IDF issue: https://github.com/espressif/esp-idf/issues/17459 + // Commit 9ed617fb17 removed gpio_func_sel() calls from uart_set_pin(), which breaks + // UART on default UART0 pins that may have residual state from boot console. + // Reset these pins before configuring UART to ensure they're in a clean state. + if (tx == U0TXD_GPIO_NUM || tx == U0RXD_GPIO_NUM) { + gpio_reset_pin(static_cast(tx)); + } + if (rx == U0TXD_GPIO_NUM || rx == U0RXD_GPIO_NUM) { + gpio_reset_pin(static_cast(rx)); + } + + // Setup pins after reset to preserve open drain/pullup/pulldown flags auto setup_pin_if_needed = [](InternalGPIOPin *pin) { if (!pin) { return; @@ -154,10 +171,6 @@ void IDFUARTComponent::load_settings(bool dump_config) { setup_pin_if_needed(this->tx_pin_); } - int8_t tx = this->tx_pin_ != nullptr ? this->tx_pin_->get_pin() : -1; - int8_t rx = this->rx_pin_ != nullptr ? this->rx_pin_->get_pin() : -1; - int8_t flow_control = this->flow_control_pin_ != nullptr ? this->flow_control_pin_->get_pin() : -1; - uint32_t invert = 0; if (this->tx_pin_ != nullptr && this->tx_pin_->is_inverted()) { invert |= UART_SIGNAL_TXD_INV; From 9c88e44300748b63f35a7f9af1db707b974311a7 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 16 Dec 2025 10:35:31 -0500 Subject: [PATCH 069/111] Bump version to 2025.12.0b5 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 039bba2136..ee19d5840d 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.0b4 +PROJECT_NUMBER = 2025.12.0b5 # 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 0aae3e2b17..9cdc210425 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.0b4" +__version__ = "2025.12.0b5" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 7298db0a7e11a07c7a21e46b75987e6886ff6b75 Mon Sep 17 00:00:00 2001 From: David Woodhouse Date: Tue, 16 Dec 2025 16:45:28 +0000 Subject: [PATCH 070/111] Add tests for source file removal detection in copy_src_tree Add tests covering the logic that detects when source files are removed: - test_copy_src_tree_detects_removed_source_file: Verifies that removing a regular source file triggers sources_changed flag - test_copy_src_tree_ignores_removed_generated_file: Verifies that removing a generated file (like build_info_data.h) does not trigger sources_changed --- tests/unit_tests/test_writer.py | 125 ++++++++++++++++++++++++++++++++ 1 file changed, 125 insertions(+) diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index c8c6ea6523..06a7d5dbdf 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -1765,3 +1765,128 @@ def test_copy_src_tree_build_info_timestamp_behavior( 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 From 38167c268f2ce295abc6add250a3e8ea0e9fc4fd Mon Sep 17 00:00:00 2001 From: David Woodhouse Date: Tue, 16 Dec 2025 17:15:19 +0000 Subject: [PATCH 071/111] Add get_config_version_hash() and make hash functions constexpr Add Application::get_config_version_hash() as a constexpr that returns fnv1a_hash_extend(config_hash, ESPHOME_VERSION). Make get_config_hash(), get_build_time(), fnv1a_hash(), and fnv1a_hash_extend() constexpr inline functions. Replace open-coded fnv1a_hash_extend(config_hash, ESPHOME_VERSION) calls with get_config_version_hash() in sensor and wifi components. Remove now-unnecessary version.h includes from component files. --- esphome/components/sen5x/sen5x.cpp | 4 +--- esphome/components/sgp30/sgp30.cpp | 4 +--- esphome/components/sgp4x/sgp4x.cpp | 4 +--- esphome/components/wifi/wifi_component.cpp | 3 +-- esphome/core/application.cpp | 4 ---- esphome/core/application.h | 9 +++++++-- esphome/core/helpers.cpp | 11 ----------- esphome/core/helpers.h | 12 ++++++++++-- 8 files changed, 21 insertions(+), 30 deletions(-) diff --git a/esphome/components/sen5x/sen5x.cpp b/esphome/components/sen5x/sen5x.cpp index a1cdeab55e..c72ccf2595 100644 --- a/esphome/components/sen5x/sen5x.cpp +++ b/esphome/components/sen5x/sen5x.cpp @@ -3,7 +3,6 @@ #include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#include "esphome/core/version.h" #include namespace esphome { @@ -159,8 +158,7 @@ void SEN5XComponent::setup() { // 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 multiple sensors can be used without conflict - uint32_t hash = fnv1a_hash_extend(App.get_config_hash(), ESPHOME_VERSION); - hash = fnv1a_hash_extend(hash, std::to_string(combined_serial)); + 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 20bb914ef9..1326356437 100644 --- a/esphome/components/sgp30/sgp30.cpp +++ b/esphome/components/sgp30/sgp30.cpp @@ -3,7 +3,6 @@ #include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#include "esphome/core/version.h" #include @@ -76,8 +75,7 @@ void SGP30Component::setup() { // 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 multiple sensors can be used without conflict - uint32_t hash = fnv1a_hash_extend(App.get_config_hash(), ESPHOME_VERSION); - hash = fnv1a_hash_extend(hash, std::to_string(this->serial_number_)); + 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 94212a18ef..7c0f51c782 100644 --- a/esphome/components/sgp4x/sgp4x.cpp +++ b/esphome/components/sgp4x/sgp4x.cpp @@ -2,7 +2,6 @@ #include "esphome/core/application.h" #include "esphome/core/log.h" #include "esphome/core/hal.h" -#include "esphome/core/version.h" #include namespace esphome { @@ -61,8 +60,7 @@ void SGP4xComponent::setup() { // 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 multiple sensors can be used without conflict - uint32_t hash = fnv1a_hash_extend(App.get_config_hash(), ESPHOME_VERSION); - hash = fnv1a_hash_extend(hash, std::to_string(this->serial_number_)); + 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/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 1560a0dc58..a550aa679d 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -28,7 +28,6 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/util.h" -#include "esphome/core/version.h" #ifdef USE_CAPTIVE_PORTAL #include "esphome/components/captive_portal/captive_portal.h" @@ -376,7 +375,7 @@ void WiFiComponent::start() { get_mac_address_pretty_into_buffer(mac_s)); this->last_connected_ = millis(); - uint32_t hash = this->has_sta() ? fnv1a_hash_extend(App.get_config_hash(), ESPHOME_VERSION) : 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/application.cpp b/esphome/core/application.cpp index 376ea3c200..9a4c0fce05 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -719,10 +719,6 @@ void Application::wake_loop_threadsafe() { } #endif // defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) -uint32_t Application::get_config_hash() { return ESPHOME_CONFIG_HASH; } - -time_t Application::get_build_time() { return ESPHOME_BUILD_TIME; } - void Application::get_build_time_string(std::span buffer) { #ifdef USE_ESP8266 strncpy_P(buffer.data(), ESPHOME_BUILD_TIME_STR, buffer.size()); diff --git a/esphome/core/application.h b/esphome/core/application.h index dfc7f23f51..9d876dc5a3 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -6,6 +6,7 @@ #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" @@ -13,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" @@ -266,10 +268,13 @@ class Application { static constexpr size_t BUILD_TIME_STR_SIZE = 26; /// Get the config hash as a 32-bit integer - uint32_t get_config_hash(); + 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 - time_t get_build_time(); + 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) diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 55466fca8a..086653fd28 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. From da67c47a762568bb3376e591a56b893267267c7e Mon Sep 17 00:00:00 2001 From: David Woodhouse Date: Tue, 16 Dec 2025 18:10:08 +0000 Subject: [PATCH 072/111] Use PROGMEM format strings to reduce RAM usage on ESP8266 Replace str_sprintf() with snprintf_P() and PSTR() to keep format strings in flash instead of RAM. Also removes 'config hash 0x' prefix to save additional bytes. --- esphome/components/mqtt/mqtt_component.cpp | 5 +++-- esphome/components/version/version_text_sensor.cpp | 8 +++++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/esphome/components/mqtt/mqtt_component.cpp b/esphome/components/mqtt/mqtt_component.cpp index 200f1f99a3..fe520bd175 100644 --- a/esphome/components/mqtt/mqtt_component.cpp +++ b/esphome/components/mqtt/mqtt_component.cpp @@ -154,8 +154,9 @@ 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] = - str_sprintf(ESPHOME_VERSION " (config hash 0x%08" PRIx32 ")", App.get_config_hash()); + char sw_version[64]; + snprintf_P(sw_version, sizeof(sw_version), PSTR(ESPHOME_VERSION " (%08" PRIx32 ")"), App.get_config_hash()); + device_info[MQTT_DEVICE_SW_VERSION] = sw_version; 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/version/version_text_sensor.cpp b/esphome/components/version/version_text_sensor.cpp index f03c91e5f5..3b9d09d1e7 100644 --- a/esphome/components/version/version_text_sensor.cpp +++ b/esphome/components/version/version_text_sensor.cpp @@ -10,14 +10,16 @@ namespace version { static const char *const TAG = "version.text_sensor"; void VersionTextSensor::setup() { + char version_str[128]; if (this->hide_timestamp_) { - this->publish_state(str_sprintf(ESPHOME_VERSION " (config hash 0x%08" PRIx32 ")", App.get_config_hash())); + snprintf_P(version_str, sizeof(version_str), PSTR(ESPHOME_VERSION " (%08" PRIx32 ")"), App.get_config_hash()); } else { char build_time_str[esphome::Application::BUILD_TIME_STR_SIZE]; App.get_build_time_string(build_time_str); - this->publish_state(str_sprintf(ESPHOME_VERSION " (config hash 0x%08" PRIx32 ", built: %s)", App.get_config_hash(), - build_time_str)); + snprintf_P(version_str, sizeof(version_str), PSTR(ESPHOME_VERSION " (%08" PRIx32 ", built: %s)"), + App.get_config_hash(), build_time_str); } + 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; } From 12734ba2581d8bfe821bff87ecb16bbc3de19cd2 Mon Sep 17 00:00:00 2001 From: David Woodhouse Date: Tue, 16 Dec 2025 19:03:13 +0000 Subject: [PATCH 073/111] Revert "Use PROGMEM format strings to reduce RAM usage on ESP8266" This reverts commit da67c47a762568bb3376e591a56b893267267c7e. --- esphome/components/mqtt/mqtt_component.cpp | 5 ++--- esphome/components/version/version_text_sensor.cpp | 8 +++----- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/esphome/components/mqtt/mqtt_component.cpp b/esphome/components/mqtt/mqtt_component.cpp index fe520bd175..200f1f99a3 100644 --- a/esphome/components/mqtt/mqtt_component.cpp +++ b/esphome/components/mqtt/mqtt_component.cpp @@ -154,9 +154,8 @@ bool MQTTComponent::send_discovery_() { device_info[MQTT_DEVICE_MANUFACTURER] = model == nullptr ? ESPHOME_PROJECT_NAME : std::string(ESPHOME_PROJECT_NAME, model - ESPHOME_PROJECT_NAME); #else - char sw_version[64]; - snprintf_P(sw_version, sizeof(sw_version), PSTR(ESPHOME_VERSION " (%08" PRIx32 ")"), App.get_config_hash()); - device_info[MQTT_DEVICE_SW_VERSION] = sw_version; + device_info[MQTT_DEVICE_SW_VERSION] = + str_sprintf(ESPHOME_VERSION " (config hash 0x%08" PRIx32 ")", 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/version/version_text_sensor.cpp b/esphome/components/version/version_text_sensor.cpp index 3b9d09d1e7..f03c91e5f5 100644 --- a/esphome/components/version/version_text_sensor.cpp +++ b/esphome/components/version/version_text_sensor.cpp @@ -10,16 +10,14 @@ namespace version { static const char *const TAG = "version.text_sensor"; void VersionTextSensor::setup() { - char version_str[128]; if (this->hide_timestamp_) { - snprintf_P(version_str, sizeof(version_str), PSTR(ESPHOME_VERSION " (%08" PRIx32 ")"), App.get_config_hash()); + this->publish_state(str_sprintf(ESPHOME_VERSION " (config hash 0x%08" PRIx32 ")", App.get_config_hash())); } else { char build_time_str[esphome::Application::BUILD_TIME_STR_SIZE]; App.get_build_time_string(build_time_str); - snprintf_P(version_str, sizeof(version_str), PSTR(ESPHOME_VERSION " (%08" PRIx32 ", built: %s)"), - App.get_config_hash(), build_time_str); + this->publish_state(str_sprintf(ESPHOME_VERSION " (config hash 0x%08" PRIx32 ", built: %s)", App.get_config_hash(), + build_time_str)); } - 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; } From 175250deb0b788cb9653908ec998916b2b9a8c7f Mon Sep 17 00:00:00 2001 From: David Woodhouse Date: Tue, 16 Dec 2025 19:20:50 +0000 Subject: [PATCH 074/111] Use PROGMEM for MQTT version format string on ESP8266 Store format string in PROGMEM and copy to RAM buffer on ESP8266 before use. On other platforms, use the format string directly. This saves RAM on ESP8266 while maintaining the same functionality. --- esphome/components/mqtt/mqtt_component.cpp | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/esphome/components/mqtt/mqtt_component.cpp b/esphome/components/mqtt/mqtt_component.cpp index 200f1f99a3..9db1b1f7c8 100644 --- a/esphome/components/mqtt/mqtt_component.cpp +++ b/esphome/components/mqtt/mqtt_component.cpp @@ -154,8 +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] = - str_sprintf(ESPHOME_VERSION " (config hash 0x%08" PRIx32 ")", App.get_config_hash()); + 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"; From a30052e7c033c2a5da3a96b6440d1435e444b459 Mon Sep 17 00:00:00 2001 From: David Woodhouse Date: Tue, 16 Dec 2025 19:22:42 +0000 Subject: [PATCH 075/111] Use PROGMEM for version text sensor strings on ESP8266 Build version string incrementally from PROGMEM literal prefix, avoiding format strings in RAM. Copy from PROGMEM on ESP8266, use directly on other platforms. --- .../version/version_text_sensor.cpp | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/esphome/components/version/version_text_sensor.cpp b/esphome/components/version/version_text_sensor.cpp index f03c91e5f5..67611e55a5 100644 --- a/esphome/components/version/version_text_sensor.cpp +++ b/esphome/components/version/version_text_sensor.cpp @@ -10,14 +10,28 @@ namespace version { static const char *const TAG = "version.text_sensor"; void VersionTextSensor::setup() { - if (this->hide_timestamp_) { - this->publish_state(str_sprintf(ESPHOME_VERSION " (config hash 0x%08" PRIx32 ")", App.get_config_hash())); - } else { + static const char prefix[] PROGMEM = ESPHOME_VERSION " (config hash 0x"; + char version_str[128]; + +#ifdef USE_ESP8266 + strcpy_P(version_str, prefix); +#else + strcpy(version_str, prefix); +#endif + + char hash_str[9]; + snprintf(hash_str, sizeof(hash_str), "%08" PRIx32, App.get_config_hash()); + strcat(version_str, hash_str); + + if (!this->hide_timestamp_) { + strcat(version_str, ", built: "); char build_time_str[esphome::Application::BUILD_TIME_STR_SIZE]; App.get_build_time_string(build_time_str); - this->publish_state(str_sprintf(ESPHOME_VERSION " (config hash 0x%08" PRIx32 ", built: %s)", App.get_config_hash(), - build_time_str)); + strcat(version_str, build_time_str); } + + strcat(version_str, ")"); + 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; } From 8429684fce4aa31ef1d199fea9610c46102da380 Mon Sep 17 00:00:00 2001 From: David Woodhouse Date: Tue, 16 Dec 2025 19:46:04 +0000 Subject: [PATCH 076/111] Fix clang-format: use uppercase PREFIX constant name --- esphome/components/version/version_text_sensor.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/version/version_text_sensor.cpp b/esphome/components/version/version_text_sensor.cpp index 67611e55a5..33ff35f001 100644 --- a/esphome/components/version/version_text_sensor.cpp +++ b/esphome/components/version/version_text_sensor.cpp @@ -10,13 +10,13 @@ namespace version { static const char *const TAG = "version.text_sensor"; void VersionTextSensor::setup() { - static const char prefix[] PROGMEM = ESPHOME_VERSION " (config hash 0x"; + static const char PREFIX[] PROGMEM = ESPHOME_VERSION " (config hash 0x"; char version_str[128]; #ifdef USE_ESP8266 - strcpy_P(version_str, prefix); + strcpy_P(version_str, PREFIX); #else - strcpy(version_str, prefix); + strcpy(version_str, PREFIX); #endif char hash_str[9]; From fa3d998c3da9f95ec48de00553062ef6c14187b5 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 16 Dec 2025 17:15:50 -0500 Subject: [PATCH 077/111] Bump version to 2025.12.0 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index ee19d5840d..7dfcbd6b6f 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.0b5 +PROJECT_NUMBER = 2025.12.0 # 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 9cdc210425..111396cab5 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.0b5" +__version__ = "2025.12.0" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From f8c9cf8fd98439e7f39a34f45a3d9998aa3d8d7c Mon Sep 17 00:00:00 2001 From: David Woodhouse Date: Tue, 16 Dec 2025 22:37:58 +0000 Subject: [PATCH 078/111] Use PROGMEM for version text sensor strings on ESP8266 Build version string incrementally from PROGMEM literals using ESPHOME_strncpy_P and ESPHOME_strncat_P. Write hash and build time directly into buffer without temporary variables. Calculate buffer size based on actual components needed. Add ESPHOME_strncat_P macro to progmem.h for cross-platform PROGMEM string concatenation. --- .../version/version_text_sensor.cpp | 27 +++++++++---------- esphome/core/progmem.h | 2 ++ 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/esphome/components/version/version_text_sensor.cpp b/esphome/components/version/version_text_sensor.cpp index 33ff35f001..56df4e96bb 100644 --- a/esphome/components/version/version_text_sensor.cpp +++ b/esphome/components/version/version_text_sensor.cpp @@ -3,6 +3,7 @@ #include "esphome/core/log.h" #include "esphome/core/version.h" #include "esphome/core/helpers.h" +#include "esphome/core/progmem.h" namespace esphome { namespace version { @@ -11,26 +12,24 @@ static const char *const TAG = "version.text_sensor"; void VersionTextSensor::setup() { static const char PREFIX[] PROGMEM = ESPHOME_VERSION " (config hash 0x"; - char version_str[128]; + 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]; -#ifdef USE_ESP8266 - strcpy_P(version_str, PREFIX); -#else - strcpy(version_str, PREFIX); -#endif + ESPHOME_strncpy_P(version_str, PREFIX, sizeof(version_str)); - char hash_str[9]; - snprintf(hash_str, sizeof(hash_str), "%08" PRIx32, App.get_config_hash()); - strcat(version_str, hash_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_) { - strcat(version_str, ", built: "); - char build_time_str[esphome::Application::BUILD_TIME_STR_SIZE]; - App.get_build_time_string(build_time_str); - strcat(version_str, build_time_str); + 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); } - strcat(version_str, ")"); + 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; } 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 From 8358ef00960e7b6e75ef050333df84b603650f95 Mon Sep 17 00:00:00 2001 From: David Woodhouse Date: Tue, 16 Dec 2025 23:06:31 +0000 Subject: [PATCH 079/111] Use ESPHOME_strncpy_P in get_build_time_string() Replace platform-specific ifdef with cross-platform ESPHOME_strncpy_P macro for consistency. --- esphome/core/application.cpp | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 9a4c0fce05..4c9cc6b2b6 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -1,6 +1,7 @@ #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 @@ -720,11 +721,7 @@ 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) { -#ifdef USE_ESP8266 - strncpy_P(buffer.data(), ESPHOME_BUILD_TIME_STR, buffer.size()); -#else - strncpy(buffer.data(), ESPHOME_BUILD_TIME_STR, buffer.size()); -#endif + ESPHOME_strncpy_P(buffer.data(), ESPHOME_BUILD_TIME_STR, buffer.size()); buffer[buffer.size() - 1] = '\0'; } From fab4efb4690468004749296b2e7372fc6090a168 Mon Sep 17 00:00:00 2001 From: Jeff Zigler <123041141+zigboi@users.noreply.github.com> Date: Tue, 16 Dec 2025 16:42:12 -0800 Subject: [PATCH 080/111] [esp32] Fix serial logging on h2, c2 & c61 (#12522) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/logger/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/components/logger/__init__.py b/esphome/components/logger/__init__.py index fb0ce92cc9..8968a5eab8 100644 --- a/esphome/components/logger/__init__.py +++ b/esphome/components/logger/__init__.py @@ -241,9 +241,12 @@ CONFIG_SCHEMA = cv.All( CONF_HARDWARE_UART, esp8266=UART0, esp32=UART0, + esp32_c2=UART0, esp32_c3=USB_SERIAL_JTAG, esp32_c5=USB_SERIAL_JTAG, esp32_c6=USB_SERIAL_JTAG, + esp32_c61=USB_SERIAL_JTAG, + esp32_h2=USB_SERIAL_JTAG, esp32_p4=USB_SERIAL_JTAG, esp32_s2=USB_CDC, esp32_s3=USB_SERIAL_JTAG, From 71f2331bc8df36bd46fac6b10fa040bdf41103b0 Mon Sep 17 00:00:00 2001 From: David Woodhouse Date: Wed, 17 Dec 2025 00:42:46 +0000 Subject: [PATCH 081/111] Fix clang-format: use lowercase buf_size variable name --- esphome/components/version/version_text_sensor.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/version/version_text_sensor.cpp b/esphome/components/version/version_text_sensor.cpp index 56df4e96bb..584b8abfb2 100644 --- a/esphome/components/version/version_text_sensor.cpp +++ b/esphome/components/version/version_text_sensor.cpp @@ -14,8 +14,8 @@ void VersionTextSensor::setup() { 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]; + 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)); From 046ea922e8af37c2b91cff9c1e6c54f69fce0812 Mon Sep 17 00:00:00 2001 From: Thomas Rupprecht Date: Wed, 17 Dec 2025 01:42:52 +0100 Subject: [PATCH 082/111] [esp32] improve types and variable naming (#12423) --- esphome/components/esp32/__init__.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 0142fd4841..b726a40508 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -118,8 +118,8 @@ ARDUINO_ALLOWED_VARIANTS = [ ] -def get_cpu_frequencies(*frequencies): - return [str(x) + "MHZ" for x in frequencies] +def get_cpu_frequencies(*frequencies: int) -> list[str]: + return [f"{frequency}MHZ" for frequency in frequencies] CPU_FREQUENCIES = { @@ -136,7 +136,7 @@ CPU_FREQUENCIES = { } # Make sure not missed here if a new variant added. -assert all(v in CPU_FREQUENCIES for v in VARIANTS) +assert all(variant in CPU_FREQUENCIES for variant in VARIANTS) FULL_CPU_FREQUENCIES = set(itertools.chain.from_iterable(CPU_FREQUENCIES.values())) @@ -250,10 +250,10 @@ def add_idf_sdkconfig_option(name: str, value: SdkconfigValueType): def add_idf_component( *, name: str, - repo: str = None, - ref: str = None, - path: str = None, - refresh: TimePeriod = None, + repo: str | None = None, + ref: str | None = None, + path: str | None = None, + refresh: TimePeriod | None = None, components: list[str] | None = None, submodules: list[str] | None = None, ): @@ -334,7 +334,7 @@ def _format_framework_espidf_version(ver: cv.Version, release: str) -> str: return f"pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v{str(ver)}/esp-idf-v{str(ver)}.{ext}" -def _is_framework_url(source: str) -> str: +def _is_framework_url(source: str) -> bool: # platformio accepts many URL schemes for framework repositories and archives including http, https, git, file, and symlink import urllib.parse @@ -1193,7 +1193,7 @@ APP_PARTITION_SIZES = { } -def get_arduino_partition_csv(flash_size): +def get_arduino_partition_csv(flash_size: str): app_partition_size = APP_PARTITION_SIZES[flash_size] eeprom_partition_size = 0x1000 # 4 KB spiffs_partition_size = 0xF000 # 60 KB @@ -1213,7 +1213,7 @@ spiffs, data, spiffs, 0x{spiffs_partition_start:X}, 0x{spiffs_partition_size: """ -def get_idf_partition_csv(flash_size): +def get_idf_partition_csv(flash_size: str): app_partition_size = APP_PARTITION_SIZES[flash_size] return f"""\ From 93621d85b08d4a84d29c396cbcca9009b472f1f3 Mon Sep 17 00:00:00 2001 From: Thomas Rupprecht Date: Wed, 17 Dec 2025 01:43:10 +0100 Subject: [PATCH 083/111] [climate] Improve temperature unit regex (#12032) --- esphome/components/climate/__init__.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/esphome/components/climate/__init__.py b/esphome/components/climate/__init__.py index b8e49db6c0..2150a30c3e 100644 --- a/esphome/components/climate/__init__.py +++ b/esphome/components/climate/__init__.py @@ -117,9 +117,7 @@ CONF_MIN_HUMIDITY = "min_humidity" CONF_MAX_HUMIDITY = "max_humidity" CONF_TARGET_HUMIDITY = "target_humidity" -visual_temperature = cv.float_with_unit( - "visual_temperature", "(°C|° C|°|C|°K|° K|K|°F|° F|F)?" -) +visual_temperature = cv.float_with_unit("visual_temperature", "(°|(° ?)?[CKF])?") VISUAL_TEMPERATURE_STEP_SCHEMA = cv.Schema( From 9727c7135cdeeed19dbf09950ce4ccf24adc86b7 Mon Sep 17 00:00:00 2001 From: Thomas Rupprecht Date: Wed, 17 Dec 2025 01:43:18 +0100 Subject: [PATCH 084/111] [openthread] channel range, fix typo, use C++17 nested namespace syntax (#12422) --- esphome/components/openthread/__init__.py | 4 ++-- esphome/components/openthread/openthread.cpp | 7 ++----- esphome/components/openthread/openthread.h | 6 ++---- esphome/components/openthread/openthread_esp.cpp | 6 ++---- .../openthread_info/openthread_info_text_sensor.cpp | 6 ++---- .../openthread_info/openthread_info_text_sensor.h | 6 ++---- 6 files changed, 12 insertions(+), 23 deletions(-) diff --git a/esphome/components/openthread/__init__.py b/esphome/components/openthread/__init__.py index 5b1abe4fb5..050e45cdc9 100644 --- a/esphome/components/openthread/__init__.py +++ b/esphome/components/openthread/__init__.py @@ -91,7 +91,7 @@ def set_sdkconfig_options(config): add_idf_sdkconfig_option("CONFIG_OPENTHREAD_SRP_CLIENT", True) add_idf_sdkconfig_option("CONFIG_OPENTHREAD_SRP_CLIENT_MAX_SERVICES", 5) - # TODO: Add suport for synchronized sleepy end devices (SSED) + # TODO: Add support for synchronized sleepy end devices (SSED) add_idf_sdkconfig_option(f"CONFIG_OPENTHREAD_{config.get(CONF_DEVICE_TYPE)}", True) @@ -102,7 +102,7 @@ OpenThreadSrpComponent = openthread_ns.class_("OpenThreadSrpComponent", cg.Compo _CONNECTION_SCHEMA = cv.Schema( { cv.Optional(CONF_PAN_ID): cv.hex_int, - cv.Optional(CONF_CHANNEL): cv.int_, + cv.Optional(CONF_CHANNEL): cv.int_range(min=11, max=26), cv.Optional(CONF_NETWORK_KEY): cv.hex_int, cv.Optional(CONF_EXT_PAN_ID): cv.hex_int, cv.Optional(CONF_NETWORK_NAME): cv.string_strict, diff --git a/esphome/components/openthread/openthread.cpp b/esphome/components/openthread/openthread.cpp index 721ab89326..90da17e2d3 100644 --- a/esphome/components/openthread/openthread.cpp +++ b/esphome/components/openthread/openthread.cpp @@ -21,8 +21,7 @@ static const char *const TAG = "openthread"; -namespace esphome { -namespace openthread { +namespace esphome::openthread { OpenThreadComponent *global_openthread_component = // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) @@ -275,7 +274,5 @@ const char *OpenThreadComponent::get_use_address() const { return this->use_addr void OpenThreadComponent::set_use_address(const char *use_address) { this->use_address_ = use_address; } -} // namespace openthread -} // namespace esphome - +} // namespace esphome::openthread #endif diff --git a/esphome/components/openthread/openthread.h b/esphome/components/openthread/openthread.h index 546128b366..3c60acaadd 100644 --- a/esphome/components/openthread/openthread.h +++ b/esphome/components/openthread/openthread.h @@ -13,8 +13,7 @@ #include #include -namespace esphome { -namespace openthread { +namespace esphome::openthread { class InstanceLock; @@ -91,6 +90,5 @@ class InstanceLock { InstanceLock() {} }; -} // namespace openthread -} // namespace esphome +} // namespace esphome::openthread #endif diff --git a/esphome/components/openthread/openthread_esp.cpp b/esphome/components/openthread/openthread_esp.cpp index 72dc521091..b47e4b884a 100644 --- a/esphome/components/openthread/openthread_esp.cpp +++ b/esphome/components/openthread/openthread_esp.cpp @@ -24,8 +24,7 @@ static const char *const TAG = "openthread"; -namespace esphome { -namespace openthread { +namespace esphome::openthread { void OpenThreadComponent::setup() { // Used eventfds: @@ -209,6 +208,5 @@ otInstance *InstanceLock::get_instance() { return esp_openthread_get_instance(); InstanceLock::~InstanceLock() { esp_openthread_lock_release(); } -} // namespace openthread -} // namespace esphome +} // namespace esphome::openthread #endif diff --git a/esphome/components/openthread_info/openthread_info_text_sensor.cpp b/esphome/components/openthread_info/openthread_info_text_sensor.cpp index 10724f3e2f..fc61ad81b2 100644 --- a/esphome/components/openthread_info/openthread_info_text_sensor.cpp +++ b/esphome/components/openthread_info/openthread_info_text_sensor.cpp @@ -3,8 +3,7 @@ #ifdef USE_OPENTHREAD #include "esphome/core/log.h" -namespace esphome { -namespace openthread_info { +namespace esphome::openthread_info { static const char *const TAG = "openthread_info"; @@ -19,6 +18,5 @@ void NetworkKeyOpenThreadInfo::dump_config() { LOG_TEXT_SENSOR("", "Network Key" void PanIdOpenThreadInfo::dump_config() { LOG_TEXT_SENSOR("", "PAN ID", this); } void ExtPanIdOpenThreadInfo::dump_config() { LOG_TEXT_SENSOR("", "Extended PAN ID", this); } -} // namespace openthread_info -} // namespace esphome +} // namespace esphome::openthread_info #endif diff --git a/esphome/components/openthread_info/openthread_info_text_sensor.h b/esphome/components/openthread_info/openthread_info_text_sensor.h index bbcd2d4655..35e46212cb 100644 --- a/esphome/components/openthread_info/openthread_info_text_sensor.h +++ b/esphome/components/openthread_info/openthread_info_text_sensor.h @@ -5,8 +5,7 @@ #include "esphome/core/component.h" #ifdef USE_OPENTHREAD -namespace esphome { -namespace openthread_info { +namespace esphome::openthread_info { using esphome::openthread::InstanceLock; @@ -213,6 +212,5 @@ class ExtPanIdOpenThreadInfo : public DatasetOpenThreadInfo, public text_sensor: std::array last_extpanid_{}; }; -} // namespace openthread_info -} // namespace esphome +} // namespace esphome::openthread_info #endif From 9cd888cef6cff7a9309ac01542d228ccc39ce1b3 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 16 Dec 2025 19:44:01 -0500 Subject: [PATCH 085/111] [spi] Use ESP-IDF driver for ESP32 Arduino (#12420) Co-authored-by: Claude --- esphome/components/spi/__init__.py | 13 ++++++----- esphome/components/spi/spi.cpp | 6 ++--- esphome/components/spi/spi.h | 31 ++++++++++---------------- esphome/components/spi/spi_arduino.cpp | 10 ++++----- esphome/components/spi/spi_esp_idf.cpp | 10 ++++----- 5 files changed, 30 insertions(+), 40 deletions(-) diff --git a/esphome/components/spi/__init__.py b/esphome/components/spi/__init__.py index 88bb3406e1..ad279dcf1a 100644 --- a/esphome/components/spi/__init__.py +++ b/esphome/components/spi/__init__.py @@ -272,10 +272,11 @@ def validate_spi_config(config): # Given an SPI index, convert to a string that represents the C++ object for it. def get_spi_interface(index): - if CORE.using_esp_idf: + platform = get_target_platform() + if platform == PLATFORM_ESP32: + # ESP32 uses ESP-IDF SPI driver for both Arduino and IDF frameworks return ["SPI2_HOST", "SPI3_HOST"][index] # Arduino code follows - platform = get_target_platform() if platform == PLATFORM_RP2040: return ["&SPI", "&SPI1"][index] if index == 0: @@ -356,7 +357,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(configs): cg.add_define("USE_SPI") cg.add_global(spi_ns.using) - if CORE.using_arduino: + if CORE.using_arduino and not CORE.is_esp32: cg.add_library("SPI", None) for spi in configs: var = cg.new_Pvariable(spi[CONF_ID]) @@ -447,13 +448,15 @@ def final_validate_device_schema(name: str, *, require_mosi: bool, require_miso: FILTER_SOURCE_FILES = filter_source_files_from_platform( { "spi_arduino.cpp": { - PlatformFramework.ESP32_ARDUINO, PlatformFramework.ESP8266_ARDUINO, PlatformFramework.RP2040_ARDUINO, PlatformFramework.BK72XX_ARDUINO, PlatformFramework.RTL87XX_ARDUINO, PlatformFramework.LN882X_ARDUINO, }, - "spi_esp_idf.cpp": {PlatformFramework.ESP32_IDF}, + "spi_esp_idf.cpp": { + PlatformFramework.ESP32_ARDUINO, + PlatformFramework.ESP32_IDF, + }, } ) diff --git a/esphome/components/spi/spi.cpp b/esphome/components/spi/spi.cpp index 00e9845a03..c4876d1a74 100644 --- a/esphome/components/spi/spi.cpp +++ b/esphome/components/spi/spi.cpp @@ -2,8 +2,7 @@ #include "esphome/core/log.h" #include "esphome/core/application.h" -namespace esphome { -namespace spi { +namespace esphome::spi { const char *const TAG = "spi"; @@ -119,5 +118,4 @@ uint16_t SPIDelegateBitBash::transfer_(uint16_t data, size_t num_bits) { return out_data; } -} // namespace spi -} // namespace esphome +} // namespace esphome::spi diff --git a/esphome/components/spi/spi.h b/esphome/components/spi/spi.h index 5bc80350da..43b55d72bc 100644 --- a/esphome/components/spi/spi.h +++ b/esphome/components/spi/spi.h @@ -1,4 +1,5 @@ #pragma once +#ifndef USE_ZEPHYR #include "esphome/core/application.h" #include "esphome/core/component.h" #include "esphome/core/hal.h" @@ -7,7 +8,13 @@ #include #include -#ifdef USE_ARDUINO +#ifdef USE_ESP32 + +#include "driver/spi_master.h" + +using SPIInterface = spi_host_device_t; + +#elif defined(USE_ARDUINO) #include @@ -17,26 +24,12 @@ using SPIInterface = SPIClassRP2040 *; using SPIInterface = SPIClass *; #endif -#endif - -#ifdef USE_ESP_IDF - -#include "driver/spi_master.h" - -using SPIInterface = spi_host_device_t; - -#endif // USE_ESP_IDF - -#ifdef USE_ZEPHYR -// TODO supprse clang-tidy. Remove after SPI driver for nrf52 is added. -using SPIInterface = void *; -#endif +#endif // USE_ESP32 / USE_ARDUINO /** * Implementation of SPI Controller mode. */ -namespace esphome { -namespace spi { +namespace esphome::spi { /// The bit-order for SPI devices. This defines how the data read from and written to the device is interpreted. enum SPIBitOrder { @@ -509,5 +502,5 @@ class SPIDevice : public SPIClient { template void transfer_array(std::array &data) { this->transfer_array(data.data(), N); } }; -} // namespace spi -} // namespace esphome +} // namespace esphome::spi +#endif // USE_ZEPHYR diff --git a/esphome/components/spi/spi_arduino.cpp b/esphome/components/spi/spi_arduino.cpp index a34e3c3c82..4267fe63ce 100644 --- a/esphome/components/spi/spi_arduino.cpp +++ b/esphome/components/spi/spi_arduino.cpp @@ -1,9 +1,8 @@ #include "spi.h" #include -namespace esphome { -namespace spi { -#ifdef USE_ARDUINO +namespace esphome::spi { +#if defined(USE_ARDUINO) && !defined(USE_ESP32) static const char *const TAG = "spi-esp-arduino"; class SPIDelegateHw : public SPIDelegate { @@ -101,6 +100,5 @@ SPIBus *SPIComponent::get_bus(SPIInterface interface, GPIOPin *clk, GPIOPin *sdo return new SPIBusHw(clk, sdo, sdi, interface); } -#endif // USE_ARDUINO -} // namespace spi -} // namespace esphome +#endif // USE_ARDUINO && !USE_ESP32 +} // namespace esphome::spi diff --git a/esphome/components/spi/spi_esp_idf.cpp b/esphome/components/spi/spi_esp_idf.cpp index 549f516eb1..a1837fa58d 100644 --- a/esphome/components/spi/spi_esp_idf.cpp +++ b/esphome/components/spi/spi_esp_idf.cpp @@ -1,10 +1,9 @@ #include "spi.h" #include -namespace esphome { -namespace spi { +namespace esphome::spi { -#ifdef USE_ESP_IDF +#ifdef USE_ESP32 static const char *const TAG = "spi-esp-idf"; static const size_t MAX_TRANSFER_SIZE = 4092; // dictated by ESP-IDF API. @@ -266,6 +265,5 @@ SPIBus *SPIComponent::get_bus(SPIInterface interface, GPIOPin *clk, GPIOPin *sdo return new SPIBusHw(clk, sdo, sdi, interface, data_pins); } -#endif -} // namespace spi -} // namespace esphome +#endif // USE_ESP32 +} // namespace esphome::spi From 18814f12dca7034e52935bbabc800427ffea501b Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 16 Dec 2025 19:44:14 -0500 Subject: [PATCH 086/111] [http_request] Use ESP-IDF for ESP32 Arduino (#12428) Co-authored-by: Claude --- esphome/components/http_request/__init__.py | 52 ++++++++----------- .../components/http_request/http_request.cpp | 6 +-- .../components/http_request/http_request.h | 6 +-- .../http_request/http_request_arduino.cpp | 15 ++---- .../http_request/http_request_arduino.h | 12 ++--- .../http_request/http_request_host.cpp | 6 +-- .../http_request/http_request_host.h | 7 ++- .../http_request/http_request_idf.cpp | 10 ++-- .../http_request/http_request_idf.h | 10 ++-- 9 files changed, 49 insertions(+), 75 deletions(-) diff --git a/esphome/components/http_request/__init__.py b/esphome/components/http_request/__init__.py index f4fa448c5b..b133aa69b2 100644 --- a/esphome/components/http_request/__init__.py +++ b/esphome/components/http_request/__init__.py @@ -69,9 +69,6 @@ def validate_url(value): def validate_ssl_verification(config): error_message = "" - if CORE.is_esp32 and not CORE.using_esp_idf and config[CONF_VERIFY_SSL]: - error_message = "ESPHome supports certificate verification only via ESP-IDF" - if CORE.is_rp2040 and config[CONF_VERIFY_SSL]: error_message = "ESPHome does not support certificate verification on RP2040" @@ -93,9 +90,9 @@ def validate_ssl_verification(config): def _declare_request_class(value): if CORE.is_host: return cv.declare_id(HttpRequestHost)(value) - if CORE.using_esp_idf: + if CORE.is_esp32: return cv.declare_id(HttpRequestIDF)(value) - if CORE.is_esp8266 or CORE.is_esp32 or CORE.is_rp2040: + if CORE.is_esp8266 or CORE.is_rp2040: return cv.declare_id(HttpRequestArduino)(value) return NotImplementedError @@ -121,11 +118,11 @@ CONFIG_SCHEMA = cv.All( cv.positive_not_null_time_period, cv.positive_time_period_milliseconds, ), - cv.SplitDefault(CONF_BUFFER_SIZE_RX, esp32_idf=512): cv.All( - cv.uint16_t, cv.only_with_esp_idf + cv.SplitDefault(CONF_BUFFER_SIZE_RX, esp32=512): cv.All( + cv.uint16_t, cv.only_on_esp32 ), - cv.SplitDefault(CONF_BUFFER_SIZE_TX, esp32_idf=512): cv.All( - cv.uint16_t, cv.only_with_esp_idf + cv.SplitDefault(CONF_BUFFER_SIZE_TX, esp32=512): cv.All( + cv.uint16_t, cv.only_on_esp32 ), cv.Optional(CONF_CA_CERTIFICATE_PATH): cv.All( cv.file_, @@ -158,25 +155,20 @@ async def to_code(config): cg.add(var.set_watchdog_timeout(timeout_ms)) if CORE.is_esp32: - if CORE.using_esp_idf: - cg.add(var.set_buffer_size_rx(config[CONF_BUFFER_SIZE_RX])) - cg.add(var.set_buffer_size_tx(config[CONF_BUFFER_SIZE_TX])) + cg.add(var.set_buffer_size_rx(config[CONF_BUFFER_SIZE_RX])) + cg.add(var.set_buffer_size_tx(config[CONF_BUFFER_SIZE_TX])) - esp32.add_idf_sdkconfig_option( - "CONFIG_MBEDTLS_CERTIFICATE_BUNDLE", - config.get(CONF_VERIFY_SSL), - ) - esp32.add_idf_sdkconfig_option( - "CONFIG_ESP_TLS_INSECURE", - not config.get(CONF_VERIFY_SSL), - ) - esp32.add_idf_sdkconfig_option( - "CONFIG_ESP_TLS_SKIP_SERVER_CERT_VERIFY", - not config.get(CONF_VERIFY_SSL), - ) - else: - cg.add_library("NetworkClientSecure", None) - cg.add_library("HTTPClient", None) + if config.get(CONF_VERIFY_SSL): + esp32.add_idf_sdkconfig_option("CONFIG_MBEDTLS_CERTIFICATE_BUNDLE", True) + + esp32.add_idf_sdkconfig_option( + "CONFIG_ESP_TLS_INSECURE", + not config.get(CONF_VERIFY_SSL), + ) + esp32.add_idf_sdkconfig_option( + "CONFIG_ESP_TLS_SKIP_SERVER_CERT_VERIFY", + not config.get(CONF_VERIFY_SSL), + ) if CORE.is_esp8266: cg.add_library("ESP8266HTTPClient", None) if CORE.is_rp2040 and CORE.using_arduino: @@ -327,13 +319,15 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( { "http_request_host.cpp": {PlatformFramework.HOST_NATIVE}, "http_request_arduino.cpp": { - PlatformFramework.ESP32_ARDUINO, PlatformFramework.ESP8266_ARDUINO, PlatformFramework.RP2040_ARDUINO, PlatformFramework.BK72XX_ARDUINO, PlatformFramework.RTL87XX_ARDUINO, PlatformFramework.LN882X_ARDUINO, }, - "http_request_idf.cpp": {PlatformFramework.ESP32_IDF}, + "http_request_idf.cpp": { + PlatformFramework.ESP32_ARDUINO, + PlatformFramework.ESP32_IDF, + }, } ) diff --git a/esphome/components/http_request/http_request.cpp b/esphome/components/http_request/http_request.cpp index 806354baf1..11dde4715a 100644 --- a/esphome/components/http_request/http_request.cpp +++ b/esphome/components/http_request/http_request.cpp @@ -4,8 +4,7 @@ #include -namespace esphome { -namespace http_request { +namespace esphome::http_request { static const char *const TAG = "http_request"; @@ -42,5 +41,4 @@ std::string HttpContainer::get_response_header(const std::string &header_name) { } } -} // namespace http_request -} // namespace esphome +} // namespace esphome::http_request diff --git a/esphome/components/http_request/http_request.h b/esphome/components/http_request/http_request.h index 8adf13b954..1b5fd9f00e 100644 --- a/esphome/components/http_request/http_request.h +++ b/esphome/components/http_request/http_request.h @@ -15,8 +15,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace http_request { +namespace esphome::http_request { struct Header { std::string name; @@ -305,5 +304,4 @@ template class HttpRequestSendAction : public Action { size_t max_response_buffer_size_{SIZE_MAX}; }; -} // namespace http_request -} // namespace esphome +} // namespace esphome::http_request diff --git a/esphome/components/http_request/http_request_arduino.cpp b/esphome/components/http_request/http_request_arduino.cpp index c64a7be554..a653942b18 100644 --- a/esphome/components/http_request/http_request_arduino.cpp +++ b/esphome/components/http_request/http_request_arduino.cpp @@ -1,6 +1,6 @@ #include "http_request_arduino.h" -#ifdef USE_ARDUINO +#if defined(USE_ARDUINO) && !defined(USE_ESP32) #include "esphome/components/network/util.h" #include "esphome/components/watchdog/watchdog.h" @@ -9,8 +9,7 @@ #include "esphome/core/defines.h" #include "esphome/core/log.h" -namespace esphome { -namespace http_request { +namespace esphome::http_request { static const char *const TAG = "http_request.arduino"; @@ -75,8 +74,6 @@ std::shared_ptr HttpRequestArduino::perform(const std::string &ur container->client_.setInsecure(); } bool status = container->client_.begin(url.c_str()); -#elif defined(USE_ESP32) - bool status = container->client_.begin(url.c_str()); #endif App.feed_wdt(); @@ -90,9 +87,6 @@ std::shared_ptr HttpRequestArduino::perform(const std::string &ur container->client_.setReuse(true); container->client_.setTimeout(this->timeout_); -#if defined(USE_ESP32) - container->client_.setConnectTimeout(this->timeout_); -#endif if (this->useragent_ != nullptr) { container->client_.setUserAgent(this->useragent_); @@ -177,7 +171,6 @@ void HttpContainerArduino::end() { this->client_.end(); } -} // namespace http_request -} // namespace esphome +} // namespace esphome::http_request -#endif // USE_ARDUINO +#endif // USE_ARDUINO && !USE_ESP32 diff --git a/esphome/components/http_request/http_request_arduino.h b/esphome/components/http_request/http_request_arduino.h index b736bb56d1..d9b5af9d81 100644 --- a/esphome/components/http_request/http_request_arduino.h +++ b/esphome/components/http_request/http_request_arduino.h @@ -2,9 +2,9 @@ #include "http_request.h" -#ifdef USE_ARDUINO +#if defined(USE_ARDUINO) && !defined(USE_ESP32) -#if defined(USE_ESP32) || defined(USE_RP2040) +#if defined(USE_RP2040) #include #include #endif @@ -15,8 +15,7 @@ #endif #endif -namespace esphome { -namespace http_request { +namespace esphome::http_request { class HttpRequestArduino; class HttpContainerArduino : public HttpContainer { @@ -36,7 +35,6 @@ class HttpRequestArduino : public HttpRequestComponent { const std::set &collect_headers) override; }; -} // namespace http_request -} // namespace esphome +} // namespace esphome::http_request -#endif // USE_ARDUINO +#endif // USE_ARDUINO && !USE_ESP32 diff --git a/esphome/components/http_request/http_request_host.cpp b/esphome/components/http_request/http_request_host.cpp index 402affc1d1..b94570be12 100644 --- a/esphome/components/http_request/http_request_host.cpp +++ b/esphome/components/http_request/http_request_host.cpp @@ -12,8 +12,7 @@ #include "esphome/core/application.h" #include "esphome/core/log.h" -namespace esphome { -namespace http_request { +namespace esphome::http_request { static const char *const TAG = "http_request.host"; @@ -139,7 +138,6 @@ void HttpContainerHost::end() { this->bytes_read_ = 0; } -} // namespace http_request -} // namespace esphome +} // namespace esphome::http_request #endif // USE_HOST diff --git a/esphome/components/http_request/http_request_host.h b/esphome/components/http_request/http_request_host.h index 886ba94938..32e149e6a3 100644 --- a/esphome/components/http_request/http_request_host.h +++ b/esphome/components/http_request/http_request_host.h @@ -2,8 +2,8 @@ #ifdef USE_HOST #include "http_request.h" -namespace esphome { -namespace http_request { + +namespace esphome::http_request { class HttpRequestHost; class HttpContainerHost : public HttpContainer { @@ -27,7 +27,6 @@ class HttpRequestHost : public HttpRequestComponent { const char *ca_path_{}; }; -} // namespace http_request -} // namespace esphome +} // namespace esphome::http_request #endif // USE_HOST diff --git a/esphome/components/http_request/http_request_idf.cpp b/esphome/components/http_request/http_request_idf.cpp index 34a3fb87eb..725a9c1c1e 100644 --- a/esphome/components/http_request/http_request_idf.cpp +++ b/esphome/components/http_request/http_request_idf.cpp @@ -1,6 +1,6 @@ #include "http_request_idf.h" -#ifdef USE_ESP_IDF +#ifdef USE_ESP32 #include "esphome/components/network/util.h" #include "esphome/components/watchdog/watchdog.h" @@ -14,8 +14,7 @@ #include "esp_task_wdt.h" -namespace esphome { -namespace http_request { +namespace esphome::http_request { static const char *const TAG = "http_request.idf"; @@ -245,7 +244,6 @@ void HttpContainerIDF::feed_wdt() { } } -} // namespace http_request -} // namespace esphome +} // namespace esphome::http_request -#endif // USE_ESP_IDF +#endif // USE_ESP32 diff --git a/esphome/components/http_request/http_request_idf.h b/esphome/components/http_request/http_request_idf.h index e51b3aaebc..4dc4736423 100644 --- a/esphome/components/http_request/http_request_idf.h +++ b/esphome/components/http_request/http_request_idf.h @@ -2,15 +2,14 @@ #include "http_request.h" -#ifdef USE_ESP_IDF +#ifdef USE_ESP32 #include #include #include #include -namespace esphome { -namespace http_request { +namespace esphome::http_request { class HttpContainerIDF : public HttpContainer { public: @@ -48,7 +47,6 @@ class HttpRequestIDF : public HttpRequestComponent { static esp_err_t http_event_handler(esp_http_client_event_t *evt); }; -} // namespace http_request -} // namespace esphome +} // namespace esphome::http_request -#endif // USE_ESP_IDF +#endif // USE_ESP32 From 08beaf875008019f6d6ecb24e8f55e1b0c9143bb Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 16 Dec 2025 20:06:12 -0500 Subject: [PATCH 087/111] [esp32] Remove Arduino-specific code from core.cpp (#12501) Co-authored-by: Claude --- .clang-tidy.hash | 2 +- esphome/components/esp32/__init__.py | 6 ---- esphome/components/esp32/core.cpp | 50 +++++----------------------- esphome/core/defines.h | 2 +- sdkconfig.defaults | 1 - tests/script/test_clang_tidy_hash.py | 2 +- 6 files changed, 12 insertions(+), 51 deletions(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index a3322ba731..13c7ce5f97 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -766420905c06eeb6c5f360f68fd965e5ddd9c4a5db6b823263d3ad3accb64a07 +6857423aecf90accd0a8bf584d36ee094a4938f872447a4efc05a2efc6dc6481 diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index b726a40508..1379fd705f 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -986,14 +986,8 @@ async def to_code(config): f"VERSION_CODE({framework_ver.major}, {framework_ver.minor}, {framework_ver.patch})" ), ) - add_idf_sdkconfig_option( - "CONFIG_ARDUINO_LOOP_STACK_SIZE", - conf[CONF_ADVANCED][CONF_LOOP_TASK_STACK_SIZE], - ) - add_idf_sdkconfig_option("CONFIG_AUTOSTART_ARDUINO", True) add_idf_sdkconfig_option("CONFIG_MBEDTLS_PSK_MODES", True) add_idf_sdkconfig_option("CONFIG_MBEDTLS_CERTIFICATE_BUNDLE", True) - add_idf_sdkconfig_option("CONFIG_ESP_PHY_REDUCE_TX_POWER", True) # ESP32-S2 Arduino: Disable USB Serial on boot to avoid TinyUSB dependency if get_esp32_variant() == VARIANT_ESP32S2: diff --git a/esphome/components/esp32/core.cpp b/esphome/components/esp32/core.cpp index 6215ff862f..d8cc909c83 100644 --- a/esphome/components/esp32/core.cpp +++ b/esphome/components/esp32/core.cpp @@ -4,25 +4,20 @@ #include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "preferences.h" -#include -#include +#include +#include #include #include #include #include -#include +#include +#include -#include +void setup(); // NOLINT(readability-redundant-declaration) +void loop(); // NOLINT(readability-redundant-declaration) -#ifdef USE_ARDUINO -#include -#else -#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 1, 0) -#include -#endif -void setup(); -void loop(); -#endif +// Weak stub for initArduino - overridden when the Arduino component is present +extern "C" __attribute__((weak)) void initArduino() {} namespace esphome { @@ -41,19 +36,7 @@ void arch_restart() { void arch_init() { // Enable the task watchdog only on the loop task (from which we're currently running) -#if defined(USE_ESP_IDF) esp_task_wdt_add(nullptr); - // Idle task watchdog is disabled on ESP-IDF -#elif defined(USE_ARDUINO) - enableLoopWDT(); - // Disable idle task watchdog on the core we're using (Arduino pins the task to a core) -#if defined(CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0) && CONFIG_ARDUINO_RUNNING_CORE == 0 - disableCore0WDT(); -#endif -#if defined(CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1) && CONFIG_ARDUINO_RUNNING_CORE == 1 - disableCore1WDT(); -#endif -#endif // If the bootloader was compiled with CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE the current // partition will get rolled back unless it is marked as valid. @@ -71,21 +54,10 @@ uint8_t progmem_read_byte(const uint8_t *addr) { return *addr; } uint32_t arch_get_cpu_cycle_count() { return esp_cpu_get_cycle_count(); } uint32_t arch_get_cpu_freq_hz() { uint32_t freq = 0; -#ifdef USE_ESP_IDF -#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 1, 0) esp_clk_tree_src_get_freq_hz(SOC_MOD_CLK_CPU, ESP_CLK_TREE_SRC_FREQ_PRECISION_CACHED, &freq); -#else - rtc_cpu_freq_config_t config; - rtc_clk_cpu_freq_get_config(&config); - freq = config.freq_mhz * 1000000U; -#endif -#elif defined(USE_ARDUINO) - freq = ESP.getCpuFreqMHz() * 1000000; -#endif return freq; } -#ifdef USE_ESP_IDF TaskHandle_t loop_task_handle = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) void loop_task(void *pv_params) { @@ -96,6 +68,7 @@ void loop_task(void *pv_params) { } extern "C" void app_main() { + initArduino(); esp32::setup_preferences(); #if CONFIG_FREERTOS_UNICORE xTaskCreate(loop_task, "loopTask", ESPHOME_LOOP_TASK_STACK_SIZE, nullptr, 1, &loop_task_handle); @@ -103,11 +76,6 @@ extern "C" void app_main() { xTaskCreatePinnedToCore(loop_task, "loopTask", ESPHOME_LOOP_TASK_STACK_SIZE, nullptr, 1, &loop_task_handle, 1); #endif } -#endif // USE_ESP_IDF - -#ifdef USE_ARDUINO -extern "C" void init() { esp32::setup_preferences(); } -#endif // USE_ARDUINO } // namespace esphome diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 750cab5bba..986ab9eff3 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -165,7 +165,6 @@ // IDF-specific feature flags #ifdef USE_ESP_IDF #define USE_MQTT_IDF_ENQUEUE -#define ESPHOME_LOOP_TASK_STACK_SIZE 8192 #endif // ESP32-specific feature flags @@ -197,6 +196,7 @@ #define ESPHOME_ESP32_BLE_GATTC_EVENT_HANDLER_COUNT 1 #define ESPHOME_ESP32_BLE_GATTS_EVENT_HANDLER_COUNT 1 #define ESPHOME_ESP32_BLE_BLE_STATUS_EVENT_HANDLER_COUNT 2 +#define ESPHOME_LOOP_TASK_STACK_SIZE 8192 #define USE_ESP32_CAMERA_JPEG_ENCODER #define USE_HTTP_REQUEST_RESPONSE #define USE_I2C diff --git a/sdkconfig.defaults b/sdkconfig.defaults index 322efb701a..72ca3f6e9c 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -13,7 +13,6 @@ CONFIG_ESP_TASK_WDT=y CONFIG_ESP_TASK_WDT_PANIC=y CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0=n CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1=n -CONFIG_AUTOSTART_ARDUINO=y # esp32_ble CONFIG_BT_ENABLED=y diff --git a/tests/script/test_clang_tidy_hash.py b/tests/script/test_clang_tidy_hash.py index b1690a6a2d..e19e7886a2 100644 --- a/tests/script/test_clang_tidy_hash.py +++ b/tests/script/test_clang_tidy_hash.py @@ -49,7 +49,7 @@ def test_calculate_clang_tidy_hash_with_sdkconfig(tmp_path: Path) -> None: clang_tidy_content = b"Checks: '-*,readability-*'\n" requirements_version = "clang-tidy==18.1.5" platformio_content = b"[env:esp32]\nplatform = espressif32\n" - sdkconfig_content = b"CONFIG_AUTOSTART_ARDUINO=y\n" + sdkconfig_content = b"" requirements_content = "clang-tidy==18.1.5\n" # Create temporary files From 431183eebcb2450be6e9327f7a26f5344ea05926 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 16 Dec 2025 20:07:09 -0500 Subject: [PATCH 088/111] [ledc,mqtt,resampler] Remove unnecessary ESP-IDF framework restrictions (#12442) Co-authored-by: Claude --- esphome/components/ledc/output.py | 2 +- esphome/components/mqtt/__init__.py | 10 +++++----- esphome/components/resampler/speaker/__init__.py | 4 +--- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/esphome/components/ledc/output.py b/esphome/components/ledc/output.py index 2133c4daf9..5e74677a84 100644 --- a/esphome/components/ledc/output.py +++ b/esphome/components/ledc/output.py @@ -48,7 +48,7 @@ CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( cv.Optional(CONF_FREQUENCY, default="1kHz"): cv.frequency, cv.Optional(CONF_CHANNEL): cv.int_range(min=0, max=15), cv.Optional(CONF_PHASE_ANGLE): cv.All( - cv.only_with_esp_idf, cv.angle, cv.float_range(min=0.0, max=360.0) + cv.angle, cv.float_range(min=0.0, max=360.0) ), } ).extend(cv.COMPONENT_SCHEMA) diff --git a/esphome/components/mqtt/__init__.py b/esphome/components/mqtt/__init__.py index 237ed2ce38..e73de49fef 100644 --- a/esphome/components/mqtt/__init__.py +++ b/esphome/components/mqtt/__init__.py @@ -233,11 +233,11 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_PASSWORD, default=""): cv.string, cv.Optional(CONF_CLEAN_SESSION, default=False): cv.boolean, cv.Optional(CONF_CLIENT_ID): cv.string, - cv.SplitDefault(CONF_IDF_SEND_ASYNC, esp32_idf=False): cv.All( - cv.boolean, cv.only_with_esp_idf + cv.SplitDefault(CONF_IDF_SEND_ASYNC, esp32=False): cv.All( + cv.boolean, cv.only_on_esp32 ), cv.Optional(CONF_CERTIFICATE_AUTHORITY): cv.All( - cv.string, cv.only_with_esp_idf + cv.string, cv.only_on_esp32 ), cv.Inclusive(CONF_CLIENT_CERTIFICATE, "cert-key-pair"): cv.All( cv.string, cv.only_on_esp32 @@ -245,8 +245,8 @@ CONFIG_SCHEMA = cv.All( cv.Inclusive(CONF_CLIENT_CERTIFICATE_KEY, "cert-key-pair"): cv.All( cv.string, cv.only_on_esp32 ), - cv.SplitDefault(CONF_SKIP_CERT_CN_CHECK, esp32_idf=False): cv.All( - cv.boolean, cv.only_with_esp_idf + cv.SplitDefault(CONF_SKIP_CERT_CN_CHECK, esp32=False): cv.All( + cv.boolean, cv.only_on_esp32 ), cv.Optional(CONF_DISCOVERY, default=True): cv.Any( cv.boolean, cv.one_of("CLEAN", upper=True) diff --git a/esphome/components/resampler/speaker/__init__.py b/esphome/components/resampler/speaker/__init__.py index def62547b2..7036862d14 100644 --- a/esphome/components/resampler/speaker/__init__.py +++ b/esphome/components/resampler/speaker/__init__.py @@ -63,9 +63,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional( CONF_BUFFER_DURATION, default="100ms" ): cv.positive_time_period_milliseconds, - cv.SplitDefault(CONF_TASK_STACK_IN_PSRAM, esp32_idf=False): cv.All( - cv.boolean, cv.only_with_esp_idf - ), + cv.Optional(CONF_TASK_STACK_IN_PSRAM, default=False): cv.boolean, cv.Optional(CONF_FILTERS, default=16): cv.int_range(min=2, max=1024), cv.Optional(CONF_TAPS, default=16): _validate_taps, } From 1122ec354f994823b2d4e1f32d0056fea35e2434 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 16 Dec 2025 20:07:57 -0500 Subject: [PATCH 089/111] [esp32] Add OTA rollback support (#12460) Co-authored-by: Claude --- esphome/components/esp32/__init__.py | 16 ++++++++++++++++ esphome/components/esp32/core.cpp | 14 +++++--------- esphome/components/safe_mode/safe_mode.cpp | 16 ++++++++++++++++ esphome/core/defines.h | 1 + tests/components/esp32/test.esp32-idf.yaml | 1 + 5 files changed, 39 insertions(+), 9 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 1379fd705f..4448b6bbe7 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -13,6 +13,7 @@ from esphome.const import ( CONF_ADVANCED, CONF_BOARD, CONF_COMPONENTS, + CONF_DISABLED, CONF_ESPHOME, CONF_FRAMEWORK, CONF_IGNORE_EFUSE_CUSTOM_MAC, @@ -24,6 +25,7 @@ from esphome.const import ( CONF_PLATFORMIO_OPTIONS, CONF_REF, CONF_REFRESH, + CONF_SAFE_MODE, CONF_SOURCE, CONF_TYPE, CONF_VARIANT, @@ -81,6 +83,7 @@ CONF_ASSERTION_LEVEL = "assertion_level" CONF_COMPILER_OPTIMIZATION = "compiler_optimization" CONF_ENABLE_IDF_EXPERIMENTAL_FEATURES = "enable_idf_experimental_features" CONF_ENABLE_LWIP_ASSERT = "enable_lwip_assert" +CONF_ENABLE_OTA_ROLLBACK = "enable_ota_rollback" CONF_EXECUTE_FROM_PSRAM = "execute_from_psram" CONF_RELEASE = "release" @@ -571,6 +574,13 @@ def final_validate(config): path=[CONF_FLASH_SIZE], ) ) + if advanced[CONF_ENABLE_OTA_ROLLBACK]: + safe_mode_config = full_config.get(CONF_SAFE_MODE) + if safe_mode_config is None or safe_mode_config.get(CONF_DISABLED, False): + _LOGGER.warning( + "OTA rollback requires safe_mode, disabling rollback support" + ) + advanced[CONF_ENABLE_OTA_ROLLBACK] = False if errs: raise cv.MultipleInvalid(errs) @@ -691,6 +701,7 @@ FRAMEWORK_SCHEMA = cv.Schema( cv.Optional(CONF_LOOP_TASK_STACK_SIZE, default=8192): cv.int_range( min=8192, max=32768 ), + cv.Optional(CONF_ENABLE_OTA_ROLLBACK, default=True): cv.boolean, } ), cv.Optional(CONF_COMPONENTS, default=[]): cv.ensure_list( @@ -1158,6 +1169,11 @@ async def to_code(config): "CONFIG_BOOTLOADER_CACHE_32BIT_ADDR_QUAD_FLASH", True ) + # Enable OTA rollback support + if advanced[CONF_ENABLE_OTA_ROLLBACK]: + add_idf_sdkconfig_option("CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE", True) + cg.add_define("USE_OTA_ROLLBACK") + cg.add_define("ESPHOME_LOOP_TASK_STACK_SIZE", advanced[CONF_LOOP_TASK_STACK_SIZE]) cg.add_define( diff --git a/esphome/components/esp32/core.cpp b/esphome/components/esp32/core.cpp index d8cc909c83..09a45c14a6 100644 --- a/esphome/components/esp32/core.cpp +++ b/esphome/components/esp32/core.cpp @@ -38,15 +38,11 @@ void arch_init() { // Enable the task watchdog only on the loop task (from which we're currently running) esp_task_wdt_add(nullptr); - // If the bootloader was compiled with CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE the current - // partition will get rolled back unless it is marked as valid. - esp_ota_img_states_t state; - const esp_partition_t *running = esp_ota_get_running_partition(); - if (esp_ota_get_state_partition(running, &state) == ESP_OK) { - if (state == ESP_OTA_IMG_PENDING_VERIFY) { - esp_ota_mark_app_valid_cancel_rollback(); - } - } + // Handle OTA rollback: mark partition valid immediately unless USE_OTA_ROLLBACK is enabled, + // in which case safe_mode will mark it valid after confirming successful boot. +#ifndef USE_OTA_ROLLBACK + esp_ota_mark_app_valid_cancel_rollback(); +#endif } void IRAM_ATTR HOT arch_feed_wdt() { esp_task_wdt_reset(); } diff --git a/esphome/components/safe_mode/safe_mode.cpp b/esphome/components/safe_mode/safe_mode.cpp index 62bbca4fb1..f8e5d7d8e5 100644 --- a/esphome/components/safe_mode/safe_mode.cpp +++ b/esphome/components/safe_mode/safe_mode.cpp @@ -9,6 +9,10 @@ #include #include +#ifdef USE_OTA_ROLLBACK +#include +#endif + namespace esphome { namespace safe_mode { @@ -32,6 +36,14 @@ void SafeModeComponent::dump_config() { ESP_LOGW(TAG, "SAFE MODE IS ACTIVE"); } } + +#ifdef USE_OTA_ROLLBACK + const esp_partition_t *last_invalid = esp_ota_get_last_invalid_partition(); + if (last_invalid != nullptr) { + ESP_LOGW(TAG, "OTA rollback detected! Rolled back from partition '%s'", last_invalid->label); + ESP_LOGW(TAG, "The device reset before the boot was marked successful"); + } +#endif } float SafeModeComponent::get_setup_priority() const { return setup_priority::AFTER_WIFI; } @@ -42,6 +54,10 @@ void SafeModeComponent::loop() { ESP_LOGI(TAG, "Boot seems successful; resetting boot loop counter"); this->clean_rtc(); this->boot_successful_ = true; +#ifdef USE_OTA_ROLLBACK + // Mark OTA partition as valid to prevent rollback + esp_ota_mark_app_valid_cancel_rollback(); +#endif // Disable loop since we no longer need to check this->disable_loop(); } diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 986ab9eff3..4cbe683723 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -170,6 +170,7 @@ // ESP32-specific feature flags #ifdef USE_ESP32 #define USE_ESPHOME_TASK_LOG_BUFFER +#define USE_OTA_ROLLBACK #define USE_BLUETOOTH_PROXY #define BLUETOOTH_PROXY_MAX_CONNECTIONS 3 diff --git a/tests/components/esp32/test.esp32-idf.yaml b/tests/components/esp32/test.esp32-idf.yaml index 6338fe98dd..0e220623a1 100644 --- a/tests/components/esp32/test.esp32-idf.yaml +++ b/tests/components/esp32/test.esp32-idf.yaml @@ -3,6 +3,7 @@ esp32: framework: type: esp-idf advanced: + enable_ota_rollback: true enable_lwip_mdns_queries: true enable_lwip_bridge_interface: true disable_libc_locks_in_iram: false # Test explicit opt-out of RAM optimization From 084f517a20dce7a259a67fa56c90ea4561cb07b1 Mon Sep 17 00:00:00 2001 From: Stuart Parmenter Date: Tue, 16 Dec 2025 19:12:33 -0800 Subject: [PATCH 090/111] [hub75] Add set_brightness action (#12521) --- esphome/components/hub75/display.py | 29 ++++++++++++++++++- esphome/components/hub75/hub75.cpp | 2 +- esphome/components/hub75/hub75_component.h | 12 ++++++-- tests/components/hub75/common.yaml | 12 ++++++++ tests/components/hub75/test.esp32-idf.yaml | 7 ++--- .../hub75/test.esp32-s3-idf-board.yaml | 7 ++--- tests/components/hub75/test.esp32-s3-idf.yaml | 7 ++--- 7 files changed, 57 insertions(+), 19 deletions(-) create mode 100644 tests/components/hub75/common.yaml diff --git a/esphome/components/hub75/display.py b/esphome/components/hub75/display.py index 81dd4ffc1c..f401f35406 100644 --- a/esphome/components/hub75/display.py +++ b/esphome/components/hub75/display.py @@ -1,6 +1,6 @@ from typing import Any -from esphome import pins +from esphome import automation, pins import esphome.codegen as cg from esphome.components import display from esphome.components.esp32 import add_idf_component @@ -17,6 +17,8 @@ from esphome.const import ( CONF_OE_PIN, CONF_UPDATE_INTERVAL, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType import esphome.final_validate as fv from esphome.types import ConfigType @@ -135,6 +137,7 @@ CLOCK_SPEEDS = { HUB75Display = hub75_ns.class_("HUB75Display", cg.PollingComponent, display.Display) Hub75Config = cg.global_ns.struct("Hub75Config") Hub75Pins = cg.global_ns.struct("Hub75Pins") +SetBrightnessAction = hub75_ns.class_("SetBrightnessAction", automation.Action) def _merge_board_pins(config: ConfigType) -> ConfigType: @@ -576,3 +579,27 @@ async def to_code(config: ConfigType) -> None: config[CONF_LAMBDA], [(display.DisplayRef, "it")], return_type=cg.void ) cg.add(var.set_writer(lambda_)) + + +@automation.register_action( + "hub75.set_brightness", + SetBrightnessAction, + cv.maybe_simple_value( + { + cv.GenerateID(): cv.use_id(HUB75Display), + cv.Required(CONF_BRIGHTNESS): cv.templatable(cv.int_range(min=0, max=255)), + }, + key=CONF_BRIGHTNESS, + ), +) +async def hub75_set_brightness_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + template_ = await cg.templatable(config[CONF_BRIGHTNESS], args, cg.uint8) + cg.add(var.set_brightness(template_)) + return var diff --git a/esphome/components/hub75/hub75.cpp b/esphome/components/hub75/hub75.cpp index e023e446c4..7317174831 100644 --- a/esphome/components/hub75/hub75.cpp +++ b/esphome/components/hub75/hub75.cpp @@ -179,7 +179,7 @@ void HOT HUB75Display::draw_pixels_at(int x_start, int y_start, int w, int h, co } } -void HUB75Display::set_brightness(int brightness) { +void HUB75Display::set_brightness(uint8_t brightness) { this->brightness_ = brightness; this->enabled_ = (brightness > 0); if (this->driver_ != nullptr) { diff --git a/esphome/components/hub75/hub75_component.h b/esphome/components/hub75/hub75_component.h index 49d4274483..f0e7ea10d5 100644 --- a/esphome/components/hub75/hub75_component.h +++ b/esphome/components/hub75/hub75_component.h @@ -5,6 +5,7 @@ #include #include "esphome/components/display/display_buffer.h" +#include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/hal.h" #include "esphome/core/log.h" @@ -34,7 +35,7 @@ class HUB75Display : public display::Display { display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override; // Brightness control (runtime mutable) - void set_brightness(int brightness); + void set_brightness(uint8_t brightness); protected: // Display internal methods @@ -46,10 +47,17 @@ class HUB75Display : public display::Display { Hub75Config config_; // Immutable configuration // Runtime state (mutable) - int brightness_{128}; + uint8_t brightness_{128}; bool enabled_{false}; }; +template class SetBrightnessAction : public Action, public Parented { + public: + TEMPLATABLE_VALUE(uint8_t, brightness) + + void play(const Ts &...x) override { this->parent_->set_brightness(this->brightness_.value(x...)); } +}; + } // namespace esphome::hub75 #endif diff --git a/tests/components/hub75/common.yaml b/tests/components/hub75/common.yaml new file mode 100644 index 0000000000..87e9e1c128 --- /dev/null +++ b/tests/components/hub75/common.yaml @@ -0,0 +1,12 @@ +esphome: + on_boot: + # Test simple value + - hub75.set_brightness: 200 + + # Test templatable value + - hub75.set_brightness: !lambda 'return 100;' + + # Test with explicit ID + - hub75.set_brightness: + id: my_hub75 + brightness: 50 diff --git a/tests/components/hub75/test.esp32-idf.yaml b/tests/components/hub75/test.esp32-idf.yaml index 9f6bd57292..dad2a02c24 100644 --- a/tests/components/hub75/test.esp32-idf.yaml +++ b/tests/components/hub75/test.esp32-idf.yaml @@ -1,8 +1,3 @@ -esp32: - board: esp32dev - framework: - type: esp-idf - display: - platform: hub75 id: my_hub75 @@ -37,3 +32,5 @@ display: then: lambda: |- ESP_LOGD("display", "1 -> 2"); + +<<: !include common.yaml diff --git a/tests/components/hub75/test.esp32-s3-idf-board.yaml b/tests/components/hub75/test.esp32-s3-idf-board.yaml index 9568ccf3aa..3723a80006 100644 --- a/tests/components/hub75/test.esp32-s3-idf-board.yaml +++ b/tests/components/hub75/test.esp32-s3-idf-board.yaml @@ -1,8 +1,3 @@ -esp32: - board: esp32-s3-devkitc-1 - framework: - type: esp-idf - display: - platform: hub75 id: hub75_display_board @@ -24,3 +19,5 @@ display: then: lambda: |- ESP_LOGD("display", "1 -> 2"); + +<<: !include common.yaml diff --git a/tests/components/hub75/test.esp32-s3-idf.yaml b/tests/components/hub75/test.esp32-s3-idf.yaml index db678c98a4..f8ee26e73d 100644 --- a/tests/components/hub75/test.esp32-s3-idf.yaml +++ b/tests/components/hub75/test.esp32-s3-idf.yaml @@ -1,8 +1,3 @@ -esp32: - board: esp32-s3-devkitc-1 - framework: - type: esp-idf - display: - platform: hub75 id: my_hub75 @@ -37,3 +32,5 @@ display: then: lambda: |- ESP_LOGD("display", "1 -> 2"); + +<<: !include common.yaml From a065990ab9f4818342579d0ed49fc1d1a1697002 Mon Sep 17 00:00:00 2001 From: Roger Fachini Date: Tue, 16 Dec 2025 19:20:12 -0800 Subject: [PATCH 091/111] [update] Add check action to trigger update checks (#12415) --- esphome/components/update/__init__.py | 18 ++++++++++++++++++ esphome/components/update/automation.h | 5 +++++ tests/components/update/common.yaml | 2 ++ 3 files changed, 25 insertions(+) diff --git a/esphome/components/update/__init__.py b/esphome/components/update/__init__.py index 7a381c85a8..e146f7e685 100644 --- a/esphome/components/update/__init__.py +++ b/esphome/components/update/__init__.py @@ -29,6 +29,9 @@ UpdateInfo = update_ns.struct("UpdateInfo") PerformAction = update_ns.class_( "PerformAction", automation.Action, cg.Parented.template(UpdateEntity) ) +CheckAction = update_ns.class_( + "CheckAction", automation.Action, cg.Parented.template(UpdateEntity) +) IsAvailableCondition = update_ns.class_( "IsAvailableCondition", automation.Condition, cg.Parented.template(UpdateEntity) ) @@ -143,6 +146,21 @@ async def update_perform_action_to_code(config, action_id, template_arg, args): return var +@automation.register_action( + "update.check", + CheckAction, + automation.maybe_simple_id( + { + cv.GenerateID(): cv.use_id(UpdateEntity), + } + ), +) +async def update_check_action_to_code(config, action_id, template_arg, args): + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + return var + + @automation.register_condition( "update.is_available", IsAvailableCondition, diff --git a/esphome/components/update/automation.h b/esphome/components/update/automation.h index 8563b855fe..af24c838b1 100644 --- a/esphome/components/update/automation.h +++ b/esphome/components/update/automation.h @@ -14,6 +14,11 @@ template class PerformAction : public Action, public Pare void play(const Ts &...x) override { this->parent_->perform(this->force_.value(x...)); } }; +template class CheckAction : public Action, public Parented { + public: + void play(const Ts &...x) override { this->parent_->check(); } +}; + template class IsAvailableCondition : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->state == UPDATE_STATE_AVAILABLE; } diff --git a/tests/components/update/common.yaml b/tests/components/update/common.yaml index 521a0a6a5c..40042945c8 100644 --- a/tests/components/update/common.yaml +++ b/tests/components/update/common.yaml @@ -9,6 +9,8 @@ esphome: update.is_available: then: - logger.log: "Update available" + else: + - update.check: - update.perform: force_update: true From 56c1691d72818ec41b42bb0c77d60c3d352af490 Mon Sep 17 00:00:00 2001 From: Thomas Rupprecht Date: Wed, 17 Dec 2025 04:52:28 +0100 Subject: [PATCH 092/111] [pca9685,sx126x,sx127x] Use frequency/float_range check (#12490) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/ade7880/sensor.py | 2 +- esphome/components/cc1101/__init__.py | 2 +- esphome/components/esp32_camera/__init__.py | 2 +- esphome/components/esp8266_pwm/output.py | 2 +- esphome/components/i2c/__init__.py | 2 +- esphome/components/ledc/output.py | 4 +++- esphome/components/libretiny_pwm/output.py | 4 +++- esphome/components/pca9685/__init__.py | 2 +- esphome/components/rp2040_pwm/output.py | 2 +- esphome/components/sx126x/__init__.py | 8 ++++++-- esphome/components/sx127x/__init__.py | 8 ++++++-- 11 files changed, 25 insertions(+), 13 deletions(-) diff --git a/esphome/components/ade7880/sensor.py b/esphome/components/ade7880/sensor.py index 39dbeb225f..beb74d7310 100644 --- a/esphome/components/ade7880/sensor.py +++ b/esphome/components/ade7880/sensor.py @@ -227,7 +227,7 @@ CONFIG_SCHEMA = cv.All( { cv.GenerateID(): cv.declare_id(ADE7880), cv.Optional(CONF_FREQUENCY, default="50Hz"): cv.All( - cv.frequency, cv.Range(min=45.0, max=66.0) + cv.frequency, cv.float_range(min=45.0, max=66.0) ), cv.Optional(CONF_IRQ0_PIN): pins.internal_gpio_input_pin_schema, cv.Required(CONF_IRQ1_PIN): pins.internal_gpio_input_pin_schema, diff --git a/esphome/components/cc1101/__init__.py b/esphome/components/cc1101/__init__.py index 1971817fb1..e314da7079 100644 --- a/esphome/components/cc1101/__init__.py +++ b/esphome/components/cc1101/__init__.py @@ -165,7 +165,7 @@ CONFIG_MAP = { CONF_OUTPUT_POWER: cv.float_range(min=-30.0, max=11.0), CONF_RX_ATTENUATION: cv.enum(RX_ATTENUATION, upper=False), CONF_DC_BLOCKING_FILTER: cv.boolean, - CONF_FREQUENCY: cv.All(cv.frequency, cv.float_range(min=300000000, max=928000000)), + CONF_FREQUENCY: cv.All(cv.frequency, cv.float_range(min=300.0e6, max=928.0e6)), CONF_IF_FREQUENCY: cv.All(cv.frequency, cv.float_range(min=25000, max=788000)), CONF_FILTER_BANDWIDTH: cv.All(cv.frequency, cv.float_range(min=58000, max=812000)), CONF_CHANNEL: cv.uint8_t, diff --git a/esphome/components/esp32_camera/__init__.py b/esphome/components/esp32_camera/__init__.py index d9d9bc0a56..4182683bdc 100644 --- a/esphome/components/esp32_camera/__init__.py +++ b/esphome/components/esp32_camera/__init__.py @@ -186,7 +186,7 @@ CONFIG_SCHEMA = cv.All( { cv.Required(CONF_PIN): pins.internal_gpio_input_pin_number, cv.Optional(CONF_FREQUENCY, default="20MHz"): cv.All( - cv.frequency, cv.Range(min=8e6, max=20e6) + cv.frequency, cv.float_range(min=8e6, max=20e6) ), } ), diff --git a/esphome/components/esp8266_pwm/output.py b/esphome/components/esp8266_pwm/output.py index 1404ef8ac3..2ddf4b9014 100644 --- a/esphome/components/esp8266_pwm/output.py +++ b/esphome/components/esp8266_pwm/output.py @@ -16,7 +16,7 @@ def valid_pwm_pin(value): esp8266_pwm_ns = cg.esphome_ns.namespace("esp8266_pwm") ESP8266PWM = esp8266_pwm_ns.class_("ESP8266PWM", output.FloatOutput, cg.Component) SetFrequencyAction = esp8266_pwm_ns.class_("SetFrequencyAction", automation.Action) -validate_frequency = cv.All(cv.frequency, cv.Range(min=1.0e-6)) +validate_frequency = cv.All(cv.frequency, cv.float_range(min=1.0e-6)) CONFIG_SCHEMA = cv.All( output.FLOAT_OUTPUT_SCHEMA.extend( diff --git a/esphome/components/i2c/__init__.py b/esphome/components/i2c/__init__.py index 9e7c9d702c..7706484e97 100644 --- a/esphome/components/i2c/__init__.py +++ b/esphome/components/i2c/__init__.py @@ -121,7 +121,7 @@ CONFIG_SCHEMA = cv.All( nrf52="100kHz", ): cv.All( cv.frequency, - cv.Range(min=0, min_included=False), + cv.float_range(min=0, min_included=False), ), cv.Optional(CONF_TIMEOUT): cv.All( cv.only_with_framework(["arduino", "esp-idf"]), diff --git a/esphome/components/ledc/output.py b/esphome/components/ledc/output.py index 5e74677a84..7a45b9dc3f 100644 --- a/esphome/components/ledc/output.py +++ b/esphome/components/ledc/output.py @@ -45,7 +45,9 @@ CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( { cv.Required(CONF_ID): cv.declare_id(LEDCOutput), cv.Required(CONF_PIN): pins.internal_gpio_output_pin_schema, - cv.Optional(CONF_FREQUENCY, default="1kHz"): cv.frequency, + cv.Optional(CONF_FREQUENCY, default="1kHz"): cv.All( + cv.frequency, cv.float_range(min=0, min_included=False) + ), cv.Optional(CONF_CHANNEL): cv.int_range(min=0, max=15), cv.Optional(CONF_PHASE_ANGLE): cv.All( cv.angle, cv.float_range(min=0.0, max=360.0) diff --git a/esphome/components/libretiny_pwm/output.py b/esphome/components/libretiny_pwm/output.py index 1eb4869da3..28556514d8 100644 --- a/esphome/components/libretiny_pwm/output.py +++ b/esphome/components/libretiny_pwm/output.py @@ -14,7 +14,9 @@ CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( { cv.Required(CONF_ID): cv.declare_id(LibreTinyPWM), cv.Required(CONF_PIN): pins.internal_gpio_output_pin_schema, - cv.Optional(CONF_FREQUENCY, default="1kHz"): cv.frequency, + cv.Optional(CONF_FREQUENCY, default="1kHz"): cv.All( + cv.frequency, cv.float_range(min=0, min_included=False) + ), } ).extend(cv.COMPONENT_SCHEMA) diff --git a/esphome/components/pca9685/__init__.py b/esphome/components/pca9685/__init__.py index 56101c2d62..0e238ff7da 100644 --- a/esphome/components/pca9685/__init__.py +++ b/esphome/components/pca9685/__init__.py @@ -38,7 +38,7 @@ CONFIG_SCHEMA = cv.All( { cv.GenerateID(): cv.declare_id(PCA9685Output), cv.Optional(CONF_FREQUENCY): cv.All( - cv.frequency, cv.Range(min=23.84, max=1525.88) + cv.frequency, cv.float_range(min=23.84, max=1525.88) ), cv.Optional(CONF_EXTERNAL_CLOCK_INPUT, default=False): cv.boolean, cv.Optional(CONF_PHASE_BALANCER, default="linear"): cv.enum( diff --git a/esphome/components/rp2040_pwm/output.py b/esphome/components/rp2040_pwm/output.py index ac1892fa29..441a52de7f 100644 --- a/esphome/components/rp2040_pwm/output.py +++ b/esphome/components/rp2040_pwm/output.py @@ -11,7 +11,7 @@ DEPENDENCIES = ["rp2040"] rp2040_pwm_ns = cg.esphome_ns.namespace("rp2040_pwm") RP2040PWM = rp2040_pwm_ns.class_("RP2040PWM", output.FloatOutput, cg.Component) SetFrequencyAction = rp2040_pwm_ns.class_("SetFrequencyAction", automation.Action) -validate_frequency = cv.All(cv.frequency, cv.Range(min=1.0e-6)) +validate_frequency = cv.All(cv.frequency, cv.float_range(min=1.0e-6)) CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( { diff --git a/esphome/components/sx126x/__init__.py b/esphome/components/sx126x/__init__.py index 4641db6483..ed878ed0d4 100644 --- a/esphome/components/sx126x/__init__.py +++ b/esphome/components/sx126x/__init__.py @@ -199,9 +199,13 @@ CONFIG_SCHEMA = ( cv.Optional(CONF_CRC_INITIAL, default=0x1D0F): cv.All( cv.hex_int, cv.Range(min=0, max=0xFFFF) ), - cv.Optional(CONF_DEVIATION, default=5000): cv.int_range(min=0, max=100000), + cv.Optional(CONF_DEVIATION, default="5kHz"): cv.All( + cv.frequency, cv.float_range(min=0, max=100000) + ), cv.Required(CONF_DIO1_PIN): pins.gpio_input_pin_schema, - cv.Required(CONF_FREQUENCY): cv.int_range(min=137000000, max=1020000000), + cv.Required(CONF_FREQUENCY): cv.All( + cv.frequency, cv.float_range(min=137.0e6, max=1020.0e6) + ), cv.Required(CONF_HW_VERSION): cv.one_of( "sx1261", "sx1262", "sx1268", "llcc68", lower=True ), diff --git a/esphome/components/sx127x/__init__.py b/esphome/components/sx127x/__init__.py index b569a75972..f3a9cca93f 100644 --- a/esphome/components/sx127x/__init__.py +++ b/esphome/components/sx127x/__init__.py @@ -196,9 +196,13 @@ CONFIG_SCHEMA = ( cv.Optional(CONF_BITSYNC): cv.boolean, cv.Optional(CONF_CODING_RATE, default="CR_4_5"): cv.enum(CODING_RATE), cv.Optional(CONF_CRC_ENABLE, default=False): cv.boolean, - cv.Optional(CONF_DEVIATION, default=5000): cv.int_range(min=0, max=100000), + cv.Optional(CONF_DEVIATION, default="5kHz"): cv.All( + cv.frequency, cv.float_range(min=0, max=100000) + ), cv.Optional(CONF_DIO0_PIN): pins.internal_gpio_input_pin_schema, - cv.Required(CONF_FREQUENCY): cv.int_range(min=137000000, max=1020000000), + cv.Required(CONF_FREQUENCY): cv.All( + cv.frequency, cv.float_range(min=137.0e6, max=1020.0e6) + ), cv.Required(CONF_MODULATION): cv.enum(MOD), cv.Optional(CONF_ON_PACKET): automation.validate_automation(single=True), cv.Optional(CONF_PA_PIN, default="BOOST"): cv.enum(PA_PIN), From 9928ab09cf94a63893d3fd10aaf0ffd937b147ea Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 17 Dec 2025 09:29:43 -0700 Subject: [PATCH 093/111] [time] Convert to C++17 nested namespace syntax (#12463) --- esphome/components/time/automation.cpp | 6 ++---- esphome/components/time/automation.h | 6 ++---- esphome/components/time/real_time_clock.cpp | 6 ++---- esphome/components/time/real_time_clock.h | 6 ++---- 4 files changed, 8 insertions(+), 16 deletions(-) diff --git a/esphome/components/time/automation.cpp b/esphome/components/time/automation.cpp index f7c1916ffe..8bc87878d1 100644 --- a/esphome/components/time/automation.cpp +++ b/esphome/components/time/automation.cpp @@ -4,8 +4,7 @@ #include -namespace esphome { -namespace time { +namespace esphome::time { static const char *const TAG = "automation"; static const int MAX_TIMESTAMP_DRIFT = 900; // how far can the clock drift before we consider @@ -92,5 +91,4 @@ SyncTrigger::SyncTrigger(RealTimeClock *rtc) : rtc_(rtc) { rtc->add_on_time_sync_callback([this]() { this->trigger(); }); } -} // namespace time -} // namespace esphome +} // namespace esphome::time diff --git a/esphome/components/time/automation.h b/esphome/components/time/automation.h index b5c8291533..4ccfc641d6 100644 --- a/esphome/components/time/automation.h +++ b/esphome/components/time/automation.h @@ -8,8 +8,7 @@ #include -namespace esphome { -namespace time { +namespace esphome::time { class CronTrigger : public Trigger<>, public Component { public: @@ -48,5 +47,4 @@ class SyncTrigger : public Trigger<>, public Component { protected: RealTimeClock *rtc_; }; -} // namespace time -} // namespace esphome +} // namespace esphome::time diff --git a/esphome/components/time/real_time_clock.cpp b/esphome/components/time/real_time_clock.cpp index 175cee0c1f..639af4457f 100644 --- a/esphome/components/time/real_time_clock.cpp +++ b/esphome/components/time/real_time_clock.cpp @@ -17,8 +17,7 @@ #include -namespace esphome { -namespace time { +namespace esphome::time { static const char *const TAG = "time"; @@ -78,5 +77,4 @@ void RealTimeClock::apply_timezone_() { } #endif -} // namespace time -} // namespace esphome +} // namespace esphome::time diff --git a/esphome/components/time/real_time_clock.h b/esphome/components/time/real_time_clock.h index 2f17bd86d6..70469e11b0 100644 --- a/esphome/components/time/real_time_clock.h +++ b/esphome/components/time/real_time_clock.h @@ -7,8 +7,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/time.h" -namespace esphome { -namespace time { +namespace esphome::time { /// The RealTimeClock class exposes common timekeeping functions via the device's local real-time clock. /// @@ -75,5 +74,4 @@ template class TimeHasTimeCondition : public Condition { RealTimeClock *parent_; }; -} // namespace time -} // namespace esphome +} // namespace esphome::time From bf6a03d1cf70f5ec8d04a129c09a941b14593b3f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 17 Dec 2025 09:29:51 -0700 Subject: [PATCH 094/111] [factory_reset] Optimize memory by storing interval as uint16_t seconds (#12462) --- esphome/components/factory_reset/__init__.py | 6 ++++-- esphome/components/factory_reset/factory_reset.cpp | 14 ++++++-------- esphome/components/factory_reset/factory_reset.h | 14 ++++++-------- 3 files changed, 16 insertions(+), 18 deletions(-) diff --git a/esphome/components/factory_reset/__init__.py b/esphome/components/factory_reset/__init__.py index f3cefe6970..5784d09ce6 100644 --- a/esphome/components/factory_reset/__init__.py +++ b/esphome/components/factory_reset/__init__.py @@ -50,7 +50,9 @@ CONFIG_SCHEMA = cv.All( cv.GenerateID(): cv.declare_id(FactoryResetComponent), cv.Optional(CONF_MAX_DELAY, default="10s"): cv.All( cv.positive_time_period_seconds, - cv.Range(min=cv.TimePeriod(milliseconds=1000)), + cv.Range( + min=cv.TimePeriod(seconds=1), max=cv.TimePeriod(seconds=65535) + ), ), cv.Optional(CONF_RESETS_REQUIRED): cv.positive_not_null_int, cv.Optional(CONF_ON_INCREMENT): validate_automation( @@ -82,7 +84,7 @@ async def to_code(config): var = cg.new_Pvariable( config[CONF_ID], reset_count, - config[CONF_MAX_DELAY].total_milliseconds, + config[CONF_MAX_DELAY].total_seconds, ) await cg.register_component(var, config) for conf in config.get(CONF_ON_INCREMENT, []): diff --git a/esphome/components/factory_reset/factory_reset.cpp b/esphome/components/factory_reset/factory_reset.cpp index c900759d90..bbbe399148 100644 --- a/esphome/components/factory_reset/factory_reset.cpp +++ b/esphome/components/factory_reset/factory_reset.cpp @@ -8,8 +8,7 @@ #if !defined(USE_RP2040) && !defined(USE_HOST) -namespace esphome { -namespace factory_reset { +namespace esphome::factory_reset { static const char *const TAG = "factory_reset"; static const uint32_t POWER_CYCLES_KEY = 0xFA5C0DE; @@ -33,10 +32,10 @@ void FactoryResetComponent::dump_config() { this->flash_.load(&count); ESP_LOGCONFIG(TAG, "Factory Reset by Reset:"); ESP_LOGCONFIG(TAG, - " Max interval between resets %" PRIu32 " seconds\n" + " Max interval between resets: %u seconds\n" " Current count: %u\n" " Factory reset after %u resets", - this->max_interval_ / 1000, count, this->required_count_); + this->max_interval_, count, this->required_count_); } void FactoryResetComponent::save_(uint8_t count) { @@ -61,8 +60,8 @@ void FactoryResetComponent::setup() { } this->save_(count); ESP_LOGD(TAG, "Power on reset detected, incremented count to %u", count); - this->set_timeout(this->max_interval_, [this]() { - ESP_LOGD(TAG, "No reset in the last %" PRIu32 " seconds, resetting count", this->max_interval_ / 1000); + this->set_timeout(static_cast(this->max_interval_) * 1000, [this]() { + ESP_LOGD(TAG, "No reset in the last %u seconds, resetting count", this->max_interval_); this->save_(0); // reset count }); } else { @@ -70,7 +69,6 @@ void FactoryResetComponent::setup() { } } -} // namespace factory_reset -} // namespace esphome +} // namespace esphome::factory_reset #endif // !defined(USE_RP2040) && !defined(USE_HOST) diff --git a/esphome/components/factory_reset/factory_reset.h b/esphome/components/factory_reset/factory_reset.h index 80942b29bd..990bb2edb6 100644 --- a/esphome/components/factory_reset/factory_reset.h +++ b/esphome/components/factory_reset/factory_reset.h @@ -9,12 +9,11 @@ #include #endif -namespace esphome { -namespace factory_reset { +namespace esphome::factory_reset { class FactoryResetComponent : public Component { public: - FactoryResetComponent(uint8_t required_count, uint32_t max_interval) - : required_count_(required_count), max_interval_(max_interval) {} + FactoryResetComponent(uint8_t required_count, uint16_t max_interval) + : max_interval_(max_interval), required_count_(required_count) {} void dump_config() override; void setup() override; @@ -26,9 +25,9 @@ class FactoryResetComponent : public Component { ~FactoryResetComponent() = default; void save_(uint8_t count); ESPPreferenceObject flash_{}; // saves the number of fast power cycles - uint8_t required_count_; // The number of boot attempts before fast boot is enabled - uint32_t max_interval_; // max interval between power cycles CallbackManager increment_callback_{}; + uint16_t max_interval_; // max interval between power cycles in seconds + uint8_t required_count_; // The number of boot attempts before fast boot is enabled }; class FastBootTrigger : public Trigger { @@ -37,7 +36,6 @@ class FastBootTrigger : public Trigger { parent->add_increment_callback([this](uint8_t current, uint8_t target) { this->trigger(current, target); }); } }; -} // namespace factory_reset -} // namespace esphome +} // namespace esphome::factory_reset #endif // !defined(USE_RP2040) && !defined(USE_HOST) From ab73ed76b8baf27cb17d5a4b4e11d01c55d0c386 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 17 Dec 2025 09:29:58 -0700 Subject: [PATCH 095/111] [esphome] Improve OTA field alignment to save 4 bytes on 32-bit (#12461) --- esphome/components/esphome/ota/ota_esphome.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index 057461e6a4..4412a65757 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -80,6 +80,7 @@ class ESPHomeOTAComponent : public ota::OTAComponent { #ifdef USE_OTA_PASSWORD std::string password_; + std::unique_ptr auth_buf_; #endif // USE_OTA_PASSWORD std::unique_ptr server_; @@ -93,7 +94,6 @@ class ESPHomeOTAComponent : public ota::OTAComponent { uint8_t handshake_buf_pos_{0}; uint8_t ota_features_{0}; #ifdef USE_OTA_PASSWORD - std::unique_ptr auth_buf_; uint8_t auth_buf_pos_{0}; uint8_t auth_type_{0}; // Store auth type to know which hasher to use #endif // USE_OTA_PASSWORD From 63fc8b4e5acca786f2fdf95269df98157d518ef6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 17 Dec 2025 09:30:12 -0700 Subject: [PATCH 096/111] [core] Refactor str_snake_case and str_sanitize to use constexpr helpers (#12454) --- esphome/core/helpers.cpp | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 55466fca8a..84079227e1 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -200,22 +200,27 @@ template std::string str_ctype_transform(const std::string &str) } std::string str_lower_case(const std::string &str) { return str_ctype_transform(str); } std::string str_upper_case(const std::string &str) { return str_ctype_transform(str); } +// Convert char to snake_case: lowercase and spaces to underscores +static constexpr char to_snake_case_char(char c) { + return (c == ' ') ? '_' : (c >= 'A' && c <= 'Z') ? c + ('a' - 'A') : c; +} +// Sanitize char: keep alphanumerics, dashes, underscores; replace others with underscore +static constexpr char to_sanitized_char(char c) { + return (c == '-' || c == '_' || (c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')) ? c : '_'; +} std::string str_snake_case(const std::string &str) { - std::string result; - result.resize(str.length()); - std::transform(str.begin(), str.end(), result.begin(), ::tolower); - std::replace(result.begin(), result.end(), ' ', '_'); + std::string result = str; + for (char &c : result) { + c = to_snake_case_char(c); + } return result; } std::string str_sanitize(const std::string &str) { - std::string out = str; - std::replace_if( - out.begin(), out.end(), - [](const char &c) { - return c != '-' && c != '_' && (c < '0' || c > '9') && (c < 'a' || c > 'z') && (c < 'A' || c > 'Z'); - }, - '_'); - return out; + std::string result = str; + for (char &c : result) { + c = to_sanitized_char(c); + } + return result; } std::string str_snprintf(const char *fmt, size_t len, ...) { std::string str; From e91c6a79ea31a076396a9b5a614e7bef7367353d Mon Sep 17 00:00:00 2001 From: Piotr Szulc Date: Wed, 17 Dec 2025 17:45:05 +0100 Subject: [PATCH 097/111] [deep_sleep] Deep sleep for BK72xx (#12267) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/deep_sleep/__init__.py | 111 ++++++++++++++++-- .../deep_sleep/deep_sleep_bk72xx.cpp | 64 ++++++++++ .../deep_sleep/deep_sleep_component.h | 31 ++++- .../deep_sleep/test.bk72xx-ard.yaml | 14 +++ 4 files changed, 206 insertions(+), 14 deletions(-) create mode 100644 esphome/components/deep_sleep/deep_sleep_bk72xx.cpp create mode 100644 tests/components/deep_sleep/test.bk72xx-ard.yaml diff --git a/esphome/components/deep_sleep/__init__.py b/esphome/components/deep_sleep/__init__.py index 8849fad7d6..3cfe7aa641 100644 --- a/esphome/components/deep_sleep/__init__.py +++ b/esphome/components/deep_sleep/__init__.py @@ -1,4 +1,4 @@ -from esphome import automation, pins +from esphome import automation, core, pins import esphome.codegen as cg from esphome.components import esp32, time from esphome.components.esp32 import ( @@ -23,16 +23,20 @@ from esphome.const import ( CONF_MINUTE, CONF_MODE, CONF_NUMBER, + CONF_PIN, CONF_PINS, CONF_RUN_DURATION, CONF_SECOND, CONF_SLEEP_DURATION, CONF_TIME_ID, CONF_WAKEUP_PIN, + PLATFORM_BK72XX, PLATFORM_ESP32, PLATFORM_ESP8266, PlatformFramework, ) +from esphome.core import CORE +from esphome.types import ConfigType WAKEUP_PINS = { VARIANT_ESP32: [ @@ -113,7 +117,7 @@ WAKEUP_PINS = { } -def validate_pin_number(value): +def validate_pin_number_esp32(value: ConfigType) -> ConfigType: valid_pins = WAKEUP_PINS.get(get_esp32_variant(), WAKEUP_PINS[VARIANT_ESP32]) if value[CONF_NUMBER] not in valid_pins: raise cv.Invalid( @@ -122,6 +126,51 @@ def validate_pin_number(value): return value +def validate_pin_number(value: ConfigType) -> ConfigType: + if not CORE.is_esp32: + return value + return validate_pin_number_esp32(value) + + +def validate_wakeup_pin( + value: ConfigType | list[ConfigType], +) -> list[ConfigType]: + if not isinstance(value, list): + processed_pins: list[ConfigType] = [{CONF_PIN: value}] + else: + processed_pins = list(value) + + for i, pin_config in enumerate(processed_pins): + # now validate each item + validated_pin = WAKEUP_PIN_SCHEMA(pin_config) + validate_pin_number(validated_pin[CONF_PIN]) + processed_pins[i] = validated_pin + + return processed_pins + + +def validate_config(config: ConfigType) -> ConfigType: + # right now only BK72XX supports the list format for wakeup pins + if CORE.is_bk72xx: + if CONF_WAKEUP_PIN_MODE in config: + wakeup_pins = config.get(CONF_WAKEUP_PIN, []) + if len(wakeup_pins) > 1: + raise cv.Invalid( + "You need to remove the global wakeup_pin_mode and define it per pin" + ) + if wakeup_pins: + wakeup_pins[0][CONF_WAKEUP_PIN_MODE] = config.pop(CONF_WAKEUP_PIN_MODE) + elif ( + isinstance(config.get(CONF_WAKEUP_PIN), list) + and len(config[CONF_WAKEUP_PIN]) > 1 + ): + raise cv.Invalid( + "Your platform does not support providing multiple entries in wakeup_pin" + ) + + return config + + def _validate_ex1_wakeup_mode(value): if value == "ALL_LOW": esp32.only_on_variant(supported=[VARIANT_ESP32], msg_prefix="ALL_LOW")(value) @@ -141,6 +190,15 @@ def _validate_ex1_wakeup_mode(value): return value +def _validate_sleep_duration(value: core.TimePeriod) -> core.TimePeriod: + if not CORE.is_bk72xx: + return value + max_duration = core.TimePeriod(hours=36) + if value > max_duration: + raise cv.Invalid("sleep duration cannot be more than 36 hours on BK72XX") + return value + + deep_sleep_ns = cg.esphome_ns.namespace("deep_sleep") DeepSleepComponent = deep_sleep_ns.class_("DeepSleepComponent", cg.Component) EnterDeepSleepAction = deep_sleep_ns.class_("EnterDeepSleepAction", automation.Action) @@ -186,6 +244,13 @@ WAKEUP_CAUSES_SCHEMA = cv.Schema( } ) +WAKEUP_PIN_SCHEMA = cv.Schema( + { + cv.Required(CONF_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_WAKEUP_PIN_MODE): cv.enum(WAKEUP_PIN_MODES, upper=True), + } +) + CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -194,14 +259,15 @@ CONFIG_SCHEMA = cv.All( cv.All(cv.only_on_esp32, WAKEUP_CAUSES_SCHEMA), cv.positive_time_period_milliseconds, ), - cv.Optional(CONF_SLEEP_DURATION): cv.positive_time_period_milliseconds, - cv.Optional(CONF_WAKEUP_PIN): cv.All( - cv.only_on_esp32, - pins.internal_gpio_input_pin_schema, - validate_pin_number, + cv.Optional(CONF_SLEEP_DURATION): cv.All( + cv.positive_time_period_milliseconds, + _validate_sleep_duration, ), + cv.Optional(CONF_WAKEUP_PIN): validate_wakeup_pin, cv.Optional(CONF_WAKEUP_PIN_MODE): cv.All( - cv.only_on_esp32, cv.enum(WAKEUP_PIN_MODES), upper=True + cv.only_on([PLATFORM_ESP32, PLATFORM_BK72XX]), + cv.enum(WAKEUP_PIN_MODES), + upper=True, ), cv.Optional(CONF_ESP32_EXT1_WAKEUP): cv.All( cv.only_on_esp32, @@ -212,7 +278,8 @@ CONFIG_SCHEMA = cv.All( cv.Schema( { cv.Required(CONF_PINS): cv.ensure_list( - pins.internal_gpio_input_pin_schema, validate_pin_number + pins.internal_gpio_input_pin_schema, + validate_pin_number_esp32, ), cv.Required(CONF_MODE): cv.All( cv.enum(EXT1_WAKEUP_MODES, upper=True), @@ -238,7 +305,8 @@ CONFIG_SCHEMA = cv.All( ), } ).extend(cv.COMPONENT_SCHEMA), - cv.only_on([PLATFORM_ESP32, PLATFORM_ESP8266]), + cv.only_on([PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_BK72XX]), + validate_config, ) @@ -249,8 +317,21 @@ async def to_code(config): if CONF_SLEEP_DURATION in config: cg.add(var.set_sleep_duration(config[CONF_SLEEP_DURATION])) if CONF_WAKEUP_PIN in config: - pin = await cg.gpio_pin_expression(config[CONF_WAKEUP_PIN]) - cg.add(var.set_wakeup_pin(pin)) + pins_as_list = config.get(CONF_WAKEUP_PIN, []) + if CORE.is_bk72xx: + cg.add(var.init_wakeup_pins_(len(pins_as_list))) + for item in pins_as_list: + cg.add( + var.add_wakeup_pin( + await cg.gpio_pin_expression(item[CONF_PIN]), + item.get( + CONF_WAKEUP_PIN_MODE, WakeupPinMode.WAKEUP_PIN_MODE_IGNORE + ), + ) + ) + else: + pin = await cg.gpio_pin_expression(pins_as_list[0][CONF_PIN]) + cg.add(var.set_wakeup_pin(pin)) if CONF_WAKEUP_PIN_MODE in config: cg.add(var.set_wakeup_pin_mode(config[CONF_WAKEUP_PIN_MODE])) if CONF_RUN_DURATION in config: @@ -305,7 +386,10 @@ DEEP_SLEEP_ENTER_SCHEMA = cv.All( cv.Schema( { cv.Exclusive(CONF_SLEEP_DURATION, "time"): cv.templatable( - cv.positive_time_period_milliseconds + cv.All( + cv.positive_time_period_milliseconds, + _validate_sleep_duration, + ) ), # Only on ESP32 due to how long the RTC on ESP8266 can stay asleep cv.Exclusive(CONF_UNTIL, "time"): cv.All( @@ -363,5 +447,6 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( PlatformFramework.ESP32_IDF, }, "deep_sleep_esp8266.cpp": {PlatformFramework.ESP8266_ARDUINO}, + "deep_sleep_bk72xx.cpp": {PlatformFramework.BK72XX_ARDUINO}, } ) diff --git a/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp b/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp new file mode 100644 index 0000000000..b5fadd7230 --- /dev/null +++ b/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp @@ -0,0 +1,64 @@ +#ifdef USE_BK72XX + +#include "deep_sleep_component.h" +#include "esphome/core/log.h" + +namespace esphome::deep_sleep { + +static const char *const TAG = "deep_sleep.bk72xx"; + +optional DeepSleepComponent::get_run_duration_() const { return this->run_duration_; } + +void DeepSleepComponent::dump_config_platform_() { + for (const WakeUpPinItem &item : this->wakeup_pins_) { + LOG_PIN(" Wakeup Pin: ", item.wakeup_pin); + } +} + +bool DeepSleepComponent::pin_prevents_sleep_(WakeUpPinItem &pinItem) const { + return (pinItem.wakeup_pin_mode == WAKEUP_PIN_MODE_KEEP_AWAKE && pinItem.wakeup_pin != nullptr && + !this->sleep_duration_.has_value() && (pinItem.wakeup_level == get_real_pin_state_(*pinItem.wakeup_pin))); +} + +bool DeepSleepComponent::prepare_to_sleep_() { + if (wakeup_pins_.size() > 0) { + for (WakeUpPinItem &item : this->wakeup_pins_) { + if (pin_prevents_sleep_(item)) { + // Defer deep sleep until inactive + if (!this->next_enter_deep_sleep_) { + this->status_set_warning(); + ESP_LOGV(TAG, "Waiting for pin to switch state to enter deep sleep..."); + } + this->next_enter_deep_sleep_ = true; + return false; + } + } + } + return true; +} + +void DeepSleepComponent::deep_sleep_() { + for (WakeUpPinItem &item : this->wakeup_pins_) { + if (item.wakeup_pin_mode == WAKEUP_PIN_MODE_INVERT_WAKEUP) { + if (item.wakeup_level == get_real_pin_state_(*item.wakeup_pin)) { + item.wakeup_level = !item.wakeup_level; + } + } + ESP_LOGI(TAG, "Wake-up on P%u %s (%d)", item.wakeup_pin->get_pin(), item.wakeup_level ? "HIGH" : "LOW", + static_cast(item.wakeup_pin_mode)); + } + + if (this->sleep_duration_.has_value()) + lt_deep_sleep_config_timer((*this->sleep_duration_ / 1000) & 0xFFFFFFFF); + + for (WakeUpPinItem &item : this->wakeup_pins_) { + lt_deep_sleep_config_gpio(1 << item.wakeup_pin->get_pin(), item.wakeup_level); + lt_deep_sleep_keep_floating_gpio(1 << item.wakeup_pin->get_pin(), true); + } + + lt_deep_sleep_enter(); +} + +} // namespace esphome::deep_sleep + +#endif // USE_BK72XX diff --git a/esphome/components/deep_sleep/deep_sleep_component.h b/esphome/components/deep_sleep/deep_sleep_component.h index bca3aa5e4d..3e6eda2257 100644 --- a/esphome/components/deep_sleep/deep_sleep_component.h +++ b/esphome/components/deep_sleep/deep_sleep_component.h @@ -19,7 +19,7 @@ namespace esphome { namespace deep_sleep { -#ifdef USE_ESP32 +#if defined(USE_ESP32) || defined(USE_BK72XX) /** The values of this enum define what should be done if deep sleep is set up with a wakeup pin on the ESP32 * and the scenario occurs that the wakeup pin is already in the wakeup state. @@ -33,7 +33,17 @@ enum WakeupPinMode { */ WAKEUP_PIN_MODE_INVERT_WAKEUP, }; +#endif +#if defined(USE_BK72XX) +struct WakeUpPinItem { + InternalGPIOPin *wakeup_pin; + WakeupPinMode wakeup_pin_mode; + bool wakeup_level; +}; +#endif // USE_BK72XX + +#ifdef USE_ESP32 #if defined(USE_ESP32) && !defined(USE_ESP32_VARIANT_ESP32C2) && !defined(USE_ESP32_VARIANT_ESP32C3) struct Ext1Wakeup { uint64_t mask; @@ -75,6 +85,13 @@ class DeepSleepComponent : public Component { void set_wakeup_pin_mode(WakeupPinMode wakeup_pin_mode); #endif // USE_ESP32 +#if defined(USE_BK72XX) + void init_wakeup_pins_(size_t capacity) { this->wakeup_pins_.init(capacity); } + void add_wakeup_pin(InternalGPIOPin *wakeup_pin, WakeupPinMode wakeup_pin_mode) { + this->wakeup_pins_.emplace_back(WakeUpPinItem{wakeup_pin, wakeup_pin_mode, !wakeup_pin->is_inverted()}); + } +#endif // USE_BK72XX + #if defined(USE_ESP32) #if !defined(USE_ESP32_VARIANT_ESP32C2) && !defined(USE_ESP32_VARIANT_ESP32C3) void set_ext1_wakeup(Ext1Wakeup ext1_wakeup); @@ -114,7 +131,17 @@ class DeepSleepComponent : public Component { bool prepare_to_sleep_(); void deep_sleep_(); +#ifdef USE_BK72XX + bool pin_prevents_sleep_(WakeUpPinItem &pinItem) const; + bool get_real_pin_state_(InternalGPIOPin &pin) const { return (pin.digital_read() ^ pin.is_inverted()); } +#endif // USE_BK72XX + optional sleep_duration_; + +#ifdef USE_BK72XX + FixedVector wakeup_pins_; +#endif // USE_BK72XX + #ifdef USE_ESP32 InternalGPIOPin *wakeup_pin_; WakeupPinMode wakeup_pin_mode_{WAKEUP_PIN_MODE_IGNORE}; @@ -124,8 +151,10 @@ class DeepSleepComponent : public Component { #endif optional touch_wakeup_; + optional wakeup_cause_to_run_duration_; #endif // USE_ESP32 + optional run_duration_; bool next_enter_deep_sleep_{false}; bool prevent_{false}; diff --git a/tests/components/deep_sleep/test.bk72xx-ard.yaml b/tests/components/deep_sleep/test.bk72xx-ard.yaml new file mode 100644 index 0000000000..2385fbb4db --- /dev/null +++ b/tests/components/deep_sleep/test.bk72xx-ard.yaml @@ -0,0 +1,14 @@ +deep_sleep: + run_duration: 30s + sleep_duration: 12h + wakeup_pin: + - pin: + number: P6 + - pin: P7 + wakeup_pin_mode: KEEP_AWAKE + - pin: + number: P10 + inverted: true + wakeup_pin_mode: INVERT_WAKEUP + +<<: !include common.yaml From 0707f383a69cbfc9c9d6365e1f43893ef16204ac Mon Sep 17 00:00:00 2001 From: Edward Firmo <94725493+edwardtfn@users.noreply.github.com> Date: Wed, 17 Dec 2025 17:45:17 +0100 Subject: [PATCH 098/111] [nextion] Use ESP-IDF for ESP32 Arduino (#9429) Co-authored-by: J. Nick Koston Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/nextion/__init__.py | 6 +- esphome/components/nextion/display.py | 5 +- esphome/components/nextion/nextion.h | 41 ++++------- .../nextion/nextion_upload_arduino.cpp | 73 ++++++++----------- ...pload_idf.cpp => nextion_upload_esp32.cpp} | 55 ++++++++------ 5 files changed, 84 insertions(+), 96 deletions(-) rename esphome/components/nextion/{nextion_upload_idf.cpp => nextion_upload_esp32.cpp} (90%) diff --git a/esphome/components/nextion/__init__.py b/esphome/components/nextion/__init__.py index 8adc49d68c..38f449dc03 100644 --- a/esphome/components/nextion/__init__.py +++ b/esphome/components/nextion/__init__.py @@ -13,14 +13,16 @@ CONF_SEND_TO_NEXTION = "send_to_nextion" FILTER_SOURCE_FILES = filter_source_files_from_platform( { - "nextion_upload_arduino.cpp": { + "nextion_upload_esp32.cpp": { PlatformFramework.ESP32_ARDUINO, + PlatformFramework.ESP32_IDF, + }, + "nextion_upload_arduino.cpp": { PlatformFramework.ESP8266_ARDUINO, PlatformFramework.RP2040_ARDUINO, PlatformFramework.BK72XX_ARDUINO, PlatformFramework.RTL87XX_ARDUINO, PlatformFramework.LN882X_ARDUINO, }, - "nextion_upload_idf.cpp": {PlatformFramework.ESP32_IDF}, } ) diff --git a/esphome/components/nextion/display.py b/esphome/components/nextion/display.py index ed6cd93027..b95df55a61 100644 --- a/esphome/components/nextion/display.py +++ b/esphome/components/nextion/display.py @@ -154,14 +154,11 @@ async def to_code(config): cg.add_define("USE_NEXTION_TFT_UPLOAD") cg.add(var.set_tft_url(config[CONF_TFT_URL])) if CORE.is_esp32: - if CORE.using_arduino: - cg.add_library("NetworkClientSecure", None) - cg.add_library("HTTPClient", None) esp32.add_idf_sdkconfig_option("CONFIG_ESP_TLS_INSECURE", True) esp32.add_idf_sdkconfig_option( "CONFIG_ESP_TLS_SKIP_SERVER_CERT_VERIFY", True ) - elif CORE.is_esp8266 and CORE.using_arduino: + elif CORE.is_esp8266: cg.add_library("ESP8266HTTPClient", None) if CONF_TOUCH_SLEEP_TIMEOUT in config: diff --git a/esphome/components/nextion/nextion.h b/esphome/components/nextion/nextion.h index 7e8f563a96..f4fc50ee7d 100644 --- a/esphome/components/nextion/nextion.h +++ b/esphome/components/nextion/nextion.h @@ -13,17 +13,12 @@ #include "esphome/components/display/display_color_utils.h" #ifdef USE_NEXTION_TFT_UPLOAD -#ifdef USE_ARDUINO #ifdef USE_ESP32 -#include -#endif // USE_ESP32 -#ifdef USE_ESP8266 +#include +#elif defined(USE_ESP8266) #include #include -#endif // USE_ESP8266 -#elif defined(USE_ESP_IDF) -#include -#endif // ARDUINO vs USE_ESP_IDF +#endif // USE_ESP32 vs USE_ESP8266 #endif // USE_NEXTION_TFT_UPLOAD namespace esphome { @@ -1078,7 +1073,7 @@ class Nextion : public NextionBase, public PollingComponent, public uart::UARTDe #ifdef USE_NEXTION_TFT_UPLOAD /** - * Set the tft file URL. https seems problematic with Arduino.. + * Set the tft file URL. */ void set_tft_url(const std::string &tft_url) { this->tft_url_ = tft_url; } @@ -1422,16 +1417,7 @@ class Nextion : public NextionBase, public PollingComponent, public uart::UARTDe uint32_t original_baud_rate_ = 0; bool upload_first_chunk_sent_ = false; -#ifdef USE_ARDUINO - /** - * will request chunk_size chunks from the web server - * and send each to the nextion - * @param HTTPClient http_client HTTP client handler. - * @param int range_start Position of next byte to transfer. - * @return position of last byte transferred, -1 for failure. - */ - int upload_by_chunks_(HTTPClient &http_client, uint32_t &range_start); -#elif defined(USE_ESP_IDF) +#ifdef USE_ESP32 /** * will request 4096 bytes chunks from the web server * and send each to Nextion @@ -1440,7 +1426,16 @@ class Nextion : public NextionBase, public PollingComponent, public uart::UARTDe * @return position of last byte transferred, -1 for failure. */ int upload_by_chunks_(esp_http_client_handle_t http_client, uint32_t &range_start); -#endif // USE_ARDUINO vs USE_ESP_IDF +#else + /** + * will request chunk_size chunks from the web server + * and send each to the nextion + * @param HTTPClient http_client HTTP client handler. + * @param int range_start Position of next byte to transfer. + * @return position of last byte transferred, -1 for failure. + */ + int upload_by_chunks_(HTTPClient &http_client, uint32_t &range_start); +#endif // USE_ESP32 vs others /** * Ends the upload process, restart Nextion and, if successful, @@ -1450,12 +1445,6 @@ class Nextion : public NextionBase, public PollingComponent, public uart::UARTDe */ bool upload_end_(bool successful); - /** - * Returns the ESP Free Heap memory. This is framework independent. - * @return Free Heap in bytes. - */ - uint32_t get_free_heap_(); - #endif // USE_NEXTION_TFT_UPLOAD bool check_connect_(); diff --git a/esphome/components/nextion/nextion_upload_arduino.cpp b/esphome/components/nextion/nextion_upload_arduino.cpp index baea938729..dfbb5a497e 100644 --- a/esphome/components/nextion/nextion_upload_arduino.cpp +++ b/esphome/components/nextion/nextion_upload_arduino.cpp @@ -1,7 +1,7 @@ #include "nextion.h" #ifdef USE_NEXTION_TFT_UPLOAD -#ifdef USE_ARDUINO +#ifndef USE_ESP32 #include #include "esphome/components/network/util.h" @@ -10,10 +10,6 @@ #include "esphome/core/log.h" #include "esphome/core/util.h" -#ifdef USE_ESP32 -#include -#endif - namespace esphome { namespace nextion { static const char *const TAG = "nextion.upload.arduino"; @@ -21,23 +17,17 @@ static const char *const TAG = "nextion.upload.arduino"; // Followed guide // https://unofficialnextion.com/t/nextion-upload-protocol-v1-2-the-fast-one/1044/2 -inline uint32_t Nextion::get_free_heap_() { -#if defined(USE_ESP32) - return heap_caps_get_free_size(MALLOC_CAP_INTERNAL); -#elif defined(USE_ESP8266) - return EspClass::getFreeHeap(); -#endif // USE_ESP32 vs USE_ESP8266 -} - int Nextion::upload_by_chunks_(HTTPClient &http_client, uint32_t &range_start) { uint32_t range_size = this->tft_size_ - range_start; - ESP_LOGV(TAG, "Heap: %" PRIu32, this->get_free_heap_()); + ESP_LOGV(TAG, "Heap: %" PRIu32, EspClass::getFreeHeap()); uint32_t range_end = ((upload_first_chunk_sent_ or this->tft_size_ < 4096) ? this->tft_size_ : 4096) - 1; ESP_LOGD(TAG, "Range start: %" PRIu32, range_start); if (range_size <= 0 or range_end <= range_start) { - ESP_LOGD(TAG, "Range end: %" PRIu32, range_end); - ESP_LOGD(TAG, "Range size: %" PRIu32, range_size); ESP_LOGE(TAG, "Invalid range"); + ESP_LOGD(TAG, + "Range end: %" PRIu32 "\n" + "Range size: %" PRIu32, + range_end, range_size); return -1; } @@ -95,14 +85,8 @@ int Nextion::upload_by_chunks_(HTTPClient &http_client, uint32_t &range_start) { this->recv_ret_string_(recv_string, upload_first_chunk_sent_ ? 500 : 5000, true); this->content_length_ -= read_len; const float upload_percentage = 100.0f * (this->tft_size_ - this->content_length_) / this->tft_size_; -#if defined(USE_ESP32) && defined(USE_PSRAM) - ESP_LOGD(TAG, "Upload: %0.2f%% (%" PRIu32 " left, heap: %" PRIu32 "+%" PRIu32 ")", upload_percentage, - this->content_length_, static_cast(heap_caps_get_free_size(MALLOC_CAP_INTERNAL)), - static_cast(heap_caps_get_free_size(MALLOC_CAP_SPIRAM))); -#else ESP_LOGD(TAG, "Upload: %0.2f%% (%" PRIu32 " left, heap: %" PRIu32 ")", upload_percentage, this->content_length_, - this->get_free_heap_()); -#endif + EspClass::getFreeHeap()); upload_first_chunk_sent_ = true; if (recv_string[0] == 0x08 && recv_string.size() == 5) { // handle partial upload request ESP_LOGD(TAG, "Recv: [%s]", @@ -148,9 +132,11 @@ int Nextion::upload_by_chunks_(HTTPClient &http_client, uint32_t &range_start) { } bool Nextion::upload_tft(uint32_t baud_rate, bool exit_reparse) { - ESP_LOGD(TAG, "TFT upload requested"); - ESP_LOGD(TAG, "Exit reparse: %s", YESNO(exit_reparse)); - ESP_LOGD(TAG, "URL: %s", this->tft_url_.c_str()); + ESP_LOGD(TAG, + "TFT upload requested\n" + "Exit reparse: %s\n" + "URL: %s", + YESNO(exit_reparse), this->tft_url_.c_str()); if (this->connection_state_.is_updating_) { ESP_LOGW(TAG, "Upload in progress"); @@ -180,15 +166,14 @@ bool Nextion::upload_tft(uint32_t baud_rate, bool exit_reparse) { ESP_LOGD(TAG, "Baud rate: %" PRIu32, baud_rate); // Define the configuration for the HTTP client - ESP_LOGV(TAG, "Init HTTP client"); - ESP_LOGV(TAG, "Heap: %" PRIu32, this->get_free_heap_()); + ESP_LOGV(TAG, + "Init HTTP client\n" + "Heap: %" PRIu32, + EspClass::getFreeHeap()); HTTPClient http_client; http_client.setTimeout(15000); // Yes 15 seconds.... Helps 8266s along bool begin_status = false; -#ifdef USE_ESP32 - begin_status = http_client.begin(this->tft_url_.c_str()); -#endif #ifdef USE_ESP8266 #if USE_ARDUINO_VERSION_CODE >= VERSION_CODE(2, 7, 0) http_client.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS); @@ -256,22 +241,24 @@ bool Nextion::upload_tft(uint32_t baud_rate, bool exit_reparse) { this->send_command_("sleep=0"); this->send_command_("dim=100"); delay(250); // NOLINT - ESP_LOGV(TAG, "Heap: %" PRIu32, this->get_free_heap_()); + ESP_LOGV(TAG, "Heap: %" PRIu32, EspClass::getFreeHeap()); App.feed_wdt(); char command[128]; // Tells the Nextion the content length of the tft file and baud rate it will be sent at // Once the Nextion accepts the command it will wait until the file is successfully uploaded // If it fails for any reason a power cycle of the display will be needed - sprintf(command, "whmi-wris %d,%d,1", this->content_length_, baud_rate); + snprintf(command, sizeof(command), "whmi-wris %" PRIu32 ",%" PRIu32 ",1", this->content_length_, baud_rate); // Clear serial receive buffer ESP_LOGV(TAG, "Clear RX buffer"); this->reset_(false); delay(250); // NOLINT - ESP_LOGV(TAG, "Heap: %" PRIu32, this->get_free_heap_()); - ESP_LOGV(TAG, "Upload cmd: %s", command); + ESP_LOGV(TAG, + "Heap: %" PRIu32 "\n" + "Upload cmd: %s", + EspClass::getFreeHeap(), command); this->send_command_(command); if (baud_rate != this->original_baud_rate_) { @@ -290,7 +277,7 @@ bool Nextion::upload_tft(uint32_t baud_rate, bool exit_reparse) { ESP_LOGD(TAG, "Upload resp: [%s] %zu B", format_hex_pretty(reinterpret_cast(response.data()), response.size()).c_str(), response.length()); - ESP_LOGV(TAG, "Heap: %" PRIu32, this->get_free_heap_()); + ESP_LOGV(TAG, "Heap: %" PRIu32, EspClass::getFreeHeap()); if (response.find(0x05) != std::string::npos) { ESP_LOGV(TAG, "Upload prep done"); @@ -302,10 +289,12 @@ bool Nextion::upload_tft(uint32_t baud_rate, bool exit_reparse) { return this->upload_end_(false); } - ESP_LOGD(TAG, "Upload TFT:"); - ESP_LOGD(TAG, " URL: %s", this->tft_url_.c_str()); - ESP_LOGD(TAG, " Size: %d bytes", this->content_length_); - ESP_LOGD(TAG, " Heap: %" PRIu32, this->get_free_heap_()); + ESP_LOGD(TAG, + "Upload TFT:\n" + " URL: %s\n" + " Size: %d bytes\n" + " Heap: %" PRIu32, + this->tft_url_.c_str(), this->content_length_, EspClass::getFreeHeap()); // Proceed with the content download as before @@ -322,7 +311,7 @@ bool Nextion::upload_tft(uint32_t baud_rate, bool exit_reparse) { return this->upload_end_(false); } App.feed_wdt(); - ESP_LOGV(TAG, "Heap: %" PRIu32 " left: %" PRIu32, this->get_free_heap_(), this->content_length_); + ESP_LOGV(TAG, "Heap: %" PRIu32 " left: %" PRIu32, EspClass::getFreeHeap(), this->content_length_); } ESP_LOGD(TAG, "Upload complete"); @@ -356,5 +345,5 @@ WiFiClient *Nextion::get_wifi_client_() { } // namespace nextion } // namespace esphome -#endif // USE_ARDUINO +#endif // NOT USE_ESP32 #endif // USE_NEXTION_TFT_UPLOAD diff --git a/esphome/components/nextion/nextion_upload_idf.cpp b/esphome/components/nextion/nextion_upload_esp32.cpp similarity index 90% rename from esphome/components/nextion/nextion_upload_idf.cpp rename to esphome/components/nextion/nextion_upload_esp32.cpp index 942e3dd6c3..29a7e3c8d7 100644 --- a/esphome/components/nextion/nextion_upload_idf.cpp +++ b/esphome/components/nextion/nextion_upload_esp32.cpp @@ -1,7 +1,7 @@ #include "nextion.h" #ifdef USE_NEXTION_TFT_UPLOAD -#ifdef USE_ESP_IDF +#ifdef USE_ESP32 #include #include @@ -14,7 +14,7 @@ namespace esphome { namespace nextion { -static const char *const TAG = "nextion.upload.idf"; +static const char *const TAG = "nextion.upload.esp32"; // Followed guide // https://unofficialnextion.com/t/nextion-upload-protocol-v1-2-the-fast-one/1044/2 @@ -25,8 +25,10 @@ int Nextion::upload_by_chunks_(esp_http_client_handle_t http_client, uint32_t &r uint32_t range_end = ((upload_first_chunk_sent_ or this->tft_size_ < 4096) ? this->tft_size_ : 4096) - 1; ESP_LOGD(TAG, "Range start: %" PRIu32, range_start); if (range_size <= 0 or range_end <= range_start) { - ESP_LOGD(TAG, "Range end: %" PRIu32, range_end); - ESP_LOGD(TAG, "Range size: %" PRIu32, range_size); + ESP_LOGD(TAG, + "Range end: %" PRIu32 "\n" + "Range size: %" PRIu32, + range_end, range_size); ESP_LOGE(TAG, "Invalid range"); return -1; } @@ -151,9 +153,11 @@ int Nextion::upload_by_chunks_(esp_http_client_handle_t http_client, uint32_t &r } bool Nextion::upload_tft(uint32_t baud_rate, bool exit_reparse) { - ESP_LOGD(TAG, "TFT upload requested"); - ESP_LOGD(TAG, "Exit reparse: %s", YESNO(exit_reparse)); - ESP_LOGD(TAG, "URL: %s", this->tft_url_.c_str()); + ESP_LOGD(TAG, + "TFT upload requested\n" + "Exit reparse: %s\n" + "URL: %s", + YESNO(exit_reparse), this->tft_url_.c_str()); if (this->connection_state_.is_updating_) { ESP_LOGW(TAG, "Upload in progress"); @@ -183,8 +187,10 @@ bool Nextion::upload_tft(uint32_t baud_rate, bool exit_reparse) { ESP_LOGD(TAG, "Baud rate: %" PRIu32, baud_rate); // Define the configuration for the HTTP client - ESP_LOGV(TAG, "Init HTTP client"); - ESP_LOGV(TAG, "Heap: %" PRIu32, esp_get_free_heap_size()); + ESP_LOGV(TAG, + "Init HTTP client\n" + "Heap: %" PRIu32, + esp_get_free_heap_size()); esp_http_client_config_t config = { .url = this->tft_url_.c_str(), .cert_pem = nullptr, @@ -208,8 +214,10 @@ bool Nextion::upload_tft(uint32_t baud_rate, bool exit_reparse) { } // Perform the HTTP request - ESP_LOGV(TAG, "Check connection"); - ESP_LOGV(TAG, "Heap: %" PRIu32, esp_get_free_heap_size()); + ESP_LOGV(TAG, + "Check connection\n" + "Heap: %" PRIu32, + esp_get_free_heap_size()); err = esp_http_client_perform(http_client); if (err != ESP_OK) { ESP_LOGE(TAG, "HTTP failed: %s", esp_err_to_name(err)); @@ -218,8 +226,10 @@ bool Nextion::upload_tft(uint32_t baud_rate, bool exit_reparse) { } // Check the HTTP Status Code - ESP_LOGV(TAG, "Check status"); - ESP_LOGV(TAG, "Heap: %" PRIu32, esp_get_free_heap_size()); + ESP_LOGV(TAG, + "Check status\n" + "Heap: %" PRIu32, + esp_get_free_heap_size()); int status_code = esp_http_client_get_status_code(http_client); if (status_code != 200 && status_code != 206) { return this->upload_end_(false); @@ -255,7 +265,7 @@ bool Nextion::upload_tft(uint32_t baud_rate, bool exit_reparse) { // Tells the Nextion the content length of the tft file and baud rate it will be sent at // Once the Nextion accepts the command it will wait until the file is successfully uploaded // If it fails for any reason a power cycle of the display will be needed - sprintf(command, "whmi-wris %" PRIu32 ",%" PRIu32 ",1", this->content_length_, baud_rate); + snprintf(command, sizeof(command), "whmi-wris %" PRIu32 ",%" PRIu32 ",1", this->content_length_, baud_rate); // Clear serial receive buffer ESP_LOGV(TAG, "Clear RX buffer"); @@ -300,10 +310,12 @@ bool Nextion::upload_tft(uint32_t baud_rate, bool exit_reparse) { return this->upload_end_(false); } - ESP_LOGD(TAG, "Uploading TFT:"); - ESP_LOGD(TAG, " URL: %s", this->tft_url_.c_str()); - ESP_LOGD(TAG, " Size: %" PRIu32 " bytes", this->content_length_); - ESP_LOGD(TAG, " Heap: %" PRIu32, esp_get_free_heap_size()); + ESP_LOGD(TAG, + "Uploading TFT:\n" + " URL: %s\n" + " Size: %" PRIu32 " bytes\n" + " Heap: %" PRIu32, + this->tft_url_.c_str(), this->content_length_, esp_get_free_heap_size()); // Proceed with the content download as before @@ -324,9 +336,8 @@ bool Nextion::upload_tft(uint32_t baud_rate, bool exit_reparse) { ESP_LOGV(TAG, "Heap: %" PRIu32 " left: %" PRIu32, esp_get_free_heap_size(), this->content_length_); } - ESP_LOGD(TAG, "TFT upload complete"); - - ESP_LOGD(TAG, "Close HTTP"); + ESP_LOGD(TAG, "TFT upload complete\n" + "Close HTTP"); esp_http_client_close(http_client); esp_http_client_cleanup(http_client); ESP_LOGV(TAG, "Connection closed"); @@ -336,5 +347,5 @@ bool Nextion::upload_tft(uint32_t baud_rate, bool exit_reparse) { } // namespace nextion } // namespace esphome -#endif // USE_ESP_IDF +#endif // USE_ESP32 #endif // USE_NEXTION_TFT_UPLOAD From f32bb618ac29bbef674a7735f8a6ffe20bb23918 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 17 Dec 2025 09:59:35 -0700 Subject: [PATCH 099/111] [esp32] Store preference keys as uint32_t, convert to string only at NVS boundary (#12494) --- esphome/components/esp32/preferences.cpp | 79 +++++++++++++----------- 1 file changed, 43 insertions(+), 36 deletions(-) diff --git a/esphome/components/esp32/preferences.cpp b/esphome/components/esp32/preferences.cpp index 7bdbb265ca..e19a85e4e3 100644 --- a/esphome/components/esp32/preferences.cpp +++ b/esphome/components/esp32/preferences.cpp @@ -4,26 +4,28 @@ #include "esphome/core/log.h" #include "esphome/core/preferences.h" #include -#include #include -#include -#include +#include #include +#include namespace esphome { namespace esp32 { static const char *const TAG = "esp32.preferences"; +// Buffer size for converting uint32_t to string: max "4294967295" (10 chars) + null terminator + 1 padding +static constexpr size_t KEY_BUFFER_SIZE = 12; + struct NVSData { - std::string key; + uint32_t key; std::unique_ptr data; size_t len; void set_data(const uint8_t *src, size_t size) { - data = std::make_unique(size); - memcpy(data.get(), src, size); - len = size; + this->data = std::make_unique(size); + memcpy(this->data.get(), src, size); + this->len = size; } }; @@ -31,27 +33,27 @@ static std::vector s_pending_save; // NOLINT(cppcoreguidelines-avoid-n class ESP32PreferenceBackend : public ESPPreferenceBackend { public: - std::string key; + uint32_t key; uint32_t nvs_handle; bool save(const uint8_t *data, size_t len) override { // try find in pending saves and update that for (auto &obj : s_pending_save) { - if (obj.key == key) { + if (obj.key == this->key) { obj.set_data(data, len); return true; } } NVSData save{}; - save.key = key; + save.key = this->key; save.set_data(data, len); s_pending_save.emplace_back(std::move(save)); - ESP_LOGVV(TAG, "s_pending_save: key: %s, len: %zu", key.c_str(), len); + ESP_LOGVV(TAG, "s_pending_save: key: %" PRIu32 ", len: %zu", this->key, len); return true; } bool load(uint8_t *data, size_t len) override { // try find in pending saves and load from that for (auto &obj : s_pending_save) { - if (obj.key == key) { + if (obj.key == this->key) { if (obj.len != len) { // size mismatch return false; @@ -61,22 +63,24 @@ class ESP32PreferenceBackend : public ESPPreferenceBackend { } } + char key_str[KEY_BUFFER_SIZE]; + snprintf(key_str, sizeof(key_str), "%" PRIu32, this->key); size_t actual_len; - esp_err_t err = nvs_get_blob(nvs_handle, key.c_str(), nullptr, &actual_len); + esp_err_t err = nvs_get_blob(this->nvs_handle, key_str, nullptr, &actual_len); if (err != 0) { - ESP_LOGV(TAG, "nvs_get_blob('%s'): %s - the key might not be set yet", key.c_str(), esp_err_to_name(err)); + ESP_LOGV(TAG, "nvs_get_blob('%s'): %s - the key might not be set yet", key_str, esp_err_to_name(err)); return false; } if (actual_len != len) { ESP_LOGVV(TAG, "NVS length does not match (%zu!=%zu)", actual_len, len); return false; } - err = nvs_get_blob(nvs_handle, key.c_str(), data, &len); + err = nvs_get_blob(this->nvs_handle, key_str, data, &len); if (err != 0) { - ESP_LOGV(TAG, "nvs_get_blob('%s') failed: %s", key.c_str(), esp_err_to_name(err)); + ESP_LOGV(TAG, "nvs_get_blob('%s') failed: %s", key_str, esp_err_to_name(err)); return false; } else { - ESP_LOGVV(TAG, "nvs_get_blob: key: %s, len: %zu", key.c_str(), len); + ESP_LOGVV(TAG, "nvs_get_blob: key: %s, len: %zu", key_str, len); } return true; } @@ -103,14 +107,12 @@ class ESP32Preferences : public ESPPreferences { } } ESPPreferenceObject make_preference(size_t length, uint32_t type, bool in_flash) override { - return make_preference(length, type); + return this->make_preference(length, type); } ESPPreferenceObject make_preference(size_t length, uint32_t type) override { auto *pref = new ESP32PreferenceBackend(); // NOLINT(cppcoreguidelines-owning-memory) - pref->nvs_handle = nvs_handle; - - uint32_t keyval = type; - pref->key = str_sprintf("%" PRIu32, keyval); + pref->nvs_handle = this->nvs_handle; + pref->key = type; return ESPPreferenceObject(pref); } @@ -123,17 +125,19 @@ class ESP32Preferences : public ESPPreferences { // goal try write all pending saves even if one fails int cached = 0, written = 0, failed = 0; esp_err_t last_err = ESP_OK; - std::string last_key{}; + uint32_t last_key = 0; // 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 %s has changed", save.key.c_str()); - if (is_changed(nvs_handle, save)) { - esp_err_t err = nvs_set_blob(nvs_handle, save.key.c_str(), save.data.get(), save.len); - ESP_LOGV(TAG, "sync: key: %s, len: %zu", save.key.c_str(), save.len); + 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); + 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) { - ESP_LOGV(TAG, "nvs_set_blob('%s', len=%zu) failed: %s", save.key.c_str(), save.len, esp_err_to_name(err)); + ESP_LOGV(TAG, "nvs_set_blob('%s', len=%zu) failed: %s", key_str, save.len, esp_err_to_name(err)); failed++; last_err = err; last_key = save.key; @@ -141,7 +145,7 @@ class ESP32Preferences : public ESPPreferences { } written++; } else { - ESP_LOGV(TAG, "NVS data not changed skipping %s len=%zu", save.key.c_str(), save.len); + ESP_LOGV(TAG, "NVS data not changed skipping %" PRIu32 " len=%zu", save.key, save.len); cached++; } s_pending_save.erase(s_pending_save.begin() + i); @@ -149,12 +153,12 @@ class ESP32Preferences : public ESPPreferences { ESP_LOGD(TAG, "Writing %d items: %d cached, %d written, %d failed", cached + written + failed, cached, written, failed); if (failed > 0) { - ESP_LOGE(TAG, "Writing %d items failed. Last error=%s for key=%s", failed, esp_err_to_name(last_err), - last_key.c_str()); + ESP_LOGE(TAG, "Writing %d items failed. Last error=%s for key=%" PRIu32, failed, esp_err_to_name(last_err), + last_key); } // note: commit on esp-idf currently is a no-op, nvs_set_blob always writes - esp_err_t err = nvs_commit(nvs_handle); + esp_err_t err = nvs_commit(this->nvs_handle); if (err != 0) { ESP_LOGV(TAG, "nvs_commit() failed: %s", esp_err_to_name(err)); return false; @@ -163,10 +167,13 @@ 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); + size_t actual_len; - esp_err_t err = nvs_get_blob(nvs_handle, to_save.key.c_str(), nullptr, &actual_len); + esp_err_t err = nvs_get_blob(nvs_handle, key_str, nullptr, &actual_len); if (err != 0) { - ESP_LOGV(TAG, "nvs_get_blob('%s'): %s - the key might not be set yet", to_save.key.c_str(), esp_err_to_name(err)); + ESP_LOGV(TAG, "nvs_get_blob('%s'): %s - the key might not be set yet", key_str, esp_err_to_name(err)); return true; } // Check size first before allocating memory @@ -174,9 +181,9 @@ class ESP32Preferences : public ESPPreferences { return true; } auto stored_data = std::make_unique(actual_len); - err = nvs_get_blob(nvs_handle, to_save.key.c_str(), stored_data.get(), &actual_len); + err = nvs_get_blob(nvs_handle, key_str, stored_data.get(), &actual_len); if (err != 0) { - ESP_LOGV(TAG, "nvs_get_blob('%s') failed: %s", to_save.key.c_str(), esp_err_to_name(err)); + ESP_LOGV(TAG, "nvs_get_blob('%s') failed: %s", key_str, esp_err_to_name(err)); return true; } return memcmp(to_save.data.get(), stored_data.get(), to_save.len) != 0; From 94763ebdabf614e13b844003212c9e0762622442 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 17 Dec 2025 09:59:40 -0700 Subject: [PATCH 100/111] [libretiny] Store preference keys as uint32_t, convert to string only at FlashDB boundary (#12500) --- esphome/components/libretiny/preferences.cpp | 74 +++++++++++--------- 1 file changed, 41 insertions(+), 33 deletions(-) diff --git a/esphome/components/libretiny/preferences.cpp b/esphome/components/libretiny/preferences.cpp index 871b186d8e..c21c5813a8 100644 --- a/esphome/components/libretiny/preferences.cpp +++ b/esphome/components/libretiny/preferences.cpp @@ -4,24 +4,27 @@ #include "esphome/core/log.h" #include "esphome/core/preferences.h" #include +#include #include #include -#include namespace esphome { namespace libretiny { static const char *const TAG = "lt.preferences"; +// Buffer size for converting uint32_t to string: max "4294967295" (10 chars) + null terminator + 1 padding +static constexpr size_t KEY_BUFFER_SIZE = 12; + struct NVSData { - std::string key; + uint32_t key; std::unique_ptr data; size_t len; void set_data(const uint8_t *src, size_t size) { - data = std::make_unique(size); - memcpy(data.get(), src, size); - len = size; + this->data = std::make_unique(size); + memcpy(this->data.get(), src, size); + this->len = size; } }; @@ -29,30 +32,30 @@ static std::vector s_pending_save; // NOLINT(cppcoreguidelines-avoid-n class LibreTinyPreferenceBackend : public ESPPreferenceBackend { public: - std::string key; + uint32_t key; fdb_kvdb_t db; fdb_blob_t blob; bool save(const uint8_t *data, size_t len) override { // try find in pending saves and update that for (auto &obj : s_pending_save) { - if (obj.key == key) { + if (obj.key == this->key) { obj.set_data(data, len); return true; } } NVSData save{}; - save.key = key; + save.key = this->key; save.set_data(data, len); s_pending_save.emplace_back(std::move(save)); - ESP_LOGVV(TAG, "s_pending_save: key: %s, len: %zu", key.c_str(), len); + ESP_LOGVV(TAG, "s_pending_save: key: %" PRIu32 ", len: %zu", this->key, len); return true; } bool load(uint8_t *data, size_t len) override { // try find in pending saves and load from that for (auto &obj : s_pending_save) { - if (obj.key == key) { + if (obj.key == this->key) { if (obj.len != len) { // size mismatch return false; @@ -62,13 +65,15 @@ class LibreTinyPreferenceBackend : public ESPPreferenceBackend { } } - fdb_blob_make(blob, data, len); - size_t actual_len = fdb_kv_get_blob(db, key.c_str(), blob); + char key_str[KEY_BUFFER_SIZE]; + snprintf(key_str, sizeof(key_str), "%" PRIu32, this->key); + fdb_blob_make(this->blob, data, len); + size_t actual_len = fdb_kv_get_blob(this->db, key_str, this->blob); if (actual_len != len) { ESP_LOGVV(TAG, "NVS length does not match (%zu!=%zu)", actual_len, len); return false; } else { - ESP_LOGVV(TAG, "fdb_kv_get_blob: key: %s, len: %zu", key.c_str(), len); + ESP_LOGVV(TAG, "fdb_kv_get_blob: key: %s, len: %zu", key_str, len); } return true; } @@ -90,16 +95,14 @@ class LibreTinyPreferences : public ESPPreferences { } ESPPreferenceObject make_preference(size_t length, uint32_t type, bool in_flash) override { - return make_preference(length, type); + return this->make_preference(length, type); } ESPPreferenceObject make_preference(size_t length, uint32_t type) override { auto *pref = new LibreTinyPreferenceBackend(); // NOLINT(cppcoreguidelines-owning-memory) - pref->db = &db; - pref->blob = &blob; - - uint32_t keyval = type; - pref->key = str_sprintf("%u", keyval); + pref->db = &this->db; + pref->blob = &this->blob; + pref->key = type; return ESPPreferenceObject(pref); } @@ -112,18 +115,20 @@ class LibreTinyPreferences : public ESPPreferences { // goal try write all pending saves even if one fails int cached = 0, written = 0, failed = 0; fdb_err_t last_err = FDB_NO_ERR; - std::string last_key{}; + uint32_t last_key = 0; // 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 %s has changed", save.key.c_str()); - if (is_changed(&db, save)) { - ESP_LOGV(TAG, "sync: key: %s, len: %zu", save.key.c_str(), save.len); - fdb_blob_make(&blob, save.data.get(), save.len); - fdb_err_t err = fdb_kv_set_blob(&db, save.key.c_str(), &blob); + 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); + 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); if (err != FDB_NO_ERR) { - ESP_LOGV(TAG, "fdb_kv_set_blob('%s', len=%zu) failed: %d", save.key.c_str(), save.len, err); + ESP_LOGV(TAG, "fdb_kv_set_blob('%s', len=%zu) failed: %d", key_str, save.len, err); failed++; last_err = err; last_key = save.key; @@ -131,7 +136,7 @@ class LibreTinyPreferences : public ESPPreferences { } written++; } else { - ESP_LOGD(TAG, "FDB data not changed; skipping %s len=%zu", save.key.c_str(), save.len); + ESP_LOGD(TAG, "FDB data not changed; skipping %" PRIu32 " len=%zu", save.key, save.len); cached++; } s_pending_save.erase(s_pending_save.begin() + i); @@ -139,17 +144,20 @@ class LibreTinyPreferences : public ESPPreferences { ESP_LOGD(TAG, "Writing %d items: %d cached, %d written, %d failed", cached + written + failed, cached, written, failed); if (failed > 0) { - ESP_LOGE(TAG, "Writing %d items failed. Last error=%d for key=%s", failed, last_err, last_key.c_str()); + ESP_LOGE(TAG, "Writing %d items failed. Last error=%d for key=%" PRIu32, failed, last_err, last_key); } 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); + struct fdb_kv kv; - fdb_kv_t kvp = fdb_kv_get_obj(db, to_save.key.c_str(), &kv); + fdb_kv_t kvp = fdb_kv_get_obj(db, key_str, &kv); if (kvp == nullptr) { - ESP_LOGV(TAG, "fdb_kv_get_obj('%s'): nullptr - the key might not be set yet", to_save.key.c_str()); + ESP_LOGV(TAG, "fdb_kv_get_obj('%s'): nullptr - the key might not be set yet", key_str); return true; } @@ -160,10 +168,10 @@ class LibreTinyPreferences : public ESPPreferences { // Allocate buffer on heap to avoid stack allocation for large data auto stored_data = std::make_unique(kv.value_len); - fdb_blob_make(&blob, stored_data.get(), kv.value_len); - size_t actual_len = fdb_kv_get_blob(db, to_save.key.c_str(), &blob); + fdb_blob_make(&this->blob, stored_data.get(), kv.value_len); + size_t actual_len = fdb_kv_get_blob(db, key_str, &this->blob); if (actual_len != kv.value_len) { - ESP_LOGV(TAG, "fdb_kv_get_blob('%s') len mismatch: %u != %u", to_save.key.c_str(), actual_len, kv.value_len); + ESP_LOGV(TAG, "fdb_kv_get_blob('%s') len mismatch: %u != %u", key_str, actual_len, kv.value_len); return true; } From 42e061c9aeb39145e82d66dc84a58d711aa6ab64 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 17 Dec 2025 10:00:19 -0700 Subject: [PATCH 101/111] [text] Avoid string copies in callbacks by passing const ref (#12504) --- esphome/components/text/text.cpp | 2 +- esphome/components/text/text.h | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/text/text.cpp b/esphome/components/text/text.cpp index 933d82c85c..d06c350832 100644 --- a/esphome/components/text/text.cpp +++ b/esphome/components/text/text.cpp @@ -23,7 +23,7 @@ void Text::publish_state(const std::string &state) { #endif } -void Text::add_on_state_callback(std::function &&callback) { +void Text::add_on_state_callback(std::function &&callback) { this->state_callback_.add(std::move(callback)); } diff --git a/esphome/components/text/text.h b/esphome/components/text/text.h index 74d08eda8a..f24464cb20 100644 --- a/esphome/components/text/text.h +++ b/esphome/components/text/text.h @@ -31,7 +31,7 @@ class Text : public EntityBase { /// Instantiate a TextCall object to modify this text component's state. TextCall make_call() { return TextCall(this); } - void add_on_state_callback(std::function &&callback); + void add_on_state_callback(std::function &&callback); protected: friend class TextCall; @@ -44,7 +44,7 @@ class Text : public EntityBase { */ virtual void control(const std::string &value) = 0; - CallbackManager state_callback_; + CallbackManager state_callback_; }; } // namespace text From 0e71fa97a70d1768682a54e004b30f5c269a3cf2 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 17 Dec 2025 12:18:25 -0500 Subject: [PATCH 102/111] [spi] Add SPIInterface stub for clang-tidy on unsupported platforms (#12532) Co-authored-by: Claude --- esphome/components/spi/spi.h | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/esphome/components/spi/spi.h b/esphome/components/spi/spi.h index 43b55d72bc..256cbcc65f 100644 --- a/esphome/components/spi/spi.h +++ b/esphome/components/spi/spi.h @@ -1,5 +1,4 @@ #pragma once -#ifndef USE_ZEPHYR #include "esphome/core/application.h" #include "esphome/core/component.h" #include "esphome/core/hal.h" @@ -24,6 +23,10 @@ using SPIInterface = SPIClassRP2040 *; using SPIInterface = SPIClass *; #endif +#elif defined(CLANG_TIDY) + +using SPIInterface = void *; // Stub for platforms without SPI (e.g., Zephyr) + #endif // USE_ESP32 / USE_ARDUINO /** @@ -503,4 +506,3 @@ class SPIDevice : public SPIClient { }; } // namespace esphome::spi -#endif // USE_ZEPHYR From d7b04a3d18a94dc77c2cdf4a9097f7557e8e9e44 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 17 Dec 2025 13:59:49 -0500 Subject: [PATCH 103/111] [nextion] Fix clang-tidy error on Zephyr for HTTPClient (#12538) Co-authored-by: Claude --- esphome/components/nextion/nextion.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/nextion/nextion.h b/esphome/components/nextion/nextion.h index f4fc50ee7d..331e901578 100644 --- a/esphome/components/nextion/nextion.h +++ b/esphome/components/nextion/nextion.h @@ -1426,7 +1426,7 @@ class Nextion : public NextionBase, public PollingComponent, public uart::UARTDe * @return position of last byte transferred, -1 for failure. */ int upload_by_chunks_(esp_http_client_handle_t http_client, uint32_t &range_start); -#else +#elif defined(USE_ARDUINO) /** * will request chunk_size chunks from the web server * and send each to the nextion @@ -1435,7 +1435,7 @@ class Nextion : public NextionBase, public PollingComponent, public uart::UARTDe * @return position of last byte transferred, -1 for failure. */ int upload_by_chunks_(HTTPClient &http_client, uint32_t &range_start); -#endif // USE_ESP32 vs others +#endif // USE_ESP32 vs USE_ARDUINO /** * Ends the upload process, restart Nextion and, if successful, From f9720026d0aa7c102de65f14856efb3138bcb43b Mon Sep 17 00:00:00 2001 From: Anna Oake Date: Wed, 17 Dec 2025 20:19:18 +0100 Subject: [PATCH 104/111] [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 b02696edc09996e7420563a22094a3b80bcf9add Mon Sep 17 00:00:00 2001 From: Jack Wilsdon Date: Wed, 17 Dec 2025 20:40:31 +0000 Subject: [PATCH 105/111] [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 3d673ac55e571624620b72fb4a3b9e934ea7670b Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 17 Dec 2025 16:13:18 -0500 Subject: [PATCH 106/111] [ci] Check changed headers in clang-tidy when using --changed (#12540) Co-authored-by: Claude --- script/clang-tidy | 26 +++++++++++++++++++------- script/helpers.py | 29 ++++++++++++++++------------- 2 files changed, 35 insertions(+), 20 deletions(-) diff --git a/script/clang-tidy b/script/clang-tidy index 142b616119..17bcafacc7 100755 --- a/script/clang-tidy +++ b/script/clang-tidy @@ -253,19 +253,31 @@ def main(): print(f"Split {args.split_at}/{args.split_num}: checking {len(files)} files") # Print file count before adding header file - print(f"\nTotal files to check: {len(files)}") + print(f"\nTotal cpp files to check: {len(files)}") + + # Add header file for checking (before early exit check) + if args.all_headers and args.split_at in (None, 1): + # When --changed is used, only include changed headers instead of all headers + if args.changed: + all_headers = [ + os.path.relpath(p, cwd) for p in git_ls_files(["esphome/**/*.h"]) + ] + changed_headers = filter_changed(all_headers) + if changed_headers: + build_all_include(changed_headers) + files.insert(0, temp_header_file) + else: + print("No changed headers to check") + else: + build_all_include() + files.insert(0, temp_header_file) + print(f"Added all-include header file, new total: {len(files)}") # Early exit if no files to check if not files: print("No files to check - exiting early") return 0 - # Only build header file if we have actual files to check - if args.all_headers and args.split_at in (None, 1): - build_all_include() - files.insert(0, temp_header_file) - print(f"Added all-include header file, new total: {len(files)}") - # Print final file list before loading idedata print_file_list(files, "Final files to process:") diff --git a/script/helpers.py b/script/helpers.py index 06a50a3092..202ac9b5fc 100644 --- a/script/helpers.py +++ b/script/helpers.py @@ -156,22 +156,25 @@ def print_error_for_file(file: str | Path, body: str | None) -> None: print() -def build_all_include() -> None: - # Build a cpp file that includes all header files in this repo. - # Otherwise header-only integrations would not be tested by clang-tidy +def build_all_include(header_files: list[str] | None = None) -> None: + # Build a cpp file that includes header files for clang-tidy to check. + # If header_files is provided, only include those headers. + # Otherwise, include all header files in the esphome directory. - # Use git ls-files to find all .h files in the esphome directory - # This is much faster than walking the filesystem - cmd = ["git", "ls-files", "esphome/**/*.h"] - proc = subprocess.run(cmd, capture_output=True, text=True, check=True) + if header_files is None: + # Use git ls-files to find all .h files in the esphome directory + # This is much faster than walking the filesystem + cmd = ["git", "ls-files", "esphome/**/*.h"] + proc = subprocess.run(cmd, capture_output=True, text=True, check=True) - # Process git output - git already returns paths relative to repo root - headers = [ - f'#include "{include_p}"' - for line in proc.stdout.strip().split("\n") - if (include_p := line.replace(os.path.sep, "/")) - ] + # Process git output - git already returns paths relative to repo root + header_files = [ + line.replace(os.path.sep, "/") + for line in proc.stdout.strip().split("\n") + if line + ] + headers = [f'#include "{h}"' for h in header_files] headers.sort() headers.append("") content = "\n".join(headers) From dc8f7abce2d0dea4458f8e0c1d52da1e20fb831c 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 107/111] [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 91c504061b90ca64c21d42ce26f833a7ec12d9b5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 17 Dec 2025 15:19:26 -0700 Subject: [PATCH 108/111] [select] Eliminate string allocation in state callbacks (#12505) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/copy/select/copy_select.cpp | 2 +- esphome/components/mqtt/mqtt_select.cpp | 3 +-- esphome/components/select/automation.h | 8 ++++++-- esphome/components/select/select.cpp | 5 ++--- esphome/components/select/select.h | 4 ++-- 5 files changed, 12 insertions(+), 10 deletions(-) diff --git a/esphome/components/copy/select/copy_select.cpp b/esphome/components/copy/select/copy_select.cpp index e45338e785..e85e08e353 100644 --- a/esphome/components/copy/select/copy_select.cpp +++ b/esphome/components/copy/select/copy_select.cpp @@ -7,7 +7,7 @@ namespace copy { static const char *const TAG = "copy.select"; void CopySelect::setup() { - source_->add_on_state_callback([this](const std::string &value, size_t index) { this->publish_state(index); }); + source_->add_on_state_callback([this](size_t index) { this->publish_state(index); }); traits.set_options(source_->traits.get_options()); diff --git a/esphome/components/mqtt/mqtt_select.cpp b/esphome/components/mqtt/mqtt_select.cpp index e1660b07ea..e48af980c8 100644 --- a/esphome/components/mqtt/mqtt_select.cpp +++ b/esphome/components/mqtt/mqtt_select.cpp @@ -21,8 +21,7 @@ void MQTTSelectComponent::setup() { call.set_option(state); call.perform(); }); - this->select_->add_on_state_callback( - [this](const std::string &state, size_t index) { this->publish_state(this->select_->option_at(index)); }); + this->select_->add_on_state_callback([this](size_t index) { this->publish_state(this->select_->option_at(index)); }); } void MQTTSelectComponent::dump_config() { diff --git a/esphome/components/select/automation.h b/esphome/components/select/automation.h index 768f2621f7..dda5403557 100644 --- a/esphome/components/select/automation.h +++ b/esphome/components/select/automation.h @@ -8,9 +8,13 @@ namespace esphome::select { class SelectStateTrigger : public Trigger { public: - explicit SelectStateTrigger(Select *parent) { - parent->add_on_state_callback([this](const std::string &value, size_t index) { this->trigger(value, index); }); + explicit SelectStateTrigger(Select *parent) : parent_(parent) { + parent->add_on_state_callback( + [this](size_t index) { this->trigger(std::string(this->parent_->option_at(index)), index); }); } + + protected: + Select *parent_; }; template class SelectSetAction : public Action { diff --git a/esphome/components/select/select.cpp b/esphome/components/select/select.cpp index 4fc4d79b08..28d7eb07d4 100644 --- a/esphome/components/select/select.cpp +++ b/esphome/components/select/select.cpp @@ -32,8 +32,7 @@ void Select::publish_state(size_t index) { this->state = option; // Update deprecated member for backward compatibility #pragma GCC diagnostic pop ESP_LOGD(TAG, "'%s': Sending state %s (index %zu)", this->get_name().c_str(), option, index); - // Callback signature requires std::string, create temporary for compatibility - this->state_callback_.call(std::string(option), index); + this->state_callback_.call(index); #if defined(USE_SELECT) && defined(USE_CONTROLLER_REGISTRY) ControllerRegistry::notify_select_update(this); #endif @@ -41,7 +40,7 @@ void Select::publish_state(size_t index) { const char *Select::current_option() const { return this->has_state() ? this->option_at(this->active_index_) : ""; } -void Select::add_on_state_callback(std::function &&callback) { +void Select::add_on_state_callback(std::function &&callback) { this->state_callback_.add(std::move(callback)); } diff --git a/esphome/components/select/select.h b/esphome/components/select/select.h index 63707f6bd6..854fdcf252 100644 --- a/esphome/components/select/select.h +++ b/esphome/components/select/select.h @@ -75,7 +75,7 @@ class Select : public EntityBase { /// Return the option value at the provided index offset (as const char* from flash). const char *option_at(size_t index) const; - void add_on_state_callback(std::function &&callback); + void add_on_state_callback(std::function &&callback); protected: friend class SelectCall; @@ -111,7 +111,7 @@ class Select : public EntityBase { } } - CallbackManager state_callback_; + CallbackManager state_callback_; }; } // namespace esphome::select From 4ddaff4027fc8c8cb4e39957087915204922a1da Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 17 Dec 2025 17:26:56 -0500 Subject: [PATCH 109/111] [esp32] Dynamically embed managed component server certificates (#12509) Co-authored-by: Claude Co-authored-by: J. Nick Koston --- esphome/components/esp32/__init__.py | 9 --------- esphome/components/esp32/post_build.py.script | 12 ++++++++++++ 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 4448b6bbe7..dc442cfbd2 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -982,15 +982,6 @@ async def to_code(config): cg.add_platformio_option("framework", "arduino, espidf") cg.add_build_flag("-DUSE_ARDUINO") cg.add_build_flag("-DUSE_ESP32_FRAMEWORK_ARDUINO") - cg.add_platformio_option( - "board_build.embed_txtfiles", - [ - "managed_components/espressif__esp_insights/server_certs/https_server.crt", - "managed_components/espressif__esp_rainmaker/server_certs/rmaker_mqtt_server.crt", - "managed_components/espressif__esp_rainmaker/server_certs/rmaker_claim_service_server.crt", - "managed_components/espressif__esp_rainmaker/server_certs/rmaker_ota_server.crt", - ], - ) cg.add_define( "USE_ARDUINO_VERSION_CODE", cg.RawExpression( diff --git a/esphome/components/esp32/post_build.py.script b/esphome/components/esp32/post_build.py.script index c995214232..5ef5860687 100644 --- a/esphome/components/esp32/post_build.py.script +++ b/esphome/components/esp32/post_build.py.script @@ -5,6 +5,7 @@ import json # noqa: E402 import os # noqa: E402 import pathlib # noqa: E402 import shutil # noqa: E402 +from glob import glob # noqa: E402 def merge_factory_bin(source, target, env): @@ -126,3 +127,14 @@ def esp32_copy_ota_bin(source, target, env): # Run merge first, then ota copy second env.AddPostAction("$BUILD_DIR/${PROGNAME}.bin", merge_factory_bin) # noqa: F821 env.AddPostAction("$BUILD_DIR/${PROGNAME}.bin", esp32_copy_ota_bin) # noqa: F821 + +# Find server certificates in managed components and generate .S files. +# Workaround for PlatformIO not processing target_add_binary_data() from managed component CMakeLists. +project_dir = env.subst("$PROJECT_DIR") +managed_components = os.path.join(project_dir, "managed_components") +if os.path.isdir(managed_components): + for cert_file in glob(os.path.join(managed_components, "**/server_certs/*.crt"), recursive=True): + try: + env.FileToAsm(cert_file, FILE_TYPE="TEXT") + except Exception as e: + print(f"Error processing {os.path.basename(cert_file)}: {e}") From 2b337aa3062b60fe7418540364052f88a86e006f 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 110/111] [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 4182683bdc..ca37cb392d 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 8c185254ef5615808acdfde082eae7d3ee604130 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 17 Dec 2025 13:04:46 -1000 Subject: [PATCH 111/111] give 6 months of get_compilation_time for back compat --- esphome/core/application.h | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/esphome/core/application.h b/esphome/core/application.h index 9d876dc5a3..f462553a81 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -280,6 +280,15 @@ class Application { /// 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_; }