Merge remote-tracking branch 'upstream/dev' into integration

This commit is contained in:
J. Nick Koston
2026-04-26 04:16:55 -05:00
3 changed files with 167 additions and 61 deletions
+7 -1
View File
@@ -242,6 +242,10 @@ PROJECT_MAX_LENGTH = 127
# Max board/model string length (must fit in single-byte varint for proto encoding)
BOARD_MAX_LENGTH = 127
# Keep in sync with ESPHOME_COMMENT_SIZE_MAX in esphome/core/application.h
# (C++ side includes the null terminator).
COMMENT_MAX_LEN = 255
AREA_SCHEMA = cv.Schema(
{
cv.GenerateID(CONF_ID): cv.declare_id(Area),
@@ -275,7 +279,9 @@ CONFIG_SCHEMA = cv.All(
cv.string_no_slash, cv.ByteLength(max=FRIENDLY_NAME_MAX_LEN)
),
cv.Optional(CONF_AREA): validate_area_config,
cv.Optional(CONF_COMMENT): cv.All(cv.string, cv.Length(max=255)),
cv.Optional(CONF_COMMENT): cv.All(
cv.string, cv.ByteLength(max=COMMENT_MAX_LEN)
),
cv.Required(CONF_BUILD_PATH): cv.string,
cv.Optional(CONF_PLATFORMIO_OPTIONS, default={}): cv.Schema(
{
+76 -25
View File
@@ -22,7 +22,6 @@ from esphome.helpers import (
read_file,
rmtree,
walk_files,
write_file,
write_file_if_changed,
)
from esphome.storage_json import StorageJSON, storage_path
@@ -171,6 +170,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"
BUILD_INFO_DATA_CPP_TARGET = "esphome/core/build_info_data.cpp"
ENTITY_TYPES_H_TARGET = "esphome/core/entity_types.h"
ESPHOME_README_TXT = """
THIS DIRECTORY IS AUTO-GENERATED, DO NOT MODIFY
@@ -209,13 +209,22 @@ def copy_src_tree():
source_files_copy = source_files_map.copy()
ignore_targets = [
Path(x) for x in (DEFINES_H_TARGET, VERSION_H_TARGET, BUILD_INFO_DATA_H_TARGET)
Path(x)
for x in (
DEFINES_H_TARGET,
VERSION_H_TARGET,
BUILD_INFO_DATA_H_TARGET,
BUILD_INFO_DATA_CPP_TARGET,
)
]
for t in ignore_targets:
source_files_copy.pop(t, None)
# Files to exclude from sources_changed tracking (generated files)
generated_files = {Path("esphome/core/build_info_data.h")}
generated_files = {
Path("esphome/core/build_info_data.h"),
Path("esphome/core/build_info_data.cpp"),
}
sources_changed = False
for fname in walk_files(CORE.relative_src_path("esphome")):
@@ -268,12 +277,15 @@ def copy_src_tree():
build_info_data_h_path = CORE.relative_src_path(
"esphome", "core", "build_info_data.h"
)
build_info_data_cpp_path = CORE.relative_src_path(
"esphome", "core", "build_info_data.cpp"
)
build_info_json_path = CORE.relative_build_path("build_info.json")
config_hash, build_time, build_time_str, comment = get_build_info()
# Defensively force a rebuild if the build_info files don't exist, or if
# there was a config change which didn't actually cause a source change
if not build_info_data_h_path.exists():
if not build_info_data_h_path.exists() or not build_info_data_cpp_path.exists():
sources_changed = True
else:
try:
@@ -288,13 +300,19 @@ def copy_src_tree():
# Write build_info header and JSON metadata
if sources_changed:
write_file(
# write_file_if_changed avoids bumping mtime on identical content,
# which is what makes the stable header actually isolate metadata churn.
write_file_if_changed(
build_info_data_h_path,
generate_build_info_data_h(
generate_build_info_data_h(),
)
write_file_if_changed(
build_info_data_cpp_path,
generate_build_info_data_cpp(
config_hash, build_time, build_time_str, comment
),
)
write_file(
write_file_if_changed(
build_info_json_path,
json.dumps(
{
@@ -345,27 +363,60 @@ def get_build_info() -> tuple[int, int, str, str]:
return config_hash, build_time, build_time_str, comment
def generate_build_info_data_h(
config_hash: int, build_time: int, build_time_str: str, comment: str
) -> str:
"""Generate build_info_data.h header with config hash, build time, and comment."""
# cpp_string_escape returns '"escaped"', slice off the quotes since template has them
escaped_comment = cpp_string_escape(comment)[1:-1]
# +1 for null terminator
comment_size = len(comment) + 1
return f"""#pragma once
// Auto-generated build_info data
#define ESPHOME_CONFIG_HASH 0x{config_hash:08x}U // NOLINT
#define ESPHOME_BUILD_TIME {build_time} // NOLINT
#define ESPHOME_COMMENT_SIZE {comment_size} // NOLINT
def generate_build_info_data_h() -> str:
"""Generate stable declarations for build info provided by generated C++."""
return """#pragma once
// Auto-generated build_info declarations
#include <cstddef>
#include <cstdint>
#include <ctime>
#ifdef USE_ESP8266
#include <pgmspace.h>
static const char ESPHOME_BUILD_TIME_STR[] PROGMEM = "{build_time_str}";
static const char ESPHOME_COMMENT_STR[] PROGMEM = "{escaped_comment}";
#else
static const char ESPHOME_BUILD_TIME_STR[] = "{build_time_str}";
static const char ESPHOME_COMMENT_STR[] = "{escaped_comment}";
#endif
namespace esphome {
extern const uint32_t ESPHOME_CONFIG_HASH;
extern const time_t ESPHOME_BUILD_TIME;
extern const size_t ESPHOME_COMMENT_SIZE;
#ifdef USE_ESP8266
extern const char ESPHOME_BUILD_TIME_STR[] PROGMEM;
extern const char ESPHOME_COMMENT_STR[] PROGMEM;
#else
extern const char ESPHOME_BUILD_TIME_STR[];
extern const char ESPHOME_COMMENT_STR[];
#endif
} // namespace esphome
"""
def generate_build_info_data_cpp(
config_hash: int, build_time: int, build_time_str: str, comment: str
) -> str:
"""Generate build_info_data.cpp with config hash, build time, and comment."""
from esphome.core.config import COMMENT_MAX_LEN
# Defense-in-depth clamp; errors="ignore" drops a partial trailing UTF-8
# sequence so the literal never decodes to a truncated codepoint.
encoded = comment.encode("utf-8")[:COMMENT_MAX_LEN]
comment = encoded.decode("utf-8", errors="ignore")
# cpp_string_escape wraps in quotes; strip them since the template has them.
escaped_comment = cpp_string_escape(comment)[1:-1]
comment_size = len(comment.encode("utf-8")) + 1 # +1 for NUL
return f"""// Auto-generated build_info data
#include "esphome/core/build_info_data.h"
namespace esphome {{
const uint32_t ESPHOME_CONFIG_HASH = 0x{config_hash:08x}U; // NOLINT
const time_t ESPHOME_BUILD_TIME = {build_time}; // NOLINT
const size_t ESPHOME_COMMENT_SIZE = {comment_size}; // NOLINT
#ifdef USE_ESP8266
const char ESPHOME_BUILD_TIME_STR[] PROGMEM = "{build_time_str}";
const char ESPHOME_COMMENT_STR[] PROGMEM = "{escaped_comment}";
#else
const char ESPHOME_BUILD_TIME_STR[] = "{build_time_str}";
const char ESPHOME_COMMENT_STR[] = "{escaped_comment}";
#endif
}} // namespace esphome
"""
+84 -35
View File
@@ -7,6 +7,7 @@ from datetime import datetime
import json
import os
from pathlib import Path
import re
import stat
from typing import Any
from unittest.mock import MagicMock, patch
@@ -32,6 +33,7 @@ from esphome.writer import (
clean_build,
clean_cmake_cache,
copy_src_tree,
generate_build_info_data_cpp,
generate_build_info_data_h,
get_build_info,
storage_should_clean,
@@ -1617,49 +1619,62 @@ def test_get_build_info_build_time_str_format(
def test_generate_build_info_data_h_format() -> None:
"""Test generate_build_info_data_h produces correct header content."""
config_hash = 0x12345678
build_time = 1700000000
build_time_str = "2023-11-14 22:13:20 +0000"
comment = "Test comment"
result = generate_build_info_data_h(
config_hash, build_time, build_time_str, comment
)
result = generate_build_info_data_h()
assert "#pragma once" in result
assert "#define ESPHOME_CONFIG_HASH 0x12345678U" in result
assert "#define ESPHOME_BUILD_TIME 1700000000" in result
assert "#define ESPHOME_COMMENT_SIZE 13" in result # len("Test comment") + 1
assert 'ESPHOME_BUILD_TIME_STR[] = "2023-11-14 22:13:20 +0000"' in result
assert 'ESPHOME_COMMENT_STR[] = "Test comment"' in result
assert "extern const uint32_t ESPHOME_CONFIG_HASH;" in result
assert "extern const time_t ESPHOME_BUILD_TIME;" in result
assert "extern const size_t ESPHOME_COMMENT_SIZE;" in result
assert "extern const char ESPHOME_BUILD_TIME_STR[]" in result
assert "extern const char ESPHOME_COMMENT_STR[]" 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", "comment")
result = generate_build_info_data_h()
# Should have ESP8266 PROGMEM conditional
assert "#ifdef USE_ESP8266" in result
assert "#include <pgmspace.h>" in result
assert "PROGMEM" in result
# Both build time and comment should have PROGMEM versions
def test_generate_build_info_data_cpp_format() -> None:
"""Test generate_build_info_data_cpp produces correct data definitions."""
result = generate_build_info_data_cpp(
0x12345678, 1700000000, "2023-11-14 22:13:20 +0000", "Test comment"
)
assert '#include "esphome/core/build_info_data.h"' in result
assert "const uint32_t ESPHOME_CONFIG_HASH = 0x12345678U;" in result
assert "const time_t ESPHOME_BUILD_TIME = 1700000000;" in result
assert "const size_t ESPHOME_COMMENT_SIZE = 13;" in result
assert 'ESPHOME_BUILD_TIME_STR[] = "2023-11-14 22:13:20 +0000"' in result
assert 'ESPHOME_COMMENT_STR[] = "Test comment"' in result
def test_generate_build_info_data_cpp_esp8266_progmem() -> None:
"""Test generate_build_info_data_cpp includes PROGMEM definitions."""
result = generate_build_info_data_cpp(0xABCDEF01, 1700000000, "test", "comment")
assert "#ifdef USE_ESP8266" in result
assert 'ESPHOME_BUILD_TIME_STR[] PROGMEM = "test"' in result
assert 'ESPHOME_COMMENT_STR[] PROGMEM = "comment"' in result
def test_generate_build_info_data_h_hash_formatting() -> None:
"""Test generate_build_info_data_h formats hash with leading zeros."""
def test_generate_build_info_data_cpp_hash_formatting() -> None:
"""Test generate_build_info_data_cpp 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
result = generate_build_info_data_cpp(0x00000001, 0, "test", "")
assert "const uint32_t 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
result = generate_build_info_data_cpp(0xFFFFFFFF, 0, "test", "")
assert "const uint32_t ESPHOME_CONFIG_HASH = 0xffffffffU;" in result
def test_generate_build_info_data_h_comment_escaping() -> None:
r"""Test generate_build_info_data_h properly escapes special characters in comment.
def test_generate_build_info_data_cpp_comment_escaping() -> None:
r"""Test generate_build_info_data_cpp properly escapes special characters in comment.
Uses cpp_string_escape which outputs octal escapes for special characters:
- backslash (ASCII 92) -> \134
@@ -1667,26 +1682,52 @@ def test_generate_build_info_data_h_comment_escaping() -> None:
- newline (ASCII 10) -> \012
"""
# Test backslash escaping (ASCII 92 = octal 134)
result = generate_build_info_data_h(0, 0, "test", "backslash\\here")
result = generate_build_info_data_cpp(0, 0, "test", "backslash\\here")
assert 'ESPHOME_COMMENT_STR[] = "backslash\\134here"' in result
# Test quote escaping (ASCII 34 = octal 042)
result = generate_build_info_data_h(0, 0, "test", 'has "quotes"')
result = generate_build_info_data_cpp(0, 0, "test", 'has "quotes"')
assert 'ESPHOME_COMMENT_STR[] = "has \\042quotes\\042"' in result
# Test newline escaping (ASCII 10 = octal 012)
result = generate_build_info_data_h(0, 0, "test", "line1\nline2")
result = generate_build_info_data_cpp(0, 0, "test", "line1\nline2")
assert 'ESPHOME_COMMENT_STR[] = "line1\\012line2"' in result
def test_generate_build_info_data_h_empty_comment() -> None:
"""Test generate_build_info_data_h handles empty comment."""
result = generate_build_info_data_h(0, 0, "test", "")
def test_generate_build_info_data_cpp_empty_comment() -> None:
"""Test generate_build_info_data_cpp handles empty comment."""
result = generate_build_info_data_cpp(0, 0, "test", "")
assert "#define ESPHOME_COMMENT_SIZE 1" in result # Just null terminator
assert "const size_t ESPHOME_COMMENT_SIZE = 1;" in result # Just null terminator
assert 'ESPHOME_COMMENT_STR[] = ""' in result
def test_generate_build_info_data_cpp_comment_size_counts_utf8_bytes() -> None:
"""Comment size is in encoded UTF-8 bytes, not characters."""
# "héllo" = 6 UTF-8 bytes + NUL.
result = generate_build_info_data_cpp(0, 0, "test", "héllo")
assert "const size_t ESPHOME_COMMENT_SIZE = 7;" in result
def test_generate_build_info_data_cpp_comment_clamped_to_buffer() -> None:
"""Generator clamps at byte level and never truncates mid-codepoint."""
# 100 thermometer-with-VS-16 sequences = 700 bytes, past the 256 buffer.
result = generate_build_info_data_cpp(0, 0, "test", "🌡️" * 100)
match = re.search(r"ESPHOME_COMMENT_SIZE = (\d+);", result)
assert match is not None
size = int(match.group(1))
assert 1 < size <= 256
lit_match = re.search(r'ESPHOME_COMMENT_STR\[\] = "([^"]*)"', result)
assert lit_match is not None
raw = re.sub(
r"\\([0-7]{3})", lambda m: chr(int(m.group(1), 8)), lit_match.group(1)
).encode("latin-1")
raw.decode("utf-8") # raises if truncation left a partial UTF-8 sequence
assert len(raw) == size - 1
@patch("esphome.writer.CORE")
@patch("esphome.writer.iter_components")
@patch("esphome.writer.walk_files")
@@ -1760,15 +1801,21 @@ def test_copy_src_tree_writes_build_info_files(
):
copy_src_tree()
# Verify build_info_data.h was written
# Verify build_info_data.h declarations and build_info_data.cpp values were 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 "extern const uint32_t ESPHOME_CONFIG_HASH;" in build_info_h_content
assert "ESPHOME_BUILD_TIME_STR" in build_info_h_content
assert "#define ESPHOME_COMMENT_SIZE" in build_info_h_content
assert "extern const size_t ESPHOME_COMMENT_SIZE;" in build_info_h_content
assert "ESPHOME_COMMENT_STR" in build_info_h_content
build_info_cpp_path = esphome_core_path / "build_info_data.cpp"
assert build_info_cpp_path.exists()
build_info_cpp_content = build_info_cpp_path.read_text()
assert "const uint32_t ESPHOME_CONFIG_HASH = 0xdeadbeefU;" in build_info_cpp_content
assert "const time_t ESPHOME_BUILD_TIME" in build_info_cpp_content
assert "const size_t ESPHOME_COMMENT_SIZE" in build_info_cpp_content
assert "ESPHOME_COMMENT_STR" in build_info_cpp_content
# Verify build_info.json was written
build_info_json_path = build_path / "build_info.json"
@@ -1835,7 +1882,9 @@ def test_copy_src_tree_detects_config_hash_change(
# 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()
build_info_cpp_path = esphome_core_path / "build_info_data.cpp"
assert build_info_cpp_path.exists()
new_content = build_info_cpp_path.read_text()
assert "0xdeadbeef" in new_content.lower()
new_json = json.loads(build_info_json_path.read_text())