mirror of
https://github.com/esphome/esphome.git
synced 2026-10-04 10:09:13 +00:00
Add the ESP8266 native build spec (knobs, defines, flags, linker scripts)
This commit is contained in:
@@ -0,0 +1,428 @@
|
||||
"""Native ninja build generator for the ESP8266 Arduino core.
|
||||
|
||||
Transliterates the PlatformIO build spec for the Arduino ESP8266 framework
|
||||
(``framework-arduinoespressif8266/tools/platformio-build.py`` plus
|
||||
``platform-espressif8266/builder/main.py``) into a ``build.ninja`` under
|
||||
``.pioenvs/<name>/``. The flag sets, defines, link line, linker-script
|
||||
generation, and ``elf2bin`` invocation deliberately match what PlatformIO
|
||||
produces so the binaries stay near-identical between the two toolchains.
|
||||
|
||||
The ``PIO_FRAMEWORK_ARDUINO_*`` knob defines (lwIP variant, NONOS SDK
|
||||
version, MMU layout, exceptions, waveform phase) keep working: they are read
|
||||
from the build flags with the same precedence as the PlatformIO builder.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
from esphome.components.esp8266 import build_surgery
|
||||
from esphome.components.esp8266.boards import BOARDS, ESP8266_LD_SCRIPTS
|
||||
from esphome.components.esp8266.const import KEY_BOARD, KEY_ESP8266, KEY_FLASH_SIZE
|
||||
from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION
|
||||
from esphome.core import CORE, EsphomeError
|
||||
from esphome.helpers import mkdir_p, write_file_if_changed
|
||||
from esphome.platformio.library import join_flag_args, split_flag_entry
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# From platformio-build.py. The first entry is the default; with multiple SDK
|
||||
# knobs set (a pathological config) ties break by table order, since
|
||||
# upstream's tie-break depends on define order and is not reproducible here.
|
||||
_NONOSDK_VERSIONS = (
|
||||
("SDK22x_190703", "NONOSDK22x_190703"),
|
||||
("SDK221", "NONOSDK221"),
|
||||
("SDK22x_190313", "NONOSDK22x_190313"),
|
||||
("SDK22x_191024", "NONOSDK22x_191024"),
|
||||
("SDK22x_191105", "NONOSDK22x_191105"),
|
||||
("SDK22x_191122", "NONOSDK22x_191122"),
|
||||
("SDK305", "NONOSDK305"),
|
||||
)
|
||||
|
||||
# knob define -> (TCP_MSS, LWIP_FEATURES, LWIP_IPV6, library name)
|
||||
_LWIP_VARIANTS = (
|
||||
("PIO_FRAMEWORK_ARDUINO_LWIP2_IPV6_LOW_MEMORY", (536, 1, 1, "lwip6-536-feat")),
|
||||
(
|
||||
"PIO_FRAMEWORK_ARDUINO_LWIP2_IPV6_HIGHER_BANDWIDTH",
|
||||
(1460, 1, 1, "lwip6-1460-feat"),
|
||||
),
|
||||
("PIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH", (1460, 1, 0, "lwip2-1460-feat")),
|
||||
("PIO_FRAMEWORK_ARDUINO_LWIP2_LOW_MEMORY_LOW_FLASH", (536, 0, 0, "lwip2-536")),
|
||||
(
|
||||
"PIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH",
|
||||
(1460, 0, 0, "lwip2-1460"),
|
||||
),
|
||||
)
|
||||
_LWIP_DEFAULT = (536, 1, 0, "lwip2-536-feat")
|
||||
|
||||
_ASFLAGS = ["-mlongcalls", "-mtext-section-literals"]
|
||||
_CFLAGS = [
|
||||
"-std=gnu17",
|
||||
"-Wpointer-arith",
|
||||
"-Wno-implicit-function-declaration",
|
||||
"-Wl,-EL",
|
||||
"-fno-inline-functions",
|
||||
"-nostdlib",
|
||||
]
|
||||
_CCFLAGS = [
|
||||
"-Os",
|
||||
"-mlongcalls",
|
||||
"-mtext-section-literals",
|
||||
"-falign-functions=4",
|
||||
"-U__STRICT_ANSI__",
|
||||
"-ffunction-sections",
|
||||
"-fdata-sections",
|
||||
"-Wall",
|
||||
"-Werror=return-type",
|
||||
"-free",
|
||||
"-fipa-pta",
|
||||
]
|
||||
_LINKFLAGS = [
|
||||
"-Os",
|
||||
"-nostdlib",
|
||||
"-Wl,--no-check-sections",
|
||||
"-Wl,-static",
|
||||
"-Wl,--gc-sections",
|
||||
"-Wl,-wrap,system_restart_local",
|
||||
"-Wl,-wrap,spi_flash_read",
|
||||
"-u",
|
||||
"app_entry",
|
||||
"-u",
|
||||
"_printf_float",
|
||||
"-u",
|
||||
"_DebugExceptionVector",
|
||||
"-u",
|
||||
"_DoubleExceptionVector",
|
||||
"-u",
|
||||
"_KernelExceptionVector",
|
||||
"-u",
|
||||
"_NMIExceptionVector",
|
||||
"-u",
|
||||
"_UserExceptionVector",
|
||||
]
|
||||
_SYSTEM_LIBS_PRE_LWIP = ["hal", "phy", "pp", "net80211"]
|
||||
_SYSTEM_LIBS_POST_LWIP = [
|
||||
"wpa",
|
||||
"crypto",
|
||||
"main",
|
||||
"wps",
|
||||
"bearssl",
|
||||
"espnow",
|
||||
"smartconfig",
|
||||
"airkiss",
|
||||
"wpa2",
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class _BuildConfig:
|
||||
"""Knob-derived build configuration (PIO_FRAMEWORK_ARDUINO_* defines)."""
|
||||
|
||||
nonosdk: str
|
||||
lwip_lib: str
|
||||
exceptions: bool
|
||||
vtables: str
|
||||
fp_in_irom: bool
|
||||
knob_defines: list[str] = field(default_factory=list)
|
||||
mmu_defines: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def _flag_defines() -> dict[str, str]:
|
||||
"""Map define name -> full ``NAME[=VALUE]`` for every -D build flag."""
|
||||
defines: dict[str, str] = {}
|
||||
for flag in CORE.build_flags:
|
||||
# Shell-lex multi-token entries the way PlatformIO does, so a knob
|
||||
# in "-DKNOB -DOTHER" or a spaced "-D KNOB" is still detected;
|
||||
# single tokens pass verbatim to keep quoting in their bodies intact.
|
||||
tokens = (
|
||||
join_flag_args(split_flag_entry(flag, "esphome"), "esphome")
|
||||
if " " in flag
|
||||
else (flag,)
|
||||
)
|
||||
for tok in tokens:
|
||||
if tok.startswith("-D") and len(tok) > 2:
|
||||
body = tok[2:]
|
||||
defines[body.split("=", 1)[0]] = body
|
||||
return defines
|
||||
|
||||
|
||||
def _resolve_build_config(defines: dict[str, str]) -> _BuildConfig:
|
||||
nonosdk = _NONOSDK_VERSIONS[0][1]
|
||||
for name, define in _NONOSDK_VERSIONS:
|
||||
if f"PIO_FRAMEWORK_ARDUINO_ESPRESSIF_{name}" in defines:
|
||||
nonosdk = define
|
||||
break
|
||||
|
||||
tcp_mss, features, ipv6, lwip_lib = _LWIP_DEFAULT
|
||||
for knob, variant in _LWIP_VARIANTS:
|
||||
if knob in defines:
|
||||
tcp_mss, features, ipv6, lwip_lib = variant
|
||||
break
|
||||
|
||||
knob_defines = [
|
||||
f"{nonosdk}=1",
|
||||
f"TCP_MSS={tcp_mss}",
|
||||
f"LWIP_FEATURES={features}",
|
||||
f"LWIP_IPV6={ipv6}",
|
||||
]
|
||||
if "PIO_FRAMEWORK_ARDUINO_WAVEFORM_LOCKED_PHASE" in defines:
|
||||
knob_defines.append("WAVEFORM_LOCKED_PHASE=1")
|
||||
|
||||
# Sorted so the pick is deterministic: the dict is built from a set of
|
||||
# build flags, whose iteration order varies between processes.
|
||||
vtables = next(
|
||||
(name for name in sorted(defines) if name.startswith("VTABLES_IN_")),
|
||||
"VTABLES_IN_FLASH",
|
||||
)
|
||||
|
||||
if "PIO_FRAMEWORK_ARDUINO_MMU_CACHE16_IRAM48" in defines:
|
||||
mmu = ["MMU_IRAM_SIZE=0xC000", "MMU_ICACHE_SIZE=0x4000"]
|
||||
elif "PIO_FRAMEWORK_ARDUINO_MMU_CACHE16_IRAM48_SECHEAP_SHARED" in defines:
|
||||
mmu = ["MMU_IRAM_SIZE=0xC000", "MMU_ICACHE_SIZE=0x4000", "MMU_IRAM_HEAP"]
|
||||
elif "PIO_FRAMEWORK_ARDUINO_MMU_CACHE16_IRAM32_SECHEAP_NOTSHARED" in defines:
|
||||
mmu = [
|
||||
"MMU_IRAM_SIZE=0x8000",
|
||||
"MMU_ICACHE_SIZE=0x4000",
|
||||
"MMU_SEC_HEAP_SIZE=0x4000",
|
||||
"MMU_SEC_HEAP=0x40108000",
|
||||
]
|
||||
elif "PIO_FRAMEWORK_ARDUINO_MMU_EXTERNAL_128K" in defines:
|
||||
mmu = [
|
||||
"MMU_IRAM_SIZE=0x8000",
|
||||
"MMU_ICACHE_SIZE=0x8000",
|
||||
"MMU_EXTERNAL_HEAP=128",
|
||||
]
|
||||
elif "PIO_FRAMEWORK_ARDUINO_MMU_EXTERNAL_1024K" in defines:
|
||||
mmu = [
|
||||
"MMU_IRAM_SIZE=0x8000",
|
||||
"MMU_ICACHE_SIZE=0x8000",
|
||||
"MMU_EXTERNAL_HEAP=256",
|
||||
]
|
||||
elif "PIO_FRAMEWORK_ARDUINO_MMU_CUSTOM" in defines:
|
||||
if "MMU_IRAM_SIZE" not in defines or "MMU_ICACHE_SIZE" not in defines:
|
||||
raise EsphomeError(
|
||||
"PIO_FRAMEWORK_ARDUINO_MMU_CUSTOM requires MMU_IRAM_SIZE and "
|
||||
"MMU_ICACHE_SIZE build flags"
|
||||
)
|
||||
# Sorted so build.ninja and the linker-script stamp stay byte-stable
|
||||
# across runs (the flag set has no deterministic iteration order).
|
||||
mmu = sorted(body for name, body in defines.items() if name.startswith("MMU_"))
|
||||
else:
|
||||
mmu = ["MMU_IRAM_SIZE=0x8000", "MMU_ICACHE_SIZE=0x8000"]
|
||||
|
||||
return _BuildConfig(
|
||||
nonosdk=nonosdk,
|
||||
lwip_lib=lwip_lib,
|
||||
exceptions="PIO_FRAMEWORK_ARDUINO_ENABLE_EXCEPTIONS" in defines,
|
||||
vtables=vtables,
|
||||
fp_in_irom="FP_IN_IROM" in defines,
|
||||
knob_defines=knob_defines,
|
||||
mmu_defines=mmu,
|
||||
)
|
||||
|
||||
|
||||
def _flash_ld_name(board: str) -> str:
|
||||
return ESP8266_LD_SCRIPTS[BOARDS[board][KEY_FLASH_SIZE]][1]
|
||||
|
||||
|
||||
def _e(value) -> str:
|
||||
"""Escape a path or token for a ninja file."""
|
||||
return str(value).replace("$", "$$").replace(":", "$:").replace(" ", "$ ")
|
||||
|
||||
|
||||
def _quote_arg(tok: str) -> str:
|
||||
"""Wrap a token in double quotes with the Windows argv rule.
|
||||
|
||||
Same escaping rule as ``subprocess.list2cmdline``: a backslash run
|
||||
doubles only immediately before a quote (or the closing quote), and the
|
||||
quote itself is escaped. POSIX sh parses the result identically for
|
||||
backslashes and quotes. ``$`` must already be doubled for ninja.
|
||||
"""
|
||||
quoted = re.sub(r'(\\*)"', lambda m: m.group(1) * 2 + '\\"', tok)
|
||||
quoted = re.sub(r"(\\+)\Z", lambda m: m.group(1) * 2, quoted)
|
||||
return f'"{quoted}"'
|
||||
|
||||
|
||||
def _q(value) -> str:
|
||||
"""Force-quote a path for the ninja command line (shell/CreateProcess)."""
|
||||
return _quote_arg(str(value).replace("$", "$$"))
|
||||
|
||||
|
||||
_NEEDS_QUOTE = re.compile(r'[\s"\']')
|
||||
|
||||
|
||||
def _shell_token(tok: str) -> str:
|
||||
"""Quote a lexed token only when needed; ``_q`` force-quotes paths.
|
||||
|
||||
Lexing strips the quoting a user wrote (``-DX="a b"`` becomes the single
|
||||
token ``-DX=a b``); re-quote on the way out so the compiler receives the
|
||||
same argv element SCons would pass under PlatformIO. After ninja
|
||||
un-doubles ``$$``, sh still expands ``$VAR`` while CreateProcess passes
|
||||
it literally -- the same divergence SCons-under-sh has, so this stays
|
||||
PlatformIO parity.
|
||||
"""
|
||||
tok = tok.replace("$", "$$") # ninja would expand a bare $ to nothing
|
||||
if not _NEEDS_QUOTE.search(tok):
|
||||
return tok
|
||||
return _quote_arg(tok)
|
||||
|
||||
|
||||
def _defines_flags(
|
||||
config: _BuildConfig, flash_mode: str, board: str, board_defines: tuple[str, ...]
|
||||
) -> list[str]:
|
||||
return [
|
||||
f"-D{d}"
|
||||
for d in (
|
||||
"F_CPU=80000000L",
|
||||
"__ets__",
|
||||
"ICACHE_FLASH",
|
||||
"_GNU_SOURCE",
|
||||
"ARDUINO=10805",
|
||||
f'ARDUINO_BOARD=\\"PLATFORMIO_{board.upper()}\\"',
|
||||
f'ARDUINO_BOARD_ID=\\"{board}\\"',
|
||||
f"FLASHMODE_{flash_mode.upper()}",
|
||||
"LWIP_OPEN_SRC",
|
||||
*config.knob_defines,
|
||||
config.vtables,
|
||||
*config.mmu_defines,
|
||||
"ESP8266",
|
||||
"ARDUINO_ARCH_ESP8266",
|
||||
*board_defines,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def _unflag_tokens() -> set[str]:
|
||||
"""``build_unflags`` entries shell-lexed to tokens, as PlatformIO matches."""
|
||||
return {
|
||||
tok
|
||||
for entry in CORE.build_unflags
|
||||
for tok in split_flag_entry(entry, "esphome build_unflags")
|
||||
}
|
||||
|
||||
|
||||
def _project_flags(
|
||||
unflags: set[str] | None = None,
|
||||
) -> tuple[list[str], list[str], list[Path], list[str]]:
|
||||
"""Split the ESPHome build flags into compile, linker, -L, and -l lists.
|
||||
|
||||
Every entry is shell-lexed the way PlatformIO's ``ParseFlags`` does, so a
|
||||
linker flag anywhere in an entry reaches the link line and
|
||||
``build_unflags`` matches individual tokens (``-Os`` inside ``-Os -g3``).
|
||||
Lexed tokens are re-quoted at emission via ``_shell_token``.
|
||||
"""
|
||||
if unflags is None:
|
||||
unflags = _unflag_tokens()
|
||||
compile_flags: list[str] = []
|
||||
link_flags: list[str] = []
|
||||
lib_dirs: list[Path] = []
|
||||
libs: list[str] = []
|
||||
for flag in sorted(CORE.build_flags):
|
||||
for tok in join_flag_args(split_flag_entry(flag, "esphome"), "esphome"):
|
||||
if tok in unflags:
|
||||
continue
|
||||
if tok.startswith("-Wl,"):
|
||||
link_flags.append(_shell_token(tok))
|
||||
elif tok.startswith("-L"):
|
||||
lib_dirs.append(Path(tok[2:]))
|
||||
elif tok.startswith("-l"):
|
||||
libs.append(tok[2:])
|
||||
else:
|
||||
compile_flags.append(_shell_token(tok))
|
||||
return compile_flags, link_flags, lib_dirs, libs
|
||||
|
||||
|
||||
def generate_ld_scripts(
|
||||
paths: dict[str, Path], config: _BuildConfig, flash_ld_name: str
|
||||
) -> None:
|
||||
"""Generate the common linker script (and testing-mode flash ld copy).
|
||||
|
||||
Runs the same preprocessor invocation as the PlatformIO builder over
|
||||
``eagle.app.v6.common.ld.h``, then applies ESPHome's surgeries: the wifi
|
||||
rate-table DRAM relocation, and enlarged memory segments in testing mode.
|
||||
"""
|
||||
framework = paths["framework_path"]
|
||||
gcc = paths["toolchain_path"] / "bin" / "xtensa-lx106-elf-gcc"
|
||||
ld_dir = CORE.relative_pioenvs_path(CORE.name, "ld")
|
||||
mkdir_p(ld_dir)
|
||||
|
||||
cmd = [str(gcc), "-CC", "-E", "-P", f"-D{config.vtables}"]
|
||||
cmd += [f"-D{d}" for d in config.mmu_defines]
|
||||
if config.fp_in_irom:
|
||||
cmd.append("-DFP_IN_IROM")
|
||||
cmd += [
|
||||
str(framework / "tools" / "sdk" / "ld" / "eagle.app.v6.common.ld.h"),
|
||||
"-o",
|
||||
"-",
|
||||
]
|
||||
|
||||
# The inputs are the command line (defines + framework version, which is
|
||||
# baked into the paths) plus testing mode; skip the preprocessor spawn on
|
||||
# incremental builds when nothing changed.
|
||||
output = ld_dir / "local.eagle.app.v6.common.ld"
|
||||
stamp = ld_dir / ".local.eagle.app.v6.common.ld.stamp"
|
||||
# The surgery constants are inputs too: an edit to build_surgery.py must
|
||||
# invalidate existing build dirs, not wait for an esphome clean.
|
||||
stamp_content = (
|
||||
" ".join(cmd)
|
||||
+ f" testing={CORE.testing_mode}"
|
||||
+ f" {build_surgery.RATETABLE_RULE}"
|
||||
+ f" {build_surgery.TESTING_IRAM_SIZE}"
|
||||
+ f" {build_surgery.TESTING_DRAM_SIZE}"
|
||||
+ f" {build_surgery.TESTING_FLASH_SIZE}"
|
||||
)
|
||||
if not (
|
||||
output.is_file()
|
||||
and stamp.is_file()
|
||||
and stamp.read_text(encoding="utf-8") == stamp_content
|
||||
):
|
||||
result = subprocess.run(
|
||||
cmd, capture_output=True, text=True, check=False, close_fds=False
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise EsphomeError(f"Generating the linker script failed:\n{result.stderr}")
|
||||
content = build_surgery.relocate_ratetable(result.stdout)
|
||||
if CORE.testing_mode:
|
||||
content = build_surgery.apply_testing_memory_patches(
|
||||
content, require=("iram1_0_seg",)
|
||||
)
|
||||
write_file_if_changed(output, content)
|
||||
stamp.write_text(stamp_content, encoding="utf-8")
|
||||
|
||||
if CORE.testing_mode:
|
||||
# A patched copy of the flash ld in the build dir; resolved through
|
||||
# the same -L path as the SDK original it shadows.
|
||||
flash_ld = framework / "tools" / "sdk" / "ld" / flash_ld_name
|
||||
write_file_if_changed(
|
||||
ld_dir / f"testing_{flash_ld_name}",
|
||||
build_surgery.apply_testing_memory_patches(
|
||||
flash_ld.read_text(encoding="utf-8"),
|
||||
require=("dram0_0_seg", "irom0_0_seg"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def get_flash_ld_path(build_dir: Path) -> Path:
|
||||
"""The flash linker script the link actually uses (for size reporting)."""
|
||||
from esphome.arduino8266.framework import (
|
||||
framework_package_version,
|
||||
get_framework_path,
|
||||
)
|
||||
|
||||
name = _flash_ld_name(CORE.data[KEY_ESP8266][KEY_BOARD])
|
||||
if CORE.testing_mode:
|
||||
return build_dir / "ld" / f"testing_{name}"
|
||||
version = framework_package_version(CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION])
|
||||
return get_framework_path(version) / "tools" / "sdk" / "ld" / name
|
||||
|
||||
|
||||
def _flash_size_str(flash_ld_name: str) -> str:
|
||||
"""Flash size for elf2bin, derived from the ld script name (PIO logic)."""
|
||||
match = re.search(r"\.flash\.(\d+)([mk])", flash_ld_name)
|
||||
if not match:
|
||||
raise EsphomeError(f"Cannot parse flash size from {flash_ld_name}")
|
||||
return f"{match.group(1)}{match.group(2).upper()}"
|
||||
@@ -0,0 +1,460 @@
|
||||
"""Drift tests for the native ESP8266 Arduino build generator.
|
||||
|
||||
These pin the build spec transliterated from the PlatformIO builder
|
||||
(framework-arduinoespressif8266/tools/platformio-build.py and
|
||||
platform-espressif8266/builder/main.py) so a change on either side of the
|
||||
toolchain seam is caught: the knob-define precedence, the define/flag sets,
|
||||
the link line, and the core source exclusions must keep matching what the
|
||||
PlatformIO toolchain produces for the same configuration.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.build_gen import arduino8266
|
||||
from esphome.build_gen.arduino8266 import (
|
||||
_defines_flags,
|
||||
_flag_defines,
|
||||
_flash_size_str,
|
||||
_resolve_build_config,
|
||||
get_flash_ld_path,
|
||||
)
|
||||
from esphome.components.esp8266.boards import BOARDS, ESP8266_BOARD_BUILD
|
||||
from esphome.components.esp8266.build_surgery import RATETABLE_RULE
|
||||
from esphome.components.esp8266.const import (
|
||||
KEY_BOARD,
|
||||
KEY_ESP8266,
|
||||
KEY_FLASH_MODE,
|
||||
KEY_SCANF_FLOAT,
|
||||
)
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION
|
||||
from esphome.core import CORE, EsphomeError
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup_core(tmp_path: Path) -> Generator[None]:
|
||||
CORE.name = "test8266"
|
||||
CORE.build_path = tmp_path
|
||||
CORE.testing_mode = False
|
||||
CORE.cpp_standard = "gnu++20"
|
||||
CORE.data[KEY_CORE] = {KEY_FRAMEWORK_VERSION: cv.Version(3, 1, 2)}
|
||||
CORE.data[KEY_ESP8266] = {
|
||||
KEY_BOARD: "nodemcuv2",
|
||||
KEY_FLASH_MODE: "dout",
|
||||
KEY_SCANF_FLOAT: False,
|
||||
}
|
||||
yield
|
||||
# CORE.reset() (the suite-wide autouse fixture) does not clear this flag
|
||||
CORE.testing_mode = False
|
||||
|
||||
|
||||
def _set_flags(*flags: str) -> None:
|
||||
CORE.build_flags = set(flags)
|
||||
|
||||
|
||||
def test_board_build_covers_every_board() -> None:
|
||||
"""Every supported board must have variant/define metadata."""
|
||||
assert set(BOARDS) <= set(ESP8266_BOARD_BUILD)
|
||||
|
||||
|
||||
def test_build_config_defaults() -> None:
|
||||
|
||||
_set_flags()
|
||||
config = _resolve_build_config(_flag_defines())
|
||||
assert config.nonosdk == "NONOSDK22x_190703"
|
||||
assert config.lwip_lib == "lwip2-536-feat"
|
||||
assert not config.exceptions
|
||||
assert config.vtables == "VTABLES_IN_FLASH"
|
||||
assert config.knob_defines == [
|
||||
"NONOSDK22x_190703=1",
|
||||
"TCP_MSS=536",
|
||||
"LWIP_FEATURES=1",
|
||||
"LWIP_IPV6=0",
|
||||
]
|
||||
assert config.mmu_defines == ["MMU_IRAM_SIZE=0x8000", "MMU_ICACHE_SIZE=0x8000"]
|
||||
|
||||
|
||||
def test_build_config_esphome_lwip_knob() -> None:
|
||||
"""The lwIP variant ESPHome selects maps to the same defines and library
|
||||
as the PlatformIO builder."""
|
||||
|
||||
_set_flags("-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH")
|
||||
config = _resolve_build_config(_flag_defines())
|
||||
assert config.lwip_lib == "lwip2-1460"
|
||||
assert "TCP_MSS=1460" in config.knob_defines
|
||||
assert "LWIP_FEATURES=0" in config.knob_defines
|
||||
assert "LWIP_IPV6=0" in config.knob_defines
|
||||
|
||||
|
||||
def test_build_config_knobs() -> None:
|
||||
|
||||
_set_flags(
|
||||
"-DPIO_FRAMEWORK_ARDUINO_ESPRESSIF_SDK305",
|
||||
"-DPIO_FRAMEWORK_ARDUINO_ENABLE_EXCEPTIONS",
|
||||
"-DPIO_FRAMEWORK_ARDUINO_MMU_CACHE16_IRAM48",
|
||||
"-DVTABLES_IN_DRAM",
|
||||
)
|
||||
config = _resolve_build_config(_flag_defines())
|
||||
assert config.nonosdk == "NONOSDK305"
|
||||
assert config.exceptions
|
||||
assert config.vtables == "VTABLES_IN_DRAM"
|
||||
assert config.mmu_defines == ["MMU_IRAM_SIZE=0xC000", "MMU_ICACHE_SIZE=0x4000"]
|
||||
|
||||
|
||||
def test_build_config_mmu_custom_requires_sizes() -> None:
|
||||
|
||||
_set_flags("-DPIO_FRAMEWORK_ARDUINO_MMU_CUSTOM")
|
||||
with pytest.raises(EsphomeError, match="MMU_IRAM_SIZE"):
|
||||
_resolve_build_config(_flag_defines())
|
||||
|
||||
_set_flags(
|
||||
"-DPIO_FRAMEWORK_ARDUINO_MMU_CUSTOM",
|
||||
"-DMMU_IRAM_SIZE=0xC000",
|
||||
"-DMMU_ICACHE_SIZE=0x4000",
|
||||
)
|
||||
config = _resolve_build_config(_flag_defines())
|
||||
# Emitted pre-sorted so build.ninja stays byte-stable across runs
|
||||
assert config.mmu_defines == [
|
||||
"MMU_ICACHE_SIZE=0x4000",
|
||||
"MMU_IRAM_SIZE=0xC000",
|
||||
]
|
||||
|
||||
|
||||
def test_defines_match_platformio_builder() -> None:
|
||||
"""The exact define set the PlatformIO builder passes for nodemcuv2/dout."""
|
||||
|
||||
_set_flags("-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH")
|
||||
assert _defines_flags(
|
||||
_resolve_build_config(_flag_defines()),
|
||||
"dout",
|
||||
"nodemcuv2",
|
||||
ESP8266_BOARD_BUILD["nodemcuv2"]["defines"],
|
||||
) == [
|
||||
"-DF_CPU=80000000L",
|
||||
"-D__ets__",
|
||||
"-DICACHE_FLASH",
|
||||
"-D_GNU_SOURCE",
|
||||
"-DARDUINO=10805",
|
||||
'-DARDUINO_BOARD=\\"PLATFORMIO_NODEMCUV2\\"',
|
||||
'-DARDUINO_BOARD_ID=\\"nodemcuv2\\"',
|
||||
"-DFLASHMODE_DOUT",
|
||||
"-DLWIP_OPEN_SRC",
|
||||
"-DNONOSDK22x_190703=1",
|
||||
"-DTCP_MSS=1460",
|
||||
"-DLWIP_FEATURES=0",
|
||||
"-DLWIP_IPV6=0",
|
||||
"-DVTABLES_IN_FLASH",
|
||||
"-DMMU_IRAM_SIZE=0x8000",
|
||||
"-DMMU_ICACHE_SIZE=0x8000",
|
||||
"-DESP8266",
|
||||
"-DARDUINO_ARCH_ESP8266",
|
||||
"-DARDUINO_ESP8266_NODEMCU_ESP12E",
|
||||
]
|
||||
|
||||
|
||||
def _make_framework(tmp_path: Path) -> dict[str, Path]:
|
||||
framework = tmp_path / "framework"
|
||||
core = framework / "cores" / "esp8266"
|
||||
core.mkdir(parents=True)
|
||||
for name in (
|
||||
"core_esp8266_main.cpp",
|
||||
"Updater.cpp",
|
||||
"core_esp8266_waveform_pwm.cpp",
|
||||
"core_esp8266_waveform_phase.cpp",
|
||||
"cont.S",
|
||||
"abi.c",
|
||||
):
|
||||
(core / name).write_text("")
|
||||
(framework / "variants" / "nodemcu").mkdir(parents=True)
|
||||
for sub in ("include", "ld", "lwip2/include", "lib"):
|
||||
(framework / "tools" / "sdk" / sub).mkdir(parents=True)
|
||||
(framework / "libraries").mkdir()
|
||||
toolchain = tmp_path / "toolchain"
|
||||
(toolchain / "bin").mkdir(parents=True)
|
||||
(toolchain / "include").mkdir()
|
||||
return {
|
||||
"framework_path": framework,
|
||||
"toolchain_path": toolchain,
|
||||
"ninja_path": Path("ninja"),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("knob", "lib", "mss", "features", "ipv6"),
|
||||
[
|
||||
("PIO_FRAMEWORK_ARDUINO_LWIP2_IPV6_LOW_MEMORY", "lwip6-536-feat", 536, 1, 1),
|
||||
(
|
||||
"PIO_FRAMEWORK_ARDUINO_LWIP2_IPV6_HIGHER_BANDWIDTH",
|
||||
"lwip6-1460-feat",
|
||||
1460,
|
||||
1,
|
||||
1,
|
||||
),
|
||||
("PIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH", "lwip2-1460-feat", 1460, 1, 0),
|
||||
("PIO_FRAMEWORK_ARDUINO_LWIP2_LOW_MEMORY_LOW_FLASH", "lwip2-536", 536, 0, 0),
|
||||
],
|
||||
)
|
||||
def test_build_config_lwip_variants(
|
||||
knob: str, lib: str, mss: int, features: int, ipv6: int
|
||||
) -> None:
|
||||
"""Every lwIP knob maps to the same defines and library as the PIO builder."""
|
||||
|
||||
_set_flags(f"-D{knob}")
|
||||
config = _resolve_build_config(_flag_defines())
|
||||
assert config.lwip_lib == lib
|
||||
assert f"TCP_MSS={mss}" in config.knob_defines
|
||||
assert f"LWIP_FEATURES={features}" in config.knob_defines
|
||||
assert f"LWIP_IPV6={ipv6}" in config.knob_defines
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("knob", "expected"),
|
||||
[
|
||||
(
|
||||
"PIO_FRAMEWORK_ARDUINO_MMU_CACHE16_IRAM48_SECHEAP_SHARED",
|
||||
["MMU_IRAM_SIZE=0xC000", "MMU_ICACHE_SIZE=0x4000", "MMU_IRAM_HEAP"],
|
||||
),
|
||||
(
|
||||
"PIO_FRAMEWORK_ARDUINO_MMU_CACHE16_IRAM32_SECHEAP_NOTSHARED",
|
||||
[
|
||||
"MMU_IRAM_SIZE=0x8000",
|
||||
"MMU_ICACHE_SIZE=0x4000",
|
||||
"MMU_SEC_HEAP_SIZE=0x4000",
|
||||
"MMU_SEC_HEAP=0x40108000",
|
||||
],
|
||||
),
|
||||
(
|
||||
"PIO_FRAMEWORK_ARDUINO_MMU_EXTERNAL_128K",
|
||||
["MMU_IRAM_SIZE=0x8000", "MMU_ICACHE_SIZE=0x8000", "MMU_EXTERNAL_HEAP=128"],
|
||||
),
|
||||
(
|
||||
"PIO_FRAMEWORK_ARDUINO_MMU_EXTERNAL_1024K",
|
||||
["MMU_IRAM_SIZE=0x8000", "MMU_ICACHE_SIZE=0x8000", "MMU_EXTERNAL_HEAP=256"],
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_build_config_mmu_variants(knob: str, expected: list[str]) -> None:
|
||||
|
||||
_set_flags(f"-D{knob}")
|
||||
assert _resolve_build_config(_flag_defines()).mmu_defines == expected
|
||||
|
||||
|
||||
def test_build_config_waveform_locked_phase() -> None:
|
||||
|
||||
_set_flags("-DPIO_FRAMEWORK_ARDUINO_WAVEFORM_LOCKED_PHASE", "-DFP_IN_IROM")
|
||||
config = _resolve_build_config(_flag_defines())
|
||||
assert "WAVEFORM_LOCKED_PHASE=1" in config.knob_defines
|
||||
assert config.fp_in_irom
|
||||
|
||||
|
||||
_COMMON_LD_H_OUTPUT = """\
|
||||
MEMORY
|
||||
{
|
||||
iram1_0_seg : org = 0x40100000, len = 0x8000
|
||||
}
|
||||
SECTIONS
|
||||
{
|
||||
.data : ALIGN(4)
|
||||
{
|
||||
_data_start = ABSOLUTE(.);
|
||||
} >dram0_0_seg :dram0_0_phdr
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def _run_generate_ld_scripts(paths: dict[str, Path]) -> Path:
|
||||
|
||||
config = _resolve_build_config(_flag_defines())
|
||||
arduino8266.generate_ld_scripts(paths, config, "eagle.flash.4m.ld")
|
||||
return CORE.relative_pioenvs_path(CORE.name, "ld")
|
||||
|
||||
|
||||
def test_generate_ld_scripts(tmp_path: Path) -> None:
|
||||
|
||||
paths = _make_framework(tmp_path)
|
||||
_set_flags("-DFP_IN_IROM")
|
||||
result = MagicMock(returncode=0, stdout=_COMMON_LD_H_OUTPUT)
|
||||
with patch.object(arduino8266.subprocess, "run", return_value=result) as mock_run:
|
||||
ld_dir = _run_generate_ld_scripts(paths)
|
||||
content = (ld_dir / "local.eagle.app.v6.common.ld").read_text()
|
||||
assert RATETABLE_RULE in content
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "-DVTABLES_IN_FLASH" in cmd
|
||||
assert "-DMMU_IRAM_SIZE=0x8000" in cmd
|
||||
assert "-DFP_IN_IROM" in cmd
|
||||
|
||||
# Unchanged inputs skip the preprocessor spawn on the next run
|
||||
with patch.object(arduino8266.subprocess, "run") as mock_run:
|
||||
_run_generate_ld_scripts(paths)
|
||||
mock_run.assert_not_called()
|
||||
|
||||
# An edit to the surgery constants invalidates the stamp (a stale linker
|
||||
# script would otherwise persist until an esphome clean)
|
||||
with (
|
||||
patch.object(arduino8266.build_surgery, "TESTING_FLASH_SIZE", "0x3000000"),
|
||||
patch.object(arduino8266.subprocess, "run", return_value=result) as mock_run,
|
||||
):
|
||||
_run_generate_ld_scripts(paths)
|
||||
mock_run.assert_called_once()
|
||||
|
||||
|
||||
def test_generate_ld_scripts_failure(tmp_path: Path) -> None:
|
||||
|
||||
paths = _make_framework(tmp_path)
|
||||
result = MagicMock(returncode=1, stderr="nope")
|
||||
with (
|
||||
patch.object(arduino8266.subprocess, "run", return_value=result),
|
||||
pytest.raises(EsphomeError, match="linker script failed"),
|
||||
):
|
||||
_run_generate_ld_scripts(paths)
|
||||
|
||||
|
||||
def test_generate_ld_scripts_testing_mode(tmp_path: Path) -> None:
|
||||
|
||||
paths = _make_framework(tmp_path)
|
||||
(paths["framework_path"] / "tools" / "sdk" / "ld" / "eagle.flash.4m.ld").write_text(
|
||||
"MEMORY\n{\n"
|
||||
" dram0_0_seg : org = 0x3FFE8000, len = 0x14000\n"
|
||||
" iram1_0_seg : org = 0x40100000, len = 0x8000\n"
|
||||
" irom0_0_seg : org = 0x40201010, len = 0xfeff0\n"
|
||||
"}\n"
|
||||
)
|
||||
CORE.testing_mode = True
|
||||
result = MagicMock(returncode=0, stdout=_COMMON_LD_H_OUTPUT)
|
||||
with patch.object(arduino8266.subprocess, "run", return_value=result):
|
||||
ld_dir = _run_generate_ld_scripts(paths)
|
||||
patched = (ld_dir / "testing_eagle.flash.4m.ld").read_text()
|
||||
assert "len = 0x2000000" in patched
|
||||
|
||||
|
||||
def test_get_flash_ld_path(tmp_path: Path) -> None:
|
||||
|
||||
CORE.testing_mode = True
|
||||
assert get_flash_ld_path(tmp_path) == (
|
||||
tmp_path / "ld" / "testing_eagle.flash.4m.ld"
|
||||
)
|
||||
|
||||
CORE.testing_mode = False
|
||||
with (
|
||||
patch(
|
||||
"esphome.arduino8266.framework.get_framework_path",
|
||||
return_value=tmp_path / "framework",
|
||||
),
|
||||
patch(
|
||||
"esphome.arduino8266.framework.framework_package_version",
|
||||
return_value="3.30102.0",
|
||||
),
|
||||
):
|
||||
assert get_flash_ld_path(tmp_path) == (
|
||||
tmp_path / "framework" / "tools" / "sdk" / "ld" / "eagle.flash.4m.ld"
|
||||
)
|
||||
|
||||
|
||||
def test_flash_size_str() -> None:
|
||||
|
||||
assert _flash_size_str("eagle.flash.4m.ld") == "4M"
|
||||
assert _flash_size_str("eagle.flash.512k.ld") == "512K"
|
||||
with pytest.raises(EsphomeError, match="Cannot parse flash size"):
|
||||
_flash_size_str("bogus.ld")
|
||||
|
||||
|
||||
def test_build_config_nonosdk_precedence() -> None:
|
||||
"""With two SDK knobs set (a pathological config), ties break
|
||||
deterministically by table order."""
|
||||
_set_flags(
|
||||
"-DPIO_FRAMEWORK_ARDUINO_ESPRESSIF_SDK305",
|
||||
"-DPIO_FRAMEWORK_ARDUINO_ESPRESSIF_SDK221",
|
||||
)
|
||||
assert _resolve_build_config(_flag_defines()).nonosdk == "NONOSDK221"
|
||||
|
||||
|
||||
def test_project_flags_trailing_bare_linker_flag_warns(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
_set_flags("-l")
|
||||
compile_flags, link_flags, lib_dirs, libs = arduino8266._project_flags()
|
||||
assert "Ignoring trailing '-l'" in caplog.text
|
||||
assert not libs
|
||||
assert not lib_dirs
|
||||
assert "-l" not in compile_flags
|
||||
assert "-l" not in link_flags
|
||||
|
||||
|
||||
def test_project_flags_lexed_entry_scatters_non_linker_tokens() -> None:
|
||||
_set_flags("-L /d -Wl,-Map=m stray")
|
||||
compile_flags, link_flags, lib_dirs, libs = arduino8266._project_flags()
|
||||
assert lib_dirs == [Path("/d")]
|
||||
assert link_flags == ["-Wl,-Map=m"]
|
||||
assert "stray" in compile_flags
|
||||
assert not libs
|
||||
|
||||
|
||||
def test_flag_defines_lexes_multi_token_entries() -> None:
|
||||
"""A knob inside a multi-token entry is detected like PlatformIO does."""
|
||||
_set_flags("-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH -DFOO=1 -Os")
|
||||
defines = _flag_defines()
|
||||
assert "PIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH" in defines
|
||||
assert defines["FOO"] == "FOO=1"
|
||||
config = _resolve_build_config(defines)
|
||||
assert config.lwip_lib == "lwip2-1460"
|
||||
|
||||
|
||||
def test_project_flags_lexes_every_entry() -> None:
|
||||
"""A linker flag anywhere in an entry reaches the link line (PIO parity)."""
|
||||
_set_flags("-DFOO=1 -lbar")
|
||||
compile_flags, _link, _dirs, libs = arduino8266._project_flags()
|
||||
assert libs == ["bar"]
|
||||
assert "-DFOO=1" in compile_flags
|
||||
|
||||
|
||||
def test_project_flags_unflags_match_tokens() -> None:
|
||||
"""build_unflags removes a token embedded in a multi-token entry."""
|
||||
_set_flags("-Os -g3")
|
||||
CORE.build_unflags = {"-Os"}
|
||||
compile_flags, _link, _dirs, _libs = arduino8266._project_flags(
|
||||
arduino8266._unflag_tokens()
|
||||
)
|
||||
assert "-g3" in compile_flags
|
||||
assert "-Os" not in compile_flags
|
||||
|
||||
|
||||
def test_project_flags_requotes_lexed_defines() -> None:
|
||||
"""A quoted spaced value stays one compiler argument after lex/emit."""
|
||||
_set_flags('-DGREETING="hello world"')
|
||||
compile_flags, _link, _dirs, _libs = arduino8266._project_flags()
|
||||
# shlex folds the quotes (as PIO's ParseFlags does); _shell_token
|
||||
# re-quotes the spaced token so the shell passes one argv element
|
||||
assert compile_flags == ['"-DGREETING=hello world"']
|
||||
|
||||
|
||||
def test_shell_token_escaping() -> None:
|
||||
"""Tokens survive both POSIX sh and the Windows CRT argv parser."""
|
||||
assert arduino8266._shell_token("-Os") == "-Os"
|
||||
# $ would be expanded (to nothing) by ninja itself
|
||||
assert arduino8266._shell_token("-DX=$HOME") == "-DX=$$HOME"
|
||||
# Backslashes not before a quote stay single (Windows path in a define)
|
||||
assert arduino8266._shell_token("-DP=C:\\x y") == '"-DP=C:\\x y"'
|
||||
# A quote is escaped and the preceding backslash run doubles
|
||||
assert arduino8266._shell_token('-DX=a\\"b c') == '"-DX=a\\\\\\"b c"'
|
||||
# A trailing backslash run doubles before the closing quote
|
||||
assert arduino8266._shell_token("a b\\") == '"a b\\\\"'
|
||||
|
||||
|
||||
def test_flag_defines_joins_spaced_define() -> None:
|
||||
"""A spaced "-D KNOB" entry is detected exactly as PlatformIO detects it."""
|
||||
_set_flags("-D PIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH")
|
||||
defines = _flag_defines()
|
||||
assert "PIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH" in defines
|
||||
assert "" not in defines
|
||||
|
||||
|
||||
def test_ninja_path_escaping() -> None:
|
||||
"""Build-statement paths and command-line paths escape differently."""
|
||||
assert arduino8266._e("a b:$c") == "a$ b$:$$c"
|
||||
assert arduino8266._q("/a b/$x") == '"/a b/$$x"'
|
||||
Reference in New Issue
Block a user