diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 1a5f4f2cf50..534b740a5db 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -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] diff --git a/esphome/platformio/toolchain.py b/esphome/platformio/toolchain.py index 32e30290ac9..0e7ffce9396 100644 --- a/esphome/platformio/toolchain.py +++ b/esphome/platformio/toolchain.py @@ -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) diff --git a/esphome/util.py b/esphome/util.py index 0b9025c73b0..2fc34f3a694 100644 --- a/esphome/util.py +++ b/esphome/util.py @@ -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 diff --git a/tests/unit_tests/test_platformio_toolchain.py b/tests/unit_tests/test_platformio_toolchain.py index 9450e8e0e16..02c11b4e451 100644 --- a/tests/unit_tests/test_platformio_toolchain.py +++ b/tests/unit_tests/test_platformio_toolchain.py @@ -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: diff --git a/tests/unit_tests/test_util.py b/tests/unit_tests/test_util.py index fcd8bf2e9c0..a4b091b7c24 100644 --- a/tests/unit_tests/test_util.py +++ b/tests/unit_tests/test_util.py @@ -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()