mirror of
https://github.com/esphome/esphome.git
synced 2026-08-31 10:06:03 +00:00
192 lines
7.7 KiB
Python
192 lines
7.7 KiB
Python
"""Subprocess entry point that applies ESPHome's PlatformIO patches.
|
|
|
|
Invoked via ``python -m esphome.platformio.runner`` instead of
|
|
``python -m platformio`` so that the patches (incremental rebuild
|
|
preservation, download retries) apply inside the subprocess. Running
|
|
PlatformIO in a subprocess keeps its ``sys.path`` mutations and other
|
|
global state from leaking into the ESPHome process.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from pathlib import Path
|
|
import sys
|
|
import time
|
|
from typing import Any
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
|
|
def patch_structhash() -> None:
|
|
"""Avoid full rebuilds when files are added or removed.
|
|
|
|
PlatformIO clears the build dir whenever its structure hash changes.
|
|
We replace that with an mtime check against ``platformio.ini`` so
|
|
incremental builds are preserved unless the project config changed.
|
|
"""
|
|
from platformio.run import cli, helpers
|
|
|
|
def patched_clean_build_dir(build_dir, *_args):
|
|
from platformio import fs
|
|
from platformio.project.helpers import get_project_dir
|
|
|
|
platformio_ini = Path(get_project_dir()) / "platformio.ini"
|
|
build_dir = Path(build_dir)
|
|
|
|
if (
|
|
build_dir.is_dir()
|
|
and platformio_ini.stat().st_mtime > build_dir.stat().st_mtime
|
|
):
|
|
fs.rmtree(build_dir)
|
|
|
|
if not build_dir.is_dir():
|
|
build_dir.mkdir(parents=True)
|
|
|
|
helpers.clean_build_dir = patched_clean_build_dir
|
|
cli.clean_build_dir = patched_clean_build_dir
|
|
|
|
|
|
def patch_file_downloader() -> None:
|
|
"""Retry PlatformIO package downloads with exponential backoff.
|
|
|
|
PlatformIO's ``FileDownloader`` uses an ``HTTPSession`` without built-in
|
|
retry. We wrap ``__init__`` to retry on transient failures and close the
|
|
session between attempts so a new TCP connection can route to a different
|
|
CDN edge node. We catch both ``PackageException`` (raised when the server
|
|
returns a non-200 status such as 502/503) and ``OSError`` -- which covers
|
|
``requests.exceptions.ConnectionError``, ``ReadTimeout``, and
|
|
``ChunkedEncodingError`` (all subclasses of ``OSError``) that get raised
|
|
when the connection is aborted before a response is parsed.
|
|
"""
|
|
from platformio.package.download import FileDownloader
|
|
from platformio.package.exception import PackageException
|
|
|
|
if getattr(FileDownloader.__init__, "_esphome_patched", False):
|
|
return
|
|
|
|
original_init = FileDownloader.__init__
|
|
|
|
def patched_init(self, *args: Any, **kwargs: Any) -> None:
|
|
max_retries = 5
|
|
|
|
for attempt in range(max_retries):
|
|
try:
|
|
original_init(self, *args, **kwargs)
|
|
return
|
|
except (PackageException, OSError) as e:
|
|
if attempt < max_retries - 1:
|
|
delay = 2 ** (attempt + 1)
|
|
_LOGGER.warning(
|
|
"Package download failed: %s. "
|
|
"Retrying in %d seconds... (attempt %d/%d)",
|
|
str(e),
|
|
delay,
|
|
attempt + 1,
|
|
max_retries,
|
|
)
|
|
# pylint: disable=protected-access,broad-except
|
|
try:
|
|
if (
|
|
hasattr(self, "_http_response")
|
|
and self._http_response is not None
|
|
):
|
|
self._http_response.close()
|
|
if hasattr(self, "_http_session"):
|
|
self._http_session.close()
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
# pylint: enable=protected-access,broad-except
|
|
time.sleep(delay)
|
|
else:
|
|
raise
|
|
|
|
patched_init._esphome_patched = True # type: ignore[attr-defined] # pylint: disable=protected-access
|
|
FileDownloader.__init__ = patched_init
|
|
|
|
|
|
_IGNORE_LIB_WARNINGS = "(?:Hash|Update)"
|
|
# Regex patterns matched against each line of PlatformIO output. Lines that
|
|
# match are dropped by RedirectText before they reach the parent process.
|
|
# Patterns are anchored at the start of the line (RedirectText uses
|
|
# ``re.match``). Disabled when the user passes ``-v`` / ``--verbose`` to
|
|
# ``esphome compile``.
|
|
FILTER_PLATFORMIO_LINES = [
|
|
r"Verbose mode can be enabled via `-v, --verbose` option.*",
|
|
r"CONFIGURATION: https://docs.platformio.org/.*",
|
|
r"DEBUG: Current.*",
|
|
r"LDF Modes:.*",
|
|
r"LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf.*",
|
|
f"Looking for {_IGNORE_LIB_WARNINGS} library in registry",
|
|
f"Warning! Library `.*'{_IGNORE_LIB_WARNINGS}.*` has not been found in PlatformIO Registry.",
|
|
f"You can ignore this message, if `.*{_IGNORE_LIB_WARNINGS}.*` is a built-in library.*",
|
|
r"Scanning dependencies...",
|
|
r"Found \d+ compatible libraries",
|
|
r"Memory Usage -> https://bit.ly/pio-memory-usage",
|
|
r"Found: https://platformio.org/lib/show/.*",
|
|
r"Using cache: .*",
|
|
r"Installing dependencies",
|
|
r"Library Manager: Already installed, built-in library",
|
|
r"Building in .* mode",
|
|
r"Advanced Memory Usage is available via .*",
|
|
r"Merged .* ELF section",
|
|
r"esptool.py v.*",
|
|
r"esptool v.*",
|
|
r"Checking size .*",
|
|
r"Retrieving maximum program size .*",
|
|
r"PLATFORM: .*",
|
|
r"PACKAGES:.*",
|
|
r" - framework-arduinoespressif.* \(.*\)",
|
|
r" - tool-esptool.* \(.*\)",
|
|
r" - toolchain-.* \(.*\)",
|
|
r"Creating BIN file .*",
|
|
r"Warning! Could not find file \".*.crt\"",
|
|
r"Warning! Arduino framework as an ESP-IDF component doesn't handle the `variant` field! The default `esp32` variant will be used.",
|
|
r"Warning: DEPRECATED: 'esptool.py' is deprecated. Please use 'esptool' instead. The '.py' suffix will be removed in a future major release.",
|
|
r"Warning: esp-idf-size exited with code 2",
|
|
r"esp_idf_size: error: unrecognized arguments: --ng",
|
|
r"Package configuration completed successfully",
|
|
]
|
|
|
|
|
|
def main() -> int:
|
|
patch_structhash()
|
|
patch_file_downloader()
|
|
|
|
# Wrap stdout/stderr with RedirectText before PlatformIO runs:
|
|
#
|
|
# 1. RedirectText.isatty() unconditionally returns True. Click, tqdm, and
|
|
# PlatformIO's own progress-bar code check ``stream.isatty()`` to
|
|
# decide whether to emit TTY-format output (``\r`` cursor moves, ANSI
|
|
# colors, fancy progress bars). With the wrapper in place they always
|
|
# emit TTY format, even when our real stdout is a pipe to the parent
|
|
# process. Downstream consumers (local terminals and the Home
|
|
# Assistant dashboard log viewer) render the TTY control sequences
|
|
# correctly, so the user sees real progress bars.
|
|
#
|
|
# 2. FILTER_PLATFORMIO_LINES is applied inside RedirectText.write() in
|
|
# this subprocess, so noisy PlatformIO output is dropped before it
|
|
# ever leaves the runner. This replaces the parent-side filtering
|
|
# that was lost when we switched from in-process to subprocess — the
|
|
# parent's ``subprocess.run`` uses ``.fileno()`` on RedirectText and
|
|
# bypasses its ``write()`` path entirely.
|
|
#
|
|
# Filtering is disabled when the user passed -v / --verbose to
|
|
# ``esphome compile``, preserving the previous in-process behavior where
|
|
# verbose mode let all PlatformIO output through unfiltered.
|
|
from esphome.util import RedirectText
|
|
|
|
is_verbose = any(arg in ("-v", "--verbose") for arg in sys.argv[1:])
|
|
filter_lines = None if is_verbose else FILTER_PLATFORMIO_LINES
|
|
|
|
sys.stdout = RedirectText(sys.stdout, filter_lines=filter_lines)
|
|
sys.stderr = RedirectText(sys.stderr, filter_lines=filter_lines)
|
|
|
|
import platformio.__main__
|
|
|
|
return platformio.__main__.main() or 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|