diff --git a/esphome/build_gen/arduino8266.py b/esphome/build_gen/arduino8266.py index bec0d0788a..8fad93ae2f 100644 --- a/esphome/build_gen/arduino8266.py +++ b/esphome/build_gen/arduino8266.py @@ -249,6 +249,15 @@ def _resolve_build_config(defines: dict[str, str]) -> _BuildConfig: if f"PIO_FRAMEWORK_ARDUINO_ESPRESSIF_{name}" in defines: nonosdk = define break + # Same compile-line/linked-artifact split as the lwIP knobs below: a + # raw NONOSDK* would define a second SDK macro while the link still + # resolves against the knob's libraries + if raw_sdk := sorted(n for n in defines if n.startswith("NONOSDK")): + raise EsphomeError( + f"{', '.join(raw_sdk)} are set by the " + "PIO_FRAMEWORK_ARDUINO_ESPRESSIF_SDK* knobs; drop the raw " + "build flags" + ) tcp_mss, features, ipv6, lwip_lib = _LWIP_DEFAULT for knob, variant in _LWIP_VARIANTS: @@ -311,6 +320,17 @@ def _resolve_build_config(defines: dict[str, str]) -> _BuildConfig: "PIO_FRAMEWORK_ARDUINO_MMU_CUSTOM requires MMU_IRAM_SIZE and " "MMU_ICACHE_SIZE build flags" ) + for size_name in ("MMU_IRAM_SIZE", "MMU_ICACHE_SIZE"): + # These reach the linker-script preprocessor; a bare or + # non-numeric value would corrupt the segment lengths and fail + # far away in ld ("48K" and a valueless flag both preprocess + # wrong; ul suffixes survive preprocessing, see build_surgery) + value = defines[size_name].partition("=")[2] + if not re.fullmatch(r"(?:0[xX][0-9a-fA-F]+|\d+)[uUlL]*", value): + raise EsphomeError( + f"{size_name} must be a numeric literal, got " + f"{value or '(no value)'}" + ) # Sorted so build.ninja and the linker-script stamp stay # byte-stable across runs (the flag set has no deterministic # iteration order). @@ -500,13 +520,17 @@ def _stat_sig(path: Path) -> str: return f"unreadable:{os.urandom(8).hex()}" -def _write_note(path: Path, text: str) -> None: - """Best-effort bookkeeping write; a failure only costs a cache miss or - a lost re-emitted warning, never the build.""" +def _write_note(path: Path, text: str, *, warn: bool = False) -> None: + """Best-effort bookkeeping write; a failure never fails the build. + + ``warn`` marks notes whose loss drops a diagnostic on later cached + builds; a lost stamp only costs a cache miss and stays at debug. + """ try: path.write_text(text, encoding="utf-8") except OSError as err: - _LOGGER.debug("Could not write %s: %s", path, err) + log = _LOGGER.warning if warn else _LOGGER.debug + log("Could not write %s: %s", path, err) def _write_generated(path: Path, content: str) -> None: @@ -606,7 +630,7 @@ def generate_ld_scripts( # Preprocessor warnings on the success path must reach the user # on this and every later cached build (see the re-emit below) _LOGGER.warning("Linker-script preprocessor: %s", result.stderr.strip()) - _write_note(stderr_note, result.stderr.strip()) + _write_note(stderr_note, result.stderr.strip(), warn=True) else: stderr_note.unlink(missing_ok=True) if "SECTIONS" not in result.stdout: diff --git a/tests/unit_tests/build_gen/test_arduino8266.py b/tests/unit_tests/build_gen/test_arduino8266.py index 7f9da21dc1..e72e7dbc25 100644 --- a/tests/unit_tests/build_gen/test_arduino8266.py +++ b/tests/unit_tests/build_gen/test_arduino8266.py @@ -1143,6 +1143,44 @@ def test_generate_ld_scripts_unreadable_note_still_warns( assert "could not be read" in caplog.text +@pytest.mark.parametrize("value", ["0x8000", "0xC000ul", "32768", "48UL"]) +def test_mmu_custom_numeric_sizes_accepted(value: str) -> None: + config = _resolve( + "-DPIO_FRAMEWORK_ARDUINO_MMU_CUSTOM", + f"-DMMU_IRAM_SIZE={value}", + "-DMMU_ICACHE_SIZE=0x8000", + ) + assert f"MMU_IRAM_SIZE={value}" in config.mmu_defines + + +@pytest.mark.parametrize("flag", ["-DMMU_IRAM_SIZE=48K", "-DMMU_IRAM_SIZE"]) +def test_mmu_custom_malformed_size_raises(flag: str) -> None: + """A bare or non-numeric size would corrupt the preprocessed segment + lengths and fail far away in ld; refuse by name.""" + with pytest.raises(EsphomeError, match="MMU_IRAM_SIZE must be a numeric"): + _resolve( + "-DPIO_FRAMEWORK_ARDUINO_MMU_CUSTOM", + flag, + "-DMMU_ICACHE_SIZE=0x8000", + ) + + +def test_raw_nonosdk_define_raises() -> None: + """A raw NONOSDK* define would split the compile line from the linked + SDK libraries, like the lwIP knob overrides.""" + with pytest.raises(EsphomeError, match="NONOSDK305 are set by the"): + _resolve("-DNONOSDK305=1") + + +def test_write_note_warn_level( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A lost stderr note drops a diagnostic on later cached builds, so it + warns; a lost stamp only costs a cache miss.""" + arduino8266._write_note(tmp_path / "missing" / "note", "x", warn=True) + assert "Could not write" in caplog.text + + def test_write_note_failure_is_best_effort( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: