diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index da5fb94d5e..0470a948f5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,7 +11,7 @@ ci: repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.15.12 + rev: v0.15.14 hooks: # Run the linter. - id: ruff diff --git a/esphome/__main__.py b/esphome/__main__.py index 9d230c3968..17eeca2900 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -2450,7 +2450,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) @@ -2465,6 +2468,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/esphome/components/sx126x/sx126x.cpp b/esphome/components/sx126x/sx126x.cpp index 6e6857fadb..83afeac50a 100644 --- a/esphome/components/sx126x/sx126x.cpp +++ b/esphome/components/sx126x/sx126x.cpp @@ -30,8 +30,8 @@ static constexpr uint8_t OCP_140MA = 0x38; // 140 mA max current static constexpr float LOW_DATA_RATE_OPTIMIZE_THRESHOLD = 16.38f; // 16.38 ms uint8_t SX126x::read_fifo_(uint8_t offset, std::vector &packet) { - this->wait_busy_(); this->enable(); + this->wait_busy_(); this->transfer_byte(RADIO_READ_BUFFER); this->transfer_byte(offset); uint8_t status = this->transfer_byte(0x00); @@ -43,8 +43,8 @@ uint8_t SX126x::read_fifo_(uint8_t offset, std::vector &packet) { } void SX126x::write_fifo_(uint8_t offset, const std::vector &packet) { - this->wait_busy_(); this->enable(); + this->wait_busy_(); this->transfer_byte(RADIO_WRITE_BUFFER); this->transfer_byte(offset); for (const uint8_t &byte : packet) { @@ -55,8 +55,8 @@ void SX126x::write_fifo_(uint8_t offset, const std::vector &packet) { } uint8_t SX126x::read_opcode_(uint8_t opcode, uint8_t *data, uint8_t size) { - this->wait_busy_(); this->enable(); + this->wait_busy_(); this->transfer_byte(opcode); uint8_t status = this->transfer_byte(0x00); for (int32_t i = 0; i < size; i++) { @@ -67,8 +67,8 @@ uint8_t SX126x::read_opcode_(uint8_t opcode, uint8_t *data, uint8_t size) { } void SX126x::write_opcode_(uint8_t opcode, uint8_t *data, uint8_t size) { - this->wait_busy_(); this->enable(); + this->wait_busy_(); this->transfer_byte(opcode); for (int32_t i = 0; i < size; i++) { this->transfer_byte(data[i]); @@ -78,8 +78,8 @@ void SX126x::write_opcode_(uint8_t opcode, uint8_t *data, uint8_t size) { } void SX126x::read_register_(uint16_t reg, uint8_t *data, uint8_t size) { - this->wait_busy_(); this->enable(); + this->wait_busy_(); this->write_byte(RADIO_READ_REGISTER); this->write_byte((reg >> 8) & 0xFF); this->write_byte((reg >> 0) & 0xFF); @@ -91,8 +91,8 @@ void SX126x::read_register_(uint16_t reg, uint8_t *data, uint8_t size) { } void SX126x::write_register_(uint16_t reg, uint8_t *data, uint8_t size) { - this->wait_busy_(); this->enable(); + this->wait_busy_(); this->write_byte(RADIO_WRITE_REGISTER); this->write_byte((reg >> 8) & 0xFF); this->write_byte((reg >> 0) & 0xFF); diff --git a/requirements_test.txt b/requirements_test.txt index ea4941a882..dbdea1d935 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==4.0.5 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating -ruff==0.15.13 # also change in .pre-commit-config.yaml when updating +ruff==0.15.14 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating pre-commit 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: