diff --git a/esphome/__main__.py b/esphome/__main__.py index bae336f011..6eae4b7bc8 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1935,6 +1935,21 @@ def command_idedata(args: ArgsProtocol, config: ConfigType) -> int: print(json.dumps(idedata, indent=2) + "\n") return 0 + if CORE.is_esp8266 and CORE.using_toolchain_arduino: + # Same contract as the ESP-IDF branch: idedata is derived from the + # build's compile_commands.json, so a compile must have run. + from esphome.arduino8266 import toolchain as arduino8266_toolchain + + idedata = arduino8266_toolchain.get_idedata() + if idedata is None: + _LOGGER.error( + "No idedata available; compile the configuration first", + ) + return 1 + + print(json.dumps(idedata, indent=2) + "\n") + return 0 + if not CORE.using_toolchain_platformio: _LOGGER.error( "The idedata command is not compatible with %s toolchain", @@ -1981,6 +1996,13 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int: objdump_path = str(toolchain.get_objdump_path()) readelf_path = str(toolchain.get_readelf_path()) + firmware_elf = toolchain.get_elf_path() + elif CORE.is_esp8266 and CORE.using_toolchain_arduino: + from esphome.arduino8266 import toolchain + + objdump_path = str(toolchain.get_objdump_path()) + readelf_path = str(toolchain.get_readelf_path()) + firmware_elf = toolchain.get_elf_path() else: from esphome.platformio import toolchain diff --git a/esphome/arduino8266/component.py b/esphome/arduino8266/component.py index 224c542363..f5c5c2d3ca 100644 --- a/esphome/arduino8266/component.py +++ b/esphome/arduino8266/component.py @@ -39,6 +39,12 @@ _LOGGER = logging.getLogger(__name__) ESP8266_PLATFORM = "espressif8266" +# Bare names components register that intentionally have no bundled library +# ("Updater" is replaced by ESPHome's native OTA backend). Anything else +# missing from the framework tree is worth a warning: it is likely a typo or +# a library the build genuinely needs. +_KNOWN_ABSENT_BUNDLED = frozenset({"Updater"}) + @dataclass class ArduinoLibrary: @@ -133,11 +139,14 @@ def resolve_libraries(framework_path: Path) -> list[ArduinoLibrary]: external.append(library) elif (framework_path / "libraries" / library.name).is_dir(): bundled.append(_bundled_library(framework_path, library.name)) + elif library.name in _KNOWN_ABSENT_BUNDLED: + _LOGGER.debug("Skipping known-absent bundled library %s", library.name) else: - # A bare name that is not a bundled library ("Updater" from the - # ota component) has nothing to build; PlatformIO's LDF-off mode - # ignores it the same way. - _LOGGER.debug("Skipping unknown bundled library %s", library.name) + _LOGGER.warning( + "Library %s is not bundled with the Arduino framework and has " + "no version or repository to download it from; skipping", + library.name, + ) converted: list[ArduinoLibrary] = [] bundled_names = {lib.name for lib in bundled} @@ -158,7 +167,8 @@ def resolve_libraries(framework_path: Path) -> list[ArduinoLibrary]: continue try: check_library_data(dep, ESP8266_PLATFORM, "arduino") - except InvalidLibrary: + except InvalidLibrary as err: + _LOGGER.debug("Skipping bundled dependency %s: %s", name, err) continue bundled_names.add(name) bundled.append(_bundled_library(framework_path, name)) diff --git a/esphome/arduino8266/framework.py b/esphome/arduino8266/framework.py index 2ae14d5f89..0a1447048b 100644 --- a/esphome/arduino8266/framework.py +++ b/esphome/arduino8266/framework.py @@ -31,6 +31,7 @@ from esphome.core import CORE, EsphomeError from esphome.framework_helpers import ( archive_extract_all, download_from_mirrors, + download_with_resume, rmdir, str_to_lst_of_str, ) @@ -47,6 +48,17 @@ TOOLCHAIN_VERSION = "2.100300.220621" NINJA_VERSION = "1.12.1" +# sha256 of the ninja release archives, so the binary we chmod and execute is +# integrity-checked. Only applies to the default download source; a mirror +# override via ESPHOME_ARDUINO8266_NINJA_MIRRORS is trusted as configured. +_NINJA_SHA256 = { + "ninja-mac.zip": "89a287444b5b3e98f88a945afa50ce937b8ffd1dcc59c555ad9b1baf855298c9", + "ninja-win.zip": "f550fec705b6d6ff58f2db3c374c2277a37691678d6aba463adcbb129108467a", + "ninja-winarm64.zip": "79c96a50e0deafec212cfa85aa57c6b74003f52d9d1673ddcd1eab1c958c5900", + "ninja-linux.zip": "6f98805688d19672bd699fbbfa2c2cf0fc054ac3df1f0e6a47664d963d530255", + "ninja-linux-aarch64.zip": "5c25c6570b0155e95fce5918cb95f1ad9870df5768653afe128db822301a05a1", +} + _REGISTRY_URL = ( "https://api.registry.platformio.org/v3/packages/platformio/tool/{package}" ) @@ -121,8 +133,10 @@ def _pio_system() -> str: return "linux_x86_64" -def _registry_download_url(package: str, version: str) -> str: - """Resolve a package's download URL for this host via the PIO registry.""" +def _registry_download( + package: str, version: str +) -> tuple[str, str | None, int | None]: + """Resolve a package's download URL, sha256, and size via the PIO registry.""" import requests url = _REGISTRY_URL.format(package=package) @@ -136,7 +150,11 @@ def _registry_download_url(package: str, version: str) -> str: for file in ver.get("files", []): systems = file.get("system") or "*" if systems == "*" or system in systems: - return file["download_url"] + return ( + file["download_url"], + (file.get("checksum") or {}).get("sha256"), + file.get("size"), + ) raise EsphomeError(f"No {package} {version} build for this platform ({system})") raise EsphomeError(f"{package} {version} not found in the package registry") @@ -147,21 +165,28 @@ def _install_package( dest: Path, mirrors: list[str], ) -> None: - """Download and extract one package if not already installed.""" + """Download, verify, and extract one package if not already installed. + + The registry path is integrity-checked against the sha256 the registry + publishes; a mirror override is trusted as configured. + """ marker = dest / ".esphome_extracted" if marker.is_file(): return rmdir(dest, msg=f"Clean up incomplete {name} install") - with tempfile.NamedTemporaryFile() as tmp: + with tempfile.TemporaryDirectory() as tmp_dir: + archive = Path(tmp_dir) / "package" _LOGGER.info("Downloading %s %s ...", name, version) if mirrors: - download_from_mirrors( - mirrors, {"VERSION": version, "SYSTEM": _pio_system()}, tmp.file - ) + with archive.open("wb") as file: + download_from_mirrors( + mirrors, {"VERSION": version, "SYSTEM": _pio_system()}, file + ) else: - download_from_mirrors([_registry_download_url(name, version)], {}, tmp.file) + url, sha256, size = _registry_download(name, version) + download_with_resume(url, archive, sha256=sha256, size=size) _LOGGER.info("Extracting %s ...", name) - archive_extract_all(tmp.file, dest, progress_header="Extracting") + archive_extract_all(archive, dest, progress_header="Extracting") marker.touch() @@ -186,14 +211,23 @@ def _check_ninja_install() -> Path: if binary.is_file(): return binary rmdir(ninja_dir, msg="Clean up incomplete ninja install") - with tempfile.NamedTemporaryFile() as tmp: + archive_name = _ninja_archive_name() + with tempfile.TemporaryDirectory() as tmp_dir: + archive = Path(tmp_dir) / archive_name _LOGGER.info("Downloading ninja %s ...", NINJA_VERSION) - download_from_mirrors( - ESPHOME_ARDUINO8266_NINJA_MIRRORS, - {"VERSION": NINJA_VERSION, "ARCHIVE": _ninja_archive_name()}, - tmp.file, - ) - archive_extract_all(tmp.file, ninja_dir) + if "ESPHOME_ARDUINO8266_NINJA_MIRRORS" in os.environ: + with archive.open("wb") as file: + download_from_mirrors( + ESPHOME_ARDUINO8266_NINJA_MIRRORS, + {"VERSION": NINJA_VERSION, "ARCHIVE": archive_name}, + file, + ) + else: + url = ESPHOME_ARDUINO8266_NINJA_MIRRORS[0].format( + VERSION=NINJA_VERSION, ARCHIVE=archive_name + ) + download_with_resume(url, archive, sha256=_NINJA_SHA256[archive_name]) + archive_extract_all(archive, ninja_dir) if not binary.is_file(): raise EsphomeError(f"ninja binary missing after extraction in {ninja_dir}") binary.chmod(binary.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) diff --git a/esphome/arduino8266/toolchain.py b/esphome/arduino8266/toolchain.py index ef0c417350..5fe297d493 100644 --- a/esphome/arduino8266/toolchain.py +++ b/esphome/arduino8266/toolchain.py @@ -13,7 +13,7 @@ from esphome.const import ( KEY_CORE, KEY_FRAMEWORK_VERSION, ) -from esphome.core import CORE +from esphome.core import CORE, EsphomeError from esphome.helpers import write_file_if_changed from esphome.types import ConfigType @@ -34,8 +34,20 @@ def get_elf_path() -> Path: return get_build_dir() / "firmware.elf" +def _toolchain_tool(name: str) -> Path: + return framework.get_toolchain_path() / "bin" / f"xtensa-lx106-elf-{name}" + + def get_addr2line_path() -> Path: - return framework.get_toolchain_path() / "bin" / "xtensa-lx106-elf-addr2line" + return _toolchain_tool("addr2line") + + +def get_objdump_path() -> Path: + return _toolchain_tool("objdump") + + +def get_readelf_path() -> Path: + return _toolchain_tool("readelf") def run_compile(config: ConfigType, verbose: bool) -> int: @@ -77,12 +89,14 @@ def _write_compile_commands( check=False, close_fds=False, ) - if result.returncode == 0: - # write_file_if_changed keeps the mtime stable on no-op builds so the - # idedata cache in get_idedata() stays valid. - write_file_if_changed(build_dir / "compile_commands.json", result.stdout) - else: - _LOGGER.warning("Could not generate compile_commands.json: %s", result.stderr) + if result.returncode != 0: + # Drop any stale database so consumers (IDE integration, clang-tidy, + # the memory analyzer) can't silently read outdated data. + (build_dir / "compile_commands.json").unlink(missing_ok=True) + raise EsphomeError(f"Could not generate compile_commands.json: {result.stderr}") + # write_file_if_changed keeps the mtime stable on no-op builds so the + # idedata cache in get_idedata() stays valid. + write_file_if_changed(build_dir / "compile_commands.json", result.stdout) def _parse_app_size(build_dir: Path) -> int | None: @@ -90,12 +104,18 @@ def _parse_app_size(build_dir: Path) -> int | None: from esphome.build_gen.arduino8266 import get_flash_ld_path from esphome.components.esp8266.build_surgery import segment_length + # Warnings, not debug: without the app size the Flash summary line is + # dropped and CI's memory-impact extraction loses its flash metric. + ld_path = get_flash_ld_path(build_dir) try: - ld_text = get_flash_ld_path(build_dir).read_text(encoding="utf-8") + ld_text = ld_path.read_text(encoding="utf-8") except OSError as err: - _LOGGER.debug("Cannot read linker script for the Flash summary: %s", err) + _LOGGER.warning("Cannot read linker script for the Flash summary: %s", err) return None - return segment_length(ld_text, "irom0_0_seg") + app_size = segment_length(ld_text, "irom0_0_seg") + if app_size is None: + _LOGGER.warning("irom0_0_seg not found in %s; skipping Flash summary", ld_path) + return app_size def _print_size_summary(build_dir: Path, toolchain_path: Path) -> None: @@ -124,6 +144,8 @@ def _print_size_summary(build_dir: Path, toolchain_path: Path) -> None: try: sections[parts[0]] = int(parts[1]) except ValueError: + # An omitted section would silently skew the reported totals + _LOGGER.warning("Unparsable size output for section %s", parts[0]) continue ram = sum(sections.get(s, 0) for s in _RAM_SECTIONS) flash = sum(sections.get(s, 0) for s in _FLASH_SECTIONS) diff --git a/esphome/build_gen/arduino8266.py b/esphome/build_gen/arduino8266.py index 8f7d7debd9..1fdcf8943a 100644 --- a/esphome/build_gen/arduino8266.py +++ b/esphome/build_gen/arduino8266.py @@ -365,7 +365,9 @@ def generate_ld_scripts( flash_ld = framework / "tools" / "sdk" / "ld" / flash_ld_name write_file_if_changed( ld_dir / f"testing_{flash_ld_name}", - apply_testing_memory_patches(flash_ld.read_text(encoding="utf-8")), + apply_testing_memory_patches( + flash_ld.read_text(encoding="utf-8"), require=True + ), ) @@ -423,13 +425,8 @@ def write_project(paths: dict[str, Path]) -> bool: libraries = resolve_libraries(framework) - # A missing framework-owned directory is a broken install; failing here - # names the path instead of producing a wall of include errors. - for required in (sdk / "include", core_dir, sdk / "lwip2" / "include", variant_dir): - if not required.is_dir(): - raise EsphomeError( - f"Arduino framework install is incomplete: missing {required}" - ) + # A missing install directory would otherwise surface as a wall of + # include errors; failing here names the path instead. include_dirs = [ src_dir, sdk / "include", @@ -438,7 +435,11 @@ def write_project(paths: dict[str, Path]) -> bool: sdk / "lwip2" / "include", variant_dir, ] - include_dirs = [d for d in include_dirs if d.is_dir()] + for required in include_dirs: + if not required.is_dir(): + raise EsphomeError( + f"Arduino toolchain install is incomplete: missing {required}" + ) for lib in libraries: include_dirs += lib.include_dirs diff --git a/esphome/components/esp8266/build_surgery.py b/esphome/components/esp8266/build_surgery.py index 8b09a3c80a..fd4d6e3db1 100644 --- a/esphome/components/esp8266/build_surgery.py +++ b/esphome/components/esp8266/build_surgery.py @@ -44,6 +44,13 @@ def relocate_ratetable(content: str) -> str: ) +_TESTING_SEGMENT_SIZES = ( + ("iram1_0_seg", TESTING_IRAM_SIZE), + ("dram0_0_seg", TESTING_DRAM_SIZE), + ("irom0_0_seg", TESTING_FLASH_SIZE), +) + + def _patch_segment_size(content: str, segment_name: str, new_size: str) -> str: pattern = ( rf"({segment_name}\s*:\s*org\s*=\s*0x[0-9a-fA-F]+\s*,\s*len\s*=\s*)" @@ -52,11 +59,26 @@ def _patch_segment_size(content: str, segment_name: str, new_size: str) -> str: return re.sub(pattern, rf"\g<1>{new_size}", content) -def apply_testing_memory_patches(content: str) -> str: - """Enlarge IRAM/DRAM/flash segments so grouped CI test builds can link.""" - content = _patch_segment_size(content, "iram1_0_seg", TESTING_IRAM_SIZE) - content = _patch_segment_size(content, "dram0_0_seg", TESTING_DRAM_SIZE) - return _patch_segment_size(content, "irom0_0_seg", TESTING_FLASH_SIZE) +def apply_testing_memory_patches(content: str, require: bool = False) -> str: + """Enlarge IRAM/DRAM/flash segments so grouped CI test builds can link. + + With ``require``, raise when a segment was not found: a silently + unpatched flash linker script would keep the real 32KB IRAM limits and + fail grouped builds far from the cause. The common linker script has no + MEMORY block, so its caller leaves ``require`` off. + """ + missing: list[str] = [] + for segment, size in _TESTING_SEGMENT_SIZES: + patched = _patch_segment_size(content, segment, size) + if patched == content: + missing.append(segment) + content = patched + if require and missing: + raise RuntimeError( + f"Testing-mode memory patch failed: segment(s) {', '.join(missing)} " + "not found (has the Arduino core linker script changed?)" + ) + return content def segment_length(content: str, segment_name: str) -> int | None: diff --git a/esphome/core/config.py b/esphome/core/config.py index 1095a4886e..be577cccbb 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -557,10 +557,12 @@ def _add_library_str(lib: str) -> None: @coroutine_with_priority(CoroPriority.FINAL) async def _add_platformio_options(pio_options: dict[str, str | list[str]]) -> None: - if CORE.using_toolchain_esp_idf: - # The native ESP-IDF build doesn't read platformio.ini; honor the - # options with a native equivalent and warn about the rest, which - # would otherwise be silently ignored. + if CORE.using_toolchain_esp_idf or ( + CORE.using_toolchain_arduino and CORE.is_esp8266 + ): + # The native builds don't read platformio.ini; honor the options + # with a native equivalent and warn about the rest, which would + # otherwise be silently ignored. for key, val in pio_options.items(): vals = [val] if isinstance(val, str) else val if key == CONF_BUILD_FLAGS: @@ -588,8 +590,9 @@ async def _add_platformio_options(pio_options: dict[str, str | list[str]]) -> No # config at upload time (upload_using_esptool) _LOGGER.warning( "esphome->platformio_options->%s is ignored when building with " - "the native ESP-IDF toolchain", + "the native '%s' toolchain", key, + CORE.toolchain.value, ) return # Add includes at the very end, so that they override everything diff --git a/tests/unit_tests/build_gen/test_arduino8266.py b/tests/unit_tests/build_gen/test_arduino8266.py index c750768654..caaae9d2b7 100644 --- a/tests/unit_tests/build_gen/test_arduino8266.py +++ b/tests/unit_tests/build_gen/test_arduino8266.py @@ -184,6 +184,7 @@ def _make_framework(tmp_path: Path) -> dict[str, Path]: (framework / "libraries").mkdir() toolchain = tmp_path / "toolchain" (toolchain / "bin").mkdir(parents=True) + (toolchain / "include").mkdir() return { "framework_path": framework, "toolchain_path": toolchain, @@ -397,7 +398,11 @@ def test_generate_ld_scripts_testing_mode(tmp_path: Path) -> None: paths = _make_framework(tmp_path) (paths["framework_path"] / "tools" / "sdk" / "ld" / "eagle.flash.4m.ld").write_text( - "MEMORY\n{\n irom0_0_seg : org = 0x40201010, len = 0xfeff0\n}\n" + "MEMORY\n{\n" + " dram0_0_seg : org = 0x3FFE8000, len = 0x14000\n" + " iram1_0_seg : org = 0x40100000, len = 0x8000\n" + " irom0_0_seg : org = 0x40201010, len = 0xfeff0\n" + "}\n" ) CORE.testing_mode = True result = MagicMock(returncode=0, stdout=_COMMON_LD_H_OUTPUT) diff --git a/tests/unit_tests/components/esp8266/test_build_surgery.py b/tests/unit_tests/components/esp8266/test_build_surgery.py index 758aec22f7..7e56d9c133 100644 --- a/tests/unit_tests/components/esp8266/test_build_surgery.py +++ b/tests/unit_tests/components/esp8266/test_build_surgery.py @@ -69,3 +69,14 @@ def test_segment_length() -> None: assert segment_length(_FLASH_LD_SNIPPET, "irom0_0_seg") == 0xFEFF0 assert segment_length(_FLASH_LD_SNIPPET, "missing_seg") is None + + +def test_testing_memory_patches_require() -> None: + """With require, a segment the patch could not find raises instead of + silently keeping the real memory limits.""" + patched = apply_testing_memory_patches(_FLASH_LD_SNIPPET, require=True) + assert "0x2000000" in patched + with pytest.raises(RuntimeError, match="iram1_0_seg"): + apply_testing_memory_patches("MEMORY { }", require=True) + # Without require (the common linker script has no MEMORY block) it is a no-op + assert apply_testing_memory_patches("MEMORY { }") == "MEMORY { }" diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index e09edd7f26..d8c671e0e5 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -1389,3 +1389,27 @@ def test_esphome_build_internals_are_yaml_only() -> None: assert markers[field].visibility is cv.Visibility.ADVANCED, field # A regular device-config field stays on the main form. assert markers[CONF_NAME_ADD_MAC_SUFFIX].visibility is None + + +@pytest.mark.asyncio +async def test_add_platformio_options_native_arduino( + caplog: pytest.LogCaptureFixture, +) -> None: + """The native ESP8266 Arduino toolchain warns about ignored options the + same way the native IDF toolchain does.""" + CORE.toolchain = Toolchain.ARDUINO + CORE.data[KEY_CORE] = { + KEY_TARGET_PLATFORM: "esp8266", + KEY_TARGET_FRAMEWORK: "arduino", + } + + await config._add_platformio_options( + { + "board_build.f_cpu": "160000000L", + "upload_speed": "115200", + } + ) + + assert "esphome->platformio_options->board_build.f_cpu is ignored" in caplog.text + assert "'arduino' toolchain" in caplog.text + assert "upload_speed" not in caplog.text diff --git a/tests/unit_tests/test_arduino8266_component.py b/tests/unit_tests/test_arduino8266_component.py index c4c1556072..74de50008b 100644 --- a/tests/unit_tests/test_arduino8266_component.py +++ b/tests/unit_tests/test_arduino8266_component.py @@ -93,12 +93,17 @@ def test_library_info_no_src_dir(tmp_path: Path) -> None: assert lib.include_dirs == [read_path.resolve()] -def test_resolve_libraries_bundled_and_unknown(tmp_path: Path) -> None: +def test_resolve_libraries_bundled_and_unknown( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: framework = _make_framework(tmp_path) _add_library("ESP8266WiFi", None) - _add_library("Updater", None) # not a bundled library: skipped + _add_library("Updater", None) # known-absent: skipped silently + _add_library("Typoo", None) # unknown: skipped with a warning libs = component.resolve_libraries(framework) assert [lib.name for lib in libs] == ["ESP8266WiFi"] + assert "Typoo" in caplog.text + assert "Updater" not in caplog.text def _converted(name: str, source_dir: Path, data: dict) -> ConvertedLibrary: diff --git a/tests/unit_tests/test_arduino8266_framework.py b/tests/unit_tests/test_arduino8266_framework.py index 74e7a4a61f..4e206a1f62 100644 --- a/tests/unit_tests/test_arduino8266_framework.py +++ b/tests/unit_tests/test_arduino8266_framework.py @@ -4,6 +4,7 @@ from __future__ import annotations import os from pathlib import Path +import stat import subprocess from unittest.mock import MagicMock, patch @@ -63,27 +64,40 @@ def _registry_response(files: list[dict]) -> MagicMock: return resp -def test_registry_download_url_matches_system() -> None: +def test_registry_download_matches_system() -> None: resp = _registry_response( [ {"system": ["windows_amd64"], "download_url": "http://x/win"}, - {"system": ["linux_x86_64"], "download_url": "http://x/linux"}, + { + "system": ["linux_x86_64"], + "download_url": "http://x/linux", + "checksum": {"sha256": "abc123"}, + "size": 42, + }, ] ) with ( patch("requests.get", return_value=resp), patch.object(framework, "_pio_system", return_value="linux_x86_64"), ): - assert framework._registry_download_url("pkg", "1.0.0") == "http://x/linux" + assert framework._registry_download("pkg", "1.0.0") == ( + "http://x/linux", + "abc123", + 42, + ) -def test_registry_download_url_wildcard_system() -> None: +def test_registry_download_wildcard_system() -> None: resp = _registry_response([{"system": "*", "download_url": "http://x/any"}]) with patch("requests.get", return_value=resp): - assert framework._registry_download_url("pkg", "1.0.0") == "http://x/any" + assert framework._registry_download("pkg", "1.0.0") == ( + "http://x/any", + None, + None, + ) -def test_registry_download_url_no_system_match() -> None: +def test_registry_download_no_system_match() -> None: resp = _registry_response( [{"system": ["windows_amd64"], "download_url": "http://x/win"}] ) @@ -92,17 +106,17 @@ def test_registry_download_url_no_system_match() -> None: patch.object(framework, "_pio_system", return_value="linux_x86_64"), pytest.raises(EsphomeError, match="No pkg 1.0.0 build"), ): - framework._registry_download_url("pkg", "1.0.0") + framework._registry_download("pkg", "1.0.0") -def test_registry_download_url_version_not_found() -> None: +def test_registry_download_version_not_found() -> None: resp = _registry_response([]) resp.json.return_value = {"versions": [{"name": "2.0.0", "files": []}]} with ( patch("requests.get", return_value=resp), pytest.raises(EsphomeError, match="not found"), ): - framework._registry_download_url("pkg", "1.0.0") + framework._registry_download("pkg", "1.0.0") def test_install_package_skips_when_marker_exists(tmp_path: Path) -> None: @@ -134,17 +148,21 @@ def test_install_package_downloads_via_mirrors(tmp_path: Path) -> None: def test_install_package_downloads_via_registry(tmp_path: Path) -> None: + """The registry path downloads with the registry's sha256 and size.""" dest = tmp_path / "pkg" with ( - patch.object(framework, "download_from_mirrors") as mock_download, + patch.object(framework, "download_with_resume") as mock_download, patch.object(framework, "archive_extract_all") as mock_extract, patch.object( - framework, "_registry_download_url", return_value="http://x/pkg.tar.gz" + framework, + "_registry_download", + return_value=("http://x/pkg.tar.gz", "abc123", 42), ), ): mock_extract.side_effect = lambda *_a, **_kw: dest.mkdir() framework._install_package("pkg", "1.0.0", dest, []) - assert mock_download.call_args[0][0] == ["http://x/pkg.tar.gz"] + assert mock_download.call_args[0][0] == "http://x/pkg.tar.gz" + assert mock_download.call_args[1] == {"sha256": "abc123", "size": 42} @pytest.mark.parametrize( @@ -187,22 +205,44 @@ def test_check_ninja_install_cached_binary(tmp_path: Path) -> None: assert framework._check_ninja_install() == binary +def _fake_ninja_extract(_archive, ninja_dir, **_kw) -> None: + ninja_dir.mkdir(parents=True, exist_ok=True) + (ninja_dir / ("ninja.exe" if os.name == "nt" else "ninja")).touch() + + def test_check_ninja_install_downloads(tmp_path: Path) -> None: - binary_name = "ninja.exe" if os.name == "nt" else "ninja" - - def fake_extract(_tmp, ninja_dir, **_kw) -> None: - ninja_dir.mkdir(parents=True, exist_ok=True) - (ninja_dir / binary_name).touch() - + """The default source is integrity-checked against the pinned sha256.""" with ( patch("shutil.which", return_value=None), patch.dict(os.environ, {"ESPHOME_ARDUINO8266_PREFIX": str(tmp_path)}), - patch.object(framework, "download_from_mirrors"), - patch.object(framework, "archive_extract_all", side_effect=fake_extract), + patch.object(framework, "download_with_resume") as mock_download, + patch.object(framework, "archive_extract_all", side_effect=_fake_ninja_extract), ): binary = framework._check_ninja_install() assert binary.is_file() - assert os.access(binary, os.X_OK) + # X_OK would consult mount flags (fails on noexec /tmp); check mode bits + assert binary.stat().st_mode & stat.S_IXUSR + archive_name = framework._ninja_archive_name() + assert mock_download.call_args[1]["sha256"] == framework._NINJA_SHA256[archive_name] + + +def test_check_ninja_install_mirror_override_skips_checksum(tmp_path: Path) -> None: + """A mirror override is trusted as configured (no pinned checksum).""" + with ( + patch("shutil.which", return_value=None), + patch.dict( + os.environ, + { + "ESPHOME_ARDUINO8266_PREFIX": str(tmp_path), + "ESPHOME_ARDUINO8266_NINJA_MIRRORS": "http://mirror/{ARCHIVE}", + }, + ), + patch.object(framework, "download_from_mirrors") as mock_download, + patch.object(framework, "archive_extract_all", side_effect=_fake_ninja_extract), + ): + binary = framework._check_ninja_install() + assert binary.is_file() + mock_download.assert_called_once() def test_check_ninja_install_missing_after_extract(tmp_path: Path) -> None: diff --git a/tests/unit_tests/test_arduino8266_toolchain.py b/tests/unit_tests/test_arduino8266_toolchain.py index 6b3e16621a..56378d0f1d 100644 --- a/tests/unit_tests/test_arduino8266_toolchain.py +++ b/tests/unit_tests/test_arduino8266_toolchain.py @@ -52,6 +52,8 @@ def test_path_getters(tmp_path: Path) -> None: assert toolchain.get_build_dir() == CORE.relative_pioenvs_path("test8266") assert toolchain.get_elf_path().name == "firmware.elf" assert toolchain.get_addr2line_path().name == "xtensa-lx106-elf-addr2line" + assert toolchain.get_objdump_path().name == "xtensa-lx106-elf-objdump" + assert toolchain.get_readelf_path().name == "xtensa-lx106-elf-readelf" def test_run_compile_build_failure(tmp_path: Path) -> None: @@ -103,16 +105,22 @@ def test_write_compile_commands(tmp_path: Path) -> None: assert (build_dir / "compile_commands.json").read_text() == "[]\n" -def test_write_compile_commands_failure( - tmp_path: Path, caplog: pytest.LogCaptureFixture -) -> None: - with patch.object( - toolchain.subprocess, - "run", - return_value=MagicMock(returncode=1, stderr="boom"), +def test_write_compile_commands_failure_removes_stale_db(tmp_path: Path) -> None: + """A failed compdb run must not leave a stale database behind.""" + from esphome.core import EsphomeError + + stale = tmp_path / "compile_commands.json" + stale.write_text("[]") + with ( + patch.object( + toolchain.subprocess, + "run", + return_value=MagicMock(returncode=1, stderr="boom"), + ), + pytest.raises(EsphomeError, match="compile_commands"), ): toolchain._write_compile_commands(tmp_path / "ninja", tmp_path, {}) - assert "Could not generate compile_commands.json" in caplog.text + assert not stale.exists() def test_parse_app_size(tmp_path: Path) -> None: diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index c099634a34..fc896ed0ca 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -7207,3 +7207,72 @@ def test_write_cpp_file_arduino_toolchain_other_platform_falls_through( assert main.write_cpp_file() == 0 mock_pio_project.assert_called_once() + + +def test_command_idedata_arduino_prints_json( + tmp_path: Path, capsys: CaptureFixture +) -> None: + """Under the native ESP8266 Arduino toolchain, idedata is emitted as JSON.""" + setup_core(platform=PLATFORM_ESP8266, tmp_path=tmp_path) + CORE.toolchain = Toolchain.ARDUINO + data = {"cxx_path": "g++", "prog_path": "/build/firmware.elf"} + + with patch( + "esphome.arduino8266.toolchain.get_idedata", return_value=data + ) as mock_get: + result = command_idedata(MagicMock(), CORE.config) + + assert result == 0 + mock_get.assert_called_once_with() + assert json.loads(capsys.readouterr().out) == data + + +def test_command_idedata_arduino_no_build_errors(tmp_path: Path) -> None: + """A missing native build (no idedata) returns an error, not a crash.""" + setup_core(platform=PLATFORM_ESP8266, tmp_path=tmp_path) + CORE.toolchain = Toolchain.ARDUINO + + with patch("esphome.arduino8266.toolchain.get_idedata", return_value=None): + result = command_idedata(MagicMock(), CORE.config) + + assert result == 1 + + +def test_command_analyze_memory_arduino_toolchain( + tmp_path: Path, + mock_write_cpp: Mock, + mock_compile_program: Mock, + mock_get_esphome_components: Mock, + mock_memory_analyzer_cli: Mock, + mock_ram_strings_analyzer: Mock, +) -> None: + """analyze-memory uses the native toolchain's binutils under + 'toolchain: arduino' instead of falling into the PlatformIO branch.""" + setup_core(platform=PLATFORM_ESP8266, tmp_path=tmp_path, name="test_device") + CORE.toolchain = Toolchain.ARDUINO + + config = {CONF_ESPHOME: {CONF_NAME: "test_device"}} + with ( + patch( + "esphome.arduino8266.toolchain.get_objdump_path", + return_value=Path("/tc/objdump"), + ), + patch( + "esphome.arduino8266.toolchain.get_readelf_path", + return_value=Path("/tc/readelf"), + ), + patch( + "esphome.arduino8266.toolchain.get_elf_path", + return_value=Path("/build/firmware.elf"), + ), + ): + result = command_analyze_memory(MockArgs(), config) + + assert result == 0 + mock_memory_analyzer_cli.assert_called_once_with( + "/build/firmware.elf", + "/tc/objdump", + "/tc/readelf", + set(), + idedata=None, + )