[core] Derive CORE state from cached config dict, drop apply_to_core

`StorageJSON.apply_to_core` was the inverse of `from_esphome_core`:
two enumerations of the same fields that had to stay in sync. When a
new CORE attribute joined the validation pipeline, both lists needed
an update.

Replace it with `_populate_core_from_validated_config(config)` in
`compiled_config.py` that reads the cached config dict directly --
same source `read_config` writes into during validation, no separate
sidecar schema. The fast path no longer touches StorageJSON at all;
the sidecar stays in place for the dashboard's existing use.

Drift surface shrinks to: name/friendly_name/build_path under the
esphome block (config schema is stable) + target_platform discovered
via `_is_target_platform` (the canonical detector) + framework via
the `<platform>.framework.type` convention every platform component
already follows.
This commit is contained in:
J. Nick Koston
2026-05-12 15:53:46 -05:00
parent aee2fc5761
commit cc6245b876
3 changed files with 85 additions and 84 deletions
+67 -14
View File
@@ -13,8 +13,7 @@ paths) round-trip cleanly, and so the small ``StorageJSON`` sidecar
isn't bloated by configs that can grow past a megabyte. Staleness
is gated by an mtime check against the source YAML; the loader
falls back to ``None`` (and the caller to ``read_config()``) whenever
the cache is missing, stale, unparseable, or the companion
``StorageJSON`` sidecar can't be loaded.
the cache is missing, stale, unparseable, or structurally incomplete.
"""
from __future__ import annotations
@@ -22,14 +21,63 @@ from __future__ import annotations
import logging
from pathlib import Path
from esphome.const import (
CONF_BUILD_PATH,
CONF_ESPHOME,
CONF_FRAMEWORK,
CONF_FRIENDLY_NAME,
CONF_NAME,
CONF_TYPE,
KEY_CORE,
KEY_TARGET_FRAMEWORK,
KEY_TARGET_PLATFORM,
)
from esphome.core import CORE
from esphome.helpers import write_file_if_changed
from esphome.storage_json import StorageJSON, ext_storage_path
from esphome.types import ConfigType
_LOGGER = logging.getLogger(__name__)
def _populate_core_from_validated_config(config: ConfigType) -> None:
"""Set up ``CORE`` from an already-validated config dict.
Reads from the same config keys ``preload_core_config`` and the
target-platform component validator would write into during a full
``read_config`` -- using the cached config dict as the canonical
source instead of duplicating the field list in a separate sidecar
schema. ``CORE.address`` and ``CORE.web_port`` aren't set here:
they're already properties that derive themselves from
``CORE.config`` on access, which the dispatcher assigns next.
"""
from esphome.core.config import _is_target_platform
esphome_block = config[CONF_ESPHOME]
CORE.name = esphome_block[CONF_NAME]
CORE.friendly_name = esphome_block.get(CONF_FRIENDLY_NAME)
if (build_path := esphome_block.get(CONF_BUILD_PATH)) is not None:
CORE.build_path = CORE.data_dir / build_path
CORE.data.setdefault(KEY_CORE, {})
for domain, sub in config.items():
if not isinstance(domain, str) or not _is_target_platform(domain):
continue
CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = domain
# Every platform component follows the
# `<platform>.framework.type` convention to express which
# framework the binary was built against (esp-idf, arduino,
# zephyr). A platform without a framework block (host) just
# leaves KEY_TARGET_FRAMEWORK unset, matching what
# `read_config` would do.
if (
isinstance(sub, dict)
and isinstance((framework := sub.get(CONF_FRAMEWORK)), dict)
and (framework_type := framework.get(CONF_TYPE)) is not None
):
CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = framework_type
break
def compiled_config_path(config_filename: str) -> Path:
"""Path to the cached validated config alongside the storage sidecar."""
return CORE.data_dir / "storage" / f"{config_filename}.validated.yaml"
@@ -64,17 +112,19 @@ def save_compiled_config(config: ConfigType) -> None:
def load_compiled_config(conf_path: Path) -> ConfigType | None:
"""Load the cached validated config and apply storage metadata to CORE.
"""Load the cached validated config and set up ``CORE``.
Single entry point for the ``upload`` / ``logs`` fast path: loads
the cache, applies the ``StorageJSON`` sidecar's platform / build
metadata to ``CORE``, and returns the config dict. Returns
Single entry point for the ``upload`` / ``logs`` fast path. Returns
``None`` (so the caller falls back to ``read_config``) when the
cache is missing, older than the source YAML, unparseable, or
when the sidecar can't be loaded. The mtime check catches the
common "user edited the YAML and forgot to recompile" case;
deeper drift (an edited ``!include`` whose parent YAML mtime
didn't change) is the user's responsibility.
structurally incomplete. The mtime check catches the common "user
edited the YAML and forgot to recompile" case; deeper drift (an
edited ``!include`` whose parent YAML mtime didn't change) is the
user's responsibility.
CORE state is derived from the cached config dict directly --
same source ``read_config`` uses -- so there's no separate sidecar
schema to keep in sync.
"""
cache_path = compiled_config_path(conf_path.name)
if not _cache_is_fresh(cache_path, conf_path):
@@ -89,8 +139,11 @@ def load_compiled_config(conf_path: Path) -> ConfigType | None:
except Exception: # pylint: disable=broad-except
return None
storage = StorageJSON.load(ext_storage_path(conf_path.name))
if storage is None:
if not isinstance(config, dict) or CONF_ESPHOME not in config:
return None
try:
_populate_core_from_validated_config(config)
except (KeyError, TypeError):
return None
storage.apply_to_core()
return config
-27
View File
@@ -256,33 +256,6 @@ class StorageJSON:
except Exception: # pylint: disable=broad-except
return None
def apply_to_core(self) -> None:
"""Populate ``CORE`` from this sidecar.
Used by the ``--from-storage-json`` fast path in
``esphome upload`` / ``esphome logs``: those subcommands read
a handful of ``CORE`` attributes (``target_platform``,
``build_path``, ``name``, ``loaded_integrations``) that the
normal flow populates during ``read_config``. Lifting them off
the sidecar lets us skip the validation pass entirely.
"""
from esphome.const import KEY_CORE, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM
CORE.name = self.name
CORE.friendly_name = self.friendly_name
CORE.build_path = self.build_path
CORE.loaded_integrations = set(self.loaded_integrations)
CORE.loaded_platforms = set(self.loaded_platforms)
core_platform = self.core_platform or (
self.target_platform.lower() if self.target_platform else None
)
CORE.data.setdefault(KEY_CORE, {})
if core_platform is not None:
CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = core_platform
if self.framework is not None:
CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = self.framework
def __eq__(self, o) -> bool:
return isinstance(o, StorageJSON) and self.as_dict() == o.as_dict()
+18 -43
View File
@@ -2,7 +2,6 @@
from __future__ import annotations
import json
import os
from pathlib import Path
from unittest.mock import patch
@@ -25,8 +24,11 @@ _VALIDATED_CONFIG_YAML = """\
esphome:
name: lite_test
friendly_name: Lite Test Device
build_path: build/lite_test
esp32:
board: nodemcu-32s
framework:
type: arduino
logger:
baud_rate: 115200
api:
@@ -43,30 +45,6 @@ wifi:
"""
def _write_storage(storage_path: Path) -> 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": "ESP32",
"build_path": "/build/lite_test",
"firmware_bin_path": "/build/lite_test/firmware.bin",
"loaded_integrations": ["api", "logger", "ota", "wifi"],
"loaded_platforms": [],
"no_mdns": False,
"framework": "arduino",
"core_platform": "esp32",
}
storage_path.write_text(json.dumps(data))
def _write_cache(cache_path: Path, body: str = _VALIDATED_CONFIG_YAML) -> Path:
"""Write the cache file and return it."""
cache_path.parent.mkdir(parents=True, exist_ok=True)
@@ -85,14 +63,14 @@ def _set_cache_mtime(cache_path: Path, yaml_path: Path, *, offset: int) -> None:
@pytest.fixture
def fresh_cache_files(tmp_path: Path) -> Path:
"""YAML + StorageJSON + cache, all consistent and fresh."""
"""YAML + cache, both consistent and fresh."""
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.yaml")
cache = _write_cache(
tmp_path / ".esphome" / "storage" / "lite_test.yaml.validated.yaml"
)
_set_cache_mtime(cache, yaml_path, offset=5)
return yaml_path
@@ -106,7 +84,7 @@ def test_compiled_config_path_lives_alongside_sidecar(setup_core: Path) -> None:
def test_load_compiled_config_happy_path(fresh_cache_files: Path) -> None:
"""Fresh cache + sidecar → returns config and populates CORE."""
"""Fresh cache → returns the config and populates CORE from it."""
config = load_compiled_config(fresh_cache_files)
assert config is not None
@@ -114,40 +92,37 @@ def test_load_compiled_config_happy_path(fresh_cache_files: Path) -> None:
assert config[CONF_API]["encryption"]["key"] == "6dGhpcyBpcyBhIHRlc3Q="
assert config["ota"][0]["password"] == "secret"
# apply_to_core ran as part of the orchestration.
# CORE state derives from the cached config dict -- same source
# `read_config` uses, no separate sidecar schema.
assert CORE.name == "lite_test"
assert CORE.build_path == Path("/build/lite_test")
assert CORE.friendly_name == "Lite Test Device"
assert CORE.build_path == CORE.data_dir / "build/lite_test"
assert CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] == "esp32"
assert CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] == "arduino"
assert "api" in CORE.loaded_integrations
@pytest.mark.parametrize(
"scenario",
["missing_cache", "stale_cache", "corrupt_cache", "missing_sidecar"],
["missing_cache", "stale_cache", "corrupt_cache", "missing_esphome_block"],
)
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.yaml"
sidecar_path = storage_dir / "lite_test.yaml.json"
cache_path = tmp_path / ".esphome" / "storage" / "lite_test.yaml.validated.yaml"
if scenario == "missing_cache":
pass # no cache, no sidecar
pass # no cache
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, "not: valid: yaml: ["), 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)
elif scenario == "missing_esphome_block":
# Parseable YAML but no esphome: block -- can't populate CORE.
_set_cache_mtime(_write_cache(cache_path, "logger:\n"), yaml_path, offset=5)
assert load_compiled_config(yaml_path) is None