[core] Show the out-of-flash tip instead of crashing the build (#18280)

This commit is contained in:
J. Nick Koston
2026-08-11 14:35:06 -05:00
committed by GitHub
parent 55bd63732d
commit 94fbfa05de
5 changed files with 172 additions and 8 deletions
+5
View File
@@ -885,6 +885,11 @@ class EsphomeCore:
return self.relative_build_path("build", "bootloader", "bootloader.bin")
return self.relative_pioenvs_path(self.name, "bootloader.bin")
@property
def is_configured(self) -> bool:
"""Whether anything has set this CORE up for a target."""
return KEY_CORE in self.data
@property
def target_platform(self):
return self.data[KEY_CORE][KEY_TARGET_PLATFORM]
+8 -1
View File
@@ -19,7 +19,7 @@ from esphome.helpers import (
rmtree,
write_file,
)
from esphome.util import FlashImage, run_external_process
from esphome.util import ESP32_ARDUINO_ENV, FlashImage, run_external_process
if TYPE_CHECKING:
from platformio.project.config import ProjectConfig
@@ -342,6 +342,13 @@ def run_platformio_cli(*args, **kwargs) -> str | int:
base_env = kwargs.pop("env", None)
env = dict(os.environ if base_env is None else base_env)
env.update(_ccache_env())
# The runner offers the out-of-flash tip but has no configured CORE, so
# tell it. Ask CORE, not is_esp32_arduino_build(), which reads this same
# variable; clear an inherited one so it cannot reach the wrong build.
if CORE.is_configured and CORE.is_esp32 and CORE.using_arduino:
env[ESP32_ARDUINO_ENV] = "1"
else:
env.pop(ESP32_ARDUINO_ENV, None)
return run_external_process(*cmd, env=env, **kwargs)
+21 -3
View File
@@ -3,6 +3,7 @@ from collections.abc import Callable, Iterable
from dataclasses import dataclass
import io
import logging
import os
from pathlib import Path
import re
import sys
@@ -141,6 +142,10 @@ def shlex_quote(s: str | Path) -> str:
return "'" + s.replace("'", "'\"'\"'") + "'"
# Tells the PlatformIO runner subprocess, which has no configured CORE, that
# this is an ESP32 Arduino build.
ESP32_ARDUINO_ENV = "ESPHOME_ESP32_ARDUINO_BUILD"
ANSI_ESCAPE = re.compile(r"\033[@-_][0-?]*[ -/]*[@-~]")
@@ -520,11 +525,24 @@ def detect_rp2040_bootsel(picotool_path: str | Path) -> BootselResult:
return BootselResult(0)
def get_esp32_arduino_flash_error_help() -> str | None:
"""Returns helpful message when ESP32 with Arduino runs out of flash space."""
def is_esp32_arduino_build() -> bool:
"""Whether the build targets ESP32 with the Arduino framework.
The PlatformIO runner subprocess has no configured CORE, so the parent
passes the answer in the environment.
"""
from esphome.core import CORE
if not (CORE.is_esp32 and CORE.using_arduino):
if not CORE.is_configured:
# The runner subprocess. A half filled in CORE still counts as
# configured, so reading from it raises instead of landing here.
return os.environ.get(ESP32_ARDUINO_ENV) == "1"
return CORE.is_esp32 and CORE.using_arduino
def get_esp32_arduino_flash_error_help() -> str | None:
"""Returns helpful message when ESP32 with Arduino runs out of flash space."""
if not is_esp32_arduino_build():
return None
from esphome.log import AnsiFore, color
+62 -1
View File
@@ -16,9 +16,10 @@ from unittest.mock import MagicMock, Mock, call, patch
import pytest
from esphome.const import KEY_CORE, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM
from esphome.core import CORE, EsphomeError
from esphome.platformio import runner, toolchain
from esphome.util import FlashImage
from esphome.util import ESP32_ARDUINO_ENV, FlashImage
def test_idedata_firmware_elf_path(setup_core: Path) -> None:
@@ -328,6 +329,66 @@ def test_idedata_null_section_raises_esphome_error(setup_core: Path) -> None:
_ = toolchain.IDEData({"extra": None}).extra_flash_images
@pytest.mark.parametrize(
("platform", "framework", "expected"),
[
("esp32", "arduino", "1"),
("esp32", "esp-idf", None),
("esp8266", "arduino", None),
],
)
def test_run_platformio_cli_flags_an_esp32_arduino_build(
setup_core: Path,
mock_run_external_process: Mock,
platform: str,
framework: str,
expected: str | None,
) -> None:
"""Only an ESP32 Arduino build is flagged, and an inherited one is cleared."""
CORE.build_path = str(setup_core / "build" / "test")
CORE.data[KEY_CORE] = {
KEY_TARGET_PLATFORM: platform,
KEY_TARGET_FRAMEWORK: framework,
}
with patch.dict(os.environ, {ESP32_ARDUINO_ENV: "1"}, clear=False):
mock_run_external_process.return_value = 0
toolchain.run_platformio_cli("test", "arg")
env = mock_run_external_process.call_args[1]["env"]
assert env.get(ESP32_ARDUINO_ENV) == expected
# Only the subprocess env is touched; ours is left as it was.
assert os.environ[ESP32_ARDUINO_ENV] == "1"
def test_run_platformio_cli_ignores_an_inherited_flag_without_core(
setup_core: Path, mock_run_external_process: Mock
) -> None:
"""An inherited flag must not end up answering for CORE."""
CORE.build_path = str(setup_core / "build" / "test")
CORE.data.pop(KEY_CORE, None)
with patch.dict(os.environ, {ESP32_ARDUINO_ENV: "1"}, clear=False):
mock_run_external_process.return_value = 0
toolchain.run_platformio_cli("test", "arg")
env = mock_run_external_process.call_args[1]["env"]
assert ESP32_ARDUINO_ENV not in env
def test_run_platformio_cli_raises_on_a_half_filled_core(
setup_core: Path, mock_run_external_process: Mock
) -> None:
"""A CORE set up but left incomplete must surface, not fall back."""
CORE.build_path = str(setup_core / "build" / "test")
CORE.data[KEY_CORE] = {}
with patch.dict(os.environ, {}, clear=False):
mock_run_external_process.return_value = 0
with pytest.raises(KeyError):
toolchain.run_platformio_cli("test", "arg")
def test_run_platformio_cli_sets_environment_variables(
setup_core: Path, mock_run_external_process: Mock
) -> None:
+76 -3
View File
@@ -14,6 +14,8 @@ from unittest.mock import MagicMock, patch
import pytest
from esphome import util
from esphome.const import KEY_CORE, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM
from esphome.core import CORE
def test_list_yaml_files_with_files_and_directories(tmp_path: Path) -> None:
@@ -517,6 +519,80 @@ def test_redirect_text_drain_is_a_no_op_when_nothing_is_held() -> None:
assert buf.getvalue() == "complete line\n"
def test_flash_error_help_is_quiet_when_core_is_unconfigured(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Regression: reading the platform used to raise in the runner."""
monkeypatch.setattr(CORE, "data", {})
monkeypatch.delenv(util.ESP32_ARDUINO_ENV, raising=False)
assert util.get_esp32_arduino_flash_error_help() is None
def test_flash_error_help_reads_the_env_var_when_core_is_unconfigured(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The parent tells the subprocess what it cannot work out for itself."""
monkeypatch.setattr(CORE, "data", {})
monkeypatch.setenv(util.ESP32_ARDUINO_ENV, "1")
help_msg = util.get_esp32_arduino_flash_error_help()
assert help_msg is not None
assert "esp-idf" in help_msg
def test_is_esp32_arduino_build_raises_on_a_half_filled_core(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A half filled in CORE is a bug, so it must raise, not fall back."""
monkeypatch.setattr(CORE, "data", {KEY_CORE: {}})
monkeypatch.setenv(util.ESP32_ARDUINO_ENV, "1")
with pytest.raises(KeyError):
util.is_esp32_arduino_build()
@pytest.mark.parametrize(
("platform", "framework", "expected"),
[
("esp32", "arduino", True),
("esp32", "esp-idf", False),
("esp8266", "arduino", False),
],
)
def test_is_esp32_arduino_build_from_a_configured_core(
monkeypatch: pytest.MonkeyPatch, platform: str, framework: str, expected: bool
) -> None:
"""With CORE set up, it is the source of truth and the env var is ignored."""
monkeypatch.setattr(
CORE,
"data",
{KEY_CORE: {KEY_TARGET_PLATFORM: platform, KEY_TARGET_FRAMEWORK: framework}},
)
monkeypatch.delenv(util.ESP32_ARDUINO_ENV, raising=False)
assert util.is_esp32_arduino_build() is expected
def test_redirect_text_survives_a_flash_error_without_core(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The overflow line goes through even from a process with no CORE."""
monkeypatch.setattr(CORE, "data", {})
monkeypatch.delenv(util.ESP32_ARDUINO_ENV, raising=False)
redirect, buf = _make_redirect(filter_lines=["ignore me"])
redirect.write("Error: The program size is greater than maximum allowed\n")
assert buf.getvalue() == "Error: The program size is greater than maximum allowed\n"
def test_redirect_text_adds_flash_size_help(monkeypatch: pytest.MonkeyPatch) -> None:
"""An out-of-flash error gets the how-to-fix note appended."""
monkeypatch.setattr(
@@ -971,7 +1047,6 @@ class TestSafePrint:
@pytest.fixture(autouse=True)
def _no_dashboard(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Default ``CORE.dashboard`` to False so each test starts hermetic."""
from esphome.core import CORE
monkeypatch.setattr(CORE, "dashboard", False)
@@ -993,7 +1068,6 @@ class TestSafePrint:
monkeypatch: pytest.MonkeyPatch,
) -> None:
r"""Dashboard mode escapes raw ``\033`` ESC bytes to literal ``\\033``."""
from esphome.core import CORE
monkeypatch.setattr(CORE, "dashboard", True)
util.safe_print("\033[0;32mhi\033[0m")
@@ -1060,7 +1134,6 @@ class TestSafePrint:
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Dashboard ESC escaping + cp1252 fallback compose correctly."""
from esphome.core import CORE
monkeypatch.setattr(CORE, "dashboard", True)
buf = io.BytesIO()