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 { }"