1
0
mirror of https://github.com/esphome/esphome.git synced 2026-02-08 00:31:58 +00:00

Merge branch 'buildinfo' into integration

This commit is contained in:
J. Nick Koston
2025-12-17 13:27:58 -10:00
97 changed files with 2091 additions and 425 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -9,6 +9,8 @@ esphome:
update.is_available:
then:
- logger.log: "Update available"
else:
- update.check:
- update.perform:
force_update: true

View File

@@ -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);

View File

@@ -0,0 +1,31 @@
esphome:
name: build-info-test
host:
api:
logger:
text_sensor:
- platform: template
name: "Config Hash"
id: config_hash_sensor
update_interval: 100ms
lambda: |-
char buf[16];
snprintf(buf, sizeof(buf), "0x%08x", App.get_config_hash());
return std::string(buf);
- platform: template
name: "Build Time"
id: build_time_sensor
update_interval: 100ms
lambda: |-
char buf[32];
snprintf(buf, sizeof(buf), "%ld", (long)App.get_build_time());
return std::string(buf);
- platform: template
name: "Build Time String"
id: build_time_str_sensor
update_interval: 100ms
lambda: |-
char buf[Application::BUILD_TIME_STR_SIZE];
App.get_build_time_string(buf);
return std::string(buf);

View File

