diff --git a/esphome/__main__.py b/esphome/__main__.py index 16a05ad552..07bbd89358 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -2449,7 +2449,10 @@ def run_esphome(argv): # Skipped when -s overrides are passed, since the cache was written # against the previous substitution set. config: ConfigType | None = None - if args.command in ("upload", "logs") and not command_line_substitutions: + cache_eligible = ( + args.command in ("upload", "logs") and not command_line_substitutions + ) + if cache_eligible: from esphome.compiled_config import load_compiled_config config = load_compiled_config(conf_path) @@ -2464,6 +2467,16 @@ def run_esphome(argv): command_line_substitutions, skip_external_update=skip_external, ) + # Refresh the cache so the next upload/logs hits the fast path + # instead of re-running read_config. Skip when the storage + # sidecar is absent (no compile has run): the cache would + # never be loaded back, so writing secrets to disk is wasted. + if cache_eligible and config is not None: + from esphome.compiled_config import save_compiled_config + from esphome.storage_json import ext_storage_path + + if ext_storage_path(conf_path.name).exists(): + save_compiled_config(config) if config is None: return 2 CORE.config = config diff --git a/tests/unit_tests/test_compiled_config.py b/tests/unit_tests/test_compiled_config.py index 8c9cfa8101..e12107152b 100644 --- a/tests/unit_tests/test_compiled_config.py +++ b/tests/unit_tests/test_compiled_config.py @@ -253,6 +253,106 @@ def test_run_esphome_upload_and_logs_fall_back_when_no_cache( mock_read.assert_called_once() +def test_run_esphome_upload_does_not_refresh_cache_without_sidecar( + tmp_path: Path, +) -> None: + """Without a StorageJSON sidecar (no compile has run), the fallback + skips the cache write -- load_compiled_config requires the sidecar, + so writing the rendered (secret-resolved) YAML would be inert and + leak secrets to disk for nothing.""" + yaml_path = tmp_path / "lite_test.yaml" + yaml_path.write_text("esphome:\n name: lite_test\n") + CORE.config_path = yaml_path + + with ( + patch( + "esphome.__main__.read_config", + return_value={"esphome": {"name": "lite_test"}}, + ), + 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", "upload", str(yaml_path)]) + + mock_save.assert_not_called() + + +@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.yaml") + _set_cache_mtime(cache, yaml_path, offset=-60) # stale + + fresh_config = {"esphome": {"name": "lite_test"}, "logger": {}} + + with ( + patch("esphome.__main__.read_config", return_value=fresh_config), + patch( + "esphome.compiled_config.save_compiled_config", wraps=save_compiled_config + ) as mock_save, + 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) + # 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.__main__.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.__main__.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: