diff --git a/esphome/build_gen/arduino8266.py b/esphome/build_gen/arduino8266.py index fd561078b4..a501c7911c 100644 --- a/esphome/build_gen/arduino8266.py +++ b/esphome/build_gen/arduino8266.py @@ -320,15 +320,18 @@ 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): + for name, body in defines.items(): + if not name.startswith("MMU_") or "=" not in body: + # Valueless flags (MMU_IRAM_HEAP) are legitimate switches + continue + # Every valued MMU_* reaches the linker-script preprocessor; a + # bare or non-numeric value would corrupt the segment lengths + # and fail far away in ld. Hex only: build_surgery's segment + # parser (and upstream's spellings) cannot read decimal. + value = body.partition("=")[2] + if not re.fullmatch(r"0[xX][0-9a-fA-F]+[uUlL]*", value): raise EsphomeError( - f"{size_name} must be a numeric literal, got " + f"{name} must be a hex literal (e.g. 0x8000), got " f"{value or '(no value)'}" ) # Sorted so build.ninja and the linker-script stamp stay @@ -336,11 +339,12 @@ def _resolve_build_config(defines: dict[str, str]) -> _BuildConfig: # iteration order). mmu = sorted(body for name, body in defines.items() if name.startswith("MMU_")) else: - if "MMU_IRAM_SIZE" in defines or "MMU_ICACHE_SIZE" in defines: + if raw := sorted(n for n in defines if n.startswith("MMU_")): # Unlike PlatformIO (whose defaults win the compile line), user - # MMU_* here would win the compile but not the linker script; refuse. + # MMU_* here would win the compile but not the linker script; + # refuse them all, like the knob branch above. raise EsphomeError( - "Custom MMU_IRAM_SIZE/MMU_ICACHE_SIZE build flags require " + f"Raw {', '.join(raw)} build flags require " "-DPIO_FRAMEWORK_ARDUINO_MMU_CUSTOM" ) mmu = list(_MMU_DEFAULT) diff --git a/esphome/build_helpers/tools_cache.py b/esphome/build_helpers/tools_cache.py index f23c859b34..e7193a8e2a 100644 --- a/esphome/build_helpers/tools_cache.py +++ b/esphome/build_helpers/tools_cache.py @@ -16,7 +16,11 @@ def tools_cache_path(env_var: str, subdir: str) -> Path: from esphome.helpers import get_str_env if prefix := get_str_env(env_var, "").strip(): + # resolve(): symlinked prefixes otherwise trip idf.py's + # venv-mismatch warning on every build return Path(prefix).expanduser().resolve() + # appauthor=False keeps the Windows path short (no vendor segment); + # deep IDF trees run into MAX_PATH otherwise return ( Path(platformdirs.user_cache_dir("esphome", appauthor=False)) / subdir ).resolve() diff --git a/esphome/helpers.py b/esphome/helpers.py index fe3c383f59..b926979e04 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -31,8 +31,9 @@ SockAddr = IPv4SockAddr | IPv6SockAddr _LOGGER = logging.getLogger(__name__) -# cv.boolean's closed spelling tables; shared so env-knob parsers cannot -# drift from what configs accept +# cv.boolean's closed spelling tables, shared with the strict env-knob +# parser (build_helpers.ccache.parse_enable_env). The legacy get_bool_env +# below keeps its own laxer table for backward compatibility. TRUTHY_BOOL_STRINGS = frozenset({"true", "yes", "on", "enable"}) FALSY_BOOL_STRINGS = frozenset({"false", "no", "off", "disable"}) diff --git a/esphome/platformio/extra_script.py b/esphome/platformio/extra_script.py index 4c4c716009..a88201266d 100644 --- a/esphome/platformio/extra_script.py +++ b/esphome/platformio/extra_script.py @@ -110,6 +110,11 @@ class _FakeSConsEnv: def get(self, key: str, default: str | None = None) -> str | None: return self._vars.get(key, default) + def __getitem__(self, key: str) -> str: + # Scripts also read env["BOARD_MCU"]; without this the broad + # handler would discard every flag the script captured + return self._vars[key] + def Append(self, **kwargs) -> None: # noqa: N802 (SCons API name) for key, value in kwargs.items(): if key not in _CAPTURED_KEYS: diff --git a/esphome/platformio/registry.py b/esphome/platformio/registry.py index d7c9a92a2c..c4b79f5c8e 100644 --- a/esphome/platformio/registry.py +++ b/esphome/platformio/registry.py @@ -211,8 +211,14 @@ def prefetch_packages( [(entry[0], _fetch(entry)) for entry in pending], ) for name, err in failures: - # install_package retries this one itself, with a visible bar - _LOGGER.debug("Prefetch of %s failed: %s", name, err) + if isinstance(err, (EsphomeError, OSError)): + # Expected download failures: install_package retries this one + # itself, with a visible bar + _LOGGER.debug("Prefetch of %s failed: %s", name, err) + else: + # Anything else is a programming error that would otherwise + # become a permanent silent no-op + _LOGGER.warning("Prefetch of %s failed: %r", name, err) def install_package( diff --git a/tests/unit_tests/build_gen/test_arduino8266.py b/tests/unit_tests/build_gen/test_arduino8266.py index bb0f095344..ba32c12c97 100644 --- a/tests/unit_tests/build_gen/test_arduino8266.py +++ b/tests/unit_tests/build_gen/test_arduino8266.py @@ -1142,7 +1142,7 @@ 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"]) +@pytest.mark.parametrize("value", ["0x8000", "0xC000ul", "0x10UL"]) def test_mmu_custom_numeric_sizes_accepted(value: str) -> None: config = _resolve( "-DPIO_FRAMEWORK_ARDUINO_MMU_CUSTOM", @@ -1152,11 +1152,19 @@ def test_mmu_custom_numeric_sizes_accepted(value: str) -> None: assert f"MMU_IRAM_SIZE={value}" in config.mmu_defines -@pytest.mark.parametrize("flag", ["-DMMU_IRAM_SIZE=48K", "-DMMU_IRAM_SIZE"]) +@pytest.mark.parametrize( + "flag", + [ + "-DMMU_IRAM_SIZE=48K", + # Decimal passes preprocessing but build_surgery's segment parser + # only reads hex, so testing-mode surgery would fail misleadingly + "-DMMU_IRAM_SIZE=32768", + ], +) 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"): + """A non-hex size would corrupt the preprocessed segment lengths (or + defeat the testing-mode surgery); refuse by name.""" + with pytest.raises(EsphomeError, match="MMU_IRAM_SIZE must be a hex"): _resolve( "-DPIO_FRAMEWORK_ARDUINO_MMU_CUSTOM", flag, @@ -1164,6 +1172,32 @@ def test_mmu_custom_malformed_size_raises(flag: str) -> None: ) +def test_mmu_custom_valueless_switch_accepted_and_others_validated() -> None: + """Valueless MMU switches (MMU_IRAM_HEAP) pass; every valued MMU_* is + hex-validated, not just the two required sizes.""" + config = _resolve( + "-DPIO_FRAMEWORK_ARDUINO_MMU_CUSTOM", + "-DMMU_IRAM_SIZE=0x8000", + "-DMMU_ICACHE_SIZE=0x8000", + "-DMMU_IRAM_HEAP", + ) + assert "MMU_IRAM_HEAP" in config.mmu_defines + with pytest.raises(EsphomeError, match="MMU_SEC_HEAP_SIZE must be a hex"): + _resolve( + "-DPIO_FRAMEWORK_ARDUINO_MMU_CUSTOM", + "-DMMU_IRAM_SIZE=0x8000", + "-DMMU_ICACHE_SIZE=0x8000", + "-DMMU_SEC_HEAP_SIZE=48K", + ) + + +def test_mmu_no_knob_rejects_any_raw_mmu_flag() -> None: + """The no-knob branch refuses every raw MMU_*, like the knob branch; a + lone switch would win the compile line but not the linker script.""" + with pytest.raises(EsphomeError, match="Raw MMU_IRAM_HEAP"): + _resolve("-DMMU_IRAM_HEAP") + + 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.""" diff --git a/tests/unit_tests/test_platformio_extra_script.py b/tests/unit_tests/test_platformio_extra_script.py index d09f02d95f..73523f4bc0 100644 --- a/tests/unit_tests/test_platformio_extra_script.py +++ b/tests/unit_tests/test_platformio_extra_script.py @@ -169,6 +169,22 @@ def test_apply_extra_script_callable_target_and_str_flags(tmp_path) -> None: assert c.data["build"]["flags"] == ["-DBASE=1", "-lesp8266"] +def test_apply_extra_script_subscript_env_read(tmp_path) -> None: + """Scripts also read env["BOARD_MCU"]; the subscript form must work or + the broad handler discards every flag the script captured.""" + (tmp_path / "src").mkdir() + script = tmp_path / "extra.py" + script.write_text("env.Append(LIBS=[env['BOARD_MCU']])\n") + + c = IDFComponent("owner/name", "1.0", source=URLSource("http://dummy")) + c.path = tmp_path + c.data = {"build": {"extraScript": "extra.py"}} + + apply_extra_script(c, board_mcu=lambda: "esp8266", pio_platform="espressif8266") + + assert c.data["build"]["flags"] == ["-lesp8266"] + + def test_apply_extra_script_no_script_and_no_flags(tmp_path) -> None: # No extraScript declared: nothing happens, the target is never resolved diff --git a/tests/unit_tests/test_platformio_library.py b/tests/unit_tests/test_platformio_library.py index 232f939dec..927f561236 100644 --- a/tests/unit_tests/test_platformio_library.py +++ b/tests/unit_tests/test_platformio_library.py @@ -166,9 +166,13 @@ def caplog_at_info(): handler.emit = records.append logger = logging.getLogger("esphome.platformio.library") logger.addHandler(handler) + # The level must actually admit INFO or the no-INFO assertions are vacuous + old_level = logger.level + logger.setLevel(logging.INFO) try: yield records finally: + logger.setLevel(old_level) logger.removeHandler(handler) diff --git a/tests/unit_tests/test_platformio_registry.py b/tests/unit_tests/test_platformio_registry.py index 3f2b739ca9..46409ba62e 100644 --- a/tests/unit_tests/test_platformio_registry.py +++ b/tests/unit_tests/test_platformio_registry.py @@ -625,3 +625,26 @@ def test_prefetch_packages_download_failure_is_debug( assert mock_download.call_count == 2 assert "Prefetch of a failed" in caplog.text assert "Prefetch of b failed" in caplog.text + + +def test_prefetch_packages_unexpected_failure_warns( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A programming error (not a download failure) surfaces at WARNING + instead of becoming a permanent silent no-op.""" + with ( + patch.object( + registry, "download_with_resume", side_effect=TypeError("bad call") + ), + patch.object( + registry, "registry_download", side_effect=_resolve_for({"a": 10, "b": 20}) + ), + ): + registry.prefetch_packages( + [ + ("a", "1.0", tmp_path / "a", []), + ("b", "2.0", tmp_path / "b", []), + ], + tmp_path / "dl", + ) + assert "TypeError" in caplog.text