[core] Cache validated config for --from-storage-json fast path

Drop the StorageJSON `compiled_config` field and the
`esphome/lite_config.py` module from the first iteration. The earlier
shape stored the entire validated config inside the JSON sidecar, but
configs can grow past a megabyte once packages and substitutions
expand, and JSON can't round-trip the YAML-specific types (lambdas,
ID instances, includes) the validation pipeline produces.

Instead: dump the validated config to its own YAML file alongside the
sidecar (`<file>.validated.yaml`), using `yaml_util.dump` so all
esphome-specific tags survive the round trip. The fast path loads it
back with `yaml_util.load_yaml(clear_secrets=False)` -- no schema
validation, no final-validate, no external-component refresh.

Staleness is gated by mtime: if the source YAML mtime is newer than
the cache, the dispatcher falls back to a full `read_config()` with a
warning. The cache is refreshed on every successful compile via
`writer.update_storage_json`, so the dashboard's compile -> upload
-> logs flow always sees a fresh cache.

Drift surface shrinks to "what does yaml_util.dump emit" rather than
"which top-level keys does upload/logs read" -- when those subcommands
gain a new dependency, the cache already carries it.
This commit is contained in:
J. Nick Koston
2026-05-12 15:34:07 -05:00
parent eeaa8dee7b
commit 6a8a1d5256
6 changed files with 399 additions and 488 deletions
+22 -9
View File
@@ -2445,25 +2445,38 @@ def run_esphome(argv):
# `read_config()` pipeline (parse + schema validate + final-validate +
# external component refresh) for those two subcommands is dead work and
# produces a wall of "Reading configuration ..." log lines for every
# remote install. Source the platform / build metadata from the
# StorageJSON sidecar instead; fall back to full validation if it's
# missing or stale so a cold cache never produces a worse outcome.
# remote install. Reload the validated config that the last compile
# cached alongside the StorageJSON sidecar; fall back to full
# validation if the cache is missing or older than the YAML so a
# cold cache never produces a worse outcome.
config = None
if getattr(args, "from_storage_json", False) and args.command in (
"upload",
"logs",
):
from esphome.lite_config import load_lite_config_from_storage
from esphome.storage_json import (
StorageJSON,
ext_storage_path,
load_compiled_config,
)
config = load_lite_config_from_storage(conf_path, command_line_substitutions)
config = load_compiled_config(conf_path)
if config is not None:
storage = StorageJSON.load(ext_storage_path(conf_path.name))
if storage is None:
config = None
else:
storage.apply_to_core()
_LOGGER.info(
"Loaded validated config cache for %s, skipping validation.",
conf_path.name,
)
if config is None:
_LOGGER.warning(
"StorageJSON sidecar missing or stale for %s; falling back to "
"full config validation.",
"Validated config cache for %s is missing or older than the "
"YAML; falling back to full config validation.",
conf_path,
)
else:
_LOGGER.info("Loaded device metadata from StorageJSON sidecar.")
if config is None:
config = read_config(
-197
View File
@@ -1,197 +0,0 @@
"""Lite-config loader for ``upload`` / ``logs`` fast paths.
When the caller (typically a dashboard) already has a known-good
firmware binary on disk and only needs the CLI to ship bytes to a
device or stream logs back, running the full ``read_config()``
pipeline is dead work. It re-parses every ``!include``, runs every
component's schema validator, executes all final-validate hooks, and
fetches/refreshes external components -- producing a fully validated
``Config`` object that the upload / logs subcommands only consult for a
handful of leaf keys.
This module loads the per-config ``StorageJSON`` sidecar that was
written by the last successful ``compile`` run, populates ``CORE`` with
the platform / build metadata from it, and re-parses just enough of the
YAML head (substitutions + packages, no schema validation) to recover
``api:`` / ``logger:`` / ``ota:`` / ``mqtt:`` / network blocks for the
subcommand callers.
If the sidecar is missing or older than the YAML, this returns ``None``
so the dispatcher can fall back to the full ``read_config()`` path.
That makes the flag a pure optimisation: a cold cache never produces a
worse outcome than today.
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any
from esphome.const import (
CONF_API,
CONF_ESPHOME,
CONF_ETHERNET,
CONF_FRIENDLY_NAME,
CONF_LOGGER,
CONF_MQTT,
CONF_NAME,
CONF_OPENTHREAD,
CONF_OTA,
CONF_USE_ADDRESS,
CONF_WEB_SERVER,
CONF_WIFI,
KEY_CORE,
KEY_TARGET_FRAMEWORK,
KEY_TARGET_PLATFORM,
)
from esphome.core import CORE
from esphome.storage_json import StorageJSON, ext_storage_path
from esphome.types import ConfigType
_LOGGER = logging.getLogger(__name__)
# Top-level keys we copy from the raw YAML into the lite config dict.
# Anything outside this list is irrelevant to ``upload`` / ``logs``.
_LITE_TOP_LEVEL_KEYS: tuple[str, ...] = (
CONF_ESPHOME,
CONF_API,
CONF_LOGGER,
CONF_OTA,
CONF_MQTT,
CONF_WIFI,
CONF_ETHERNET,
CONF_OPENTHREAD,
CONF_WEB_SERVER,
)
def _parse_yaml_head(
conf_path: Path, command_line_substitutions: dict[str, Any] | None
) -> dict[str, Any] | None:
"""Load and lightly process the YAML, without schema validation.
Resolves ``!include`` / ``!secret`` (via ``yaml_util``),
substitutions, and ``packages:`` -- the three passes whose output
the upload / logs subcommands need. Returns ``None`` on any error
so the caller can fall back to the full ``read_config()`` path.
"""
from esphome import yaml_util
from esphome.components.packages import resolve_packages
from esphome.components.substitutions import do_substitution_pass
try:
config = yaml_util.load_yaml(conf_path)
except Exception: # pylint: disable=broad-except
return None
try:
config = do_substitution_pass(config, command_line_substitutions)
except Exception: # pylint: disable=broad-except
return None
try:
config = resolve_packages(
config, command_line_substitutions=command_line_substitutions
)
except Exception: # pylint: disable=broad-except
return None
return config
def _build_lite_config(raw_config: dict[str, Any]) -> ConfigType:
"""Project the raw YAML down to the keys upload / logs read."""
return {key: raw_config[key] for key in _LITE_TOP_LEVEL_KEYS if key in raw_config}
def _populate_core_from_storage(storage: StorageJSON) -> None:
"""Populate ``CORE`` fields the subcommands read off the platform."""
CORE.name = storage.name
CORE.friendly_name = storage.friendly_name
CORE.build_path = storage.build_path
CORE.loaded_integrations = set(storage.loaded_integrations)
CORE.loaded_platforms = set(storage.loaded_platforms)
core_platform = storage.core_platform or (
storage.target_platform.lower() if storage.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 storage.framework is not None:
CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = storage.framework
def _ensure_address_in_config(config: ConfigType, storage: StorageJSON) -> None:
"""Make sure ``CORE.address`` resolves once the lite config is set.
``CORE.address`` reads ``use_address`` off the wifi / ethernet /
openthread block; if the dashboard rewrote the YAML between the
last compile and now those blocks may be missing from the lite
parse. As a backstop, lift the address from the StorageJSON when
we have one and the YAML doesn't supply one already.
"""
if storage.address is None:
return
for network_type in (CONF_WIFI, CONF_ETHERNET, CONF_OPENTHREAD):
if network_type in config and CONF_USE_ADDRESS in config[network_type]:
return
# Fall back to a synthetic wifi block so ``CORE.address`` resolves.
config.setdefault(CONF_WIFI, {})[CONF_USE_ADDRESS] = storage.address
def load_lite_config_from_storage(
conf_path: Path, command_line_substitutions: dict[str, Any] | None = None
) -> ConfigType | None:
"""Build a minimal config + populate CORE from the StorageJSON sidecar.
Returns the lite config dict on success, or ``None`` when the
dispatcher should fall back to ``read_config()`` (sidecar missing,
stale, unreadable, or required fields absent).
"""
storage_path = ext_storage_path(conf_path.name)
try:
yaml_stat = conf_path.stat()
except OSError:
return None
try:
storage_stat = storage_path.stat()
except OSError:
return None
# The sidecar is written by `compile`; if the YAML has been edited
# since, the cached platform / loaded_integrations may no longer
# describe what the binary on disk was built from. Fall back to a
# full validation pass in that case.
if storage_stat.st_mtime < yaml_stat.st_mtime:
return None
storage = StorageJSON.load(storage_path)
if storage is None:
return None
if not storage.target_platform and not storage.core_platform:
# An incomplete sidecar (e.g. from a wizard run that never
# compiled) can't drive the upload / logs subcommands.
return None
raw_config = _parse_yaml_head(conf_path, command_line_substitutions)
if raw_config is None:
return None
if CONF_ESPHOME not in raw_config or CONF_NAME not in raw_config[CONF_ESPHOME]:
return None
config = _build_lite_config(raw_config)
# Backfill `esphome:` block fields from the sidecar so downstream
# callers that read `config["esphome"]["name"]` work even when the
# YAML uses substitutions that didn't survive the light parse.
esphome_block = config.setdefault(CONF_ESPHOME, {})
esphome_block.setdefault(CONF_NAME, storage.name)
if storage.friendly_name is not None:
esphome_block.setdefault(CONF_FRIENDLY_NAME, storage.friendly_name)
_populate_core_from_storage(storage)
_ensure_address_in_config(config, storage)
return config
+104 -1
View File
@@ -11,7 +11,7 @@ from esphome import const
from esphome.const import CONF_DISABLED, CONF_MDNS
from esphome.core import CORE
from esphome.helpers import write_file_if_changed
from esphome.types import CoreType
from esphome.types import ConfigType, CoreType
_LOGGER = logging.getLogger(__name__)
@@ -56,6 +56,82 @@ def archive_storage_path() -> Path:
return CORE.relative_config_path("archive")
def compiled_config_path(config_filename: str) -> Path:
"""Path to the cached validated config alongside the storage sidecar.
Written after every successful compile from the validated config
dict (via ``yaml_util.dump``). Powers the dispatcher's
``--from-storage-json`` fast path in ``esphome upload`` and
``esphome logs`` so they can skip the full ``read_config()``
validation pipeline.
Lives next to the existing JSON sidecar (``<filename>.json``) but
in its own file so the small metadata sidecar isn't bloated by
configs that can reach a megabyte or more once packages and
substitutions are expanded.
"""
return CORE.data_dir / "storage" / f"{config_filename}.validated.yaml"
def save_compiled_config(config: ConfigType) -> None:
"""Dump the validated config to its sidecar YAML file.
Called by the writer at the end of ``compile`` so the next call
to ``esphome upload --from-storage-json`` / ``esphome logs
--from-storage-json`` for this YAML can skip validation. Failures
here are non-fatal: the worst case is that the fast path falls
back to a full ``read_config`` next time.
"""
from esphome import yaml_util
try:
# show_secrets=True so the cache is self-contained; the file
# lives next to the binary it describes, in the same trust zone
# as the rest of .esphome/storage/.
rendered = yaml_util.dump(config, show_secrets=True)
except Exception as err: # pylint: disable=broad-except
_LOGGER.debug("Skipping compiled config cache write: %s", err)
return
try:
write_file_if_changed(compiled_config_path(CORE.config_filename), rendered)
except OSError as err:
_LOGGER.debug("Skipping compiled config cache write: %s", err)
def load_compiled_config(config_path: Path) -> ConfigType | None:
"""Load the cached validated config for ``--from-storage-json``.
Returns ``None`` (so the caller falls back to ``read_config``) if
the cache is missing or older than the source YAML. 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 —
this flag is opt-in and assumes the caller knows the binary on
disk matches the cache.
"""
cache_path = compiled_config_path(config_path.name)
try:
yaml_mtime = config_path.stat().st_mtime
except OSError:
return None
try:
cache_mtime = cache_path.stat().st_mtime
except OSError:
return None
if cache_mtime < yaml_mtime:
return None
from esphome import yaml_util
try:
# clear_secrets=False so we don't disturb any in-flight secret
# state; the cache is self-contained and resolves no !secret
# references.
return yaml_util.load_yaml(cache_path, clear_secrets=False)
except Exception: # pylint: disable=broad-except
return None
def _to_path_if_not_none(value: str | None) -> Path | None:
"""Convert a string to Path if it's not None."""
return Path(value) if value is not None else None
@@ -256,6 +332,33 @@ 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()
+9 -1
View File
@@ -24,7 +24,7 @@ from esphome.helpers import (
walk_files,
write_file_if_changed,
)
from esphome.storage_json import StorageJSON, storage_path
from esphome.storage_json import StorageJSON, save_compiled_config, storage_path
_LOGGER = logging.getLogger(__name__)
@@ -109,6 +109,14 @@ def update_storage_json() -> None:
path = storage_path()
old = StorageJSON.load(path)
new = StorageJSON.from_esphome_core(CORE, old)
# Always refresh the validated-config cache so `esphome upload
# --from-storage-json` and `esphome logs --from-storage-json` can
# skip re-validating after this compile. Lives in its own file
# next to the sidecar; mtime gates staleness on the read side.
if CORE.config is not None:
save_compiled_config(CORE.config)
if old == new:
return