diff --git a/esphome/config.py b/esphome/config.py index 641b6ec1b48..431559ce9ce 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -997,6 +997,8 @@ def validate_config( ) -> Config: result = Config() + CORE.skip_external_update = skip_external_update + loader.clear_component_meta_finders() loader.install_custom_components_meta_finder() diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 009fef2f863..4fecebcd8d2 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -615,6 +615,9 @@ class EsphomeCore: self.address_cache: AddressCache | None = None # Cached config hash (computed lazily) self._config_hash: int | None = None + # When True, skip network freshness checks for cached external files + # (e.g. for `esphome logs`, where remote downloads aren't needed) + self.skip_external_update: bool = False def reset(self): from esphome.pins import PIN_SCHEMA_REGISTRY @@ -644,6 +647,7 @@ class EsphomeCore: self.current_component = None self.address_cache = None self._config_hash = None + self.skip_external_update = False PIN_SCHEMA_REGISTRY.reset() @contextmanager diff --git a/esphome/external_files.py b/esphome/external_files.py index 55711e1b790..af207589f65 100644 --- a/esphome/external_files.py +++ b/esphome/external_files.py @@ -82,6 +82,9 @@ def compute_local_file_dir(domain: str) -> Path: def download_content(url: str, path: Path, timeout=NETWORK_TIMEOUT) -> bytes: + if CORE.skip_external_update and path.exists(): + _LOGGER.debug("Skipping update for %s (refresh disabled)", url) + return path.read_bytes() if not has_remote_file_changed(url, path): _LOGGER.debug("Remote file has not changed %s", url) return path.read_bytes() diff --git a/tests/unit_tests/test_external_files.py b/tests/unit_tests/test_external_files.py index a319fae83d4..50c90059d5f 100644 --- a/tests/unit_tests/test_external_files.py +++ b/tests/unit_tests/test_external_files.py @@ -236,3 +236,51 @@ def test_download_content_with_network_error_no_cache_fails( with pytest.raises(Invalid, match="Could not download from.*Network error"): external_files.download_content(url, test_file) + + +@patch("esphome.external_files.requests.get") +@patch("esphome.external_files.has_remote_file_changed") +def test_download_content_skip_external_update_uses_cache( + mock_has_changed: MagicMock, mock_get: MagicMock, setup_core: Path +) -> None: + """Test download_content skips network checks when CORE.skip_external_update is set.""" + test_file = setup_core / "cached.txt" + cached_content = b"cached content" + test_file.write_bytes(cached_content) + + CORE.skip_external_update = True + try: + url = "https://example.com/file.txt" + result = external_files.download_content(url, test_file) + finally: + CORE.skip_external_update = False + + assert result == cached_content + mock_has_changed.assert_not_called() + mock_get.assert_not_called() + + +@patch("esphome.external_files.requests.get") +@patch("esphome.external_files.has_remote_file_changed") +def test_download_content_skip_external_update_downloads_when_missing( + mock_has_changed: MagicMock, mock_get: MagicMock, setup_core: Path +) -> None: + """Test download_content still downloads when file is missing, even with skip_external_update.""" + test_file = setup_core / "missing.txt" + new_content = b"fresh content" + + mock_has_changed.return_value = True + mock_response = MagicMock() + mock_response.content = new_content + mock_response.raise_for_status = MagicMock() + mock_get.return_value = mock_response + + CORE.skip_external_update = True + try: + url = "https://example.com/file.txt" + result = external_files.download_content(url, test_file) + finally: + CORE.skip_external_update = False + + assert result == new_content + assert test_file.read_bytes() == new_content