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():