diff --git a/esphome/__main__.py b/esphome/__main__.py index 553a8b390f..27bb64a4df 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -776,6 +776,13 @@ def compile_program(args: ArgsProtocol, config: ConfigType) -> int: check_placeholder_credentials(config) + # Keep this here, NOT in codegen: config-hash and --only-generate must keep + # working on machines that cannot run the toolchain. + if CORE.is_esp8266: + from esphome.components.esp8266 import check_rosetta + + check_rosetta() + # NOTE: "Build path:" format is parsed by script/ci_memory_impact_extract.py # If you change this format, update the regex in that script as well _LOGGER.info("Compiling app... Build path: %s", CORE.build_path) diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index 0e0e2f77d7..7ce10d465d 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -1,5 +1,6 @@ import logging from pathlib import Path +import platform import re import subprocess @@ -20,9 +21,15 @@ from esphome.const import ( PLATFORM_ESP8266, ThreadModel, ) -from esphome.core import CORE, CoroPriority, Lambda, coroutine_with_priority +from esphome.core import ( + CORE, + CoroPriority, + EsphomeError, + Lambda, + coroutine_with_priority, +) from esphome.core.config import BOARD_MAX_LENGTH -from esphome.helpers import copy_file_if_changed +from esphome.helpers import IS_MACOS, copy_file_if_changed from esphome.types import ConfigType from .boards import BOARDS, ESP8266_LD_SCRIPTS @@ -237,6 +244,32 @@ CONFIG_SCHEMA = cv.All( ) +def check_rosetta() -> None: + """Fail fast when the x86_64 ESP8266 toolchain cannot run on this Mac. + + There is no native arm64 build of the xtensa-lx106 toolchain; on Apple + Silicon it runs under Rosetta 2, which macOS updates can remove. + """ + if not IS_MACOS or platform.machine() != "arm64": + return + try: + result = subprocess.run( + ["/usr/bin/arch", "-x86_64", "/usr/bin/true"], + capture_output=True, + close_fds=False, + check=False, + ) + except OSError: + return # arch(1) unavailable; let the build proceed + if result.returncode != 0: + raise EsphomeError( + "ESP8266 builds on Apple Silicon Macs use an Intel (x86_64) " + "compiler that requires Rosetta 2, which is not installed on " + "this system. Install it with:\n" + " softwareupdate --install-rosetta --agree-to-license" + ) + + @coroutine_with_priority(CoroPriority.PLATFORM) async def to_code(config): cg.add(esp8266_ns.setup_preferences()) diff --git a/tests/unit_tests/components/test_esp8266.py b/tests/unit_tests/components/test_esp8266.py index 318fd2d889..fb0e437d24 100644 --- a/tests/unit_tests/components/test_esp8266.py +++ b/tests/unit_tests/components/test_esp8266.py @@ -1,9 +1,15 @@ """Tests for ESP8266 component.""" +from __future__ import annotations + +from collections.abc import Generator +from unittest.mock import MagicMock, patch + import pytest -from esphome.components.esp8266 import lambdas_use_scanf_float -from esphome.core import Lambda +from esphome.components import esp8266 +from esphome.components.esp8266 import check_rosetta, lambdas_use_scanf_float +from esphome.core import EsphomeError, Lambda from esphome.types import ConfigType @@ -60,3 +66,54 @@ def test_lambdas_use_scanf_float_nested() -> None: """Test detection in deeply nested config.""" config: ConfigType = {"a": {"b": {"c": [Lambda('sscanf(buf, "%f", &v)')]}}} assert lambdas_use_scanf_float(config) is True + + +@pytest.fixture +def apple_silicon_run(monkeypatch: pytest.MonkeyPatch) -> Generator[MagicMock]: + """Simulate an Apple Silicon Mac and yield the mocked subprocess.run.""" + monkeypatch.setattr(esp8266, "IS_MACOS", True) + with ( + patch("esphome.components.esp8266.platform.machine", return_value="arm64"), + patch("esphome.components.esp8266.subprocess.run") as mock_run, + ): + yield mock_run + + +@pytest.mark.parametrize( + ("is_macos", "machine"), + [ + (False, "arm64"), + (True, "x86_64"), + ], +) +def test_check_rosetta_skips_other_systems( + monkeypatch: pytest.MonkeyPatch, is_macos: bool, machine: str +) -> None: + """The check only probes on Apple Silicon Macs.""" + monkeypatch.setattr(esp8266, "IS_MACOS", is_macos) + with ( + patch("esphome.components.esp8266.platform.machine", return_value=machine), + patch("esphome.components.esp8266.subprocess.run") as mock_run, + ): + check_rosetta() + mock_run.assert_not_called() + + +def test_check_rosetta_installed(apple_silicon_run: MagicMock) -> None: + """No error when the x86_64 probe succeeds (Rosetta present).""" + apple_silicon_run.return_value = MagicMock(returncode=0) + check_rosetta() + apple_silicon_run.assert_called_once() + + +def test_check_rosetta_missing(apple_silicon_run: MagicMock) -> None: + """A failing x86_64 probe raises an actionable error.""" + apple_silicon_run.return_value = MagicMock(returncode=1) + with pytest.raises(EsphomeError, match="softwareupdate --install-rosetta"): + check_rosetta() + + +def test_check_rosetta_arch_unavailable(apple_silicon_run: MagicMock) -> None: + """The build proceeds when arch(1) cannot be executed.""" + apple_silicon_run.side_effect = OSError("no such file") + check_rosetta() diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index a1ed89bf5d..7de11d0568 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -5450,6 +5450,43 @@ def _setup_build_info_test( return build_info_path, firmware_path +def test_compile_program_esp8266_runs_rosetta_check(tmp_path: Path) -> None: + """Test that compile_program runs the Rosetta preflight for ESP8266 targets.""" + setup_core(platform=PLATFORM_ESP8266, tmp_path=tmp_path, name="test_device") + + config: dict[str, Any] = {CONF_ESPHOME: {CONF_NAME: "test_device"}} + args = MockArgs() + + with ( + patch( + "esphome.components.esp8266.check_rosetta", + side_effect=EsphomeError("Rosetta 2 is not installed"), + ) as mock_check, + pytest.raises(EsphomeError, match="Rosetta 2 is not installed"), + ): + compile_program(args, config) + + mock_check.assert_called_once() + + +def test_compile_program_skips_rosetta_check_on_other_platforms( + tmp_path: Path, + mock_compile_build_info_run_compile: Mock, + mock_compile_build_info_get_idedata: Mock, +) -> None: + """Test that the Rosetta preflight does not run for non-ESP8266 targets.""" + _setup_build_info_test(tmp_path, firmware_first=True) + + config: dict[str, Any] = {CONF_ESPHOME: {CONF_NAME: "test_device"}} + args = MockArgs() + + with patch("esphome.components.esp8266.check_rosetta") as mock_check: + result = compile_program(args, config) + + assert result == 0 + mock_check.assert_not_called() + + def test_compile_program_emits_build_info_when_firmware_rebuilt( tmp_path: Path, caplog: pytest.LogCaptureFixture,