Files
esphome/esphome/platformio/runner.py
T

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())