mirror of
https://github.com/esphome/esphome.git
synced 2026-08-17 10:52:56 +08:00
[core] Show the out-of-flash tip instead of crashing the build (#18280)
This commit is contained in:
@@ -885,6 +885,11 @@ class EsphomeCore:
|
|||||||
return self.relative_build_path("build", "bootloader", "bootloader.bin")
|
return self.relative_build_path("build", "bootloader", "bootloader.bin")
|
||||||
return self.relative_pioenvs_path(self.name, "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
|
@property
|
||||||
def target_platform(self):
|
def target_platform(self):
|
||||||
return self.data[KEY_CORE][KEY_TARGET_PLATFORM]
|
return self.data[KEY_CORE][KEY_TARGET_PLATFORM]
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ from esphome.helpers import (
|
|||||||
rmtree,
|
rmtree,
|
||||||
write_file,
|
write_file,
|
||||||
)
|
)
|
||||||
from esphome.util import FlashImage, run_external_process
|
from esphome.util import ESP32_ARDUINO_ENV, FlashImage, run_external_process
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from platformio.project.config import ProjectConfig
|
from platformio.project.config import ProjectConfig
|
||||||
@@ -342,6 +342,13 @@ def run_platformio_cli(*args, **kwargs) -> str | int:
|
|||||||
base_env = kwargs.pop("env", None)
|
base_env = kwargs.pop("env", None)
|
||||||
env = dict(os.environ if base_env is None else base_env)
|
env = dict(os.environ if base_env is None else base_env)
|
||||||
env.update(_ccache_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)
|
return run_external_process(*cmd, env=env, **kwargs)
|
||||||
|
|
||||||
|
|||||||
+21
-3
@@ -3,6 +3,7 @@ from collections.abc import Callable, Iterable
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
import io
|
import io
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
@@ -141,6 +142,10 @@ def shlex_quote(s: str | Path) -> str:
|
|||||||
return "'" + s.replace("'", "'\"'\"'") + "'"
|
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-?]*[ -/]*[@-~]")
|
ANSI_ESCAPE = re.compile(r"\033[@-_][0-?]*[ -/]*[@-~]")
|
||||||
|
|
||||||
|
|
||||||
@@ -520,11 +525,24 @@ def detect_rp2040_bootsel(picotool_path: str | Path) -> BootselResult:
|
|||||||
return BootselResult(0)
|
return BootselResult(0)
|
||||||
|
|
||||||
|
|
||||||
def get_esp32_arduino_flash_error_help() -> str | None:
|
def is_esp32_arduino_build() -> bool:
|
||||||
"""Returns helpful message when ESP32 with Arduino runs out of flash space."""
|
"""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
|
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
|
return None
|
||||||
|
|
||||||
from esphome.log import AnsiFore, color
|
from esphome.log import AnsiFore, color
|
||||||
|
|||||||
@@ -16,9 +16,10 @@ from unittest.mock import MagicMock, Mock, call, patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from esphome.const import KEY_CORE, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM
|
||||||
from esphome.core import CORE, EsphomeError
|
from esphome.core import CORE, EsphomeError
|
||||||
from esphome.platformio import runner, toolchain
|
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:
|
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
|
_ = 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(
|
def test_run_platformio_cli_sets_environment_variables(
|
||||||
setup_core: Path, mock_run_external_process: Mock
|
setup_core: Path, mock_run_external_process: Mock
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ from unittest.mock import MagicMock, patch
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from esphome import util
|
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:
|
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"
|
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:
|
def test_redirect_text_adds_flash_size_help(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
"""An out-of-flash error gets the how-to-fix note appended."""
|
"""An out-of-flash error gets the how-to-fix note appended."""
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
@@ -971,7 +1047,6 @@ class TestSafePrint:
|
|||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def _no_dashboard(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
def _no_dashboard(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
"""Default ``CORE.dashboard`` to False so each test starts hermetic."""
|
"""Default ``CORE.dashboard`` to False so each test starts hermetic."""
|
||||||
from esphome.core import CORE
|
|
||||||
|
|
||||||
monkeypatch.setattr(CORE, "dashboard", False)
|
monkeypatch.setattr(CORE, "dashboard", False)
|
||||||
|
|
||||||
@@ -993,7 +1068,6 @@ class TestSafePrint:
|
|||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
r"""Dashboard mode escapes raw ``\033`` ESC bytes to literal ``\\033``."""
|
r"""Dashboard mode escapes raw ``\033`` ESC bytes to literal ``\\033``."""
|
||||||
from esphome.core import CORE
|
|
||||||
|
|
||||||
monkeypatch.setattr(CORE, "dashboard", True)
|
monkeypatch.setattr(CORE, "dashboard", True)
|
||||||
util.safe_print("\033[0;32mhi\033[0m")
|
util.safe_print("\033[0;32mhi\033[0m")
|
||||||
@@ -1060,7 +1134,6 @@ class TestSafePrint:
|
|||||||
self, monkeypatch: pytest.MonkeyPatch
|
self, monkeypatch: pytest.MonkeyPatch
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Dashboard ESC escaping + cp1252 fallback compose correctly."""
|
"""Dashboard ESC escaping + cp1252 fallback compose correctly."""
|
||||||
from esphome.core import CORE
|
|
||||||
|
|
||||||
monkeypatch.setattr(CORE, "dashboard", True)
|
monkeypatch.setattr(CORE, "dashboard", True)
|
||||||
buf = io.BytesIO()
|
buf = io.BytesIO()
|
||||||
|
|||||||
Reference in New Issue
Block a user