diff --git a/esphome/build_gen/arduino8266.py b/esphome/build_gen/arduino8266.py index fd198b2e0c..5b14af5a1d 100644 --- a/esphome/build_gen/arduino8266.py +++ b/esphome/build_gen/arduino8266.py @@ -1065,10 +1065,10 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool: " rspfile_content = $in_newline", " description = LINK $out", "rule elf2bin", - # --flash_size deliberately stays board-derived, as under - # PlatformIO (which reads upload.maximum_size, not the ldscript). + # --flash_size follows PlatformIO: ldscript filename first, then + # upload.maximum_size, so the header always matches the layout. # -W: the framework's own elf2bin.py trips SyntaxWarning on 3.12+. - f" command = $python -W ignore::SyntaxWarning {_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", + f" command = $python -W ignore::SyntaxWarning {_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 {_elf2bin_flash_size(board, flash_ld_name)} --path {_q(toolchain_bin)} --out $out", " description = BIN $out", "rule copy", " command = $python $buildtool copy $in $out", @@ -1189,3 +1189,28 @@ def _flash_size_str(flash_size: int) -> str: """Flash size argument for elf2bin (e.g. ``4M``, ``512K``).""" mb = 1024 * 1024 return f"{flash_size // mb}M" if flash_size >= mb else f"{flash_size // 1024}K" + + +# Same pattern PlatformIO's _get_flash_size applies to the ldscript path +_LD_FLASH_SIZE_RE = re.compile(r"\.flash\.(\d+[mk]).*\.ld") +# The framework elf2bin.py's --flash_size choices +_ELF2BIN_FLASH_SIZES = frozenset({"256K", "512K", "1M", "2M", "4M", "8M", "16M"}) + + +def _elf2bin_flash_size(board: str, flash_ld_name: str) -> str: + """Image-header flash size as PlatformIO derives it: ldscript filename, + else board_upload.maximum_size, else the board table. The SDK clamps the + chip to the header size at boot, so a header smaller than the linked + layout breaks OTA writes on the running device.""" + if match := _LD_FLASH_SIZE_RE.search(flash_ld_name): + token = match.group(1) + base = 1024 if token[-1] == "k" else 1024 * 1024 + return _flash_size_str(int(token[:-1]) * base) + if max_size := _pio_option("board_upload.maximum_size", ""): + if ( + not max_size.isdigit() + or (size := _flash_size_str(int(max_size))) not in _ELF2BIN_FLASH_SIZES + ): + raise EsphomeError(f"Invalid board_upload.maximum_size value {max_size!r}") + return size + return _flash_size_str(BOARDS[board][KEY_FLASH_SIZE]) diff --git a/esphome/core/config.py b/esphome/core/config.py index bb3f03d366..281d430410 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -549,7 +549,9 @@ def _add_library_str(lib: str) -> None: # platformio_options keys the native ESP8266 Arduino generator (a later PR # in this chain) will honor; its ignored-option warning will consume the same # list so the two cannot drift -NATIVE_ARDUINO_PIO_OPTIONS = frozenset({"board_build.f_cpu", "board_build.ldscript"}) +NATIVE_ARDUINO_PIO_OPTIONS = frozenset( + {"board_build.f_cpu", "board_build.ldscript", "board_upload.maximum_size"} +) # The full set that survives into CORE.platformio_options under the native # arduino toolchain: lib_ignore is the only specially-translated key below # that is stored rather than translated away. Consumed by the esp8266 native @@ -609,9 +611,13 @@ async def _add_platformio_options(pio_options: dict[str, str | list[str]]) -> No # through to the ignored-option warning). Other native # toolchains have no equivalent and fall through too. cg.add_platformio_option(key, vals[-1]) - elif key != "upload_speed": - # upload_speed needs no handling: it is read from the raw - # config at upload time (upload_using_esptool) + elif key != "upload_speed" and not ( + key == "board_upload.flash_size" and CORE.using_toolchain_arduino + ): + # upload_speed is read from the raw config at upload time. + # board_upload.flash_size is dropped silently on arduino: + # PlatformIO's esp8266 builder never reads it either, and + # published configs (Athom) commonly set it. _LOGGER.warning( "esphome->platformio_options->%s is ignored when building with " "the native '%s' toolchain", diff --git a/tests/unit_tests/build_gen/test_arduino8266.py b/tests/unit_tests/build_gen/test_arduino8266.py index 4a0355fd01..066746d36d 100644 --- a/tests/unit_tests/build_gen/test_arduino8266.py +++ b/tests/unit_tests/build_gen/test_arduino8266.py @@ -21,12 +21,18 @@ from esphome.arduino8266.framework import InstalledPaths, toolchain_tool from esphome.build_gen import arduino8266 from esphome.build_gen.arduino8266 import ( _defines_flags, + _elf2bin_flash_size, _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.boards import ( + BOARDS, + ESP8266_BOARD_BUILD, + KEY_FLASH_SIZE, + board_ld_script, +) from esphome.components.esp8266.build_surgery import RATETABLE_RULE from esphome.components.esp8266.const import KEY_BOARD, KEY_ESP8266, KEY_SCANF_FLOAT import esphome.config_validation as cv @@ -747,6 +753,54 @@ def test_flash_size_str() -> None: assert _flash_size_str(512 * 1024) == "512K" +def test_elf2bin_flash_size() -> None: + """The image-header size follows the ldscript filename like PlatformIO, + falling back to board_upload.maximum_size and then the board table.""" + assert _elf2bin_flash_size("esp8285", "eagle.flash.2m.ld") == "2M" + assert _elf2bin_flash_size("esp01", "eagle.flash.512k.ld") == "512K" + assert _elf2bin_flash_size("nodemcuv2", "eagle.flash.4m1m.ld") == "4M" + # The testing-mode prefix still matches (search, not match) + assert _elf2bin_flash_size("esp8285", "testing_eagle.flash.2m.ld") == "2M" + # Custom ldscript name: board_upload.maximum_size wins over the board + CORE.platformio_options["board_upload.maximum_size"] = "2097152" + assert _elf2bin_flash_size("esp8285", "custom.ld") == "2M" + del CORE.platformio_options["board_upload.maximum_size"] + assert _elf2bin_flash_size("esp8285", "custom.ld") == "1M" + + +@pytest.mark.parametrize("bad", ["2MB", "3145728", "-1"]) +def test_elf2bin_flash_size_rejects_bad_maximum_size(bad: str) -> None: + """A non-numeric or unsupported board_upload.maximum_size fails by name + instead of a ValueError or a late elf2bin choices error.""" + CORE.platformio_options["board_upload.maximum_size"] = bad + with pytest.raises(EsphomeError, match="board_upload.maximum_size"): + _elf2bin_flash_size("esp8285", "custom.ld") + + +def test_elf2bin_flash_size_default_matches_board_table() -> None: + """Without an ldscript override, every board's own ldscript parses to + the board-table size, so the emitted --flash_size is unchanged.""" + for board, entry in BOARDS.items(): + assert _elf2bin_flash_size(board, board_ld_script(entry)) == _flash_size_str( + entry[KEY_FLASH_SIZE] + ), board + + +def test_write_project_flash_size_follows_ldscript_override( + tmp_path: Path, +) -> None: + """An ldscript overriding the board's flash size drives the image header + too (the Athom shape: esp8285 with eagle.flash.2m.ld). A 1M header over + a 2M layout clamps the chip below the OTA scratch area and bricks OTA.""" + paths = _make_framework(tmp_path) + (paths.framework / "variants" / "esp8285").mkdir() + CORE.data[KEY_ESP8266][KEY_BOARD] = "esp8285" + CORE.platformio_options["board_build.ldscript"] = "eagle.flash.2m.ld" + content = _write_ninja(paths) + assert "--flash_size 2M" in content + assert "eagle.flash.2m.ld" in content + + def test_write_project_testing_mode(tmp_path: Path) -> None: paths = _make_framework(tmp_path) CORE.testing_mode = True diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index 189c024b3a..cfb9651e6b 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -1341,6 +1341,8 @@ async def test_add_platformio_options_native_idf( "lib_ignore": "libsodium", "upload_speed": "115200", "board_build.f_flash": "80000000L", + # Silently dropped on arduino only; warns here + "board_upload.flash_size": "2MB", } ) @@ -1351,6 +1353,9 @@ async def test_add_platformio_options_native_idf( # nothing else lands in platformio_options on the native toolchain. assert CORE.platformio_options == {"lib_ignore": ["libsodium"]} assert "esphome->platformio_options->board_build.f_flash is ignored" in caplog.text + assert ( + "esphome->platformio_options->board_upload.flash_size is ignored" in caplog.text + ) assert "upload_speed" not in caplog.text # build_flags has a first-class esphome equivalent, so it is deprecated. # lib_deps/lib_ignore are kept as valid platformio_options (no warning). @@ -1464,13 +1469,21 @@ async def test_add_platformio_options_native_arduino( "board_build.ldscript": ["eagle.flash.2m.ld", "eagle.flash.4m2m.ld"], "board_build.filesystem": "littlefs", "upload_speed": "115200", + # The Athom shape: maximum_size is the elf2bin fallback, + # flash_size is dropped silently (PlatformIO never reads it) + "board_upload.maximum_size": "2097152", + "board_upload.flash_size": "2MB", } ) assert CORE.platformio_options["board_build.f_cpu"] == "160000000L" assert CORE.platformio_options["board_build.ldscript"] == "eagle.flash.4m2m.ld" + assert CORE.platformio_options["board_upload.maximum_size"] == "2097152" + assert "board_upload.flash_size" not in CORE.platformio_options assert "board_build.f_cpu is ignored" not in caplog.text assert "board_build.ldscript is ignored" not in caplog.text + assert "board_upload.maximum_size is ignored" not in caplog.text + assert "board_upload.flash_size is ignored" not in caplog.text assert ( "esphome->platformio_options->board_build.filesystem is ignored" in caplog.text )