[esp8266] Fail fast when Rosetta 2 is missing on Apple Silicon Macs (#17737)

This commit is contained in:
J. Nick Koston
2026-07-21 08:25:51 +12:00
committed by Jesse Hills
parent 7c55de311f
commit f5f1c48f51
4 changed files with 138 additions and 4 deletions
+7
View File
@@ -776,6 +776,13 @@ def compile_program(args: ArgsProtocol, config: ConfigType) -> int:
check_placeholder_credentials(config) 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 # 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 # If you change this format, update the regex in that script as well
_LOGGER.info("Compiling app... Build path: %s", CORE.build_path) _LOGGER.info("Compiling app... Build path: %s", CORE.build_path)
+35 -2
View File
@@ -1,5 +1,6 @@
import logging import logging
from pathlib import Path from pathlib import Path
import platform
import re import re
import subprocess import subprocess
@@ -20,9 +21,15 @@ from esphome.const import (
PLATFORM_ESP8266, PLATFORM_ESP8266,
ThreadModel, 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.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 esphome.types import ConfigType
from .boards import BOARDS, ESP8266_LD_SCRIPTS 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) @coroutine_with_priority(CoroPriority.PLATFORM)
async def to_code(config): async def to_code(config):
cg.add(esp8266_ns.setup_preferences()) cg.add(esp8266_ns.setup_preferences())
+59 -2
View File
@@ -1,9 +1,15 @@
"""Tests for ESP8266 component.""" """Tests for ESP8266 component."""
from __future__ import annotations
from collections.abc import Generator
from unittest.mock import MagicMock, patch
import pytest import pytest
from esphome.components.esp8266 import lambdas_use_scanf_float from esphome.components import esp8266
from esphome.core import Lambda from esphome.components.esp8266 import check_rosetta, lambdas_use_scanf_float
from esphome.core import EsphomeError, Lambda
from esphome.types import ConfigType from esphome.types import ConfigType
@@ -60,3 +66,54 @@ def test_lambdas_use_scanf_float_nested() -> None:
"""Test detection in deeply nested config.""" """Test detection in deeply nested config."""
config: ConfigType = {"a": {"b": {"c": [Lambda('sscanf(buf, "%f", &v)')]}}} config: ConfigType = {"a": {"b": {"c": [Lambda('sscanf(buf, "%f", &v)')]}}}
assert lambdas_use_scanf_float(config) is True 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()
+37
View File
@@ -5450,6 +5450,43 @@ def _setup_build_info_test(
return build_info_path, firmware_path 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( def test_compile_program_emits_build_info_when_firmware_rebuilt(
tmp_path: Path, tmp_path: Path,
caplog: pytest.LogCaptureFixture, caplog: pytest.LogCaptureFixture,