From cdd8a16cb96250017c9779f5488c6dabb7614f64 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 20 Aug 2026 12:53:50 -0500 Subject: [PATCH 01/12] Add the ESP8266 linker-script surgery and board build metadata --- esphome/components/esp8266/boards.py | 111 ++++++++++++++++++ esphome/components/esp8266/build_surgery.py | 92 +++++++++++++++ esphome/components/esp8266/const.py | 3 + .../components/esp8266/test_build_surgery.py | 87 ++++++++++++++ 4 files changed, 293 insertions(+) create mode 100644 esphome/components/esp8266/build_surgery.py create mode 100644 tests/unit_tests/components/esp8266/test_build_surgery.py diff --git a/esphome/components/esp8266/boards.py b/esphome/components/esp8266/boards.py index 02bfa9e662..e5a9628e90 100644 --- a/esphome/components/esp8266/boards.py +++ b/esphome/components/esp8266/boards.py @@ -360,3 +360,114 @@ BOARDS = { "flash_size": FLASH_SIZE_4_MB, }, } + +""" +ESP8266_BOARD_BUILD generate with: + +git clone https://github.com/platformio/platform-espressif8266 +python3 - <<'EOF' +import json, glob, os +for f in sorted(glob.glob("platform-espressif8266/boards/*.json")): + b = json.load(open(f))["build"] + extra = b["extra_flags"] + extra = extra.split() if isinstance(extra, str) else extra + defines = [ + e[2:] for e in extra if e not in ("-DESP8266", "-DARDUINO_ARCH_ESP8266") + ] + entries = ", ".join(f'"{d}"' for d in defines) + ("," if len(defines) == 1 else "") + board = os.path.splitext(os.path.basename(f))[0] + print(f' "{board}": {{"variant": "{b["variant"]}", "defines": ({entries})}},') +EOF +""" + +# Per-board Arduino core build metadata for the native (PlatformIO-free) +# toolchain: the variant directory (supplies pins_arduino.h) and the +# board-identity defines the PlatformIO builder passes via build.extra_flags. +# -DESP8266 and -DARDUINO_ARCH_ESP8266 are shared by every board and added by +# the generator; only the per-board defines are listed here. +ESP8266_BOARD_BUILD = { + "agruminolemon": { + "variant": "agruminolemonv4", + "defines": ("ARDUINO_ESP8266_AGRUMINO_LEMON_V4",), + }, + "d1": {"variant": "d1", "defines": ("ARDUINO_ESP8266_WEMOS_D1R1",)}, + "d1_mini": {"variant": "d1_mini", "defines": ("ARDUINO_ESP8266_WEMOS_D1MINI",)}, + "d1_mini_lite": { + "variant": "d1_mini", + "defines": ("ARDUINO_ESP8266_WEMOS_D1MINILITE",), + }, + "d1_mini_pro": { + "variant": "d1_mini", + "defines": ("ARDUINO_ESP8266_WEMOS_D1MINIPRO",), + }, + "d1_wroom_02": { + "variant": "d1_mini", + "defines": ("ARDUINO_ESP8266_WEMOS_D1WROOM02",), + }, + "eduinowifi": { + "variant": "eduinowifi", + "defines": ("ARDUINO_ESP8266_SCHIRMILABS_EDUINO_WIFI",), + }, + "esp01": {"variant": "generic", "defines": ("ARDUINO_ESP8266_ESP01",)}, + "esp01_1m": {"variant": "generic", "defines": ("ARDUINO_ESP8266_ESP01",)}, + "esp07": {"variant": "generic", "defines": ("ARDUINO_ESP8266_ESP07",)}, + "esp07s": {"variant": "nodemcu", "defines": ("ARDUINO_ESP8266_ESP07",)}, + "esp12e": {"variant": "nodemcu", "defines": ("ARDUINO_ESP8266_ESP12",)}, + "esp210": {"variant": "generic", "defines": ("ARDUINO_ESP8266_ESP210",)}, + "esp8285": {"variant": "esp8285", "defines": ("ARDUINO_ESP8266_ESP01",)}, + "esp_wroom_02": { + "variant": "nodemcu", + "defines": ("ARDUINO_ESP8266_ESP_WROOM_02",), + }, + "espduino": {"variant": "ESPDuino", "defines": ("ARDUINO_ESP8266_ESP13",)}, + "espectro": {"variant": "espectro", "defines": ("ARDUINO_ESP8266_ESPECTRO_CORE",)}, + "espino": {"variant": "espino", "defines": ("ARDUINO_ESP8266_ESP12",)}, + "espinotee": {"variant": "espinotee", "defines": ("ARDUINO_ESP8266_ESP13",)}, + "espmxdevkit": { + "variant": "esp8285", + "defines": ("ARDUINO_ESP8266_ESP01", "LED_BUILTIN=16"), + }, + "espresso_lite_v1": { + "variant": "espresso_lite_v1", + "defines": ("ARDUINO_ESP8266_ESPRESSO_LITE_V1",), + }, + "espresso_lite_v2": { + "variant": "espresso_lite_v2", + "defines": ("ARDUINO_ESP8266_ESPRESSO_LITE_V2",), + }, + "gen4iod": {"variant": "generic", "defines": ("ARDUINO_GEN4_IOD",)}, + "heltec_wifi_kit_8": { + "variant": "wifi_kit_8", + "defines": ("ARDUINO_wifi_kit_8",), + }, + "huzzah": {"variant": "adafruit", "defines": ("ARDUINO_ESP8266_ADAFRUIT_HUZZAH",)}, + "inventone": {"variant": "inventone", "defines": ("ARDUINO_ESP8266_INVENT_ONE",)}, + "modwifi": {"variant": "generic", "defines": ("ARDUINO_MOD_WIFI_ESP8266",)}, + "nodemcu": {"variant": "nodemcu", "defines": ("ARDUINO_ESP8266_NODEMCU",)}, + "nodemcuv2": {"variant": "nodemcu", "defines": ("ARDUINO_ESP8266_NODEMCU_ESP12E",)}, + "oak": {"variant": "oak", "defines": ("ARDUINO_ESP8266_OAK",)}, + "phoenix_v1": { + "variant": "phoenix_v1", + "defines": ("ARDUINO_ESP8266_PHOENIX_V1",), + }, + "phoenix_v2": { + "variant": "phoenix_v2", + "defines": ("ARDUINO_ESP8266_PHOENIX_V2",), + }, + "sonoff_basic": {"variant": "itead", "defines": ("ARDUINO_ESP8266_SONOFF_BASIC",)}, + "sonoff_s20": {"variant": "itead", "defines": ("ARDUINO_ESP8266_SONOFF_S20",)}, + "sonoff_sv": {"variant": "itead", "defines": ("ARDUINO_ESP8266_SONOFF_SV",)}, + "sonoff_th": {"variant": "itead", "defines": ("ARDUINO_ESP8266_SONOFF_TH",)}, + "sparkfunBlynk": {"variant": "thing", "defines": ("ARDUINO_ESP8266_THING",)}, + "thing": {"variant": "thing", "defines": ("ARDUINO_ESP8266_THING",)}, + "thingdev": {"variant": "thing", "defines": ("ARDUINO_ESP8266_THING_DEV",)}, + "wifi_slot": {"variant": "wifi_slot", "defines": ("ARDUINO_AMPERKA_WIFI_SLOT",)}, + "wifiduino": {"variant": "wifiduino", "defines": ("ARDUINO_WIFIDUINO_ESP8266",)}, + "wifinfo": {"variant": "wifinfo", "defines": ("ARDUINO_WIFINFO",)}, + "wio_link": {"variant": "wiolink", "defines": ("ARDUINO_ESP8266_WIO_LINK",)}, + "wio_node": {"variant": "nodemcu", "defines": ("ARDUINO_ESP8266_ESP_WROOM_02",)}, + "xinabox_cw01": { + "variant": "xinabox", + "defines": ("ARDUINO_ESP8266_XINABOX_CW01",), + }, +} diff --git a/esphome/components/esp8266/build_surgery.py b/esphome/components/esp8266/build_surgery.py new file mode 100644 index 0000000000..039ecaf0c6 --- /dev/null +++ b/esphome/components/esp8266/build_surgery.py @@ -0,0 +1,92 @@ +"""Linker-script surgery shared with the native (PlatformIO-free) toolchain. + +These mirror the PlatformIO extra scripts in this directory +(``relocate_ratetable.py.script`` and ``testing_mode.py.script``), which run +inside SCons and must stay self-contained. The native build generator applies +the same patches to the linker scripts it generates, so the logic lives here +as plain functions. Keep both in sync when changing either. +""" + +from __future__ import annotations + +from collections.abc import Collection +import re + +# Move the NONOS SDK wifi rate tables from flash to DRAM; see +# relocate_ratetable.py.script for the full background (NONOS SDK issue 320). +RATETABLE_RULE = "*libnet80211.a:ieee80211_phy.o(.irom.text .irom.text.*)" +# Match the whole line: "_data_start" is also a substring of the +# "_dport0_data_start" line in the earlier .dport0.data section +_RATETABLE_ANCHOR = re.compile(r"^\s*_data_start = ABSOLUTE\(\.\);", re.MULTILINE) + +# Memory sizes for testing mode (allow larger builds for CI component grouping) +TESTING_IRAM_SIZE = "0x200000" # 2MB +TESTING_DRAM_SIZE = "0x200000" # 2MB +TESTING_FLASH_SIZE = "0x2000000" # 32MB + + +def relocate_ratetable(content: str) -> str: + """Insert the rate-table DRAM rule into a generated common linker script.""" + if RATETABLE_RULE in content: + return content + match = _RATETABLE_ANCHOR.search(content) + if match is None: + raise RuntimeError( + "'_data_start' anchor not found in the generated linker script; " + "cannot apply wifi rate table DRAM relocation " + "(has the Arduino core linker script changed?)" + ) + insert_pos = match.end() + return ( + content[:insert_pos] + + "\n /* ESPHome: wifi rate tables must live in DRAM, see NONOS SDK issue 320 */" + + f"\n {RATETABLE_RULE}" + + content[insert_pos:] + ) + + +_TESTING_SEGMENT_SIZES = ( + ("iram1_0_seg", TESTING_IRAM_SIZE), + ("dram0_0_seg", TESTING_DRAM_SIZE), + ("irom0_0_seg", TESTING_FLASH_SIZE), +) + + +def _patch_segment_size(content: str, segment_name: str, new_size: str) -> str: + pattern = ( + rf"({segment_name}\s*:\s*org\s*=\s*0x[0-9a-fA-F]+\s*,\s*len\s*=\s*)" + r"0x[0-9a-fA-F]+" + ) + return re.sub(pattern, rf"\g<1>{new_size}", content) + + +def apply_testing_memory_patches(content: str, require: Collection[str]) -> str: + """Enlarge IRAM/DRAM/flash segments so grouped CI test builds can link. + + ``require`` names the segments this file must define; a silently + unpatched segment would keep the real memory limits and fail grouped + builds far from the cause. The segments are split across the two linker + scripts (iram1_0_seg in the generated common one, dram0_0_seg and + irom0_0_seg in the flash one), so each caller requires only its own. + """ + missing = set(require) + for segment, size in _TESTING_SEGMENT_SIZES: + patched = _patch_segment_size(content, segment, size) + if patched != content: + missing.discard(segment) + content = patched + if missing: + raise RuntimeError( + f"Testing-mode memory patch failed: segment(s) {', '.join(sorted(missing))} " + "not found (has the Arduino core linker script changed?)" + ) + return content + + +def segment_length(content: str, segment_name: str) -> int | None: + """Read a memory segment's length from linker script content.""" + match = re.search( + rf"{segment_name}\s*:.+len\s*=\s*(0x[\da-fA-F]+)", + content, + ) + return int(match.group(1), 16) if match else None diff --git a/esphome/components/esp8266/const.py b/esphome/components/esp8266/const.py index 3e89ab989f..8729007e3a 100644 --- a/esphome/components/esp8266/const.py +++ b/esphome/components/esp8266/const.py @@ -15,6 +15,9 @@ CONF_ENABLE_SERIAL1 = "enable_serial1" KEY_WAVEFORM_REQUIRED = "waveform_required" KEY_SERIAL_REQUIRED = "serial_required" KEY_SERIAL1_REQUIRED = "serial1_required" +# Set for the native (non-PlatformIO) toolchain's build generator +KEY_FLASH_MODE = "flash_mode" +KEY_SCANF_FLOAT = "scanf_float" # esp8266 namespace is already defined by arduino, manually prefix esphome esp8266_ns = cg.global_ns.namespace("esphome").namespace("esp8266") diff --git a/tests/unit_tests/components/esp8266/test_build_surgery.py b/tests/unit_tests/components/esp8266/test_build_surgery.py new file mode 100644 index 0000000000..200954494b --- /dev/null +++ b/tests/unit_tests/components/esp8266/test_build_surgery.py @@ -0,0 +1,87 @@ +"""Tests for the linker-script surgery shared with the native toolchain.""" + +from __future__ import annotations + +import pytest + +from esphome.components.esp8266.build_surgery import ( + RATETABLE_RULE, + apply_testing_memory_patches, + relocate_ratetable, +) + +_COMMON_LD_SNIPPET = """\ + .dport0.data : ALIGN(4) + { + _dport0_data_start = ABSOLUTE(.); + } >dport0_0_seg :dport0_0_phdr + .data : ALIGN(4) + { + _data_start = ABSOLUTE(.); + *(.data) + } >dram0_0_seg :dram0_0_phdr +""" + +_FLASH_LD_SNIPPET = """\ +MEMORY +{ + dport0_0_seg : org = 0x3FF00000, len = 0x10 + dram0_0_seg : org = 0x3FFE8000, len = 0x14000 + iram1_0_seg : org = 0x40100000, len = 0x8000 + irom0_0_seg : org = 0x40201010, len = 0xfeff0 +} +""" + + +def test_relocate_ratetable_inserts_after_data_start() -> None: + patched = relocate_ratetable(_COMMON_LD_SNIPPET) + assert RATETABLE_RULE in patched + # Inserted after the .data section's anchor, not the .dport0.data one + assert patched.index("_data_start = ABSOLUTE(.);") < patched.index(RATETABLE_RULE) + assert patched.index(RATETABLE_RULE) < patched.index("*(.data)") + # Idempotent on an already-patched script + assert relocate_ratetable(patched) == patched + + +def test_relocate_ratetable_requires_anchor() -> None: + with pytest.raises(RuntimeError, match="_data_start"): + relocate_ratetable("SECTIONS { }") + + +def test_testing_memory_patches_enlarge_segments() -> None: + patched = apply_testing_memory_patches(_FLASH_LD_SNIPPET, require=()) + assert ( + "iram1_0_seg : org = 0x40100000, len = 0x200000" + in patched + ) + assert ( + "dram0_0_seg : org = 0x3FFE8000, len = 0x200000" + in patched + ) + assert ( + "irom0_0_seg : org = 0x40201010, len = 0x2000000" + in patched + ) + + +def test_segment_length() -> None: + from esphome.components.esp8266.build_surgery import segment_length + + assert segment_length(_FLASH_LD_SNIPPET, "irom0_0_seg") == 0xFEFF0 + assert segment_length(_FLASH_LD_SNIPPET, "missing_seg") is None + + +def test_testing_memory_patches_require() -> None: + """A required segment the patch could not find raises instead of + silently keeping the real memory limits.""" + patched = apply_testing_memory_patches( + _FLASH_LD_SNIPPET, require=("dram0_0_seg", "irom0_0_seg") + ) + assert "0x2000000" in patched + with pytest.raises(RuntimeError, match="dram0_0_seg, irom0_0_seg"): + apply_testing_memory_patches( + "MEMORY { }", require=("dram0_0_seg", "irom0_0_seg") + ) + # Segments a file does not require are patched opportunistically only + # With nothing required, unmatched content passes through unchanged + assert apply_testing_memory_patches("MEMORY { }", require=()) == "MEMORY { }" From 5f3d73720e54df216be2e4c16623356d7b55391d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 20 Aug 2026 13:44:12 -0500 Subject: [PATCH 02/12] Patch exactly the named segments and share the segment-line pattern --- esphome/components/esp8266/build_surgery.py | 56 +++++++++---------- .../components/esp8266/test_build_surgery.py | 48 +++++++--------- 2 files changed, 45 insertions(+), 59 deletions(-) diff --git a/esphome/components/esp8266/build_surgery.py b/esphome/components/esp8266/build_surgery.py index 039ecaf0c6..9aeee79216 100644 --- a/esphome/components/esp8266/build_surgery.py +++ b/esphome/components/esp8266/build_surgery.py @@ -5,6 +5,7 @@ These mirror the PlatformIO extra scripts in this directory inside SCons and must stay self-contained. The native build generator applies the same patches to the linker scripts it generates, so the logic lives here as plain functions. Keep both in sync when changing either. +``segment_length`` is native-toolchain-only and has no script twin. """ from __future__ import annotations @@ -45,48 +46,41 @@ def relocate_ratetable(content: str) -> str: ) -_TESTING_SEGMENT_SIZES = ( - ("iram1_0_seg", TESTING_IRAM_SIZE), - ("dram0_0_seg", TESTING_DRAM_SIZE), - ("irom0_0_seg", TESTING_FLASH_SIZE), -) +_TESTING_SEGMENT_SIZES = { + "iram1_0_seg": TESTING_IRAM_SIZE, + "dram0_0_seg": TESTING_DRAM_SIZE, + "irom0_0_seg": TESTING_FLASH_SIZE, +} -def _patch_segment_size(content: str, segment_name: str, new_size: str) -> str: - pattern = ( +def _segment_line_re(segment_name: str) -> re.Pattern[str]: + """The MEMORY line for one segment: `` : org = 0x..., len = 0x...``.""" + return re.compile( rf"({segment_name}\s*:\s*org\s*=\s*0x[0-9a-fA-F]+\s*,\s*len\s*=\s*)" - r"0x[0-9a-fA-F]+" + r"(0x[0-9a-fA-F]+)" ) - return re.sub(pattern, rf"\g<1>{new_size}", content) -def apply_testing_memory_patches(content: str, require: Collection[str]) -> str: - """Enlarge IRAM/DRAM/flash segments so grouped CI test builds can link. +def apply_testing_memory_patches(content: str, segments: Collection[str]) -> str: + """Enlarge the named memory segments so grouped CI test builds can link. - ``require`` names the segments this file must define; a silently - unpatched segment would keep the real memory limits and fail grouped - builds far from the cause. The segments are split across the two linker - scripts (iram1_0_seg in the generated common one, dram0_0_seg and - irom0_0_seg in the flash one), so each caller requires only its own. + Each caller passes the segments its linker script defines; a segment + that fails to match raises, since a silently kept real memory limit + would fail grouped builds far from the cause. """ - missing = set(require) - for segment, size in _TESTING_SEGMENT_SIZES: - patched = _patch_segment_size(content, segment, size) - if patched != content: - missing.discard(segment) - content = patched - if missing: - raise RuntimeError( - f"Testing-mode memory patch failed: segment(s) {', '.join(sorted(missing))} " - "not found (has the Arduino core linker script changed?)" + for segment in segments: + content, count = _segment_line_re(segment).subn( + rf"\g<1>{_TESTING_SEGMENT_SIZES[segment]}", content ) + if count == 0: + raise RuntimeError( + f"Testing-mode memory patch failed: segment {segment} " + "not found (has the Arduino core linker script changed?)" + ) return content def segment_length(content: str, segment_name: str) -> int | None: """Read a memory segment's length from linker script content.""" - match = re.search( - rf"{segment_name}\s*:.+len\s*=\s*(0x[\da-fA-F]+)", - content, - ) - return int(match.group(1), 16) if match else None + match = _segment_line_re(segment_name).search(content) + return int(match.group(2), 16) if match else None diff --git a/tests/unit_tests/components/esp8266/test_build_surgery.py b/tests/unit_tests/components/esp8266/test_build_surgery.py index 200954494b..0e5a26294a 100644 --- a/tests/unit_tests/components/esp8266/test_build_surgery.py +++ b/tests/unit_tests/components/esp8266/test_build_surgery.py @@ -4,10 +4,12 @@ from __future__ import annotations import pytest +from esphome.components.esp8266.boards import BOARDS, ESP8266_BOARD_BUILD from esphome.components.esp8266.build_surgery import ( RATETABLE_RULE, apply_testing_memory_patches, relocate_ratetable, + segment_length, ) _COMMON_LD_SNIPPET = """\ @@ -49,39 +51,29 @@ def test_relocate_ratetable_requires_anchor() -> None: def test_testing_memory_patches_enlarge_segments() -> None: - patched = apply_testing_memory_patches(_FLASH_LD_SNIPPET, require=()) - assert ( - "iram1_0_seg : org = 0x40100000, len = 0x200000" - in patched - ) - assert ( - "dram0_0_seg : org = 0x3FFE8000, len = 0x200000" - in patched - ) - assert ( - "irom0_0_seg : org = 0x40201010, len = 0x2000000" - in patched + patched = apply_testing_memory_patches( + _FLASH_LD_SNIPPET, ("iram1_0_seg", "dram0_0_seg", "irom0_0_seg") ) + assert segment_length(patched, "iram1_0_seg") == 0x200000 + assert segment_length(patched, "dram0_0_seg") == 0x200000 + assert segment_length(patched, "irom0_0_seg") == 0x2000000 + # Untouched segments keep their sizes + assert segment_length(patched, "dport0_0_seg") == 0x10 def test_segment_length() -> None: - from esphome.components.esp8266.build_surgery import segment_length - assert segment_length(_FLASH_LD_SNIPPET, "irom0_0_seg") == 0xFEFF0 assert segment_length(_FLASH_LD_SNIPPET, "missing_seg") is None -def test_testing_memory_patches_require() -> None: - """A required segment the patch could not find raises instead of - silently keeping the real memory limits.""" - patched = apply_testing_memory_patches( - _FLASH_LD_SNIPPET, require=("dram0_0_seg", "irom0_0_seg") - ) - assert "0x2000000" in patched - with pytest.raises(RuntimeError, match="dram0_0_seg, irom0_0_seg"): - apply_testing_memory_patches( - "MEMORY { }", require=("dram0_0_seg", "irom0_0_seg") - ) - # Segments a file does not require are patched opportunistically only - # With nothing required, unmatched content passes through unchanged - assert apply_testing_memory_patches("MEMORY { }", require=()) == "MEMORY { }" +def test_testing_memory_patches_missing_segment_raises() -> None: + """A named segment the patch could not find raises instead of silently + keeping the real memory limits.""" + with pytest.raises(RuntimeError, match="dram0_0_seg"): + apply_testing_memory_patches("MEMORY { }", ("dram0_0_seg",)) + + +def test_board_build_covers_every_board() -> None: + """Every supported board has native build metadata (the table may carry + extras that BOARDS does not expose).""" + assert set(BOARDS) <= set(ESP8266_BOARD_BUILD) From 8fb649658390d67a6ee0fb789d3a92213314a543 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 20 Aug 2026 14:14:32 -0500 Subject: [PATCH 03/12] Export a surgery fingerprint for linker-script cache stamps --- esphome/components/esp8266/build_surgery.py | 21 ++++++++++++++++++- .../components/esp8266/test_build_surgery.py | 13 ++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp8266/build_surgery.py b/esphome/components/esp8266/build_surgery.py index 9aeee79216..c0034f7bad 100644 --- a/esphome/components/esp8266/build_surgery.py +++ b/esphome/components/esp8266/build_surgery.py @@ -11,11 +11,15 @@ as plain functions. Keep both in sync when changing either. from __future__ import annotations from collections.abc import Collection +import hashlib import re # Move the NONOS SDK wifi rate tables from flash to DRAM; see # relocate_ratetable.py.script for the full background (NONOS SDK issue 320). RATETABLE_RULE = "*libnet80211.a:ieee80211_phy.o(.irom.text .irom.text.*)" +_RATETABLE_COMMENT = ( + "/* ESPHome: wifi rate tables must live in DRAM, see NONOS SDK issue 320 */" +) # Match the whole line: "_data_start" is also a substring of the # "_dport0_data_start" line in the earlier .dport0.data section _RATETABLE_ANCHOR = re.compile(r"^\s*_data_start = ABSOLUTE\(\.\);", re.MULTILINE) @@ -40,7 +44,7 @@ def relocate_ratetable(content: str) -> str: insert_pos = match.end() return ( content[:insert_pos] - + "\n /* ESPHome: wifi rate tables must live in DRAM, see NONOS SDK issue 320 */" + + f"\n {_RATETABLE_COMMENT}" + f"\n {RATETABLE_RULE}" + content[insert_pos:] ) @@ -84,3 +88,18 @@ def segment_length(content: str, segment_name: str) -> int | None: """Read a memory segment's length from linker script content.""" match = _segment_line_re(segment_name).search(content) return int(match.group(2), 16) if match else None + + +def surgery_fingerprint() -> str: + """Fingerprint of every behavioral input to the surgeries. + + Linker-script caches include it so an edit here invalidates them. + Native-toolchain-only, like ``segment_length``; no script twin. + """ + parts = ( + RATETABLE_RULE, + _RATETABLE_COMMENT, + _RATETABLE_ANCHOR.pattern, + repr(sorted(_TESTING_SEGMENT_SIZES.items())), + ) + return hashlib.sha256("|".join(parts).encode()).hexdigest() diff --git a/tests/unit_tests/components/esp8266/test_build_surgery.py b/tests/unit_tests/components/esp8266/test_build_surgery.py index 0e5a26294a..64e36f1591 100644 --- a/tests/unit_tests/components/esp8266/test_build_surgery.py +++ b/tests/unit_tests/components/esp8266/test_build_surgery.py @@ -77,3 +77,16 @@ def test_board_build_covers_every_board() -> None: """Every supported board has native build metadata (the table may carry extras that BOARDS does not expose).""" assert set(BOARDS) <= set(ESP8266_BOARD_BUILD) + + +def test_surgery_fingerprint_tracks_inputs() -> None: + """The fingerprint changes with any behavioral input, so linker-script + caches stamped with it self-invalidate on surgery edits.""" + from unittest.mock import patch + + from esphome.components.esp8266 import build_surgery + + base = build_surgery.surgery_fingerprint() + assert base == build_surgery.surgery_fingerprint() + with patch.object(build_surgery, "_TESTING_SEGMENT_SIZES", {"iram1_0_seg": "0x1"}): + assert build_surgery.surgery_fingerprint() != base From b2ac49d8a8e3cab39738f646be54ea62d8f60c59 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 20 Aug 2026 14:38:25 -0500 Subject: [PATCH 04/12] Anchor the segment regex, reject unknown segments, and pin real-script fixtures --- esphome/components/esp8266/boards.py | 5 +-- esphome/components/esp8266/build_surgery.py | 24 ++++++++++---- .../components/esp8266/test_build_surgery.py | 33 +++++++++++++++++-- 3 files changed, 51 insertions(+), 11 deletions(-) diff --git a/esphome/components/esp8266/boards.py b/esphome/components/esp8266/boards.py index e5a9628e90..2646682766 100644 --- a/esphome/components/esp8266/boards.py +++ b/esphome/components/esp8266/boards.py @@ -362,9 +362,10 @@ BOARDS = { } """ -ESP8266_BOARD_BUILD generate with: +ESP8266_BOARD_BUILD generate with (v4.2.1 is the platform version the +native toolchain mirrors; regenerate against the tag when bumping it): -git clone https://github.com/platformio/platform-espressif8266 +git clone -b v4.2.1 https://github.com/platformio/platform-espressif8266 python3 - <<'EOF' import json, glob, os for f in sorted(glob.glob("platform-espressif8266/boards/*.json")): diff --git a/esphome/components/esp8266/build_surgery.py b/esphome/components/esp8266/build_surgery.py index c0034f7bad..47f9042f08 100644 --- a/esphome/components/esp8266/build_surgery.py +++ b/esphome/components/esp8266/build_surgery.py @@ -58,21 +58,33 @@ _TESTING_SEGMENT_SIZES = { def _segment_line_re(segment_name: str) -> re.Pattern[str]: - """The MEMORY line for one segment: `` : org = 0x..., len = 0x...``.""" + """The MEMORY line for one segment: `` : org = 0x..., len = 0x...``. + + Anchored to the start of the line so a name never matches inside a + longer one (``ram0_0_seg`` must not read ``dram0_0_seg``). The size + group stops at the hex digits, leaving any ``ul`` suffix (from the + preprocessed ``MMU_IRAM_SIZE``) in place. + """ return re.compile( - rf"({segment_name}\s*:\s*org\s*=\s*0x[0-9a-fA-F]+\s*,\s*len\s*=\s*)" - r"(0x[0-9a-fA-F]+)" + rf"(^[ \t]*{re.escape(segment_name)}" + r"\s*:\s*org\s*=\s*0x[0-9a-fA-F]+\s*,\s*len\s*=\s*)" + r"(0x[0-9a-fA-F]+)", + re.MULTILINE, ) def apply_testing_memory_patches(content: str, segments: Collection[str]) -> str: """Enlarge the named memory segments so grouped CI test builds can link. - Each caller passes the segments its linker script defines; a segment - that fails to match raises, since a silently kept real memory limit - would fail grouped builds far from the cause. + Each caller passes the segments its linker script defines: the + generated common ld carries ``iram1_0_seg``; the flash ld carries + ``dram0_0_seg`` and ``irom0_0_seg``. A segment that fails to match + raises, since a silently kept real memory limit would fail grouped + builds far from the cause. """ for segment in segments: + if segment not in _TESTING_SEGMENT_SIZES: + raise RuntimeError(f"Unknown testing-mode segment {segment!r}") content, count = _segment_line_re(segment).subn( rf"\g<1>{_TESTING_SEGMENT_SIZES[segment]}", content ) diff --git a/tests/unit_tests/components/esp8266/test_build_surgery.py b/tests/unit_tests/components/esp8266/test_build_surgery.py index 64e36f1591..e62a2270fb 100644 --- a/tests/unit_tests/components/esp8266/test_build_surgery.py +++ b/tests/unit_tests/components/esp8266/test_build_surgery.py @@ -24,16 +24,26 @@ _COMMON_LD_SNIPPET = """\ } >dram0_0_seg :dram0_0_phdr """ +# Shaped like the real SDK flash ld scripts: no iram1_0_seg (that lives in +# the generated common ld only) _FLASH_LD_SNIPPET = """\ MEMORY { dport0_0_seg : org = 0x3FF00000, len = 0x10 dram0_0_seg : org = 0x3FFE8000, len = 0x14000 - iram1_0_seg : org = 0x40100000, len = 0x8000 irom0_0_seg : org = 0x40201010, len = 0xfeff0 } """ +# Shaped like the preprocessed common ld: MMU_IRAM_SIZE expands with a ul +# suffix the patcher must leave in place +_COMMON_LD_MEMORY_SNIPPET = """\ +MEMORY +{ + iram1_0_seg : org = 0x40100000, len = 0x8000ul +} +""" + def test_relocate_ratetable_inserts_after_data_start() -> None: patched = relocate_ratetable(_COMMON_LD_SNIPPET) @@ -52,15 +62,32 @@ def test_relocate_ratetable_requires_anchor() -> None: def test_testing_memory_patches_enlarge_segments() -> None: patched = apply_testing_memory_patches( - _FLASH_LD_SNIPPET, ("iram1_0_seg", "dram0_0_seg", "irom0_0_seg") + _FLASH_LD_SNIPPET, ("dram0_0_seg", "irom0_0_seg") ) - assert segment_length(patched, "iram1_0_seg") == 0x200000 assert segment_length(patched, "dram0_0_seg") == 0x200000 assert segment_length(patched, "irom0_0_seg") == 0x2000000 # Untouched segments keep their sizes assert segment_length(patched, "dport0_0_seg") == 0x10 +def test_testing_memory_patches_keep_ul_suffix() -> None: + """The common ld's preprocessed sizes carry a ul suffix; the patch must + replace only the hex digits, as testing_mode.py.script does.""" + patched = apply_testing_memory_patches(_COMMON_LD_MEMORY_SNIPPET, ("iram1_0_seg",)) + assert "len = 0x200000ul" in patched + assert segment_length(patched, "iram1_0_seg") == 0x200000 + + +def test_segment_length_requires_whole_name() -> None: + """A name must match its own line, never inside a longer segment name.""" + assert segment_length(_FLASH_LD_SNIPPET, "ram0_0_seg") is None + + +def test_testing_memory_patches_unknown_segment_raises() -> None: + with pytest.raises(RuntimeError, match="Unknown testing-mode segment"): + apply_testing_memory_patches("MEMORY { }", ("bogus_seg",)) + + def test_segment_length() -> None: assert segment_length(_FLASH_LD_SNIPPET, "irom0_0_seg") == 0xFEFF0 assert segment_length(_FLASH_LD_SNIPPET, "missing_seg") is None From 4d92741e692925f910aa4103a45721e2c054edd9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 20 Aug 2026 16:01:06 -0500 Subject: [PATCH 05/12] Pin the decoy anchor, guard unselected segments, fingerprint the module source --- esphome/components/esp8266/boards.py | 37 +++++++++---------- esphome/components/esp8266/build_surgery.py | 25 ++++++++----- .../components/esp8266/test_build_surgery.py | 25 ++++++++----- 3 files changed, 50 insertions(+), 37 deletions(-) diff --git a/esphome/components/esp8266/boards.py b/esphome/components/esp8266/boards.py index 2646682766..4f137e95cd 100644 --- a/esphome/components/esp8266/boards.py +++ b/esphome/components/esp8266/boards.py @@ -361,31 +361,30 @@ BOARDS = { }, } -""" -ESP8266_BOARD_BUILD generate with (v4.2.1 is the platform version the -native toolchain mirrors; regenerate against the tag when bumping it): - -git clone -b v4.2.1 https://github.com/platformio/platform-espressif8266 -python3 - <<'EOF' -import json, glob, os -for f in sorted(glob.glob("platform-espressif8266/boards/*.json")): - b = json.load(open(f))["build"] - extra = b["extra_flags"] - extra = extra.split() if isinstance(extra, str) else extra - defines = [ - e[2:] for e in extra if e not in ("-DESP8266", "-DARDUINO_ARCH_ESP8266") - ] - entries = ", ".join(f'"{d}"' for d in defines) + ("," if len(defines) == 1 else "") - board = os.path.splitext(os.path.basename(f))[0] - print(f' "{board}": {{"variant": "{b["variant"]}", "defines": ({entries})}},') -EOF -""" # Per-board Arduino core build metadata for the native (PlatformIO-free) # toolchain: the variant directory (supplies pins_arduino.h) and the # board-identity defines the PlatformIO builder passes via build.extra_flags. # -DESP8266 and -DARDUINO_ARCH_ESP8266 are shared by every board and added by # the generator; only the per-board defines are listed here. +# +# ESP8266_BOARD_BUILD generate with (v4.2.1 is the platform version the +# native toolchain mirrors; regenerate against the tag when bumping it): +# +# git clone -b v4.2.1 https://github.com/platformio/platform-espressif8266 +# python3 - <<'EOF' +# import json, glob, os +# for f in sorted(glob.glob("platform-espressif8266/boards/*.json")): +# b = json.load(open(f))["build"] +# extra = b["extra_flags"] +# extra = extra.split() if isinstance(extra, str) else extra +# defines = [ +# e[2:] for e in extra if e not in ("-DESP8266", "-DARDUINO_ARCH_ESP8266") +# ] +# entries = ", ".join(f'"{d}"' for d in defines) + ("," if len(defines) == 1 else "") +# board = os.path.splitext(os.path.basename(f))[0] +# print(f' "{board}": {{"variant": "{b["variant"]}", "defines": ({entries})}},') +# EOF ESP8266_BOARD_BUILD = { "agruminolemon": { "variant": "agruminolemonv4", diff --git a/esphome/components/esp8266/build_surgery.py b/esphome/components/esp8266/build_surgery.py index 47f9042f08..97ce750dd5 100644 --- a/esphome/components/esp8266/build_surgery.py +++ b/esphome/components/esp8266/build_surgery.py @@ -82,6 +82,14 @@ def apply_testing_memory_patches(content: str, segments: Collection[str]) -> str raises, since a silently kept real memory limit would fail grouped builds far from the cause. """ + for segment in _TESTING_SEGMENT_SIZES: + if segment not in segments and _segment_line_re(segment).search(content): + # A known segment left unpatched would keep its real memory limit + # and silently under-provision the testing build + raise RuntimeError( + f"Testing-mode segment {segment} is present in the linker " + "script but was not selected for patching" + ) for segment in segments: if segment not in _TESTING_SEGMENT_SIZES: raise RuntimeError(f"Unknown testing-mode segment {segment!r}") @@ -103,15 +111,14 @@ def segment_length(content: str, segment_name: str) -> int | None: def surgery_fingerprint() -> str: - """Fingerprint of every behavioral input to the surgeries. + """Fingerprint of this module's source, covering every behavioral input. - Linker-script caches include it so an edit here invalidates them. + Linker-script caches include it so an edit here invalidates them; hashing + the source over-invalidates on comment edits, which is the safe direction. Native-toolchain-only, like ``segment_length``; no script twin. """ - parts = ( - RATETABLE_RULE, - _RATETABLE_COMMENT, - _RATETABLE_ANCHOR.pattern, - repr(sorted(_TESTING_SEGMENT_SIZES.items())), - ) - return hashlib.sha256("|".join(parts).encode()).hexdigest() + import inspect + import sys + + source = inspect.getsource(sys.modules[__name__]) + return hashlib.sha256(source.encode()).hexdigest() diff --git a/tests/unit_tests/components/esp8266/test_build_surgery.py b/tests/unit_tests/components/esp8266/test_build_surgery.py index e62a2270fb..944518b1fa 100644 --- a/tests/unit_tests/components/esp8266/test_build_surgery.py +++ b/tests/unit_tests/components/esp8266/test_build_surgery.py @@ -49,7 +49,8 @@ def test_relocate_ratetable_inserts_after_data_start() -> None: patched = relocate_ratetable(_COMMON_LD_SNIPPET) assert RATETABLE_RULE in patched # Inserted after the .data section's anchor, not the .dport0.data one - assert patched.index("_data_start = ABSOLUTE(.);") < patched.index(RATETABLE_RULE) + # (whose closing brace bounds the decoy block) + assert RATETABLE_RULE not in patched[: patched.index("} >dport0_0_seg")] assert patched.index(RATETABLE_RULE) < patched.index("*(.data)") # Idempotent on an already-patched script assert relocate_ratetable(patched) == patched @@ -106,14 +107,20 @@ def test_board_build_covers_every_board() -> None: assert set(BOARDS) <= set(ESP8266_BOARD_BUILD) -def test_surgery_fingerprint_tracks_inputs() -> None: - """The fingerprint changes with any behavioral input, so linker-script - caches stamped with it self-invalidate on surgery edits.""" - from unittest.mock import patch +def test_surgery_fingerprint_covers_module_source() -> None: + """The fingerprint hashes the module source, so any surgery edit + invalidates linker-script caches stamped with it.""" + import hashlib + import inspect from esphome.components.esp8266 import build_surgery - base = build_surgery.surgery_fingerprint() - assert base == build_surgery.surgery_fingerprint() - with patch.object(build_surgery, "_TESTING_SEGMENT_SIZES", {"iram1_0_seg": "0x1"}): - assert build_surgery.surgery_fingerprint() != base + expected = hashlib.sha256(inspect.getsource(build_surgery).encode()).hexdigest() + assert build_surgery.surgery_fingerprint() == expected + + +def test_testing_memory_patches_present_but_unselected_raises() -> None: + """A known segment left off the caller's list must fail, not silently + keep its real memory limit.""" + with pytest.raises(RuntimeError, match="not selected"): + apply_testing_memory_patches(_FLASH_LD_SNIPPET, ("dram0_0_seg",)) From 77c41d2579ca0ad1dfcce26d976a73e5c8784db7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 20 Aug 2026 20:34:24 -0500 Subject: [PATCH 06/12] State what the fingerprint test actually pins --- tests/unit_tests/components/esp8266/test_build_surgery.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit_tests/components/esp8266/test_build_surgery.py b/tests/unit_tests/components/esp8266/test_build_surgery.py index 944518b1fa..7e6e7ea680 100644 --- a/tests/unit_tests/components/esp8266/test_build_surgery.py +++ b/tests/unit_tests/components/esp8266/test_build_surgery.py @@ -108,8 +108,8 @@ def test_board_build_covers_every_board() -> None: def test_surgery_fingerprint_covers_module_source() -> None: - """The fingerprint hashes the module source, so any surgery edit - invalidates linker-script caches stamped with it.""" + """Pins the mechanism: the fingerprint is the sha256 of the module + source (behavioral coverage follows from that, not from this test).""" import hashlib import inspect From 862b7f7c3e1100e35d49bd42e93de01f077562bb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 20 Aug 2026 22:08:41 -0500 Subject: [PATCH 07/12] Pin fingerprint stability and sensitivity behaviorally, note the platform pairing --- esphome/components/esp8266/boards.py | 3 ++ .../components/esp8266/test_build_surgery.py | 33 +++++++++++++++---- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/esphome/components/esp8266/boards.py b/esphome/components/esp8266/boards.py index 4f137e95cd..71681d99a3 100644 --- a/esphome/components/esp8266/boards.py +++ b/esphome/components/esp8266/boards.py @@ -365,6 +365,9 @@ BOARDS = { # Per-board Arduino core build metadata for the native (PlatformIO-free) # toolchain: the variant directory (supplies pins_arduino.h) and the # board-identity defines the PlatformIO builder passes via build.extra_flags. +# Valid for platform 4.x only (older tags differ, e.g. esp8285's variant); +# the native toolchain's validator enforces that pairing by requiring core +# >= 3.1.1 and rejecting a custom platform_version. # -DESP8266 and -DARDUINO_ARCH_ESP8266 are shared by every board and added by # the generator; only the per-board defines are listed here. # diff --git a/tests/unit_tests/components/esp8266/test_build_surgery.py b/tests/unit_tests/components/esp8266/test_build_surgery.py index 7e6e7ea680..b1c3fd7be8 100644 --- a/tests/unit_tests/components/esp8266/test_build_surgery.py +++ b/tests/unit_tests/components/esp8266/test_build_surgery.py @@ -107,16 +107,35 @@ def test_board_build_covers_every_board() -> None: assert set(BOARDS) <= set(ESP8266_BOARD_BUILD) -def test_surgery_fingerprint_covers_module_source() -> None: - """Pins the mechanism: the fingerprint is the sha256 of the module - source (behavioral coverage follows from that, not from this test).""" - import hashlib - import inspect +def test_surgery_fingerprint_is_stable_and_sensitive(tmp_path) -> None: + """The properties the linker-script cache depends on: the fingerprint is + stable across calls and changes when the module's source changes.""" + import importlib.util + from pathlib import Path as _Path + import sys from esphome.components.esp8266 import build_surgery - expected = hashlib.sha256(inspect.getsource(build_surgery).encode()).hexdigest() - assert build_surgery.surgery_fingerprint() == expected + first = build_surgery.surgery_fingerprint() + assert first == build_surgery.surgery_fingerprint() + assert len(first) == 64 + int(first, 16) # sha256 hex digest + + # A modified copy of the module must fingerprint differently + copy = tmp_path / "build_surgery_variant.py" + copy.write_text( + _Path(build_surgery.__file__).read_text(encoding="utf-8") + + "\nEXTRA_BEHAVIORAL_INPUT = 1\n", + encoding="utf-8", + ) + spec = importlib.util.spec_from_file_location("build_surgery_variant", copy) + variant = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = variant + try: + spec.loader.exec_module(variant) + assert variant.surgery_fingerprint() != first + finally: + del sys.modules[spec.name] def test_testing_memory_patches_present_but_unselected_raises() -> None: From d7ee8242281c003700ec836bdb26d05b10ba4822 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 21 Aug 2026 20:56:27 -0500 Subject: [PATCH 08/12] Phrase the board-table regen comment imperatively --- esphome/components/esp8266/boards.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/esp8266/boards.py b/esphome/components/esp8266/boards.py index 71681d99a3..5be8011ba4 100644 --- a/esphome/components/esp8266/boards.py +++ b/esphome/components/esp8266/boards.py @@ -371,7 +371,7 @@ BOARDS = { # -DESP8266 and -DARDUINO_ARCH_ESP8266 are shared by every board and added by # the generator; only the per-board defines are listed here. # -# ESP8266_BOARD_BUILD generate with (v4.2.1 is the platform version the +# Regenerate ESP8266_BOARD_BUILD with (v4.2.1 is the platform version the # native toolchain mirrors; regenerate against the tag when bumping it): # # git clone -b v4.2.1 https://github.com/platformio/platform-espressif8266 From e714848fb915e00727f63dd9e178cdaf4231b1db Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 11:35:35 -0500 Subject: [PATCH 09/12] Trim comment essays and hoist function-local test imports --- esphome/components/esp8266/boards.py | 11 +++-------- esphome/components/esp8266/build_surgery.py | 10 ++-------- .../components/esp8266/test_build_surgery.py | 12 ++++++------ 3 files changed, 11 insertions(+), 22 deletions(-) diff --git a/esphome/components/esp8266/boards.py b/esphome/components/esp8266/boards.py index 5be8011ba4..d458442dbd 100644 --- a/esphome/components/esp8266/boards.py +++ b/esphome/components/esp8266/boards.py @@ -362,14 +362,9 @@ BOARDS = { } -# Per-board Arduino core build metadata for the native (PlatformIO-free) -# toolchain: the variant directory (supplies pins_arduino.h) and the -# board-identity defines the PlatformIO builder passes via build.extra_flags. -# Valid for platform 4.x only (older tags differ, e.g. esp8285's variant); -# the native toolchain's validator enforces that pairing by requiring core -# >= 3.1.1 and rejecting a custom platform_version. -# -DESP8266 and -DARDUINO_ARCH_ESP8266 are shared by every board and added by -# the generator; only the per-board defines are listed here. +# Per-board variant dir + identity defines from platform-espressif8266 4.x +# build.extra_flags; the shared -DESP8266/-DARDUINO_ARCH_ESP8266 are added +# by the generator. # # Regenerate ESP8266_BOARD_BUILD with (v4.2.1 is the platform version the # native toolchain mirrors; regenerate against the tag when bumping it): diff --git a/esphome/components/esp8266/build_surgery.py b/esphome/components/esp8266/build_surgery.py index 97ce750dd5..2df1d5dbb8 100644 --- a/esphome/components/esp8266/build_surgery.py +++ b/esphome/components/esp8266/build_surgery.py @@ -84,8 +84,6 @@ def apply_testing_memory_patches(content: str, segments: Collection[str]) -> str """ for segment in _TESTING_SEGMENT_SIZES: if segment not in segments and _segment_line_re(segment).search(content): - # A known segment left unpatched would keep its real memory limit - # and silently under-provision the testing build raise RuntimeError( f"Testing-mode segment {segment} is present in the linker " "script but was not selected for patching" @@ -111,12 +109,8 @@ def segment_length(content: str, segment_name: str) -> int | None: def surgery_fingerprint() -> str: - """Fingerprint of this module's source, covering every behavioral input. - - Linker-script caches include it so an edit here invalidates them; hashing - the source over-invalidates on comment edits, which is the safe direction. - Native-toolchain-only, like ``segment_length``; no script twin. - """ + """Hash of this module's source; linker-script caches include it so an + edit here invalidates them.""" import inspect import sys diff --git a/tests/unit_tests/components/esp8266/test_build_surgery.py b/tests/unit_tests/components/esp8266/test_build_surgery.py index b1c3fd7be8..411a35eb96 100644 --- a/tests/unit_tests/components/esp8266/test_build_surgery.py +++ b/tests/unit_tests/components/esp8266/test_build_surgery.py @@ -2,8 +2,13 @@ from __future__ import annotations +import importlib.util +from pathlib import Path +import sys + import pytest +from esphome.components.esp8266 import build_surgery from esphome.components.esp8266.boards import BOARDS, ESP8266_BOARD_BUILD from esphome.components.esp8266.build_surgery import ( RATETABLE_RULE, @@ -110,11 +115,6 @@ def test_board_build_covers_every_board() -> None: def test_surgery_fingerprint_is_stable_and_sensitive(tmp_path) -> None: """The properties the linker-script cache depends on: the fingerprint is stable across calls and changes when the module's source changes.""" - import importlib.util - from pathlib import Path as _Path - import sys - - from esphome.components.esp8266 import build_surgery first = build_surgery.surgery_fingerprint() assert first == build_surgery.surgery_fingerprint() @@ -124,7 +124,7 @@ def test_surgery_fingerprint_is_stable_and_sensitive(tmp_path) -> None: # A modified copy of the module must fingerprint differently copy = tmp_path / "build_surgery_variant.py" copy.write_text( - _Path(build_surgery.__file__).read_text(encoding="utf-8") + Path(build_surgery.__file__).read_text(encoding="utf-8") + "\nEXTRA_BEHAVIORAL_INPUT = 1\n", encoding="utf-8", ) From 04825d3a58d17bced1bed9284b61aa2410d60685 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 22:32:26 -0500 Subject: [PATCH 10/12] Reconcile BOARDS with the generated table: d1_wroom_02 joins with its shipped flash layout --- esphome/components/esp8266/__init__.py | 8 +++++--- esphome/components/esp8266/boards.py | 9 +++++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index 3dd9750c6f..affd8a7177 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -397,8 +397,8 @@ async def to_code(config: ConfigType) -> None: ) if config[CONF_BOARD] in BOARDS: - flash_size = BOARDS[config[CONF_BOARD]][KEY_FLASH_SIZE] - ld_scripts = ESP8266_LD_SCRIPTS[flash_size] + board_data = BOARDS[config[CONF_BOARD]] + ld_scripts = ESP8266_LD_SCRIPTS[board_data[KEY_FLASH_SIZE]] if ver <= cv.Version(2, 3, 0): # No ld script support @@ -407,7 +407,9 @@ async def to_code(config: ConfigType) -> None: # Old ld script path ld_script = ld_scripts[0] else: - ld_script = ld_scripts[1] + # 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]) 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 d458442dbd..397de60e24 100644 --- a/esphome/components/esp8266/boards.py +++ b/esphome/components/esp8266/boards.py @@ -199,6 +199,15 @@ BOARDS = { "name": "WeMos D1 mini Pro", "flash_size": FLASH_SIZE_16_MB, }, + "d1_wroom_02": { + "name": "WeMos D1 ESP-WROOM-02", + "flash_size": FLASH_SIZE_2_MB, + # This board joined BOARDS after shipping with the manifest default + # (64 KB filesystem region); the flash-size default (2m.ld) would + # move _FS_end and with it the preferences sector, wiping existing + # devices' flash-backed state on update. + "ldscript": "eagle.flash.2m64.ld", + }, "d1": { "name": "WEMOS D1 R1", "flash_size": FLASH_SIZE_4_MB, From a25509bda0571dfd16d48909ed8e3cb2183cbbb6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 23 Aug 2026 13:54:22 -0500 Subject: [PATCH 11/12] 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 7ca1b79544c7b0704ddd7098f8290f9558271ac2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 23 Aug 2026 15:27:36 -0500 Subject: [PATCH 12/12] Name the ldscript key, warn when a pre-2.4.2 core cannot honor it, pin the rule KEY_LDSCRIPT replaces the bare literal at both the table entry and the lookup, so a typo cannot silently fall back to the layout-moving default; the regeneration recipe notes that overrides must survive a refresh; a board pinning an override on an Arduino core <= 2.4.2 warns that the layout cannot be honored; and board_ld_script gains the test pinning d1_wroom_02's shipped layout and the size defaults. --- esphome/components/esp8266/__init__.py | 12 +++++++++++- esphome/components/esp8266/boards.py | 11 +++++++---- esphome/components/esp8266/const.py | 3 +++ .../unit_tests/components/esp8266/test_boards.py | 15 +++++++++++++++ 4 files changed, 36 insertions(+), 5 deletions(-) create mode 100644 tests/unit_tests/components/esp8266/test_boards.py diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index 369489b6a9..593e80056f 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -44,6 +44,7 @@ from .const import ( KEY_BOARD, KEY_ESP8266, KEY_FLASH_SIZE, + KEY_LDSCRIPT, KEY_PIN_INITIAL_STATES, KEY_SERIAL1_REQUIRED, KEY_SERIAL_REQUIRED, @@ -404,7 +405,16 @@ async def to_code(config: ConfigType) -> None: # No ld script support ld_script = None elif ver <= cv.Version(2, 4, 2): - # Old ld script path + # Old ld script path; the modern per-board override names do + # not exist in this core's SDK, so it cannot be honored + if KEY_LDSCRIPT in board_data: + _LOGGER.warning( + "Board %s pins %s, which Arduino core %s cannot honor; " + "using the default flash layout", + config[CONF_BOARD], + board_data[KEY_LDSCRIPT], + ver, + ) ld_script = ld_scripts[0] else: # A per-board override preserves a layout the board shipped diff --git a/esphome/components/esp8266/boards.py b/esphome/components/esp8266/boards.py index 35d9d30e8c..268c6b50aa 100644 --- a/esphome/components/esp8266/boards.py +++ b/esphome/components/esp8266/boards.py @@ -1,4 +1,4 @@ -from .const import KEY_FLASH_SIZE +from .const import KEY_FLASH_SIZE, KEY_LDSCRIPT FLASH_SIZE_1_MB = 2**20 FLASH_SIZE_512_KB = FLASH_SIZE_1_MB // 2 @@ -166,7 +166,8 @@ ESP8266_BOARD_PINS = { } """ -BOARDS generate with: +BOARDS generate with (preserve per-board KEY_LDSCRIPT overrides such as +d1_wroom_02; the recipe emits only name/flash_size): git clone https://github.com/platformio/platform-espressif8266 for x in platform-espressif8266/boards/*.json; do @@ -192,7 +193,9 @@ def board_ld_script(board_data: dict) -> str: 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]) + return board_data.get( + KEY_LDSCRIPT, ESP8266_LD_SCRIPTS[board_data[KEY_FLASH_SIZE]][1] + ) BOARDS = { @@ -219,7 +222,7 @@ BOARDS = { # (64 KB filesystem region); the flash-size default (2m.ld) would # move _FS_end and with it the preferences sector, wiping existing # devices' flash-backed state on update. - "ldscript": "eagle.flash.2m64.ld", + KEY_LDSCRIPT: "eagle.flash.2m64.ld", }, "d1": { "name": "WEMOS D1 R1", diff --git a/esphome/components/esp8266/const.py b/esphome/components/esp8266/const.py index 8729007e3a..c6f550c594 100644 --- a/esphome/components/esp8266/const.py +++ b/esphome/components/esp8266/const.py @@ -71,3 +71,6 @@ def enable_serial1() -> None: enable_serial1() """ CORE.data.setdefault(KEY_ESP8266, {})[KEY_SERIAL1_REQUIRED] = True + + +KEY_LDSCRIPT = "ldscript" diff --git a/tests/unit_tests/components/esp8266/test_boards.py b/tests/unit_tests/components/esp8266/test_boards.py new file mode 100644 index 0000000000..288e0a1eb9 --- /dev/null +++ b/tests/unit_tests/components/esp8266/test_boards.py @@ -0,0 +1,15 @@ +"""Tests for the per-board linker-script rule.""" + +from esphome.components.esp8266.boards import BOARDS, board_ld_script + + +def test_d1_wroom_02_keeps_its_shipped_layout() -> None: + """The override must survive a BOARDS regeneration or key typo: the + 2m.ld default moves _FS_end and the preferences sector on deployed + devices.""" + assert board_ld_script(BOARDS["d1_wroom_02"]) == "eagle.flash.2m64.ld" + + +def test_default_boards_use_the_flash_size_layout() -> None: + assert board_ld_script(BOARDS["d1_mini"]) == "eagle.flash.4m.ld" + assert board_ld_script(BOARDS["esp01_1m"]) == "eagle.flash.1m.ld"