mirror of
https://github.com/esphome/esphome.git
synced 2026-08-23 06:36:23 +00:00
Platforms normalize toolchain-sensitive keys differently (the esp32 board name, for one), so a config validated under a mismatching --toolchain must not overwrite the cache while the sidecar keeps the compile's toolchain. The guard lives in _refresh_sidecar where the old sidecar is already loaded; legacy sidecars without the field are unaffected.
941 lines
35 KiB
Python
941 lines
35 KiB
Python
"""Tests for the validated-config cache used by upload/logs."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from contextlib import contextmanager
|
|
from ipaddress import IPv4Address, IPv4Network
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from unittest.mock import patch
|
|
from uuid import UUID
|
|
|
|
import pytest
|
|
|
|
from esphome import const, yaml_util
|
|
from esphome.__main__ import run_esphome
|
|
from esphome.compiled_config import (
|
|
_LAMBDA_KEY,
|
|
compiled_config_path,
|
|
load_compiled_config,
|
|
save_compiled_config,
|
|
save_compiled_config_and_sidecar,
|
|
)
|
|
from esphome.const import (
|
|
CONF_API,
|
|
CONF_ESPHOME,
|
|
CONF_NAME,
|
|
KEY_CORE,
|
|
KEY_ESP32,
|
|
KEY_TARGET_FRAMEWORK,
|
|
KEY_TARGET_PLATFORM,
|
|
KEY_VARIANT,
|
|
Toolchain,
|
|
)
|
|
from esphome.core import (
|
|
CORE,
|
|
ID,
|
|
EsphomeError,
|
|
HexInt,
|
|
Lambda,
|
|
MACAddress,
|
|
TimePeriodMilliseconds,
|
|
)
|
|
from esphome.storage_json import StorageJSON
|
|
from esphome.util import OrderedDict
|
|
|
|
_VALIDATED_CONFIG = {
|
|
"esphome": {"name": "lite_test", "friendly_name": "Lite Test Device"},
|
|
"esp32": {"board": "nodemcu-32s"},
|
|
"logger": {"baud_rate": 115200},
|
|
"api": {"port": 6053, "encryption": {"key": "6dGhpcyBpcyBhIHRlc3Q="}},
|
|
"ota": [{"platform": "esphome", "port": 3232, "password": "secret"}],
|
|
"wifi": {"ssid": "ssid", "use_address": "192.168.1.42"},
|
|
}
|
|
|
|
|
|
def _cache_body(config: dict | None = None) -> str:
|
|
"""Render the JSON envelope the production save writes."""
|
|
return json.dumps(
|
|
{"v": 1, "esphome": const.__version__, "config": config or _VALIDATED_CONFIG}
|
|
)
|
|
|
|
|
|
def _write_storage(
|
|
storage_path: Path,
|
|
*,
|
|
esp_platform: str | None = "ESP32",
|
|
core_platform: str | None = "esp32",
|
|
build_path: str | None = "/build/lite_test",
|
|
toolchain: str | None = None,
|
|
) -> None:
|
|
"""Write a vanilla StorageJSON sidecar for the cache tests."""
|
|
storage_path.parent.mkdir(parents=True, exist_ok=True)
|
|
data = {
|
|
"storage_version": 1,
|
|
"name": "lite_test",
|
|
"friendly_name": "Lite Test Device",
|
|
"comment": None,
|
|
"esphome_version": "2026.1.0",
|
|
"src_version": 1,
|
|
"address": "192.168.1.42",
|
|
"web_port": None,
|
|
"esp_platform": esp_platform,
|
|
"build_path": build_path,
|
|
"firmware_bin_path": "/build/lite_test/firmware.bin",
|
|
"loaded_integrations": ["api", "logger", "ota", "wifi"],
|
|
"loaded_platforms": [],
|
|
"no_mdns": False,
|
|
"framework": "arduino",
|
|
"core_platform": core_platform,
|
|
"toolchain": toolchain,
|
|
}
|
|
storage_path.write_text(json.dumps(data), encoding="utf-8")
|
|
|
|
|
|
def _write_cache(cache_path: Path, body: str | None = None) -> Path:
|
|
"""Write the cache file and return it."""
|
|
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
|
cache_path.write_text(body if body is not None else _cache_body(), encoding="utf-8")
|
|
return cache_path
|
|
|
|
|
|
def _set_cache_mtime(cache_path: Path, yaml_path: Path, *, offset: int) -> None:
|
|
"""Force the cache file's mtime relative to the source YAML.
|
|
|
|
Positive offset → cache is fresh. Negative → cache is stale.
|
|
"""
|
|
yaml_stat = yaml_path.stat()
|
|
os.utime(cache_path, (yaml_stat.st_atime, yaml_stat.st_mtime + offset))
|
|
|
|
|
|
@pytest.fixture
|
|
def primed_storage(tmp_path: Path) -> Path:
|
|
"""YAML + StorageJSON sidecar, no cache yet."""
|
|
yaml_path = tmp_path / "lite_test.yaml"
|
|
yaml_path.write_text("esphome:\n name: lite_test\n")
|
|
CORE.config_path = yaml_path
|
|
_write_storage(tmp_path / ".esphome" / "storage" / "lite_test.yaml.json")
|
|
return yaml_path
|
|
|
|
|
|
@pytest.fixture
|
|
def fresh_cache_files(primed_storage: Path) -> Path:
|
|
"""YAML + StorageJSON + cache, all consistent and fresh."""
|
|
storage_dir = primed_storage.parent / ".esphome" / "storage"
|
|
cache = _write_cache(storage_dir / "lite_test.yaml.validated.json")
|
|
_set_cache_mtime(cache, primed_storage, offset=5)
|
|
return primed_storage
|
|
|
|
|
|
def test_compiled_config_path_lives_alongside_sidecar(setup_core: Path) -> None:
|
|
"""The cache file shape is predictable from the YAML filename."""
|
|
path = compiled_config_path("device.yaml")
|
|
assert path.name == "device.yaml.validated.json"
|
|
assert path.parent.name == "storage"
|
|
|
|
|
|
def test_load_compiled_config_happy_path(fresh_cache_files: Path) -> None:
|
|
"""Fresh cache + sidecar → returns config and populates CORE."""
|
|
config = load_compiled_config(fresh_cache_files)
|
|
|
|
assert config is not None
|
|
assert config[CONF_ESPHOME][CONF_NAME] == "lite_test"
|
|
assert config[CONF_API]["encryption"]["key"] == "6dGhpcyBpcyBhIHRlc3Q="
|
|
assert config["ota"][0]["password"] == "secret"
|
|
|
|
# The fast path loads plain scalars; no per-node source ranges exist.
|
|
assert type(config[CONF_ESPHOME][CONF_NAME]) is str
|
|
|
|
# apply_to_core populated exactly what upload/logs read off CORE.
|
|
assert CORE.name == "lite_test"
|
|
assert CORE.build_path == Path("/build/lite_test")
|
|
assert CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] == "esp32"
|
|
assert CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] == "arduino"
|
|
# upload_using_esptool reads get_esp32_variant() off CORE.data[KEY_ESP32].
|
|
assert CORE.data[KEY_ESP32][KEY_VARIANT] == "ESP32"
|
|
|
|
|
|
def test_load_compiled_config_populates_esp32_variant(tmp_path: Path) -> None:
|
|
"""ESP32 variants survive the cache fast path so esptool gets the right --chip."""
|
|
yaml_path = tmp_path / "lite_test.yaml"
|
|
yaml_path.write_text("esphome:\n name: lite_test\n")
|
|
CORE.config_path = yaml_path
|
|
|
|
storage_dir = tmp_path / ".esphome" / "storage"
|
|
_write_storage(storage_dir / "lite_test.yaml.json", esp_platform="ESP32S3")
|
|
cache = _write_cache(storage_dir / "lite_test.yaml.validated.json")
|
|
_set_cache_mtime(cache, yaml_path, offset=5)
|
|
|
|
assert load_compiled_config(yaml_path) is not None
|
|
assert CORE.data[KEY_ESP32][KEY_VARIANT] == "ESP32S3"
|
|
|
|
|
|
def test_load_compiled_config_skips_esp32_block_for_other_platforms(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""Non-esp32 targets shouldn't fabricate an esp32 data block."""
|
|
yaml_path = tmp_path / "lite_test.yaml"
|
|
yaml_path.write_text("esphome:\n name: lite_test\n")
|
|
CORE.config_path = yaml_path
|
|
|
|
storage_dir = tmp_path / ".esphome" / "storage"
|
|
_write_storage(
|
|
storage_dir / "lite_test.yaml.json",
|
|
esp_platform="ESP8266",
|
|
core_platform="esp8266",
|
|
)
|
|
cache = _write_cache(storage_dir / "lite_test.yaml.validated.json")
|
|
_set_cache_mtime(cache, yaml_path, offset=5)
|
|
|
|
assert load_compiled_config(yaml_path) is not None
|
|
assert KEY_ESP32 not in CORE.data
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"scenario",
|
|
["missing_cache", "stale_cache", "corrupt_cache", "missing_sidecar"],
|
|
)
|
|
def test_load_compiled_config_falls_back(tmp_path: Path, scenario: str) -> None:
|
|
"""All non-happy cases return None so the caller falls back."""
|
|
yaml_path = tmp_path / "lite_test.yaml"
|
|
yaml_path.write_text("esphome:\n name: lite_test\n")
|
|
CORE.config_path = yaml_path
|
|
storage_dir = tmp_path / ".esphome" / "storage"
|
|
cache_path = storage_dir / "lite_test.yaml.validated.json"
|
|
sidecar_path = storage_dir / "lite_test.yaml.json"
|
|
|
|
if scenario == "missing_cache":
|
|
pass # no cache, no sidecar
|
|
elif scenario == "stale_cache":
|
|
_write_storage(sidecar_path)
|
|
_set_cache_mtime(_write_cache(cache_path), yaml_path, offset=-60)
|
|
elif scenario == "corrupt_cache":
|
|
_write_storage(sidecar_path)
|
|
_set_cache_mtime(
|
|
_write_cache(cache_path, '{"v": 1, "config": {'), yaml_path, offset=5
|
|
)
|
|
elif scenario == "missing_sidecar":
|
|
# Cache fresh + parseable, but no StorageJSON → can't populate CORE.
|
|
_set_cache_mtime(_write_cache(cache_path), yaml_path, offset=5)
|
|
|
|
assert load_compiled_config(yaml_path) is None
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"body",
|
|
[
|
|
pytest.param(
|
|
json.dumps(
|
|
{"v": 999, "esphome": const.__version__, "config": {"esphome": {}}}
|
|
),
|
|
id="wrong_version",
|
|
),
|
|
pytest.param(
|
|
json.dumps({"esphome": const.__version__, "config": {"esphome": {}}}),
|
|
id="missing_version",
|
|
),
|
|
pytest.param(
|
|
json.dumps({"v": 1, "esphome": "2020.1.0", "config": {"esphome": {}}}),
|
|
id="other_esphome_version",
|
|
),
|
|
pytest.param(
|
|
json.dumps({"v": 1, "config": {"esphome": {}}}),
|
|
id="missing_esphome_version",
|
|
),
|
|
pytest.param(
|
|
json.dumps(
|
|
{
|
|
"v": 1,
|
|
"esphome": const.__version__,
|
|
"config": ["not", "a", "dict"],
|
|
}
|
|
),
|
|
id="non_dict_config",
|
|
),
|
|
pytest.param(
|
|
json.dumps({"v": 1, "esphome": const.__version__}), id="missing_config"
|
|
),
|
|
pytest.param(json.dumps(["not", "an", "envelope"]), id="non_dict_envelope"),
|
|
],
|
|
)
|
|
def test_load_compiled_config_rejects_bad_envelope(
|
|
primed_storage: Path, body: str
|
|
) -> None:
|
|
"""A foreign or future cache shape falls back instead of half-loading."""
|
|
storage_dir = primed_storage.parent / ".esphome" / "storage"
|
|
cache = _write_cache(storage_dir / "lite_test.yaml.validated.json", body)
|
|
_set_cache_mtime(cache, primed_storage, offset=5)
|
|
|
|
assert load_compiled_config(primed_storage) is None
|
|
|
|
|
|
def test_load_ignores_legacy_yaml_cache(primed_storage: Path) -> None:
|
|
"""A fresh pre-JSON ``.validated.yaml`` alone can't drive the fast path."""
|
|
storage_dir = primed_storage.parent / ".esphome" / "storage"
|
|
legacy = _write_cache(
|
|
storage_dir / "lite_test.yaml.validated.yaml", "esphome:\n name: lite_test\n"
|
|
)
|
|
_set_cache_mtime(legacy, primed_storage, offset=5)
|
|
|
|
assert load_compiled_config(primed_storage) is None
|
|
|
|
|
|
def test_save_removes_stale_legacy_yaml_cache(tmp_path: Path) -> None:
|
|
"""A successful save leaves only the JSON cache behind."""
|
|
CORE.config_path = tmp_path / "lite_test.yaml"
|
|
legacy = tmp_path / ".esphome" / "storage" / "lite_test.yaml.validated.yaml"
|
|
legacy.parent.mkdir(parents=True, exist_ok=True)
|
|
legacy.write_text("esphome:\n name: lite_test\n")
|
|
|
|
save_compiled_config({"esphome": {"name": "lite_test"}})
|
|
|
|
assert compiled_config_path("lite_test.yaml").is_file()
|
|
assert not legacy.exists()
|
|
|
|
|
|
def test_save_removes_legacy_yaml_even_when_write_fails(tmp_path: Path) -> None:
|
|
"""The secret-bearing legacy cache goes away regardless of write outcome."""
|
|
CORE.config_path = tmp_path / "lite_test.yaml"
|
|
legacy = tmp_path / ".esphome" / "storage" / "lite_test.yaml.validated.yaml"
|
|
legacy.parent.mkdir(parents=True, exist_ok=True)
|
|
legacy.write_text("esphome:\n name: lite_test\n")
|
|
|
|
with patch("esphome.compiled_config.write_file", side_effect=RuntimeError("boom")):
|
|
save_compiled_config({"esphome": {"name": "lite_test"}})
|
|
|
|
assert not legacy.exists()
|
|
assert not compiled_config_path("lite_test.yaml").exists()
|
|
|
|
|
|
def test_save_warns_when_legacy_cache_unremovable(
|
|
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
|
) -> None:
|
|
"""A secret-bearing legacy file that won't unlink warns; the write proceeds."""
|
|
CORE.config_path = tmp_path / "lite_test.yaml"
|
|
legacy = tmp_path / ".esphome" / "storage" / "lite_test.yaml.validated.yaml"
|
|
legacy.parent.mkdir(parents=True, exist_ok=True)
|
|
legacy.mkdir() # unlink() on a directory raises OSError
|
|
|
|
with caplog.at_level("WARNING", logger="esphome.compiled_config"):
|
|
save_compiled_config({"esphome": {"name": "lite_test"}})
|
|
|
|
assert "legacy validated-config cache" in caplog.text
|
|
assert compiled_config_path("lite_test.yaml").is_file()
|
|
|
|
|
|
@pytest.mark.parametrize("command", ["upload", "logs"])
|
|
def test_run_esphome_upload_and_logs_use_cache_when_fresh(
|
|
command: str,
|
|
fresh_cache_files: Path,
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""upload/logs skip read_config() when the cache is fresh."""
|
|
captured: dict = {}
|
|
|
|
def _stub(_args, config):
|
|
captured["config"] = config
|
|
return 0
|
|
|
|
with (
|
|
caplog.at_level("INFO", logger="esphome.__main__"),
|
|
patch("esphome.config.read_config") as mock_read,
|
|
patch.dict("esphome.__main__.POST_CONFIG_ACTIONS", {command: _stub}),
|
|
):
|
|
assert run_esphome(["esphome", command, str(fresh_cache_files)]) == 0
|
|
|
|
mock_read.assert_not_called()
|
|
assert captured["config"][CONF_ESPHOME][CONF_NAME] == "lite_test"
|
|
assert captured["config"][CONF_API]["encryption"]["key"] == "6dGhpcyBpcyBhIHRlc3Q="
|
|
# The success-branch log line is part of the patch; assert on it so
|
|
# branch coverage stays unambiguous in CI.
|
|
assert "Loaded validated config cache" in caplog.text
|
|
|
|
|
|
@pytest.mark.parametrize("command", ["upload", "logs"])
|
|
def test_run_esphome_upload_and_logs_fall_back_when_no_cache(
|
|
tmp_path: Path, command: str
|
|
) -> None:
|
|
"""Without a cache, the dispatcher falls back to read_config()."""
|
|
yaml_path = tmp_path / "lite_test.yaml"
|
|
yaml_path.write_text("esphome:\n name: lite_test\n")
|
|
|
|
with (
|
|
patch("esphome.config.read_config", return_value=None) as mock_read,
|
|
patch.dict(
|
|
"esphome.__main__.POST_CONFIG_ACTIONS",
|
|
{command: lambda args, config: 0},
|
|
),
|
|
):
|
|
assert run_esphome(["esphome", command, str(yaml_path)]) == 2
|
|
|
|
mock_read.assert_called_once()
|
|
|
|
|
|
def _storage_fixture(tmp_path: Path) -> StorageJSON:
|
|
"""A loaded StorageJSON instance matching _write_storage's contents."""
|
|
fixture = tmp_path / "fixture_storage.json"
|
|
_write_storage(fixture)
|
|
return StorageJSON.load(fixture)
|
|
|
|
|
|
def _bare_yaml(tmp_path: Path) -> Path:
|
|
"""A minimal YAML with CORE.config_path pointed at it."""
|
|
yaml_path = tmp_path / "lite_test.yaml"
|
|
yaml_path.write_text("esphome:\n name: lite_test\n")
|
|
CORE.config_path = yaml_path
|
|
return yaml_path
|
|
|
|
|
|
@contextmanager
|
|
def _fallback_run(command: str = "upload", **from_core_kwargs) -> Any:
|
|
"""Patch the fallback path's collaborators for a run_esphome call.
|
|
|
|
Without kwargs, from_esphome_core stays real (yielded mock is None).
|
|
"""
|
|
with (
|
|
patch(
|
|
"esphome.config.read_config",
|
|
return_value={"esphome": {"name": "lite_test"}},
|
|
) as mock_read,
|
|
patch.dict(
|
|
"esphome.__main__.POST_CONFIG_ACTIONS",
|
|
{command: lambda args, config: 0},
|
|
),
|
|
):
|
|
if not from_core_kwargs:
|
|
yield mock_read, None
|
|
return
|
|
with patch.object(
|
|
StorageJSON, "from_esphome_core", **from_core_kwargs
|
|
) as mock_from_core:
|
|
yield mock_read, mock_from_core
|
|
|
|
|
|
@pytest.mark.parametrize("command", ["upload", "logs"])
|
|
def test_run_esphome_fallback_writes_sidecar_and_cache_without_sidecar(
|
|
tmp_path: Path, command: str
|
|
) -> None:
|
|
"""A never-compiled config caches on its first upload/logs run: the
|
|
fallback writes the StorageJSON sidecar itself (load_compiled_config
|
|
needs it), so the second run hits the fast path."""
|
|
yaml_path = _bare_yaml(tmp_path)
|
|
storage_dir = tmp_path / ".esphome" / "storage"
|
|
|
|
with _fallback_run(command, return_value=_storage_fixture(tmp_path)) as (
|
|
mock_read,
|
|
mock_from_core,
|
|
):
|
|
assert run_esphome(["esphome", command, str(yaml_path)]) == 0
|
|
mock_from_core.assert_called_once()
|
|
assert (storage_dir / "lite_test.yaml.validated.json").exists()
|
|
storage = StorageJSON.load(storage_dir / "lite_test.yaml.json")
|
|
assert storage is not None
|
|
# No compile happened, so the sidecar must not claim one.
|
|
assert mock_from_core.call_args.kwargs == {"claim_build": False}
|
|
|
|
# The second run loads the cache instead of re-validating.
|
|
assert run_esphome(["esphome", command, str(yaml_path)]) == 0
|
|
mock_read.assert_called_once()
|
|
|
|
|
|
# as_dict serialized unset paths as str(None) until 2026.9; files
|
|
# written by those wizards are still on disk.
|
|
_WIZARD_SIDECAR_CASES = pytest.mark.parametrize(
|
|
"wizard_kwargs",
|
|
[
|
|
{"esp_platform": None, "core_platform": None, "build_path": None},
|
|
{"build_path": None},
|
|
{"build_path": "None"},
|
|
],
|
|
ids=["legacy_wizard", "modern_wizard", "none_string_wizard"],
|
|
)
|
|
|
|
|
|
def _prime_core(tmp_path: Path) -> None:
|
|
"""Set the post-validation CORE state from_esphome_core reads."""
|
|
CORE.name = "lite_test"
|
|
CORE.build_path = tmp_path / "build" / "lite_test"
|
|
CORE.data[KEY_CORE] = {
|
|
KEY_TARGET_PLATFORM: "esp8266",
|
|
KEY_TARGET_FRAMEWORK: "arduino",
|
|
}
|
|
|
|
|
|
@_WIZARD_SIDECAR_CASES
|
|
def test_run_esphome_fallback_completes_wizard_sidecar(
|
|
tmp_path: Path, wizard_kwargs: dict[str, Any]
|
|
) -> None:
|
|
"""A wizard-written sidecar can't drive the fast path (no build_path;
|
|
older wizards also no platform fields); the fallback rewrites it from
|
|
CORE so the cache loads on the next run."""
|
|
yaml_path = _bare_yaml(tmp_path)
|
|
storage_dir = tmp_path / ".esphome" / "storage"
|
|
_write_storage(storage_dir / "lite_test.yaml.json", **wizard_kwargs)
|
|
|
|
with _fallback_run(return_value=_storage_fixture(tmp_path)) as (_, mock_from_core):
|
|
assert run_esphome(["esphome", "upload", str(yaml_path)]) == 0
|
|
|
|
mock_from_core.assert_called_once()
|
|
storage = StorageJSON.load(storage_dir / "lite_test.yaml.json")
|
|
assert storage is not None and storage.core_platform == "esp32"
|
|
# What the wizard recorded about a build (nothing, or a real one)
|
|
# carries through instead of being stamped with this run's values.
|
|
assert storage.esphome_version == "2026.1.0"
|
|
assert load_compiled_config(yaml_path) is not None
|
|
|
|
|
|
def test_run_esphome_fallback_skips_cache_when_sidecar_write_fails(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""A failed sidecar write is non-fatal and skips the cache save too:
|
|
without the sidecar the cache could never be loaded back, so writing
|
|
it would only leave resolved secrets on disk."""
|
|
yaml_path = _bare_yaml(tmp_path)
|
|
|
|
with (
|
|
_fallback_run(side_effect=RuntimeError("boom")),
|
|
patch("esphome.compiled_config.save_compiled_config") as mock_save,
|
|
):
|
|
assert run_esphome(["esphome", "upload", str(yaml_path)]) == 0
|
|
|
|
mock_save.assert_not_called()
|
|
assert not (tmp_path / ".esphome" / "storage" / "lite_test.yaml.json").exists()
|
|
|
|
|
|
def test_run_esphome_fallback_write_failure_takes_io_branch(
|
|
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
|
) -> None:
|
|
"""StorageJSON.save raises EsphomeError (write_file wraps OSError into
|
|
it), which must land in the plain I/O warning, not the traceback
|
|
branch for structural bugs."""
|
|
yaml_path = _bare_yaml(tmp_path)
|
|
|
|
with (
|
|
_fallback_run(return_value=_storage_fixture(tmp_path)),
|
|
patch.object(StorageJSON, "save", side_effect=EsphomeError("boom")),
|
|
patch("esphome.compiled_config.save_compiled_config") as mock_save,
|
|
caplog.at_level("WARNING", logger="esphome.compiled_config"),
|
|
):
|
|
assert run_esphome(["esphome", "upload", str(yaml_path)]) == 0
|
|
|
|
mock_save.assert_not_called()
|
|
assert "Could not refresh the storage sidecar" in caplog.text
|
|
assert "Unexpected error" not in caplog.text
|
|
|
|
|
|
def test_run_esphome_fallback_leaves_unreadable_sidecar_alone(tmp_path: Path) -> None:
|
|
"""A present-but-corrupt sidecar is not overwritten: it may hold a real
|
|
build's metadata, and replacing it would suppress the next compile's
|
|
clean of a possibly incoherent build tree. The cache save is skipped."""
|
|
yaml_path = _bare_yaml(tmp_path)
|
|
storage_dir = tmp_path / ".esphome" / "storage"
|
|
sidecar = storage_dir / "lite_test.yaml.json"
|
|
sidecar.parent.mkdir(parents=True, exist_ok=True)
|
|
sidecar.write_text("{truncated", encoding="utf-8")
|
|
|
|
with _fallback_run(return_value=None) as (_, mock_from_core):
|
|
assert run_esphome(["esphome", "upload", str(yaml_path)]) == 0
|
|
|
|
mock_from_core.assert_not_called()
|
|
assert sidecar.read_text(encoding="utf-8") == "{truncated"
|
|
assert not (storage_dir / "lite_test.yaml.validated.json").exists()
|
|
|
|
|
|
def test_run_esphome_fallback_skips_cache_when_rebuilt_sidecar_incomplete(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""If the rebuilt sidecar would still be incomplete, nothing is written:
|
|
the cache could never be loaded back, so saving it would only rewrite
|
|
resolved secrets on every run."""
|
|
yaml_path = _bare_yaml(tmp_path)
|
|
storage_dir = tmp_path / ".esphome" / "storage"
|
|
|
|
incomplete = tmp_path / "incomplete_storage.json"
|
|
_write_storage(incomplete, build_path=None)
|
|
|
|
with _fallback_run(return_value=StorageJSON.load(incomplete)):
|
|
assert run_esphome(["esphome", "upload", str(yaml_path)]) == 0
|
|
|
|
assert not (storage_dir / "lite_test.yaml.json").exists()
|
|
assert not (storage_dir / "lite_test.yaml.validated.json").exists()
|
|
|
|
|
|
def test_run_esphome_fallback_sidecar_records_platformio_toolchain(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""The toolchain fallback runs before the sidecar write, so platforms
|
|
whose validators leave CORE.toolchain unset record the same
|
|
"platformio" a compile writes, not null."""
|
|
yaml_path = _bare_yaml(tmp_path)
|
|
_prime_core(tmp_path)
|
|
assert CORE.toolchain is None
|
|
|
|
with _fallback_run():
|
|
assert run_esphome(["esphome", "upload", str(yaml_path)]) == 0
|
|
|
|
storage = StorageJSON.load(
|
|
tmp_path / ".esphome" / "storage" / "lite_test.yaml.json"
|
|
)
|
|
assert storage is not None
|
|
assert storage.toolchain == "platformio"
|
|
|
|
|
|
@pytest.mark.parametrize("existing_sidecar", [None, "wizard"])
|
|
def test_run_esphome_fallback_skips_sidecar_when_build_tree_exists(
|
|
tmp_path: Path, existing_sidecar: str | None
|
|
) -> None:
|
|
"""An existing build tree with a missing or wizard-only sidecar keeps
|
|
it that way: the mismatch is what makes the next compile wipe the
|
|
unknown tree, so the fallback writes nothing and skips the cache."""
|
|
yaml_path = _bare_yaml(tmp_path)
|
|
_prime_core(tmp_path)
|
|
CORE.build_path.mkdir(parents=True)
|
|
storage_dir = tmp_path / ".esphome" / "storage"
|
|
if existing_sidecar == "wizard":
|
|
_write_storage(storage_dir / "lite_test.yaml.json", build_path=None)
|
|
wizard_body = (storage_dir / "lite_test.yaml.json").read_text(encoding="utf-8")
|
|
|
|
with _fallback_run(return_value=_storage_fixture(tmp_path)) as (_, mock_from_core):
|
|
assert run_esphome(["esphome", "upload", str(yaml_path)]) == 0
|
|
|
|
mock_from_core.assert_not_called()
|
|
assert not (storage_dir / "lite_test.yaml.validated.json").exists()
|
|
if existing_sidecar == "wizard":
|
|
sidecar_body = (storage_dir / "lite_test.yaml.json").read_text(encoding="utf-8")
|
|
assert sidecar_body == wizard_body
|
|
else:
|
|
assert not (storage_dir / "lite_test.yaml.json").exists()
|
|
|
|
|
|
def test_save_compiled_config_and_sidecar_builds_real_sidecar(tmp_path: Path) -> None:
|
|
"""Drive the real from_esphome_core on the fallback path: the
|
|
post-validation CORE state yields a complete, loadable sidecar."""
|
|
yaml_path = _bare_yaml(tmp_path)
|
|
_prime_core(tmp_path)
|
|
CORE.config = {CONF_ESPHOME: {CONF_NAME: "lite_test"}}
|
|
CORE.toolchain = Toolchain.PLATFORMIO
|
|
|
|
save_compiled_config_and_sidecar(CORE.config)
|
|
|
|
storage = StorageJSON.load(
|
|
tmp_path / ".esphome" / "storage" / "lite_test.yaml.json"
|
|
)
|
|
assert storage is not None
|
|
assert storage.core_platform == "esp8266"
|
|
assert storage.build_path is not None
|
|
# No compile happened, so the sidecar must not claim one.
|
|
assert storage.esphome_version is None
|
|
assert storage.firmware_bin_path is None
|
|
assert load_compiled_config(yaml_path) is not None
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("sidecar_toolchain", "saved"),
|
|
[
|
|
("esp-idf", False),
|
|
("platformio", True),
|
|
(None, True), # legacy sidecar without the field: guard is inert
|
|
],
|
|
)
|
|
def test_save_compiled_config_and_sidecar_toolchain_mismatch(
|
|
tmp_path: Path, sidecar_toolchain: str | None, saved: bool
|
|
) -> None:
|
|
"""A config validated under a different toolchain than the compile's
|
|
must not overwrite the cache: platforms normalize toolchain-sensitive
|
|
keys differently and the sidecar keeps the compile's toolchain."""
|
|
yaml_path = _bare_yaml(tmp_path)
|
|
_prime_core(tmp_path)
|
|
CORE.config = {CONF_ESPHOME: {CONF_NAME: "lite_test"}}
|
|
CORE.toolchain = Toolchain.PLATFORMIO
|
|
_write_storage(
|
|
tmp_path / ".esphome" / "storage" / "lite_test.yaml.json",
|
|
toolchain=sidecar_toolchain,
|
|
)
|
|
|
|
save_compiled_config_and_sidecar(CORE.config)
|
|
|
|
cache = tmp_path / ".esphome" / "storage" / "lite_test.yaml.validated.json"
|
|
assert cache.exists() is saved
|
|
assert (load_compiled_config(yaml_path) is not None) is saved
|
|
|
|
|
|
@pytest.mark.parametrize("command", ["upload", "logs"])
|
|
def test_run_esphome_upload_and_logs_refresh_cache_on_fallback(
|
|
tmp_path: Path, command: str
|
|
) -> None:
|
|
"""A stale-cache fallback rewrites the cache so the next call hits
|
|
the fast path. Without this, every upload/logs after a YAML edit
|
|
pays for read_config() until the next compile rewrites the cache."""
|
|
yaml_path = tmp_path / "lite_test.yaml"
|
|
yaml_path.write_text("esphome:\n name: lite_test\n")
|
|
CORE.config_path = yaml_path
|
|
|
|
storage_dir = tmp_path / ".esphome" / "storage"
|
|
_write_storage(storage_dir / "lite_test.yaml.json")
|
|
cache = _write_cache(storage_dir / "lite_test.yaml.validated.json")
|
|
_set_cache_mtime(cache, yaml_path, offset=-60) # stale
|
|
|
|
fresh_config = {"esphome": {"name": "lite_test"}, "logger": {}}
|
|
|
|
with (
|
|
patch("esphome.config.read_config", return_value=fresh_config),
|
|
patch(
|
|
"esphome.compiled_config.save_compiled_config", wraps=save_compiled_config
|
|
) as mock_save,
|
|
patch.object(StorageJSON, "from_esphome_core") as mock_from_core,
|
|
patch.dict(
|
|
"esphome.__main__.POST_CONFIG_ACTIONS",
|
|
{command: lambda args, config: 0},
|
|
),
|
|
):
|
|
assert run_esphome(["esphome", command, str(yaml_path)]) == 0
|
|
|
|
mock_save.assert_called_once_with(fresh_config)
|
|
# The compile-written sidecar is complete; the fallback leaves it alone.
|
|
mock_from_core.assert_not_called()
|
|
# mtime is now newer than the source YAML, so a follow-up call hits
|
|
# the fast path instead of repeating read_config.
|
|
assert cache.stat().st_mtime >= yaml_path.stat().st_mtime
|
|
|
|
|
|
def test_run_esphome_upload_with_substitution_does_not_refresh_cache(
|
|
fresh_cache_files: Path,
|
|
) -> None:
|
|
"""`-s` substitutions skip the cache on both read and write -- saving
|
|
here would clobber the cache with a substitution-specific config."""
|
|
with (
|
|
patch("esphome.config.read_config", return_value={"esphome": {}}),
|
|
patch("esphome.compiled_config.save_compiled_config") as mock_save,
|
|
patch.dict(
|
|
"esphome.__main__.POST_CONFIG_ACTIONS",
|
|
{"upload": lambda args, config: 0},
|
|
),
|
|
):
|
|
run_esphome(["esphome", "-s", "var", "val", "upload", str(fresh_cache_files)])
|
|
|
|
mock_save.assert_not_called()
|
|
|
|
|
|
def test_run_esphome_compile_does_not_refresh_cache_via_fallback(
|
|
fresh_cache_files: Path,
|
|
) -> None:
|
|
"""Compile writes the cache through update_storage_json, not via the
|
|
upload/logs fallback path -- the fallback save would skip the
|
|
storage_should_clean check."""
|
|
with (
|
|
patch("esphome.config.read_config", return_value={"esphome": {}}),
|
|
patch("esphome.compiled_config.save_compiled_config") as mock_save,
|
|
patch.dict(
|
|
"esphome.__main__.POST_CONFIG_ACTIONS",
|
|
{"compile": lambda args, config: 0},
|
|
),
|
|
):
|
|
run_esphome(["esphome", "compile", str(fresh_cache_files)])
|
|
|
|
mock_save.assert_not_called()
|
|
|
|
|
|
def test_run_esphome_upload_with_substitution_skips_cache(
|
|
fresh_cache_files: Path,
|
|
) -> None:
|
|
"""`-s key value` forces a fresh validation -- the cache was written
|
|
against the prior substitution set, so reusing it would silently
|
|
ignore the override."""
|
|
with (
|
|
patch("esphome.config.read_config", return_value=None) as mock_read,
|
|
patch.dict(
|
|
"esphome.__main__.POST_CONFIG_ACTIONS",
|
|
{"upload": lambda args, config: 0},
|
|
),
|
|
):
|
|
run_esphome(["esphome", "-s", "var", "val", "upload", str(fresh_cache_files)])
|
|
|
|
mock_read.assert_called_once()
|
|
|
|
|
|
def test_run_esphome_compile_does_not_use_cache(fresh_cache_files: Path) -> None:
|
|
"""The compile subcommand always re-validates -- it's what writes the cache."""
|
|
with (
|
|
patch("esphome.config.read_config", return_value=None) as mock_read,
|
|
patch.dict(
|
|
"esphome.__main__.POST_CONFIG_ACTIONS",
|
|
{"compile": lambda args, config: 0},
|
|
),
|
|
):
|
|
run_esphome(["esphome", "compile", str(fresh_cache_files)])
|
|
|
|
mock_read.assert_called_once()
|
|
|
|
|
|
def test_save_compiled_config_writes_cache(tmp_path: Path) -> None:
|
|
"""`save_compiled_config` writes the JSON envelope next to the sidecar."""
|
|
CORE.config_path = tmp_path / "lite_test.yaml"
|
|
save_compiled_config({"esphome": {"name": "lite_test"}, "logger": {}})
|
|
|
|
cache_path = compiled_config_path("lite_test.yaml")
|
|
assert cache_path.is_file()
|
|
envelope = json.loads(cache_path.read_text())
|
|
assert envelope["v"] == 1
|
|
assert envelope["esphome"] == const.__version__
|
|
assert envelope["config"] == {"esphome": {"name": "lite_test"}, "logger": {}}
|
|
|
|
|
|
def test_save_compiled_config_swallows_write_errors(
|
|
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
|
) -> None:
|
|
"""Failures during the write are non-fatal -- a bad cache just means
|
|
the next fast path falls back to read_config()."""
|
|
CORE.config_path = tmp_path / "lite_test.yaml"
|
|
with patch("esphome.compiled_config.write_file", side_effect=RuntimeError("boom")):
|
|
save_compiled_config({"esphome": {"name": "lite_test"}})
|
|
assert not compiled_config_path("lite_test.yaml").exists()
|
|
|
|
|
|
def test_save_stringifies_unknown_values(tmp_path: Path) -> None:
|
|
"""A type with no dedicated encoding stores its string form."""
|
|
|
|
class Weird:
|
|
def __str__(self) -> str:
|
|
return "weird-str"
|
|
|
|
CORE.config_path = tmp_path / "lite_test.yaml"
|
|
save_compiled_config({"esphome": {"name": "lite_test", "weird": Weird()}})
|
|
envelope = json.loads(compiled_config_path("lite_test.yaml").read_text())
|
|
assert envelope["config"]["esphome"]["weird"] == "weird-str"
|
|
|
|
|
|
def test_save_skips_cache_on_unserializable_key(tmp_path: Path) -> None:
|
|
"""A non-basic dict key aborts the write; the fast path falls back."""
|
|
CORE.config_path = tmp_path / "lite_test.yaml"
|
|
save_compiled_config({"esphome": {("a", "b"): "lite_test"}})
|
|
assert not compiled_config_path("lite_test.yaml").exists()
|
|
|
|
|
|
def _normalize(value: Any) -> Any:
|
|
"""Make Lambda comparable; everything else compares by value already."""
|
|
if isinstance(value, Lambda):
|
|
return ("__lambda__", value.value)
|
|
if isinstance(value, dict):
|
|
return {k: _normalize(v) for k, v in value.items()}
|
|
if isinstance(value, (list, tuple)):
|
|
return [_normalize(v) for v in value]
|
|
return value
|
|
|
|
|
|
def _round_trip_config() -> OrderedDict:
|
|
"""A post-validation shaped config exercising every representer type."""
|
|
return OrderedDict(
|
|
{
|
|
"esphome": OrderedDict(
|
|
{
|
|
"name": "lite_test",
|
|
"build_path": Path("/build/lite_test"),
|
|
"on_boot": [
|
|
OrderedDict(
|
|
{
|
|
"trigger_id": ID("trigger_1", type="Trigger"),
|
|
"then": [{"lambda": Lambda('ESP_LOGD("t", "x");')}],
|
|
}
|
|
)
|
|
],
|
|
}
|
|
),
|
|
"wifi": OrderedDict(
|
|
{
|
|
"id": ID("wifi_id", type="WiFiComponent"),
|
|
"reboot_timeout": TimePeriodMilliseconds(milliseconds=900000),
|
|
"use_address": IPv4Address("192.168.1.42"),
|
|
"subnet": IPv4Network("192.168.1.0/24"),
|
|
"mac": MACAddress(0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x01),
|
|
}
|
|
),
|
|
"misc": OrderedDict(
|
|
{
|
|
"uuid": UUID("12345678-1234-5678-1234-567812345678"),
|
|
"toolchain": Toolchain.PLATFORMIO,
|
|
"hex": HexInt(0x1234),
|
|
"levels": (1, 2.5, True, None),
|
|
"empty": {},
|
|
}
|
|
),
|
|
}
|
|
)
|
|
|
|
|
|
def test_cache_round_trip_matches_yaml_cache(primed_storage: Path) -> None:
|
|
"""The JSON cache loads the same tree the YAML cache used to."""
|
|
config = _round_trip_config()
|
|
save_compiled_config(config)
|
|
from_json = load_compiled_config(primed_storage)
|
|
assert from_json is not None
|
|
|
|
yaml_cache = primed_storage.parent / "dumped.yaml"
|
|
yaml_cache.write_text(yaml_util.dump(config, show_secrets=True))
|
|
from_yaml = yaml_util.load_yaml(
|
|
yaml_cache, clear_secrets=False, track_document_range=False
|
|
)
|
|
|
|
assert _normalize(from_json) == _normalize(from_yaml)
|
|
|
|
|
|
def test_lambda_sentinel_round_trips(primed_storage: Path) -> None:
|
|
"""A !lambda body comes back as a Lambda with the same source."""
|
|
body = 'id(sensor_1).publish_state(42);\nreturn "multi\\nline";'
|
|
save_compiled_config(
|
|
{
|
|
"esphome": {"name": "lite_test"},
|
|
"script": [{"then": [{"lambda": Lambda(body)}]}],
|
|
}
|
|
)
|
|
|
|
config = load_compiled_config(primed_storage)
|
|
assert config is not None
|
|
revived = config["script"][0]["then"][0]["lambda"]
|
|
assert isinstance(revived, Lambda)
|
|
assert revived.value == body
|
|
|
|
|
|
def test_object_hook_requires_exact_shape(primed_storage: Path) -> None:
|
|
"""Only the exact one-key string-valued sentinel revives a Lambda."""
|
|
storage_dir = primed_storage.parent / ".esphome" / "storage"
|
|
config = {
|
|
"esphome": {"name": "lite_test"},
|
|
"extra_key": {_LAMBDA_KEY: "x", "y": 1},
|
|
"non_str": {_LAMBDA_KEY: 5},
|
|
}
|
|
cache = _write_cache(
|
|
storage_dir / "lite_test.yaml.validated.json", _cache_body(config)
|
|
)
|
|
_set_cache_mtime(cache, primed_storage, offset=5)
|
|
|
|
loaded = load_compiled_config(primed_storage)
|
|
assert loaded is not None
|
|
assert loaded["extra_key"] == {_LAMBDA_KEY: "x", "y": 1}
|
|
assert loaded["non_str"] == {_LAMBDA_KEY: 5}
|
|
|
|
|
|
def test_int_keys_coerce_to_strings(primed_storage: Path) -> None:
|
|
"""Non-str basic keys stringify; validated configs only use string keys."""
|
|
save_compiled_config({"esphome": {"name": "lite_test"}, "table": {1: "a", 2: "b"}})
|
|
|
|
config = load_compiled_config(primed_storage)
|
|
assert config is not None
|
|
assert config["table"] == {"1": "a", "2": "b"}
|
|
|
|
|
|
@_WIZARD_SIDECAR_CASES
|
|
def test_load_compiled_config_rejects_wizard_only_sidecar(
|
|
tmp_path: Path, wizard_kwargs: dict[str, Any]
|
|
) -> None:
|
|
"""A wizard-written sidecar (no build_path; older wizards also no
|
|
platform fields) can't drive upload/logs, so the fast path falls back."""
|
|
yaml_path = _bare_yaml(tmp_path)
|
|
storage_dir = tmp_path / ".esphome" / "storage"
|
|
_write_storage(storage_dir / "lite_test.yaml.json", **wizard_kwargs)
|
|
cache_path = _write_cache(storage_dir / "lite_test.yaml.validated.json")
|
|
_set_cache_mtime(cache_path, yaml_path, offset=5)
|
|
|
|
assert load_compiled_config(yaml_path) is None
|