Address review: wire analyze-memory and idedata for the native toolchain, warn on ignored platformio_options, verify downloads, surface silent failures

This commit is contained in:
J. Nick Koston
2026-08-20 01:34:15 -05:00
parent 60b7b874f1
commit 5df08d36be
14 changed files with 360 additions and 84 deletions
+22
View File
@@ -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
+15 -5
View File
@@ -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))
+51 -17
View File
@@ -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)
+33 -11
View File
@@ -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)
+10 -9
View File
@@ -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
+27 -5
View File
@@ -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:
+8 -5
View File
@@ -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
@@ -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)
@@ -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 { }"
+24
View File
@@ -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
@@ -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:
+61 -21
View File
@@ -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:
+16 -8
View File
@@ -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:
+69
View File
@@ -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,
)