diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index e012117b4f..39c036e6d7 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -785,6 +785,7 @@ def _prefetch_idf_tool_archives( # The installer downloads anything missing itself; never let the # prefetch become a new way for the install to fail. _LOGGER.warning("ESP-IDF tool prefetch failed: %s", e) + _LOGGER.debug("Prefetch failure detail", exc_info=True) def _check_esphome_idf_framework_install( diff --git a/esphome/platformio/extra_script.py b/esphome/platformio/extra_script.py index 921fe2fd5d..363c40561f 100644 --- a/esphome/platformio/extra_script.py +++ b/esphome/platformio/extra_script.py @@ -114,10 +114,19 @@ class _FakeSConsEnv: self.result = ExtraScriptResult() self._warned_methods: set[str] = set() self._warned_keys: set[str] = set() + self._warned_gets: set[str] = set() # ----- SCons env API the common scripts use ----- def get(self, key: str, default: str | None = None) -> str | None: + if key not in self._vars and key not in self._warned_gets: + # A script branching on an unmodelled var silently takes the + # default branch; make that diagnosable from a normal build log + self._warned_gets.add(key) + _LOGGER.warning( + "PIO extra-script env.get(%r) is not modelled; returning the default", + key, + ) return self._vars.get(key, default) def __getitem__(self, key: str) -> str: @@ -140,6 +149,12 @@ class _FakeSConsEnv: bucket = getattr(self.result, key.lower()) bucket.extend(items) + # Same keys, same flattened capture; ordering/dedup don't matter since + # the consumer re-orders anyway + Prepend = Append + AppendUnique = Append + PrependUnique = Append + # ----- Everything else is a no-op so unsupported scripts don't crash ----- def __getattr__(self, name: str): @@ -267,7 +282,7 @@ def captured_as_build_flags( flags.extend( f"-L{shlex.quote(_anchored(path))}" for path in _strs(result.libpath, "LIBPATH") ) - flags.extend(f"-l{lib}" for lib in _strs(result.libs, "LIBS")) + flags.extend(f"-l{shlex.quote(lib)}" for lib in _strs(result.libs, "LIBS")) for define in result.cppdefines: # SCons also accepts dict/list CPPDEFINES; formatting those blind # would hand the compiler garbage like -D{'FOO': '1'} @@ -277,6 +292,8 @@ def captured_as_build_flags( flags.append(f"-D{define}") else: _LOGGER.warning("Ignoring unsupported CPPDEFINES entry %r", define) - flags.extend(_strs(result.linkflags, "LINKFLAGS")) - flags.extend(_strs(result.cppflags, "CPPFLAGS")) + # Each captured entry is one argv token in SCons; quote so the + # lex_build_flags round-trip cannot split a spaced value into two + flags.extend(shlex.quote(f) for f in _strs(result.linkflags, "LINKFLAGS")) + flags.extend(shlex.quote(f) for f in _strs(result.cppflags, "CPPFLAGS")) return flags diff --git a/esphome/platformio/library.py b/esphome/platformio/library.py index ce74d828da..e7ce437570 100644 --- a/esphome/platformio/library.py +++ b/esphome/platformio/library.py @@ -925,6 +925,12 @@ def _content_lengths(urls: list[str]) -> list[int | None]: """Content-Length per URL via HEAD requests; None when unknown.""" import requests + from esphome.happy_eyeballs import ensure_happy_eyeballs + + # Same convention as every other network call: without it a broken-IPv6 + # network burns the full timeout per HEAD before falling back + ensure_happy_eyeballs() + def head(url: str) -> int | None: try: resp = requests.head(url, timeout=10, allow_redirects=True) diff --git a/tests/unit_tests/test_platformio_extra_script.py b/tests/unit_tests/test_platformio_extra_script.py index 81de70cdf7..a4eeb5bf41 100644 --- a/tests/unit_tests/test_platformio_extra_script.py +++ b/tests/unit_tests/test_platformio_extra_script.py @@ -432,6 +432,41 @@ def test_run_extra_script_bad_encoding_is_best_effort(tmp_path, caplog) -> None: assert "is not UTF-8" in caplog.text +@pytest.mark.parametrize("method", ("Prepend", "AppendUnique", "PrependUnique")) +def test_append_variants_capture_like_append(method: str) -> None: + """Prepend/AppendUnique/PrependUnique write the captured keys too.""" + env = _FakeSConsEnv( + board_mcu="esp8266", pio_env="esphome_esp8266", pio_platform="espressif8266" + ) + getattr(env, method)(LIBS=["algobsec"], LIBPATH=["lib"]) + assert env.result.libs == ["algobsec"] + assert env.result.libpath == ["lib"] + + +def test_env_get_unknown_key_warns_once(caplog) -> None: + """A script branching on an unmodelled env var is diagnosable.""" + env = _FakeSConsEnv( + board_mcu="esp8266", pio_env="esphome_esp8266", pio_platform="espressif8266" + ) + assert env.get("BOARD") is None + assert env.get("BOARD", "d1") == "d1" + assert env.get("BOARD_MCU") == "esp8266" + assert caplog.text.count("env.get('BOARD') is not modelled") == 1 + assert "BOARD_MCU" not in caplog.text + + +def test_spaced_linkflag_survives_relexing(tmp_path) -> None: + """A captured argv token with a space stays one token after lexing.""" + result = ExtraScriptResult( + linkflags=["-Wl,-T my linker.ld"], cppflags=["-include my hdr.h"] + ) + flags = captured_as_build_flags(result, library_dir=tmp_path) + assert lex_build_flags(flags, "test") == [ + "-Wl,-T my linker.ld", + "-include my hdr.h", + ] + + def test_uncaptured_append_key_warns_once(caplog) -> None: """A loop of Appends to the same uncaptured key warns once."""