@@ -0,0 +1,117 @@
"""Integration test for build_info values."""
from __future__ import annotations
import asyncio
from datetime import datetime
import re
import time
from aioesphomeapi import EntityState, TextSensorState
import pytest
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_build_info(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test that build_info values are sane."""
async with run_compiled(yaml_config), api_client_connected() as client:
device_info = await client.device_info()
assert device_info is not None
assert device_info.name == "build-info-test"
# Verify compilation_time from device_info is present and parseable
# The format is ISO 8601 with timezone: "YYYY-MM-DD HH:MM:SS +ZZZZ"
compilation_time = device_info.compilation_time
assert compilation_time is not None
# Validate the ISO format: "YYYY-MM-DD HH:MM:SS +ZZZZ"
parsed = datetime.strptime(compilation_time, "%Y-%m-%d %H:%M:%S %z")
assert parsed.year >= time.localtime().tm_year
# Get entities
entities, _ = await client.list_entities_services()
# Find our text sensors by object_id
config_hash_entity = next(
(e for e in entities if e.object_id == "config_hash"), None
)
build_time_entity = next(
(e for e in entities if e.object_id == "build_time"), None
)
build_time_str_entity = next(
(e for e in entities if e.object_id == "build_time_string"), None
)
assert config_hash_entity is not None, "Config Hash sensor not found"
assert build_time_entity is not None, "Build Time sensor not found"
assert build_time_str_entity is not None, "Build Time String sensor not found"
# Wait for all three text sensors to have valid states
loop = asyncio.get_running_loop()
states: dict[int, TextSensorState] = {}
all_received = loop.create_future()
expected_keys = {
config_hash_entity.key,
build_time_entity.key,
build_time_str_entity.key,
}
def on_state(state: EntityState) -> None:
if isinstance(state, TextSensorState) and not state.missing_state:
states[state.key] = state
if expected_keys <= states.keys() and not all_received.done():
all_received.set_result(True)
client.subscribe_states(on_state)
try:
await asyncio.wait_for(all_received, timeout=5.0)
except TimeoutError:
pytest.fail(
f"Timeout waiting for text sensor states. Got: {list(states.keys())}"
)
config_hash_state = states[config_hash_entity.key]
build_time_state = states[build_time_entity.key]
build_time_str_state = states[build_time_str_entity.key]
# Validate config_hash format (0x followed by 8 hex digits)
config_hash = config_hash_state.state
assert re.match(r"^0x[0-9a-f]{8}$", config_hash), (
f"config_hash should be 0x followed by 8 hex digits, got: {config_hash}"
)
# Validate build_time is a reasonable Unix timestamp
build_time = int(build_time_state.state)
current_time = int(time.time())
# Build time should be within last hour and not in the future
assert build_time <= current_time, (
f"build_time {build_time} should not be in the future (current: {current_time})"
)
assert build_time > current_time - 3600, (
f"build_time {build_time} should be within the last hour"
)
# Validate build_time_str matches the new ISO format
build_time_str = build_time_str_state.state
# Format: "YYYY-MM-DD HH:MM:SS +ZZZZ"
parsed_build_time = datetime.strptime(build_time_str, "%Y-%m-%d %H:%M:%S %z")
assert parsed_build_time.year >= time.localtime().tm_year
# Verify build_time_str matches what we get from build_time timestamp
expected_str = time.strftime("%Y-%m-%d %H:%M:%S %z", time.localtime(build_time))
assert build_time_str == expected_str, (
f"build_time_str '{build_time_str}' should match timestamp '{expected_str}'"
)
# Verify compilation_time matches build_time_str (they should be the same)
assert compilation_time == build_time_str, (
f"compilation_time '{compilation_time}' should match "
f"build_time_str '{build_time_str}'"
)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -1,6 +1,10 @@
"""Test writer module functionality."""
from collections.abc import Callable
from contextlib import contextmanager
from dataclasses import dataclass
from datetime import datetime
import json
import os
from pathlib import Path
import stat
@@ -20,6 +24,9 @@ from esphome.writer import (
clean_all,
clean_build,
clean_cmake_cache,
copy_src_tree,
generate_build_info_data_h,
get_build_info,
storage_should_clean,
update_storage_json,
write_cpp,
@@ -1166,3 +1173,721 @@ def test_clean_build_reraises_for_other_errors(
finally:
# Cleanup - restore write permission so tmp_path cleanup works
os.chmod(subdir, stat.S_IRWXU)
# Tests for get_build_info()
@patch("esphome.writer.CORE")
def test_get_build_info_new_build(
mock_core: MagicMock,
tmp_path: Path,
) -> None:
"""Test get_build_info returns new build_time when no existing build_info.json."""
build_info_path = tmp_path / "build_info.json"
mock_core.relative_build_path.return_value = build_info_path
mock_core.config_hash = 0x12345678
config_hash, build_time, build_time_str = get_build_info()
assert config_hash == 0x12345678
assert isinstance(build_time, int)
assert build_time > 0
assert isinstance(build_time_str, str)
# Verify build_time_str format matches expected pattern
assert len(build_time_str) >= 19 # e.g., "2025-12-15 16:27:44 +0000"
@patch("esphome.writer.CORE")
def test_get_build_info_always_returns_current_time(
mock_core: MagicMock,
tmp_path: Path,
) -> None:
"""Test get_build_info always returns current build_time."""
build_info_path = tmp_path / "build_info.json"
mock_core.relative_build_path.return_value = build_info_path
mock_core.config_hash = 0x12345678
# Create existing build_info.json with matching config_hash and version
existing_build_time = 1700000000
existing_build_time_str = "2023-11-14 22:13:20 +0000"
build_info_path.write_text(
json.dumps(
{
"config_hash": 0x12345678,
"build_time": existing_build_time,
"build_time_str": existing_build_time_str,
"esphome_version": "2025.1.0-dev",
}
)
)
with patch("esphome.writer.__version__", "2025.1.0-dev"):
config_hash, build_time, build_time_str = get_build_info()
assert config_hash == 0x12345678
# get_build_info now always returns current time
assert build_time != existing_build_time
assert build_time > existing_build_time
assert build_time_str != existing_build_time_str
@patch("esphome.writer.CORE")
def test_get_build_info_config_changed(
mock_core: MagicMock,
tmp_path: Path,
) -> None:
"""Test get_build_info returns new build_time when config hash changed."""
build_info_path = tmp_path / "build_info.json"
mock_core.relative_build_path.return_value = build_info_path
mock_core.config_hash = 0xABCDEF00 # Different from existing
# Create existing build_info.json with different config_hash
existing_build_time = 1700000000
build_info_path.write_text(
json.dumps(
{
"config_hash": 0x12345678, # Different
"build_time": existing_build_time,
"build_time_str": "2023-11-14 22:13:20 +0000",
"esphome_version": "2025.1.0-dev",
}
)
)
with patch("esphome.writer.__version__", "2025.1.0-dev"):
config_hash, build_time, build_time_str = get_build_info()
assert config_hash == 0xABCDEF00
assert build_time != existing_build_time # New time generated
assert build_time > existing_build_time
@patch("esphome.writer.CORE")
def test_get_build_info_version_changed(
mock_core: MagicMock,
tmp_path: Path,
) -> None:
"""Test get_build_info returns new build_time when ESPHome version changed."""
build_info_path = tmp_path / "build_info.json"
mock_core.relative_build_path.return_value = build_info_path
mock_core.config_hash = 0x12345678
# Create existing build_info.json with different version
existing_build_time = 1700000000
build_info_path.write_text(
json.dumps(
{
"config_hash": 0x12345678,
"build_time": existing_build_time,
"build_time_str": "2023-11-14 22:13:20 +0000",
"esphome_version": "2024.12.0", # Old version
}
)
)
with patch("esphome.writer.__version__", "2025.1.0-dev"): # New version
config_hash, build_time, build_time_str = get_build_info()
assert config_hash == 0x12345678
assert build_time != existing_build_time # New time generated
assert build_time > existing_build_time
@patch("esphome.writer.CORE")
def test_get_build_info_invalid_json(
mock_core: MagicMock,
tmp_path: Path,
) -> None:
"""Test get_build_info handles invalid JSON gracefully."""
build_info_path = tmp_path / "build_info.json"
mock_core.relative_build_path.return_value = build_info_path
mock_core.config_hash = 0x12345678
# Create invalid JSON file
build_info_path.write_text("not valid json {{{")
config_hash, build_time, build_time_str = get_build_info()
assert config_hash == 0x12345678
assert isinstance(build_time, int)
assert build_time > 0
@patch("esphome.writer.CORE")
def test_get_build_info_missing_keys(
mock_core: MagicMock,
tmp_path: Path,
) -> None:
"""Test get_build_info handles missing keys gracefully."""
build_info_path = tmp_path / "build_info.json"
mock_core.relative_build_path.return_value = build_info_path
mock_core.config_hash = 0x12345678
# Create JSON with missing keys
build_info_path.write_text(json.dumps({"config_hash": 0x12345678}))
with patch("esphome.writer.__version__", "2025.1.0-dev"):
config_hash, build_time, build_time_str = get_build_info()
assert config_hash == 0x12345678
assert isinstance(build_time, int)
assert build_time > 0
@patch("esphome.writer.CORE")
def test_get_build_info_build_time_str_format(
mock_core: MagicMock,
tmp_path: Path,
) -> None:
"""Test get_build_info returns correctly formatted build_time_str."""
build_info_path = tmp_path / "build_info.json"
mock_core.relative_build_path.return_value = build_info_path
mock_core.config_hash = 0x12345678
config_hash, build_time, build_time_str = get_build_info()
# Verify the format matches "%Y-%m-%d %H:%M:%S %z"
# e.g., "2025-12-15 16:27:44 +0000"
parsed = datetime.strptime(build_time_str, "%Y-%m-%d %H:%M:%S %z")
assert parsed.year >= 2024
def test_generate_build_info_data_h_format() -> None:
"""Test generate_build_info_data_h produces correct header content."""
config_hash = 0x12345678
build_time = 1700000000
build_time_str = "2023-11-14 22:13:20 +0000"
result = generate_build_info_data_h(config_hash, build_time, build_time_str)
assert "#pragma once" in result
assert "#define ESPHOME_CONFIG_HASH 0x12345678U" in result
assert "#define ESPHOME_BUILD_TIME 1700000000" in result
assert 'ESPHOME_BUILD_TIME_STR[] = "2023-11-14 22:13:20 +0000"' in result
def test_generate_build_info_data_h_esp8266_progmem() -> None:
"""Test generate_build_info_data_h includes PROGMEM for ESP8266."""
result = generate_build_info_data_h(0xABCDEF01, 1700000000, "test")
# Should have ESP8266 PROGMEM conditional
assert "#ifdef USE_ESP8266" in result
assert "#include <pgmspace.h>" in result
assert "PROGMEM" in result
def test_generate_build_info_data_h_hash_formatting() -> None:
"""Test generate_build_info_data_h formats hash with leading zeros."""
# Test with small hash value that needs leading zeros
result = generate_build_info_data_h(0x00000001, 0, "test")
assert "#define ESPHOME_CONFIG_HASH 0x00000001U" in result
# Test with larger hash value
result = generate_build_info_data_h(0xFFFFFFFF, 0, "test")
assert "#define ESPHOME_CONFIG_HASH 0xffffffffU" in result
@patch("esphome.writer.CORE")
@patch("esphome.writer.iter_components")
@patch("esphome.writer.walk_files")
def test_copy_src_tree_writes_build_info_files(
mock_walk_files: MagicMock,
mock_iter_components: MagicMock,
mock_core: MagicMock,
tmp_path: Path,
) -> None:
"""Test copy_src_tree writes build_info_data.h and build_info.json."""
# Setup directory structure
src_path = tmp_path / "src"
src_path.mkdir()
esphome_core_path = src_path / "esphome" / "core"
esphome_core_path.mkdir(parents=True)
build_path = tmp_path / "build"
build_path.mkdir()
# Create mock source files for defines.h and version.h
mock_defines_h = esphome_core_path / "defines.h"
mock_defines_h.write_text("// mock defines.h")
mock_version_h = esphome_core_path / "version.h"
mock_version_h.write_text("// mock version.h")
# Create mock FileResource that returns our temp files
@dataclass(frozen=True)
class MockFileResource:
package: str
resource: str
_path: Path
@contextmanager
def path(self):
yield self._path
# Create mock resources for defines.h and version.h (required by copy_src_tree)
mock_resources = [
MockFileResource(
package="esphome.core",
resource="defines.h",
_path=mock_defines_h,
),
MockFileResource(
package="esphome.core",
resource="version.h",
_path=mock_version_h,
),
]
# Create mock component with resources
mock_component = MagicMock()
mock_component.resources = mock_resources
# Setup mocks
mock_core.relative_src_path.side_effect = lambda *args: src_path.joinpath(*args)
mock_core.relative_build_path.side_effect = lambda *args: build_path.joinpath(*args)
mock_core.defines = []
mock_core.config_hash = 0xDEADBEEF
mock_core.target_platform = "test_platform"
mock_core.config = {}
mock_iter_components.return_value = [("core", mock_component)]
mock_walk_files.return_value = []
# Create mock module without copy_files attribute (causes AttributeError which is caught)
mock_module = MagicMock(spec=[]) # Empty spec = no copy_files attribute
with (
patch("esphome.writer.__version__", "2025.1.0-dev"),
patch("esphome.writer.importlib.import_module", return_value=mock_module),
):
copy_src_tree()
# Verify build_info_data.h was written
build_info_h_path = esphome_core_path / "build_info_data.h"
assert build_info_h_path.exists()
build_info_h_content = build_info_h_path.read_text()
assert "#define ESPHOME_CONFIG_HASH 0xdeadbeefU" in build_info_h_content
assert "#define ESPHOME_BUILD_TIME" in build_info_h_content
assert "ESPHOME_BUILD_TIME_STR" in build_info_h_content
# Verify build_info.json was written
build_info_json_path = build_path / "build_info.json"
assert build_info_json_path.exists()
build_info_json = json.loads(build_info_json_path.read_text())
assert build_info_json["config_hash"] == 0xDEADBEEF
assert "build_time" in build_info_json
assert "build_time_str" in build_info_json
assert build_info_json["esphome_version"] == "2025.1.0-dev"
@patch("esphome.writer.CORE")
@patch("esphome.writer.iter_components")
@patch("esphome.writer.walk_files")
def test_copy_src_tree_detects_config_hash_change(
mock_walk_files: MagicMock,
mock_iter_components: MagicMock,
mock_core: MagicMock,
tmp_path: Path,
) -> None:
"""Test copy_src_tree detects when config_hash changes."""
# Setup directory structure
src_path = tmp_path / "src"
src_path.mkdir()
esphome_core_path = src_path / "esphome" / "core"
esphome_core_path.mkdir(parents=True)
build_path = tmp_path / "build"
build_path.mkdir()
# Create existing build_info.json with different config_hash
build_info_json_path = build_path / "build_info.json"
build_info_json_path.write_text(
json.dumps(
{
"config_hash": 0x12345678, # Different from current
"build_time": 1700000000,
"build_time_str": "2023-11-14 22:13:20 +0000",
"esphome_version": "2025.1.0-dev",
}
)
)
# Create existing build_info_data.h
build_info_h_path = esphome_core_path / "build_info_data.h"
build_info_h_path.write_text("// old build_info_data.h")
# Setup mocks
mock_core.relative_src_path.side_effect = lambda *args: src_path.joinpath(*args)
mock_core.relative_build_path.side_effect = lambda *args: build_path.joinpath(*args)
mock_core.defines = []
mock_core.config_hash = 0xDEADBEEF # Different from existing
mock_core.target_platform = "test_platform"
mock_core.config = {}
mock_iter_components.return_value = []
mock_walk_files.return_value = []
with (
patch("esphome.writer.__version__", "2025.1.0-dev"),
patch("esphome.writer.importlib.import_module") as mock_import,
):
mock_import.side_effect = AttributeError
copy_src_tree()
# Verify build_info files were updated due to config_hash change
assert build_info_h_path.exists()
new_content = build_info_h_path.read_text()
assert "0xdeadbeef" in new_content.lower()
new_json = json.loads(build_info_json_path.read_text())
assert new_json["config_hash"] == 0xDEADBEEF
@patch("esphome.writer.CORE")
@patch("esphome.writer.iter_components")
@patch("esphome.writer.walk_files")
def test_copy_src_tree_detects_version_change(
mock_walk_files: MagicMock,
mock_iter_components: MagicMock,
mock_core: MagicMock,
tmp_path: Path,
) -> None:
"""Test copy_src_tree detects when esphome_version changes."""
# Setup directory structure
src_path = tmp_path / "src"
src_path.mkdir()
esphome_core_path = src_path / "esphome" / "core"
esphome_core_path.mkdir(parents=True)
build_path = tmp_path / "build"
build_path.mkdir()
# Create existing build_info.json with different version
build_info_json_path = build_path / "build_info.json"
build_info_json_path.write_text(
json.dumps(
{
"config_hash": 0xDEADBEEF,
"build_time": 1700000000,
"build_time_str": "2023-11-14 22:13:20 +0000",
"esphome_version": "2024.12.0", # Old version
}
)
)
# Create existing build_info_data.h
build_info_h_path = esphome_core_path / "build_info_data.h"
build_info_h_path.write_text("// old build_info_data.h")
# Setup mocks
mock_core.relative_src_path.side_effect = lambda *args: src_path.joinpath(*args)
mock_core.relative_build_path.side_effect = lambda *args: build_path.joinpath(*args)
mock_core.defines = []
mock_core.config_hash = 0xDEADBEEF
mock_core.target_platform = "test_platform"
mock_core.config = {}
mock_iter_components.return_value = []
mock_walk_files.return_value = []
with (
patch("esphome.writer.__version__", "2025.1.0-dev"), # New version
patch("esphome.writer.importlib.import_module") as mock_import,
):
mock_import.side_effect = AttributeError
copy_src_tree()
# Verify build_info files were updated due to version change
assert build_info_h_path.exists()
new_json = json.loads(build_info_json_path.read_text())
assert new_json["esphome_version"] == "2025.1.0-dev"
@patch("esphome.writer.CORE")
@patch("esphome.writer.iter_components")
@patch("esphome.writer.walk_files")
def test_copy_src_tree_handles_invalid_build_info_json(
mock_walk_files: MagicMock,
mock_iter_components: MagicMock,
mock_core: MagicMock,
tmp_path: Path,
) -> None:
"""Test copy_src_tree handles invalid build_info.json gracefully."""
# Setup directory structure
src_path = tmp_path / "src"
src_path.mkdir()
esphome_core_path = src_path / "esphome" / "core"
esphome_core_path.mkdir(parents=True)
build_path = tmp_path / "build"
build_path.mkdir()
# Create invalid build_info.json
build_info_json_path = build_path / "build_info.json"
build_info_json_path.write_text("invalid json {{{")
# Create existing build_info_data.h
build_info_h_path = esphome_core_path / "build_info_data.h"
build_info_h_path.write_text("// old build_info_data.h")
# Setup mocks
mock_core.relative_src_path.side_effect = lambda *args: src_path.joinpath(*args)
mock_core.relative_build_path.side_effect = lambda *args: build_path.joinpath(*args)
mock_core.defines = []
mock_core.config_hash = 0xDEADBEEF
mock_core.target_platform = "test_platform"
mock_core.config = {}
mock_iter_components.return_value = []
mock_walk_files.return_value = []
with (
patch("esphome.writer.__version__", "2025.1.0-dev"),
patch("esphome.writer.importlib.import_module") as mock_import,
):
mock_import.side_effect = AttributeError
copy_src_tree()
# Verify build_info files were created despite invalid JSON
assert build_info_h_path.exists()
new_json = json.loads(build_info_json_path.read_text())
assert new_json["config_hash"] == 0xDEADBEEF
@patch("esphome.writer.CORE")
@patch("esphome.writer.iter_components")
@patch("esphome.writer.walk_files")
def test_copy_src_tree_build_info_timestamp_behavior(
mock_walk_files: MagicMock,
mock_iter_components: MagicMock,
mock_core: MagicMock,
tmp_path: Path,
) -> None:
"""Test build_info behaviour: regenerated on change, preserved when unchanged."""
# Setup directory structure
src_path = tmp_path / "src"
src_path.mkdir()
esphome_core_path = src_path / "esphome" / "core"
esphome_core_path.mkdir(parents=True)
esphome_components_path = src_path / "esphome" / "components"
esphome_components_path.mkdir(parents=True)
build_path = tmp_path / "build"
build_path.mkdir()
# Create a source file
source_file = tmp_path / "source" / "test.cpp"
source_file.parent.mkdir()
source_file.write_text("// version 1")
# Create destination file in build tree
dest_file = esphome_components_path / "test.cpp"
# Create mock FileResource
@dataclass(frozen=True)
class MockFileResource:
package: str
resource: str
_path: Path
@contextmanager
def path(self):
yield self._path
mock_resources = [
MockFileResource(
package="esphome.components",
resource="test.cpp",
_path=source_file,
),
]
mock_component = MagicMock()
mock_component.resources = mock_resources
# Setup mocks
mock_core.relative_src_path.side_effect = lambda *args: src_path.joinpath(*args)
mock_core.relative_build_path.side_effect = lambda *args: build_path.joinpath(*args)
mock_core.defines = []
mock_core.config_hash = 0xDEADBEEF
mock_core.target_platform = "test_platform"
mock_core.config = {}
mock_iter_components.return_value = [("test", mock_component)]
build_info_json_path = build_path / "build_info.json"
# First run: initial setup, should create build_info
mock_walk_files.return_value = []
with (
patch("esphome.writer.__version__", "2025.1.0-dev"),
patch("esphome.writer.importlib.import_module") as mock_import,
):
mock_import.side_effect = AttributeError
copy_src_tree()
# Manually set an old timestamp for testing
old_timestamp = 1700000000
old_timestamp_str = "2023-11-14 22:13:20 +0000"
build_info_json_path.write_text(
json.dumps(
{
"config_hash": 0xDEADBEEF,
"build_time": old_timestamp,
"build_time_str": old_timestamp_str,
"esphome_version": "2025.1.0-dev",
}
)
)
# Second run: no changes, should NOT regenerate build_info
mock_walk_files.return_value = [str(dest_file)]
with (
patch("esphome.writer.__version__", "2025.1.0-dev"),
patch("esphome.writer.importlib.import_module") as mock_import,
):
mock_import.side_effect = AttributeError
copy_src_tree()
second_json = json.loads(build_info_json_path.read_text())
second_timestamp = second_json["build_time"]
# Verify timestamp was NOT changed
assert second_timestamp == old_timestamp, (
f"build_info should not be regenerated when no files change: "
f"{old_timestamp} != {second_timestamp}"
)
# Third run: change source file, should regenerate build_info with new timestamp
source_file.write_text("// version 2")
with (
patch("esphome.writer.__version__", "2025.1.0-dev"),
patch("esphome.writer.importlib.import_module") as mock_import,
):
mock_import.side_effect = AttributeError
copy_src_tree()
third_json = json.loads(build_info_json_path.read_text())
third_timestamp = third_json["build_time"]
# Verify timestamp WAS changed
assert third_timestamp != old_timestamp, (
f"build_info should be regenerated when source file changes: "
f"{old_timestamp} == {third_timestamp}"
)
assert third_timestamp > old_timestamp
@patch("esphome.writer.CORE")
@patch("esphome.writer.iter_components")
@patch("esphome.writer.walk_files")
def test_copy_src_tree_detects_removed_source_file(
mock_walk_files: MagicMock,
mock_iter_components: MagicMock,
mock_core: MagicMock,
tmp_path: Path,
) -> None:
"""Test copy_src_tree detects when a non-generated source file is removed."""
# Setup directory structure
src_path = tmp_path / "src"
src_path.mkdir()
esphome_components_path = src_path / "esphome" / "components"
esphome_components_path.mkdir(parents=True)
build_path = tmp_path / "build"
build_path.mkdir()
# Create an existing source file in the build tree
existing_file = esphome_components_path / "test.cpp"
existing_file.write_text("// test file")
# Setup mocks - no components, so the file should be removed
mock_core.relative_src_path.side_effect = lambda *args: src_path.joinpath(*args)
mock_core.relative_build_path.side_effect = lambda *args: build_path.joinpath(*args)
mock_core.defines = []
mock_core.config_hash = 0xDEADBEEF
mock_core.target_platform = "test_platform"
mock_core.config = {}
mock_iter_components.return_value = [] # No components = file should be removed
mock_walk_files.return_value = [str(existing_file)]
# Create existing build_info.json
build_info_json_path = build_path / "build_info.json"
old_timestamp = 1700000000
build_info_json_path.write_text(
json.dumps(
{
"config_hash": 0xDEADBEEF,
"build_time": old_timestamp,
"build_time_str": "2023-11-14 22:13:20 +0000",
"esphome_version": "2025.1.0-dev",
}
)
)
with (
patch("esphome.writer.__version__", "2025.1.0-dev"),
patch("esphome.writer.importlib.import_module") as mock_import,
):
mock_import.side_effect = AttributeError
copy_src_tree()
# Verify file was removed
assert not existing_file.exists()
# Verify build_info was regenerated due to source file removal
new_json = json.loads(build_info_json_path.read_text())
assert new_json["build_time"] != old_timestamp
@patch("esphome.writer.CORE")
@patch("esphome.writer.iter_components")
@patch("esphome.writer.walk_files")
def test_copy_src_tree_ignores_removed_generated_file(
mock_walk_files: MagicMock,
mock_iter_components: MagicMock,
mock_core: MagicMock,
tmp_path: Path,
) -> None:
"""Test copy_src_tree doesn't mark sources_changed when only generated file removed."""
# Setup directory structure
src_path = tmp_path / "src"
src_path.mkdir()
esphome_core_path = src_path / "esphome" / "core"
esphome_core_path.mkdir(parents=True)
build_path = tmp_path / "build"
build_path.mkdir()
# Create existing build_info_data.h (a generated file)
build_info_h = esphome_core_path / "build_info_data.h"
build_info_h.write_text("// old generated file")
# Setup mocks
mock_core.relative_src_path.side_effect = lambda *args: src_path.joinpath(*args)
mock_core.relative_build_path.side_effect = lambda *args: build_path.joinpath(*args)
mock_core.defines = []
mock_core.config_hash = 0xDEADBEEF
mock_core.target_platform = "test_platform"
mock_core.config = {}
mock_iter_components.return_value = []
# walk_files returns the generated file, but it's not in source_files_copy
mock_walk_files.return_value = [str(build_info_h)]
# Create existing build_info.json with old timestamp
build_info_json_path = build_path / "build_info.json"
old_timestamp = 1700000000
build_info_json_path.write_text(
json.dumps(
{
"config_hash": 0xDEADBEEF,
"build_time": old_timestamp,
"build_time_str": "2023-11-14 22:13:20 +0000",
"esphome_version": "2025.1.0-dev",
}
)
)
with (
patch("esphome.writer.__version__", "2025.1.0-dev"),
patch("esphome.writer.importlib.import_module") as mock_import,
):
mock_import.side_effect = AttributeError
copy_src_tree()
# Verify build_info_data.h was regenerated (not removed)
assert build_info_h.exists()
# Note: build_info.json will have a new timestamp because get_build_info()
# always returns current time. The key test is that the old build_info_data.h
# file was removed and regenerated, not that it triggered sources_changed.
new_json = json.loads(build_info_json_path.read_text())
assert new_json["config_hash"] == 0xDEADBEEF

View File

@@ -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:")