From 6b5b13c17ef5b21732bda5b185526e28cbfa3168 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 23 Aug 2026 13:51:54 -0500 Subject: [PATCH 1/3] Pin the LOW_MEMORY lwIP fall-through, veto the stamp on a stuck stale note LOW_MEMORY is upstream's else branch, so the default variant stands in for it and every listed knob wins -- pinned for the ordinary SNTP multi-server config where esp8266's HIGHER_BANDWIDTH_LOW_FLASH must prevail. A stale warn note that cannot be removed now vetoes the stamp (mirror of the lost-note case), and the unreadable-cached-note warning names its cause. --- esphome/build_gen/arduino8266.py | 17 +++++-- .../unit_tests/build_gen/test_arduino8266.py | 45 +++++++++++++++++++ 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/esphome/build_gen/arduino8266.py b/esphome/build_gen/arduino8266.py index 6d6ae49b10..389803c417 100644 --- a/esphome/build_gen/arduino8266.py +++ b/esphome/build_gen/arduino8266.py @@ -14,7 +14,6 @@ from the build flags with the same precedence as the PlatformIO builder. from __future__ import annotations -from contextlib import suppress from dataclasses import dataclass import hashlib import logging @@ -120,6 +119,10 @@ _LWIP_VARIANTS = { 1460, 0, 0, "lwip2-1460" ), } +# The default is PIO_FRAMEWORK_ARDUINO_LWIP2_LOW_MEMORY's variant: upstream +# has no branch for that spelling (it is the else), so any listed knob wins +# over it -- sntp emits LOW_MEMORY while esp8266 always emits +# HIGHER_BANDWIDTH_LOW_FLASH, and the latter must win as under PlatformIO _LWIP_DEFAULT = _LwipVariant(536, 1, 0, "lwip2-536-feat") # Knob define -> MMU_* defines; first match wins, in insertion order (as @@ -633,8 +636,13 @@ def generate_ld_scripts( _LOGGER.warning("Linker-script preprocessor: %s", result.stderr.strip()) note_persisted = _write_note(stderr_note, result.stderr.strip(), warn=True) else: - with suppress(OSError): + try: stderr_note.unlink(missing_ok=True) + except OSError as err: + # A kept stale note would re-emit an obsolete diagnostic on + # every cache hit; skip the stamp so -E re-derives the truth + _LOGGER.debug("Could not remove %s: %s", stderr_note, err) + note_persisted = False if "SECTIONS" not in result.stdout: # A degenerate zero-exit run must not be stamped as a good cache raise EsphomeError( @@ -660,11 +668,12 @@ def generate_ld_scripts( "Linker-script preprocessor: %s", stderr_note.read_text(encoding="utf-8"), ) - except (OSError, UnicodeDecodeError): + except (OSError, UnicodeDecodeError) as err: _LOGGER.warning( "A cached linker-script preprocessor diagnostic exists at %s " - "but could not be read", + "but could not be read: %s", stderr_note, + err, ) if CORE.testing_mode: diff --git a/tests/unit_tests/build_gen/test_arduino8266.py b/tests/unit_tests/build_gen/test_arduino8266.py index 810c1bad12..057c451807 100644 --- a/tests/unit_tests/build_gen/test_arduino8266.py +++ b/tests/unit_tests/build_gen/test_arduino8266.py @@ -219,6 +219,8 @@ def _make_framework(tmp_path: Path) -> InstalledPaths: ), ("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), + # LOW_MEMORY has no upstream branch: it is the default (else) variant + ("PIO_FRAMEWORK_ARDUINO_LWIP2_LOW_MEMORY", "lwip2-536-feat", 536, 1, 0), ], ) def test_build_config_lwip_variants( @@ -233,6 +235,17 @@ def test_build_config_lwip_variants( assert f"LWIP_IPV6={ipv6}" in config.knob_defines +def test_lwip_low_memory_loses_to_listed_knobs() -> None: + """The ordinary SNTP multi-server config: sntp emits LOW_MEMORY, esp8266 + always emits HIGHER_BANDWIDTH_LOW_FLASH, and the listed knob must win + exactly as in platformio-build.py's elif chain.""" + config = _resolve( + "-DPIO_FRAMEWORK_ARDUINO_LWIP2_LOW_MEMORY", + "-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH", + ) + assert config.lwip_lib == "lwip2-1460" + + @pytest.mark.parametrize( ("knob", "expected"), [ @@ -657,6 +670,38 @@ def test_generate_ld_scripts_lost_warn_note_vetoes_the_stamp( assert caplog.text.count("Linker-script preprocessor: warning: something") == 2 +def test_generate_ld_scripts_unremovable_stale_note_vetoes_the_stamp( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A stale warn note that cannot be removed skips the stamp, so the + obsolete diagnostic is not re-emitted on cache hits forever.""" + paths = _make_framework(tmp_path) + _set_flags() + warn = MagicMock(returncode=0, stdout=_COMMON_LD_H_OUTPUT, stderr="warning: old") + clean = MagicMock(returncode=0, stdout=_COMMON_LD_H_OUTPUT, stderr="") + with patch.object(arduino8266.subprocess, "run", return_value=warn): + _run_generate_ld_scripts(paths) + + real_unlink = Path.unlink + + def fail_note_unlink(self: Path, missing_ok: bool = False) -> None: + if self.name.endswith(".stderr"): + raise OSError("locked") + real_unlink(self, missing_ok=missing_ok) + + # Flags changed -> regenerate; clean stderr but the stale note is stuck + _set_flags("-DVTABLES_IN_DRAM") + with ( + patch.object(arduino8266.subprocess, "run", return_value=clean), + patch.object(Path, "unlink", fail_note_unlink), + ): + _run_generate_ld_scripts(paths) + # Unstamped: the next build re-runs -E instead of trusting the cache + with patch.object(arduino8266.subprocess, "run", return_value=clean) as run3: + _run_generate_ld_scripts(paths) + run3.assert_called_once() + + def test_build_config_mmu_knob_with_raw_mmu_flag_raises() -> None: """A variant knob plus a raw MMU_* define would split the compile line from the linker script; refuse like the no-knob case.""" From abcfaa18100b8959f9f1d26e0a585a2f5c6323b8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 23 Aug 2026 13:54:22 -0500 Subject: [PATCH 2/3] Share the per-board ldscript rule via boards.board_ld_script One source of truth for the PlatformIO pinning and the native generator's fallback, so the per-board rule cannot drift. --- esphome/components/esp8266/__init__.py | 4 ++-- esphome/components/esp8266/boards.py | 13 +++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index affd8a7177..369489b6a9 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -35,7 +35,7 @@ from esphome.platformio.toolchain import copy_ccache_script from esphome.storage_json import StorageJSON from esphome.types import ConfigType -from .boards import BOARDS, ESP8266_LD_SCRIPTS +from .boards import BOARDS, ESP8266_LD_SCRIPTS, board_ld_script from .const import ( CONF_EARLY_PIN_INIT, CONF_ENABLE_SERIAL, @@ -409,7 +409,7 @@ async def to_code(config: ConfigType) -> None: else: # A per-board override preserves a layout the board shipped # with (see d1_wroom_02 in boards.py) - ld_script = board_data.get("ldscript", ld_scripts[1]) + ld_script = board_ld_script(board_data) if ld_script is not None: cg.add_platformio_option("board_build.ldscript", ld_script) diff --git a/esphome/components/esp8266/boards.py b/esphome/components/esp8266/boards.py index 397de60e24..35d9d30e8c 100644 --- a/esphome/components/esp8266/boards.py +++ b/esphome/components/esp8266/boards.py @@ -1,3 +1,5 @@ +from .const import KEY_FLASH_SIZE + FLASH_SIZE_1_MB = 2**20 FLASH_SIZE_512_KB = FLASH_SIZE_1_MB // 2 FLASH_SIZE_2_MB = 2 * FLASH_SIZE_1_MB @@ -182,6 +184,17 @@ for x in platform-espressif8266/boards/*.json; do done | sort """ + +def board_ld_script(board_data: dict) -> str: + """The modern (core > 2.4.2) flash linker script for a board: its + shipped-layout override, else the size default (the no-FS layout). + + Single source of truth for the PlatformIO pinning in __init__ and the + native generator's fallback, so the per-board rule cannot drift. + """ + return board_data.get("ldscript", ESP8266_LD_SCRIPTS[board_data[KEY_FLASH_SIZE]][1]) + + BOARDS = { "agruminolemon": { "name": "Lifely Agrumino Lemon v4", From 4cc3012d9857d8f00c5e61436877d9d5e4613f8e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 23 Aug 2026 13:56:27 -0500 Subject: [PATCH 3/3] Read build_src_flags from its producer, share the ldscript rule, shape-check flash_mode The throw_stubs force-include now comes from the build_src_flags option esp8266/__init__ pins, collapsing the two spellings to one source of truth; -include paths resolve against the source root. The ldscript fallback consumes boards.board_ld_script instead of duplicating the per-board rule, flash_mode is checked against its closed set before landing unquoted in the elf2bin command, and the board-gate comment no longer names a nonexistent validator. --- esphome/build_gen/arduino8266.py | 40 +++++++++++++------ .../unit_tests/build_gen/test_arduino8266.py | 4 ++ 2 files changed, 31 insertions(+), 13 deletions(-) diff --git a/esphome/build_gen/arduino8266.py b/esphome/build_gen/arduino8266.py index d6cdbdb8f6..0a07d2c958 100644 --- a/esphome/build_gen/arduino8266.py +++ b/esphome/build_gen/arduino8266.py @@ -34,7 +34,7 @@ from esphome.components.esp8266 import build_surgery from esphome.components.esp8266.boards import ( BOARDS, ESP8266_BOARD_BUILD, - ESP8266_LD_SCRIPTS, + board_ld_script, ) from esphome.components.esp8266.const import ( KEY_BOARD, @@ -107,6 +107,8 @@ def _apply_surgery(fn, *args: object) -> str: # Every supported board's f_flash is 40 MHz; re-check on a platform bump +# board_flash_mode's closed set (cv.one_of in esp8266/__init__.py) +_FLASH_MODES = frozenset({"qio", "qout", "dio", "dout"}) _FLASH_FREQ_MHZ = 40 # From platformio-build.py. Knob suffix -> SDK define; the first entry is @@ -454,12 +456,9 @@ def _flash_ld_name(board: str) -> str: """ override = _pio_option("board_build.ldscript", "") if not override: - # The same per-board override the PlatformIO path pins (layout - # preservation, see boards.py) - board_data = BOARDS[board] - return board_data.get( - "ldscript", ESP8266_LD_SCRIPTS[board_data[KEY_FLASH_SIZE]][1] - ) + # The same shared rule the PlatformIO path pins (layout + # preservation, see boards.board_ld_script) + return board_ld_script(BOARDS[board]) if Path(override).name != override: raise EsphomeError( f"board_build.ldscript must be a bare script name, got {override!r}" @@ -826,12 +825,17 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool: config = _resolve_build_config(flag_defines) esp8266_data = CORE.data[KEY_ESP8266] board = esp8266_data[KEY_BOARD] - # Config validation (_validate_native_toolchain) already gates boards; + # Config validation already gates boards; # kept as defense-in-depth for direct calls, since CONF_BOARD itself is # a free-form string if board not in ESP8266_BOARD_BUILD: raise EsphomeError(f"Board '{board}' is not supported by the native toolchain") board_build = ESP8266_BOARD_BUILD[board] + flash_mode = esp8266_data[KEY_FLASH_MODE] + if flash_mode not in _FLASH_MODES: + # Lands unquoted in the elf2bin command and a -D body; validation + # (cv.one_of on board_flash_mode) already gates it, defense-in-depth + raise EsphomeError(f"Invalid flash mode {flash_mode!r}") flash_ld_name = _flash_ld_name(board) generate_ld_scripts(paths, config, flash_ld_name) @@ -885,9 +889,7 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool: project_lib_dirs, project_libs, ) = _project_flags(unflags, build_tokens) - defines = _defines_flags( - config, esp8266_data[KEY_FLASH_MODE], board, board_build["defines"] - ) + defines = _defines_flags(config, flash_mode, board, board_build["defines"]) includes = [f"-I{_q(d)}" for d in include_dirs] common = _CCFLAGS + defines + includes + project_compile_flags @@ -1004,7 +1006,7 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool: "rule elf2bin", # --flash_size deliberately stays board-derived, as under # PlatformIO (which reads upload.maximum_size, not the ldscript). - f" command = $python {_q(framework / 'tools' / 'elf2bin.py')} --eboot {_q(framework / 'bootloaders' / 'eboot' / 'eboot.elf')} --app $in --flash_mode {esp8266_data[KEY_FLASH_MODE]} --flash_freq {_FLASH_FREQ_MHZ} --flash_size {_flash_size_str(BOARDS[board][KEY_FLASH_SIZE])} --path {_q(toolchain_bin)} --out $out", + f" command = $python {_q(framework / 'tools' / 'elf2bin.py')} --eboot {_q(framework / 'bootloaders' / 'eboot' / 'eboot.elf')} --app $in --flash_mode {flash_mode} --flash_freq {_FLASH_FREQ_MHZ} --flash_size {_flash_size_str(BOARDS[board][KEY_FLASH_SIZE])} --path {_q(toolchain_bin)} --out $out", " description = BIN $out", "rule copy", " command = $python $buildtool copy $in $out", @@ -1071,7 +1073,19 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool: lines.append(f"build {_e(archive)}: ar {' '.join(objs)}") archives.append(archive) - src_extra = f"-include {_q(src_dir / 'esphome' / 'components' / 'esp8266' / 'throw_stubs.h')}" + # One source of truth with the PlatformIO path: esp8266/__init__ pins + # build_src_flags (the throw_stubs force-include); -include paths + # resolve against the source root + src_parts: list[str] = [] + src_it = iter( + lex_build_flags(_pio_option("build_src_flags", ""), "build_src_flags") + ) + for tok in src_it: + if tok == "-include": + src_parts.append(f"-include {_q(src_dir / next(src_it, ''))}") + else: + src_parts.append(_shell_token(tok)) + src_extra = " ".join(src_parts) # One shared variable instead of repeating the flags line on every src # edge (hundreds of edges in a real project) lines.append(f"srcflags = {src_extra}") diff --git a/tests/unit_tests/build_gen/test_arduino8266.py b/tests/unit_tests/build_gen/test_arduino8266.py index 4d8dcd2f2a..b6fcbd32b3 100644 --- a/tests/unit_tests/build_gen/test_arduino8266.py +++ b/tests/unit_tests/build_gen/test_arduino8266.py @@ -51,6 +51,10 @@ def _setup_core(tmp_path: Path) -> Generator[None]: KEY_FLASH_MODE: "dout", KEY_SCANF_FLOAT: False, } + # The producer esp8266/__init__ pins unconditionally + CORE.platformio_options = { + "build_src_flags": "-include esphome/components/esp8266/throw_stubs.h" + } yield # CORE.reset() (the suite-wide autouse fixture) does not clear this flag CORE.testing_mode = False