mirror of
https://github.com/esphome/esphome.git
synced 2026-08-23 06:36:23 +00:00
Compare commits
353
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
230b6926ab | ||
|
|
4f315ae8ba | ||
|
|
4e88823faf | ||
|
|
ec6123e0ef | ||
|
|
ad2a3e120a | ||
|
|
e5fcaf3892 | ||
|
|
af16c444d4 | ||
|
|
327630523c | ||
|
|
d5e3d44fd4 | ||
|
|
32097fcb00 | ||
|
|
4bb0fa8761 | ||
|
|
1b109518f7 | ||
|
|
fd077062c9 | ||
|
|
a1d0713a54 | ||
|
|
9e96d53868 | ||
|
|
905c806009 | ||
|
|
99ec278c67 | ||
|
|
6b7a56be67 | ||
|
|
6a0e7dc595 | ||
|
|
6fda5b9a07 | ||
|
|
f65aa87450 | ||
|
|
2dfa23f5e5 | ||
|
|
e4911b4cc2 | ||
|
|
e714ef4dfc | ||
|
|
f0203528f5 | ||
|
|
cd291fb35b | ||
|
|
b9317de2e8 | ||
|
|
4d6300b400 | ||
|
|
a5e87d3c8f | ||
|
|
ba1ad765ea | ||
|
|
767bb61a14 | ||
|
|
21abf1692b | ||
|
|
a22c2a9768 | ||
|
|
32a0fff6b8 | ||
|
|
2f0ad8a57d | ||
|
|
75150fda52 | ||
|
|
791e2b8204 | ||
|
|
ce52b93249 | ||
|
|
44d447ada0 | ||
|
|
f49a5879c3 | ||
|
|
918444ecbb | ||
|
|
d26d3fdc33 | ||
|
|
3d540b1597 | ||
|
|
ca79cb1a20 | ||
|
|
e557e84468 | ||
|
|
5c30acfe5b | ||
|
|
cc27854fb2 | ||
|
|
044d593f09 | ||
|
|
68e04f5982 | ||
|
|
2218743c4a | ||
|
|
de709dec71 | ||
|
|
2e3dc3c514 | ||
|
|
149ca8c954 | ||
|
|
45c4a9b143 | ||
|
|
bf0a4ea40f | ||
|
|
0d33c3bf07 | ||
|
|
36b0b62a8f | ||
|
|
a174c306d8 | ||
|
|
35f3087fcc | ||
|
|
8475a4bf09 | ||
|
|
3e4ace6b84 | ||
|
|
d72e2f1c60 | ||
|
|
cbbc0a8fa9 | ||
|
|
1f0fa0ef8b | ||
|
|
225c3f4cdb | ||
|
|
3697401bc7 | ||
|
|
c3297c2663 | ||
|
|
c3129d598c | ||
|
|
82b20313b0 | ||
|
|
27aef88077 | ||
|
|
a5fde9429d | ||
|
|
e63d995d1f | ||
|
|
ad0859c5bd | ||
|
|
0c5586c7dc | ||
|
|
4ece0da68c | ||
|
|
712ee229e5 | ||
|
|
80c9c4cef3 | ||
|
|
bc98ba3b46 | ||
|
|
3974a810bc | ||
|
|
dcf921d5f6 | ||
|
|
466d67788e | ||
|
|
f2f5ea127e | ||
|
|
2acde317d2 | ||
|
|
d1d2f186ab | ||
|
|
11964b86a8 | ||
|
|
a7777e4589 | ||
|
|
4e6a8e297e | ||
|
|
2315f54f2e | ||
|
|
7f6aaeb46c | ||
|
|
7a938af0f8 | ||
|
|
689861ffc9 | ||
|
|
776b3844f9 | ||
|
|
b888d41cb9 | ||
|
|
8bcf42921a | ||
|
|
b3f058613c | ||
|
|
97701122be | ||
|
|
f1d043c840 | ||
|
|
1652088009 | ||
|
|
4cbfdd3200 | ||
|
|
520bf3f63b | ||
|
|
1f2b532394 | ||
|
|
a2a93cc186 | ||
|
|
6171561b70 | ||
|
|
91836a8c69 | ||
|
|
35af304911 | ||
|
|
4aba6c4670 | ||
|
|
d653a550bb | ||
|
|
f599e37375 | ||
|
|
6f79518ee2 | ||
|
|
961bff4790 | ||
|
|
cb02e15184 | ||
|
|
910147139a | ||
|
|
ab49aff931 | ||
|
|
8a4ade10e6 | ||
|
|
8cffd44ffd | ||
|
|
e16008fc58 | ||
|
|
35a1136bea | ||
|
|
622be18709 | ||
|
|
ade6dbabae | ||
|
|
182dc7f636 | ||
|
|
d7af5f37b6 | ||
|
|
4e48764bbe | ||
|
|
deecfade13 | ||
|
|
ec237a2d06 | ||
|
|
a2267eb686 | ||
|
|
62a4cb3fe4 | ||
|
|
d3eaac6f47 | ||
|
|
2eec194d87 | ||
|
|
3a9a6edc0e | ||
|
|
51221688f6 | ||
|
|
f695e9dceb | ||
|
|
d8f6368716 | ||
|
|
a97b836012 | ||
|
|
2d2750e031 | ||
|
|
0d508ccbc0 | ||
|
|
bf7a617934 | ||
|
|
803eef7de6 | ||
|
|
ae2893992a | ||
|
|
6b46e9fd03 | ||
|
|
2f053371ce | ||
|
|
3bd4cdb760 | ||
|
|
9533c09b69 | ||
|
|
2dac9112be | ||
|
|
478584354e | ||
|
|
9c7f68b813 | ||
|
|
b62d59ca26 | ||
|
|
e52703617e | ||
|
|
57cbadbe9f | ||
|
|
a6ca775a48 | ||
|
|
315f41e884 | ||
|
|
4a1138820d | ||
|
|
7c07276c71 | ||
|
|
60cf44de3b | ||
|
|
072b65c0e2 | ||
|
|
9676e0c0f0 | ||
|
|
cedecf1f83 | ||
|
|
f28be76c84 | ||
|
|
cdde9dd230 | ||
|
|
935db6a63c | ||
|
|
90b29e231d | ||
|
|
315abf21f8 | ||
|
|
24e5b1800f | ||
|
|
11eca53a18 | ||
|
|
f08e06f574 | ||
|
|
27f4db8458 | ||
|
|
4eb31c45da | ||
|
|
4bea2dd058 | ||
|
|
db0e52a36b | ||
|
|
6a963ba794 | ||
|
|
cbf9e0ab14 | ||
|
|
a527b21761 | ||
|
|
4d63564cdd | ||
|
|
012b571a2e | ||
|
|
4a16365f13 | ||
|
|
587873a2da | ||
|
|
4101ad4fb1 | ||
|
|
e9d6132c61 | ||
|
|
7298eed5b3 | ||
|
|
78a1e88915 | ||
|
|
d8c02221cc | ||
|
|
76d8c9eb46 | ||
|
|
a703acf85f | ||
|
|
7fb18f395f | ||
|
|
97ed253c38 | ||
|
|
cbf1241401 | ||
|
|
33aea420ac | ||
|
|
e3d29e9fff | ||
|
|
4dc2d34399 | ||
|
|
c07a5a8da4 | ||
|
|
93a768531d | ||
|
|
e3cf2407ca | ||
|
|
45cbe0aa4b | ||
|
|
1f1cab9c37 | ||
|
|
85335de839 | ||
|
|
0f7cd3fada | ||
|
|
653f9e8f2a | ||
|
|
510f667bd1 | ||
|
|
c93cf55967 | ||
|
|
f3f6fe8a7d | ||
|
|
efe1b0e744 | ||
|
|
6cb446baf8 | ||
|
|
0fa081cd80 | ||
|
|
8fd8766167 | ||
|
|
225c62dbf1 | ||
|
|
0b0194cf41 | ||
|
|
8b9773c4bd | ||
|
|
078bcba473 | ||
|
|
85acafb646 | ||
|
|
18a2aa2097 | ||
|
|
88f1c4474f | ||
|
|
6ed01b06c3 | ||
|
|
24eb6354f9 | ||
|
|
355d02ee6a | ||
|
|
f5ac480dff | ||
|
|
19555e8eaf | ||
|
|
d47dc0d992 | ||
|
|
6a363c1746 | ||
|
|
b0a214830a | ||
|
|
38744e4222 | ||
|
|
9624abcd57 | ||
|
|
5857bfcf52 | ||
|
|
378a01a838 | ||
|
|
e266a449e0 | ||
|
|
6a0ce046fd | ||
|
|
a0e82cd000 | ||
|
|
a56c465d6d | ||
|
|
b3d908d10e | ||
|
|
09fd3fb53b | ||
|
|
a255f3b07c | ||
|
|
57b69cae81 | ||
|
|
9fccf1ce33 | ||
|
|
b8bb4453e0 | ||
|
|
c1a0ed0d46 | ||
|
|
4969d9bf4a | ||
|
|
1c5441b69e | ||
|
|
ec8437fea2 | ||
|
|
8513dfeebd | ||
|
|
b42babf749 | ||
|
|
e1c4984498 | ||
|
|
3063b9067a | ||
|
|
a73180e073 | ||
|
|
b7a3181d13 | ||
|
|
1958746ae0 | ||
|
|
62c27826ee | ||
|
|
cc3f9be805 | ||
|
|
b05efb0811 | ||
|
|
321bc3b459 | ||
|
|
84a3c39562 | ||
|
|
b3afd951fe | ||
|
|
8dd065997f | ||
|
|
13e84e9bff | ||
|
|
7ad97697b2 | ||
|
|
878883bb67 | ||
|
|
8c671ae8ea | ||
|
|
ac1160de25 | ||
|
|
5cec72020f | ||
|
|
0268abc1e4 | ||
|
|
c103e4d974 | ||
|
|
06fc234b12 | ||
|
|
12f8b14418 | ||
|
|
b0b3017d78 | ||
|
|
47780fec6e | ||
|
|
c3e7d37d29 | ||
|
|
77fa411f04 | ||
|
|
1bdddcc55e | ||
|
|
a5a1a017d0 | ||
|
|
9f3df90dba | ||
|
|
e20c43520b | ||
|
|
eb38afb569 | ||
|
|
60896b3533 | ||
|
|
46baef5231 | ||
|
|
f93d46e684 | ||
|
|
14a58b788d | ||
|
|
454f299cd2 | ||
|
|
70a3880c9f | ||
|
|
8008eda57f | ||
|
|
8af1b9a523 | ||
|
|
9c543c9776 | ||
|
|
fd47d5bcd2 | ||
|
|
57b7f03e42 | ||
|
|
96aabe428a | ||
|
|
cb2dfddf17 | ||
|
|
20f0e37f43 | ||
|
|
f4eefa111d | ||
|
|
2bbca6229a | ||
|
|
a50c4db187 | ||
|
|
1ecbb57b24 | ||
|
|
2e0b330366 | ||
|
|
aea97d1022 | ||
|
|
65f5c302c5 | ||
|
|
16be3b1bbb | ||
|
|
84940f1726 | ||
|
|
519ea38d73 | ||
|
|
51af18cdfb | ||
|
|
efacdd57ac | ||
|
|
2280d3352d | ||
|
|
0fad14be59 | ||
|
|
3d8a3c6527 | ||
|
|
e618c99f7d | ||
|
|
89df2787f2 | ||
|
|
53f1cc116a | ||
|
|
c8e816bf48 | ||
|
|
53935615f1 | ||
|
|
360c51329c | ||
|
|
2e183f262b | ||
|
|
37de984df5 | ||
|
|
c96c05eea4 | ||
|
|
2cf4087e2f | ||
|
|
5b19c139cb | ||
|
|
e187522490 | ||
|
|
79b8bf2657 | ||
|
|
e36b8c5446 | ||
|
|
9c52c349d6 | ||
|
|
d4861d89b0 | ||
|
|
3166136db0 | ||
|
|
8f5f349e1b | ||
|
|
83b4da1777 | ||
|
|
e6debb980d | ||
|
|
b3d5fccba5 | ||
|
|
6a84d67786 | ||
|
|
b90aeb17e3 | ||
|
|
3a51514fc1 | ||
|
|
6ad3e0d8ad | ||
|
|
5a5496f7f7 | ||
|
|
9d9953ea47 | ||
|
|
7d9c18a330 | ||
|
|
113259275c | ||
|
|
38b6f222b5 | ||
|
|
c72de504fd | ||
|
|
ff167e5193 | ||
|
|
2861cbb749 | ||
|
|
8bf699cf79 | ||
|
|
375cf95240 | ||
|
|
f44398b09e | ||
|
|
1307cc25c5 | ||
|
|
353ef2dee1 | ||
|
|
18e3eac419 | ||
|
|
d9cc72c650 | ||
|
|
0fe9d87124 | ||
|
|
5398ca98eb | ||
|
|
bbf2788684 | ||
|
|
c7e1d044a3 | ||
|
|
0620ce81c3 | ||
|
|
fa45c4a016 | ||
|
|
3d7c0eac57 | ||
|
|
e3936f500f | ||
|
|
cea86989c9 | ||
|
|
1db7309f4a | ||
|
|
1d0a1d2b38 | ||
|
|
46fa42fa33 | ||
|
|
3e77654719 | ||
|
|
04ad9524f5 | ||
|
|
72da2b1ce2 |
+9
-8
@@ -2734,10 +2734,14 @@ def run_esphome(argv):
|
||||
# Skipped when -s overrides are passed, since the cache was written
|
||||
# against the previous substitution set.
|
||||
config: ConfigType | None = None
|
||||
cache_eligible = (
|
||||
cache_write_eligible = (
|
||||
args.command in ("upload", "logs") and not command_line_substitutions
|
||||
)
|
||||
if cache_eligible:
|
||||
# An explicit --toolchain must re-run the per-platform validators, so
|
||||
# gate only the cache read; the refresh below saves the result unless
|
||||
# the sidecar records a different toolchain.
|
||||
cache_read_eligible = cache_write_eligible and args.toolchain is None
|
||||
if cache_read_eligible:
|
||||
from esphome.compiled_config import load_compiled_config
|
||||
|
||||
config = load_compiled_config(conf_path)
|
||||
@@ -2761,17 +2765,14 @@ def run_esphome(argv):
|
||||
return 2
|
||||
CORE.config = config
|
||||
|
||||
# Fallback for platforms whose validators didn't set the toolchain
|
||||
# (only the esp32 component reads esp32.framework.toolchain). All
|
||||
# other platforms only support PlatformIO today. Must run before the
|
||||
# cache refresh below so its sidecar records the same toolchain a
|
||||
# compile would.
|
||||
# The cache fast path skips validation, and legacy sidecars lack the
|
||||
# toolchain field. Must run before the cache refresh below.
|
||||
if CORE.toolchain is None:
|
||||
CORE.toolchain = Toolchain.PLATFORMIO
|
||||
|
||||
# Refresh the cache so the next upload/logs hits the fast path
|
||||
# instead of re-running read_config.
|
||||
if cache_eligible and cache_missed:
|
||||
if cache_write_eligible and cache_missed:
|
||||
from esphome.compiled_config import save_compiled_config_and_sidecar
|
||||
|
||||
save_compiled_config_and_sidecar(config)
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
"""Native (PlatformIO-free) build support for the ESP8266 Arduino core.
|
||||
|
||||
This package downloads the Arduino ESP8266 core and the xtensa-lx106
|
||||
toolchain, generates a ninja build for them plus the ESPHome sources, and
|
||||
drives the build directly — the ESP8266 equivalent of ``esphome.espidf``.
|
||||
|
||||
Deliberately importable without the esp8266 component to avoid circular
|
||||
imports; the component wires these modules in via lazy imports.
|
||||
"""
|
||||
@@ -0,0 +1,164 @@
|
||||
"""Download and install the Arduino ESP8266 core, toolchain, and ninja.
|
||||
|
||||
Artifacts land in a machine-global cache (shared across projects, like the
|
||||
ESP-IDF install in ``esphome.espidf.framework``):
|
||||
|
||||
<cache>/arduino8266/frameworks/<version>/ framework-arduinoespressif8266
|
||||
<cache>/arduino8266/toolchains/<version>/ toolchain-xtensa (gcc 10.3)
|
||||
|
||||
Packages come from the PlatformIO registry (identical bits to the PlatformIO
|
||||
backend); ``ESPHOME_ARDUINO8266_*_MIRRORS`` overrides the URLs. ninja comes
|
||||
from PATH or the ninja PyPI wheel.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import NamedTuple
|
||||
|
||||
from esphome.build_helpers.ccache import ccache_defaults_env
|
||||
from esphome.build_helpers.ninja import find_ninja
|
||||
from esphome.build_helpers.tools_cache import ARDUINO8266_TOOLS_CACHE, tools_cache_path
|
||||
from esphome.core import EsphomeError, Version
|
||||
from esphome.framework_helpers import str_to_lst_of_str
|
||||
from esphome.platformio.registry import install_package, prefetch_packages
|
||||
|
||||
FRAMEWORK_PACKAGE = "framework-arduinoespressif8266"
|
||||
TOOLCHAIN_PACKAGE = "toolchain-xtensa"
|
||||
# gcc 10.3, the toolchain Arduino core 3.x builds with; the build
|
||||
# generator's compile flags are tuned to it.
|
||||
TOOLCHAIN_VERSION = "2.100300.220621"
|
||||
|
||||
ESPHOME_ARDUINO8266_FRAMEWORK_MIRRORS = str_to_lst_of_str(
|
||||
os.environ.get("ESPHOME_ARDUINO8266_FRAMEWORK_MIRRORS", "")
|
||||
)
|
||||
ESPHOME_ARDUINO8266_TOOLCHAIN_MIRRORS = str_to_lst_of_str(
|
||||
os.environ.get("ESPHOME_ARDUINO8266_TOOLCHAIN_MIRRORS", "")
|
||||
)
|
||||
|
||||
|
||||
def get_arduino8266_tools_path() -> Path:
|
||||
# Machine-global so all projects share one install; see
|
||||
# espidf.framework.get_idf_tools_path for the location rationale.
|
||||
return tools_cache_path(*ARDUINO8266_TOOLS_CACHE)
|
||||
|
||||
|
||||
# 3.1.1 rather than 3.1.0: the registry has no package for 3.1.0, and the
|
||||
# encoder below cannot name 3.0.0/3.0.1 either (see its docstring)
|
||||
MIN_FRAMEWORK_VERSION = Version(3, 1, 1)
|
||||
|
||||
|
||||
def framework_package_version(ver: Version) -> str:
|
||||
"""Map an Arduino core version to its registry package version (3.1.2 ->
|
||||
3.30102.0; the leading 3 is the package major).
|
||||
|
||||
Exact registry names only for cores > 2.6.2 and >= 3.0.2; callers floor
|
||||
at MIN_FRAMEWORK_VERSION.
|
||||
"""
|
||||
if ver.major > 3:
|
||||
raise EsphomeError(
|
||||
f"Arduino core {ver} is not supported yet; "
|
||||
"the newest known core series is 3.x"
|
||||
)
|
||||
if ver <= Version(2, 6, 2):
|
||||
# Cores <= 2.6.2 use the older 1.x/2.x package-major encodings (same
|
||||
# boundary as _format_framework_arduino_version's era guard)
|
||||
raise EsphomeError(
|
||||
f"Arduino core {ver} uses an older package encoding than this "
|
||||
"helper implements (newer than 2.6.2)"
|
||||
)
|
||||
return f"3.{ver.major}{ver.minor:02d}{ver.patch:02d}.0"
|
||||
|
||||
|
||||
def get_framework_path(package_version: str) -> Path:
|
||||
return get_arduino8266_tools_path() / "frameworks" / package_version
|
||||
|
||||
|
||||
def get_toolchain_path() -> Path:
|
||||
return get_arduino8266_tools_path() / "toolchains" / TOOLCHAIN_VERSION
|
||||
|
||||
|
||||
class InstalledPaths(NamedTuple):
|
||||
"""Locations of the installed framework, toolchain, and ninja binary."""
|
||||
|
||||
framework: Path
|
||||
toolchain: Path
|
||||
ninja: Path
|
||||
|
||||
|
||||
def check_and_install(framework_version: Version) -> InstalledPaths:
|
||||
"""Ensure framework, toolchain, and ninja are installed; return their paths."""
|
||||
if framework_version < MIN_FRAMEWORK_VERSION:
|
||||
# Config validation enforces this too; keep the module honest when
|
||||
# called directly.
|
||||
raise EsphomeError(
|
||||
f"The native toolchain requires the Arduino core "
|
||||
f">= {MIN_FRAMEWORK_VERSION}, got {framework_version}"
|
||||
)
|
||||
# Probe the cheap local dependency before ~110 MB of downloads
|
||||
ninja_path = find_ninja()
|
||||
package_version = framework_package_version(framework_version)
|
||||
framework_path = get_framework_path(package_version)
|
||||
downloads_dir = get_arduino8266_tools_path() / "downloads"
|
||||
toolchain_path = get_toolchain_path()
|
||||
# One spec per package: the prefetch and the installs must agree
|
||||
specs = (
|
||||
(
|
||||
FRAMEWORK_PACKAGE,
|
||||
package_version,
|
||||
framework_path,
|
||||
ESPHOME_ARDUINO8266_FRAMEWORK_MIRRORS,
|
||||
("cores/esp8266", "tools/sdk", "libraries"),
|
||||
),
|
||||
(
|
||||
TOOLCHAIN_PACKAGE,
|
||||
TOOLCHAIN_VERSION,
|
||||
toolchain_path,
|
||||
ESPHOME_ARDUINO8266_TOOLCHAIN_MIRRORS,
|
||||
# xtensa-lx106-elf pins the target: every gcc package has a bin/
|
||||
("bin", "xtensa-lx106-elf"),
|
||||
),
|
||||
)
|
||||
# Fetch both archives at once; the installs below verify and extract
|
||||
prefetch_packages([spec[:4] for spec in specs], downloads_dir)
|
||||
for name, version, dest, mirrors, expect in specs:
|
||||
install_package(name, version, dest, mirrors, downloads_dir, expect=expect)
|
||||
return InstalledPaths(
|
||||
framework=framework_path, toolchain=toolchain_path, ninja=ninja_path
|
||||
)
|
||||
|
||||
|
||||
def toolchain_tool(toolchain_path: Path, name: str) -> Path:
|
||||
"""Path to one toolchain tool (gcc, g++, ar, size, addr2line, ...).
|
||||
|
||||
The single owner of the ``bin/xtensa-lx106-elf-<name>`` layout and the
|
||||
Windows suffix, so a toolchain package bump touches one spot.
|
||||
"""
|
||||
suffix = ".exe" if os.name == "nt" else ""
|
||||
return toolchain_path / "bin" / f"xtensa-lx106-elf-{name}{suffix}"
|
||||
|
||||
|
||||
def get_build_env(toolchain_path: Path, ccache: str | None) -> dict[str, str]:
|
||||
env = os.environ.copy()
|
||||
# Drop empty entries: a trailing separator from an absent PATH would
|
||||
# make the shell search the current directory for tools
|
||||
parts = [
|
||||
str(toolchain_path / "bin"),
|
||||
*filter(None, env.get("PATH", "").split(os.pathsep)),
|
||||
]
|
||||
env["PATH"] = os.pathsep.join(parts)
|
||||
env.update(ccache_env(ccache))
|
||||
return env
|
||||
|
||||
|
||||
def ccache_env(ccache: str | None) -> dict[str, str]:
|
||||
"""Return ccache settings for the build subprocess (not os.environ).
|
||||
|
||||
``ccache`` is the pre-resolved binary (resolve_ccache_path), or None
|
||||
when disabled. Values the user already set in the environment are
|
||||
respected.
|
||||
"""
|
||||
if ccache is None:
|
||||
return {}
|
||||
return ccache_defaults_env(get_arduino8266_tools_path() / "ccache")
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Shared ccache policy for build backends: env-knob parsing, binary
|
||||
resolution, and default ``CCACHE_*`` values."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from esphome.framework_helpers import strip_win_long_path_prefix, tool_version_runs
|
||||
from esphome.helpers import FALSY_BOOL_STRINGS, TRUTHY_BOOL_STRINGS
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# cv.boolean's spelling tables plus the 1/0 env convention
|
||||
TRUTHY_ENV_STRINGS = TRUTHY_BOOL_STRINGS | {"1"}
|
||||
FALSY_ENV_STRINGS = FALSY_BOOL_STRINGS | {"0"}
|
||||
|
||||
|
||||
def _ccache_runs(ccache: str) -> bool:
|
||||
"""Return True when the ``ccache`` found on PATH actually runs."""
|
||||
return tool_version_runs(
|
||||
ccache,
|
||||
"Ignoring ccache at %s because it failed to run; compiling without ccache",
|
||||
)
|
||||
|
||||
|
||||
def parse_enable_env(name: str) -> bool | None:
|
||||
"""Strictly parse an on/off environment knob; None when unset or invalid.
|
||||
|
||||
``bool(str)`` truthiness would flip ``no``/``off`` to enabled, so only
|
||||
1/true/yes/on and 0/false/no/off count; anything else warns and reads
|
||||
as unset so the caller's default policy applies.
|
||||
"""
|
||||
raw = os.environ.get(name)
|
||||
if raw is None:
|
||||
return None
|
||||
lowered = raw.strip().lower()
|
||||
if lowered in TRUTHY_ENV_STRINGS:
|
||||
return True
|
||||
if lowered in FALSY_ENV_STRINGS:
|
||||
return False
|
||||
_LOGGER.warning("Ignoring unrecognized %s=%r; use 1 or 0", name, raw)
|
||||
return None
|
||||
|
||||
|
||||
def resolve_ccache_path() -> str | None:
|
||||
"""The ccache binary to wrap compiles with, or None when disabled.
|
||||
|
||||
An explicit ``ESPHOME_CCACHE_ENABLE=1`` skips the runnability probe; the
|
||||
Windows extended-length prefix is stripped before probing (#18399).
|
||||
"""
|
||||
import shutil
|
||||
|
||||
explicit = parse_enable_env("ESPHOME_CCACHE_ENABLE")
|
||||
if explicit is False:
|
||||
return None
|
||||
ccache = shutil.which("ccache")
|
||||
if ccache is None:
|
||||
if explicit:
|
||||
_LOGGER.warning(
|
||||
"ESPHOME_CCACHE_ENABLE is set but no ccache binary is on PATH; "
|
||||
"compiling without ccache"
|
||||
)
|
||||
return None
|
||||
ccache = strip_win_long_path_prefix(ccache)
|
||||
if not explicit and not _ccache_runs(ccache):
|
||||
return None
|
||||
return ccache
|
||||
|
||||
|
||||
def ccache_defaults_env(cache_dir: Path) -> dict[str, str]:
|
||||
"""Default ``CCACHE_*`` values for a build subprocess (not os.environ).
|
||||
|
||||
Values the user already set in the environment are respected. Depend
|
||||
mode is on: both native backends emit depfiles (-MMD / CMake), which
|
||||
keeps cache-miss overhead low.
|
||||
"""
|
||||
from esphome.core import CORE
|
||||
|
||||
# An unset build_path means the env was built before preload; fail loudly
|
||||
# rather than silently drop CCACHE_BASEDIR.
|
||||
if CORE.build_path is None:
|
||||
raise ValueError(
|
||||
"CORE.build_path must be set before constructing the build environment"
|
||||
)
|
||||
defaults = {
|
||||
"CCACHE_DIR": str(cache_dir),
|
||||
"CCACHE_NOHASHDIR": "true",
|
||||
"CCACHE_DEPEND": "1",
|
||||
"CCACHE_BASEDIR": str(Path(CORE.build_path).resolve()),
|
||||
}
|
||||
return {k: v for k, v in defaults.items() if k not in os.environ}
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Platform-neutral helpers for ninja-driven native builds."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
|
||||
from esphome.core import EsphomeError
|
||||
from esphome.framework_helpers import strip_win_long_path_prefix, tool_version_runs
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _ninja_runs(binary: str) -> bool:
|
||||
"""Whether the ninja found on PATH actually runs (see tool_version_runs)."""
|
||||
return tool_version_runs(
|
||||
binary,
|
||||
"Ignoring ninja at %s because it failed to run; "
|
||||
"falling back to the bundled wheel",
|
||||
)
|
||||
|
||||
|
||||
def find_ninja() -> Path:
|
||||
"""Locate the ninja binary: a runnable PATH hit first, else the ninja
|
||||
PyPI wheel."""
|
||||
if binary := shutil.which("ninja"):
|
||||
binary = strip_win_long_path_prefix(binary)
|
||||
if _ninja_runs(binary):
|
||||
return Path(binary)
|
||||
import_error: ImportError | None = None
|
||||
try:
|
||||
import ninja
|
||||
except ImportError as err:
|
||||
import_error = err
|
||||
wheel_binary = None
|
||||
else:
|
||||
wheel_binary = Path(ninja.BIN_DIR) / (
|
||||
"ninja.exe" if os.name == "nt" else "ninja"
|
||||
)
|
||||
if wheel_binary is None or not wheel_binary.is_file():
|
||||
raise EsphomeError(
|
||||
"ninja not found on PATH or in the ninja package; reinstall the "
|
||||
"esphome Python environment"
|
||||
) from import_error
|
||||
return wheel_binary
|
||||
|
||||
|
||||
def escape(value: Path | str) -> str:
|
||||
"""Escape a path or token for a ninja file."""
|
||||
return str(value).replace("$", "$$").replace(":", "$:").replace(" ", "$ ")
|
||||
|
||||
|
||||
def quote_arg(tok: str) -> str:
|
||||
"""Quote with the CreateProcess argv rule (as ``subprocess.list2cmdline``):
|
||||
backslash runs double only before a quote. Windows-only; ``$`` must
|
||||
already be doubled for ninja.
|
||||
"""
|
||||
quoted = re.sub(r'(\\*)"', lambda m: m.group(1) * 2 + '\\"', tok)
|
||||
quoted = re.sub(r"(\\+)\Z", lambda m: m.group(1) * 2, quoted)
|
||||
return f'"{quoted}"'
|
||||
|
||||
|
||||
# Force-quote any token containing a character outside the shlex.quote-style
|
||||
# safe set: ninja hands POSIX commands to /bin/sh -c, so bare (, ;, <, *, `
|
||||
# and friends would be re-parsed as shell syntax.
|
||||
_NEEDS_QUOTE = re.compile(r"[^\w@%+=:,./-]")
|
||||
|
||||
|
||||
def shell_token(tok: str, force: bool = False) -> str:
|
||||
"""Re-quote a lexed token for the platform shell; ``force`` always quotes.
|
||||
|
||||
Single quotes on POSIX (/bin/sh), the argv rule on Windows
|
||||
(CreateProcess). ``$`` is doubled first because ninja expands it before
|
||||
the command reaches the shell.
|
||||
"""
|
||||
tok = tok.replace("$", "$$") # ninja would expand a bare $ to nothing
|
||||
if not (force or not tok or _NEEDS_QUOTE.search(tok)):
|
||||
return tok
|
||||
# An empty token must become '' / "" or it vanishes from the argv
|
||||
if os.name == "nt":
|
||||
return quote_arg(tok)
|
||||
# shlex.quote's rule; inlined because the $-doubled token must not be
|
||||
# re-examined for safe characters
|
||||
return "'" + tok.replace("'", "'\"'\"'") + "'"
|
||||
|
||||
|
||||
def quote_path(value: Path | str) -> str:
|
||||
"""Force-quote a path for the ninja command line (shell/CreateProcess)."""
|
||||
return shell_token(str(value), force=True)
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Machine-global tools cache location shared by the native backends."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def tools_cache_path(env_var: str, subdir: str) -> Path:
|
||||
"""A backend's machine-global tools directory, with an env override.
|
||||
|
||||
A blank/whitespace override is treated as unset: ``Path("")`` resolves
|
||||
to the CWD, which ``clean-all`` would then delete.
|
||||
"""
|
||||
import platformdirs
|
||||
|
||||
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()
|
||||
|
||||
|
||||
# (env override, cache subdir) per native backend. writer.clean_all wipes
|
||||
# every entry via tools_cache_path, so listing a cache here is the single
|
||||
# step that registers it for removal; the backends' own path getters use
|
||||
# the same named pairs so the two cannot drift.
|
||||
IDF_TOOLS_CACHE = ("ESPHOME_ESP_IDF_PREFIX", "idf")
|
||||
SDK_NRF_TOOLS_CACHE = ("ESPHOME_SDK_NRF_PREFIX", "sdk-nrf")
|
||||
ARDUINO8266_TOOLS_CACHE = ("ESPHOME_ARDUINO8266_PREFIX", "arduino8266")
|
||||
TOOLS_CACHE_SPECS = (IDF_TOOLS_CACHE, SDK_NRF_TOOLS_CACHE, ARDUINO8266_TOOLS_CACHE)
|
||||
@@ -100,6 +100,21 @@ def _refresh_sidecar() -> bool:
|
||||
)
|
||||
return False
|
||||
if old is not None and old.can_apply_to_core():
|
||||
if (
|
||||
old.toolchain is not None
|
||||
and CORE.toolchain is not None
|
||||
and old.toolchain != CORE.toolchain.value
|
||||
):
|
||||
# Platforms normalize toolchain-sensitive keys differently;
|
||||
# never cache a config validated under a different toolchain
|
||||
# than the compile's
|
||||
_LOGGER.debug(
|
||||
"Not caching: config validated with toolchain %r but the "
|
||||
"last compile used %r",
|
||||
CORE.toolchain.value,
|
||||
old.toolchain,
|
||||
)
|
||||
return False
|
||||
# Compile-written; nothing to refresh.
|
||||
return True
|
||||
if CORE.build_path is not None and CORE.build_path.exists():
|
||||
|
||||
@@ -1072,19 +1072,11 @@ def _check_esp_idf_versions(config: ConfigType) -> ConfigType:
|
||||
return config
|
||||
|
||||
|
||||
def _validate_toolchain(value) -> Toolchain:
|
||||
return Toolchain(
|
||||
cv.one_of(Toolchain.PLATFORMIO, Toolchain.ESP_IDF, lower=True)(value)
|
||||
)
|
||||
|
||||
|
||||
def _resolve_toolchain(value: ConfigType) -> ConfigType:
|
||||
# Resolve toolchain: CLI (already on CORE.toolchain) > YAML > default.
|
||||
# Runs before _detect_variant so downstream validators can rely on
|
||||
# CORE.toolchain instead of re-resolving it from the config dict.
|
||||
if CORE.toolchain is None:
|
||||
CORE.toolchain = value.get(CONF_TOOLCHAIN, Toolchain.ESP_IDF)
|
||||
return value
|
||||
_TOOLCHAINS = (Toolchain.PLATFORMIO, Toolchain.ESP_IDF)
|
||||
_validate_toolchain = cv.toolchain_enum(_TOOLCHAINS)
|
||||
# Runs before _detect_variant so downstream validators can rely on
|
||||
# CORE.toolchain instead of re-resolving it from the config dict.
|
||||
_resolve_toolchain = cv.resolve_toolchain("ESP32", _TOOLCHAINS, Toolchain.ESP_IDF)
|
||||
|
||||
|
||||
def _check_versions(config: ConfigType) -> ConfigType:
|
||||
|
||||
@@ -136,7 +136,16 @@ def _format_framework_arduino_version(ver: cv.Version) -> str:
|
||||
return f"~1.{ver.major}{ver.minor:02d}{ver.patch:02d}.0"
|
||||
if ver <= cv.Version(2, 6, 2):
|
||||
return f"~2.{ver.major}{ver.minor:02d}{ver.patch:02d}.0"
|
||||
return f"~3.{ver.major}{ver.minor:02d}{ver.patch:02d}.0"
|
||||
# Same encoding the native toolchain uses for its package download, so a
|
||||
# version bump cannot drift between the two paths.
|
||||
from esphome.arduino8266.framework import framework_package_version
|
||||
|
||||
try:
|
||||
return f"~{framework_package_version(ver)}"
|
||||
except EsphomeError as err:
|
||||
# Anchor the 4.x rejection to the framework version line instead of
|
||||
# aborting with a bare traceback-level error
|
||||
raise cv.Invalid(str(err), path=[CONF_VERSION]) from err
|
||||
|
||||
|
||||
# NOTE: Keep this in mind when updating the recommended version:
|
||||
@@ -246,6 +255,9 @@ CONFIG_SCHEMA = cv.All(
|
||||
cv.Optional(CONF_ENABLE_SCANF_FLOAT): cv.boolean,
|
||||
}
|
||||
),
|
||||
# Until the native toolchain lands, PlatformIO is the only backend;
|
||||
# reject a --toolchain this platform cannot serve yet.
|
||||
cv.require_platformio_toolchain("ESP8266"),
|
||||
set_core_data,
|
||||
)
|
||||
|
||||
@@ -397,8 +409,8 @@ async def to_code(config: ConfigType) -> None:
|
||||
)
|
||||
|
||||
if config[CONF_BOARD] in BOARDS:
|
||||
flash_size = BOARDS[config[CONF_BOARD]][KEY_FLASH_SIZE]
|
||||
ld_scripts = ESP8266_LD_SCRIPTS[flash_size]
|
||||
board_data = BOARDS[config[CONF_BOARD]]
|
||||
ld_scripts = ESP8266_LD_SCRIPTS[board_data[KEY_FLASH_SIZE]]
|
||||
|
||||
if ver <= cv.Version(2, 3, 0):
|
||||
# No ld script support
|
||||
@@ -407,7 +419,9 @@ async def to_code(config: ConfigType) -> None:
|
||||
# Old ld script path
|
||||
ld_script = ld_scripts[0]
|
||||
else:
|
||||
ld_script = ld_scripts[1]
|
||||
# A per-board override preserves a layout the board shipped
|
||||
# with (see d1_wroom_02 in boards.py)
|
||||
ld_script = board_data.get("ldscript", ld_scripts[1])
|
||||
|
||||
if ld_script is not None:
|
||||
cg.add_platformio_option("board_build.ldscript", ld_script)
|
||||
|
||||
@@ -199,6 +199,15 @@ BOARDS = {
|
||||
"name": "WeMos D1 mini Pro",
|
||||
"flash_size": FLASH_SIZE_16_MB,
|
||||
},
|
||||
"d1_wroom_02": {
|
||||
"name": "WeMos D1 ESP-WROOM-02",
|
||||
"flash_size": FLASH_SIZE_2_MB,
|
||||
# This board joined BOARDS after shipping with the manifest default
|
||||
# (64 KB filesystem region); the flash-size default (2m.ld) would
|
||||
# move _FS_end and with it the preferences sector, wiping existing
|
||||
# devices' flash-backed state on update.
|
||||
"ldscript": "eagle.flash.2m64.ld",
|
||||
},
|
||||
"d1": {
|
||||
"name": "WEMOS D1 R1",
|
||||
"flash_size": FLASH_SIZE_4_MB,
|
||||
@@ -360,3 +369,112 @@ BOARDS = {
|
||||
"flash_size": FLASH_SIZE_4_MB,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# Per-board variant dir + identity defines from platform-espressif8266 4.x
|
||||
# build.extra_flags; the shared -DESP8266/-DARDUINO_ARCH_ESP8266 are added
|
||||
# by the generator.
|
||||
#
|
||||
# Regenerate ESP8266_BOARD_BUILD with (v4.2.1 is the platform version the
|
||||
# native toolchain mirrors; regenerate against the tag when bumping it):
|
||||
#
|
||||
# git clone -b v4.2.1 https://github.com/platformio/platform-espressif8266
|
||||
# python3 - <<'EOF'
|
||||
# import json, glob, os
|
||||
# for f in sorted(glob.glob("platform-espressif8266/boards/*.json")):
|
||||
# b = json.load(open(f))["build"]
|
||||
# extra = b["extra_flags"]
|
||||
# extra = extra.split() if isinstance(extra, str) else extra
|
||||
# defines = [
|
||||
# e[2:] for e in extra if e not in ("-DESP8266", "-DARDUINO_ARCH_ESP8266")
|
||||
# ]
|
||||
# entries = ", ".join(f'"{d}"' for d in defines) + ("," if len(defines) == 1 else "")
|
||||
# board = os.path.splitext(os.path.basename(f))[0]
|
||||
# print(f' "{board}": {{"variant": "{b["variant"]}", "defines": ({entries})}},')
|
||||
# EOF
|
||||
ESP8266_BOARD_BUILD = {
|
||||
"agruminolemon": {
|
||||
"variant": "agruminolemonv4",
|
||||
"defines": ("ARDUINO_ESP8266_AGRUMINO_LEMON_V4",),
|
||||
},
|
||||
"d1": {"variant": "d1", "defines": ("ARDUINO_ESP8266_WEMOS_D1R1",)},
|
||||
"d1_mini": {"variant": "d1_mini", "defines": ("ARDUINO_ESP8266_WEMOS_D1MINI",)},
|
||||
"d1_mini_lite": {
|
||||
"variant": "d1_mini",
|
||||
"defines": ("ARDUINO_ESP8266_WEMOS_D1MINILITE",),
|
||||
},
|
||||
"d1_mini_pro": {
|
||||
"variant": "d1_mini",
|
||||
"defines": ("ARDUINO_ESP8266_WEMOS_D1MINIPRO",),
|
||||
},
|
||||
"d1_wroom_02": {
|
||||
"variant": "d1_mini",
|
||||
"defines": ("ARDUINO_ESP8266_WEMOS_D1WROOM02",),
|
||||
},
|
||||
"eduinowifi": {
|
||||
"variant": "eduinowifi",
|
||||
"defines": ("ARDUINO_ESP8266_SCHIRMILABS_EDUINO_WIFI",),
|
||||
},
|
||||
"esp01": {"variant": "generic", "defines": ("ARDUINO_ESP8266_ESP01",)},
|
||||
"esp01_1m": {"variant": "generic", "defines": ("ARDUINO_ESP8266_ESP01",)},
|
||||
"esp07": {"variant": "generic", "defines": ("ARDUINO_ESP8266_ESP07",)},
|
||||
"esp07s": {"variant": "nodemcu", "defines": ("ARDUINO_ESP8266_ESP07",)},
|
||||
"esp12e": {"variant": "nodemcu", "defines": ("ARDUINO_ESP8266_ESP12",)},
|
||||
"esp210": {"variant": "generic", "defines": ("ARDUINO_ESP8266_ESP210",)},
|
||||
"esp8285": {"variant": "esp8285", "defines": ("ARDUINO_ESP8266_ESP01",)},
|
||||
"esp_wroom_02": {
|
||||
"variant": "nodemcu",
|
||||
"defines": ("ARDUINO_ESP8266_ESP_WROOM_02",),
|
||||
},
|
||||
"espduino": {"variant": "ESPDuino", "defines": ("ARDUINO_ESP8266_ESP13",)},
|
||||
"espectro": {"variant": "espectro", "defines": ("ARDUINO_ESP8266_ESPECTRO_CORE",)},
|
||||
"espino": {"variant": "espino", "defines": ("ARDUINO_ESP8266_ESP12",)},
|
||||
"espinotee": {"variant": "espinotee", "defines": ("ARDUINO_ESP8266_ESP13",)},
|
||||
"espmxdevkit": {
|
||||
"variant": "esp8285",
|
||||
"defines": ("ARDUINO_ESP8266_ESP01", "LED_BUILTIN=16"),
|
||||
},
|
||||
"espresso_lite_v1": {
|
||||
"variant": "espresso_lite_v1",
|
||||
"defines": ("ARDUINO_ESP8266_ESPRESSO_LITE_V1",),
|
||||
},
|
||||
"espresso_lite_v2": {
|
||||
"variant": "espresso_lite_v2",
|
||||
"defines": ("ARDUINO_ESP8266_ESPRESSO_LITE_V2",),
|
||||
},
|
||||
"gen4iod": {"variant": "generic", "defines": ("ARDUINO_GEN4_IOD",)},
|
||||
"heltec_wifi_kit_8": {
|
||||
"variant": "wifi_kit_8",
|
||||
"defines": ("ARDUINO_wifi_kit_8",),
|
||||
},
|
||||
"huzzah": {"variant": "adafruit", "defines": ("ARDUINO_ESP8266_ADAFRUIT_HUZZAH",)},
|
||||
"inventone": {"variant": "inventone", "defines": ("ARDUINO_ESP8266_INVENT_ONE",)},
|
||||
"modwifi": {"variant": "generic", "defines": ("ARDUINO_MOD_WIFI_ESP8266",)},
|
||||
"nodemcu": {"variant": "nodemcu", "defines": ("ARDUINO_ESP8266_NODEMCU",)},
|
||||
"nodemcuv2": {"variant": "nodemcu", "defines": ("ARDUINO_ESP8266_NODEMCU_ESP12E",)},
|
||||
"oak": {"variant": "oak", "defines": ("ARDUINO_ESP8266_OAK",)},
|
||||
"phoenix_v1": {
|
||||
"variant": "phoenix_v1",
|
||||
"defines": ("ARDUINO_ESP8266_PHOENIX_V1",),
|
||||
},
|
||||
"phoenix_v2": {
|
||||
"variant": "phoenix_v2",
|
||||
"defines": ("ARDUINO_ESP8266_PHOENIX_V2",),
|
||||
},
|
||||
"sonoff_basic": {"variant": "itead", "defines": ("ARDUINO_ESP8266_SONOFF_BASIC",)},
|
||||
"sonoff_s20": {"variant": "itead", "defines": ("ARDUINO_ESP8266_SONOFF_S20",)},
|
||||
"sonoff_sv": {"variant": "itead", "defines": ("ARDUINO_ESP8266_SONOFF_SV",)},
|
||||
"sonoff_th": {"variant": "itead", "defines": ("ARDUINO_ESP8266_SONOFF_TH",)},
|
||||
"sparkfunBlynk": {"variant": "thing", "defines": ("ARDUINO_ESP8266_THING",)},
|
||||
"thing": {"variant": "thing", "defines": ("ARDUINO_ESP8266_THING",)},
|
||||
"thingdev": {"variant": "thing", "defines": ("ARDUINO_ESP8266_THING_DEV",)},
|
||||
"wifi_slot": {"variant": "wifi_slot", "defines": ("ARDUINO_AMPERKA_WIFI_SLOT",)},
|
||||
"wifiduino": {"variant": "wifiduino", "defines": ("ARDUINO_WIFIDUINO_ESP8266",)},
|
||||
"wifinfo": {"variant": "wifinfo", "defines": ("ARDUINO_WIFINFO",)},
|
||||
"wio_link": {"variant": "wiolink", "defines": ("ARDUINO_ESP8266_WIO_LINK",)},
|
||||
"wio_node": {"variant": "nodemcu", "defines": ("ARDUINO_ESP8266_ESP_WROOM_02",)},
|
||||
"xinabox_cw01": {
|
||||
"variant": "xinabox",
|
||||
"defines": ("ARDUINO_ESP8266_XINABOX_CW01",),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Linker-script surgery shared with the native (PlatformIO-free) toolchain.
|
||||
|
||||
These mirror the PlatformIO extra scripts in this directory
|
||||
(``relocate_ratetable.py.script`` and ``testing_mode.py.script``), which run
|
||||
inside SCons and must stay self-contained. The native build generator applies
|
||||
the same patches to the linker scripts it generates, so the logic lives here
|
||||
as plain functions. Keep both in sync when changing either.
|
||||
``segment_length`` is native-toolchain-only and has no script twin.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Collection
|
||||
import hashlib
|
||||
import re
|
||||
|
||||
# Move the NONOS SDK wifi rate tables from flash to DRAM; see
|
||||
# relocate_ratetable.py.script for the full background (NONOS SDK issue 320).
|
||||
RATETABLE_RULE = "*libnet80211.a:ieee80211_phy.o(.irom.text .irom.text.*)"
|
||||
_RATETABLE_COMMENT = (
|
||||
"/* ESPHome: wifi rate tables must live in DRAM, see NONOS SDK issue 320 */"
|
||||
)
|
||||
# Match the whole line: "_data_start" is also a substring of the
|
||||
# "_dport0_data_start" line in the earlier .dport0.data section
|
||||
_RATETABLE_ANCHOR = re.compile(r"^\s*_data_start = ABSOLUTE\(\.\);", re.MULTILINE)
|
||||
|
||||
# Memory sizes for testing mode (allow larger builds for CI component grouping)
|
||||
TESTING_IRAM_SIZE = "0x200000" # 2MB
|
||||
TESTING_DRAM_SIZE = "0x200000" # 2MB
|
||||
TESTING_FLASH_SIZE = "0x2000000" # 32MB
|
||||
|
||||
|
||||
def relocate_ratetable(content: str) -> str:
|
||||
"""Insert the rate-table DRAM rule into a generated common linker script."""
|
||||
if RATETABLE_RULE in content:
|
||||
return content
|
||||
match = _RATETABLE_ANCHOR.search(content)
|
||||
if match is None:
|
||||
raise RuntimeError(
|
||||
"'_data_start' anchor not found in the generated linker script; "
|
||||
"cannot apply wifi rate table DRAM relocation "
|
||||
"(has the Arduino core linker script changed?)"
|
||||
)
|
||||
insert_pos = match.end()
|
||||
return (
|
||||
content[:insert_pos]
|
||||
+ f"\n {_RATETABLE_COMMENT}"
|
||||
+ f"\n {RATETABLE_RULE}"
|
||||
+ content[insert_pos:]
|
||||
)
|
||||
|
||||
|
||||
_TESTING_SEGMENT_SIZES = {
|
||||
"iram1_0_seg": TESTING_IRAM_SIZE,
|
||||
"dram0_0_seg": TESTING_DRAM_SIZE,
|
||||
"irom0_0_seg": TESTING_FLASH_SIZE,
|
||||
}
|
||||
|
||||
|
||||
def _segment_line_re(segment_name: str) -> re.Pattern[str]:
|
||||
"""The MEMORY line for one segment: ``<seg> : org = 0x..., len = 0x...``.
|
||||
|
||||
Anchored to the start of the line so a name never matches inside a
|
||||
longer one (``ram0_0_seg`` must not read ``dram0_0_seg``). The size
|
||||
group stops at the hex digits, leaving any ``ul`` suffix (from the
|
||||
preprocessed ``MMU_IRAM_SIZE``) in place.
|
||||
"""
|
||||
return re.compile(
|
||||
rf"(^[ \t]*{re.escape(segment_name)}"
|
||||
r"\s*:\s*org\s*=\s*0x[0-9a-fA-F]+\s*,\s*len\s*=\s*)"
|
||||
r"(0x[0-9a-fA-F]+)",
|
||||
re.MULTILINE,
|
||||
)
|
||||
|
||||
|
||||
def apply_testing_memory_patches(content: str, segments: Collection[str]) -> str:
|
||||
"""Enlarge the named memory segments so grouped CI test builds can link.
|
||||
|
||||
Each caller passes the segments its linker script defines: the
|
||||
generated common ld carries ``iram1_0_seg``; the flash ld carries
|
||||
``dram0_0_seg`` and ``irom0_0_seg``. A segment that fails to match
|
||||
raises, since a silently kept real memory limit would fail grouped
|
||||
builds far from the cause.
|
||||
"""
|
||||
for segment in _TESTING_SEGMENT_SIZES:
|
||||
if segment not in segments and _segment_line_re(segment).search(content):
|
||||
raise RuntimeError(
|
||||
f"Testing-mode segment {segment} is present in the linker "
|
||||
"script but was not selected for patching"
|
||||
)
|
||||
for segment in segments:
|
||||
if segment not in _TESTING_SEGMENT_SIZES:
|
||||
raise RuntimeError(f"Unknown testing-mode segment {segment!r}")
|
||||
content, count = _segment_line_re(segment).subn(
|
||||
rf"\g<1>{_TESTING_SEGMENT_SIZES[segment]}", content
|
||||
)
|
||||
if count == 0:
|
||||
raise RuntimeError(
|
||||
f"Testing-mode memory patch failed: segment {segment} "
|
||||
"not found (has the Arduino core linker script changed?)"
|
||||
)
|
||||
return content
|
||||
|
||||
|
||||
def segment_length(content: str, segment_name: str) -> int | None:
|
||||
"""Read a memory segment's length from linker script content."""
|
||||
match = _segment_line_re(segment_name).search(content)
|
||||
return int(match.group(2), 16) if match else None
|
||||
|
||||
|
||||
def surgery_fingerprint() -> str:
|
||||
"""Hash of this module's source; linker-script caches include it so an
|
||||
edit here invalidates them."""
|
||||
import inspect
|
||||
import sys
|
||||
|
||||
source = inspect.getsource(sys.modules[__name__])
|
||||
return hashlib.sha256(source.encode()).hexdigest()
|
||||
@@ -15,6 +15,9 @@ CONF_ENABLE_SERIAL1 = "enable_serial1"
|
||||
KEY_WAVEFORM_REQUIRED = "waveform_required"
|
||||
KEY_SERIAL_REQUIRED = "serial_required"
|
||||
KEY_SERIAL1_REQUIRED = "serial1_required"
|
||||
# Set for the native (non-PlatformIO) toolchain's build generator
|
||||
KEY_FLASH_MODE = "flash_mode"
|
||||
KEY_SCANF_FLOAT = "scanf_float"
|
||||
|
||||
# esp8266 namespace is already defined by arduino, manually prefix esphome
|
||||
esp8266_ns = cg.global_ns.namespace("esphome").namespace("esp8266")
|
||||
|
||||
@@ -37,6 +37,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
cv.Optional(CONF_MAC_ADDRESS, default="98:35:69:ab:f6:79"): cv.mac_address,
|
||||
}
|
||||
),
|
||||
cv.require_platformio_toolchain("host"),
|
||||
set_core_data,
|
||||
)
|
||||
|
||||
|
||||
@@ -300,7 +300,7 @@ FRAMEWORK_SCHEMA = cv.All(
|
||||
_check_debug_order,
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = cv.All(_notify_old_style)
|
||||
CONFIG_SCHEMA = cv.All(_notify_old_style, cv.require_platformio_toolchain("LibreTiny"))
|
||||
|
||||
BASE_SCHEMA = cv.Schema(
|
||||
{
|
||||
@@ -314,6 +314,7 @@ BASE_SCHEMA = cv.Schema(
|
||||
)
|
||||
|
||||
BASE_SCHEMA.add_extra(_detect_variant)
|
||||
BASE_SCHEMA.add_extra(cv.require_platformio_toolchain("LibreTiny"))
|
||||
BASE_SCHEMA.add_extra(_update_core_data)
|
||||
|
||||
|
||||
|
||||
@@ -125,10 +125,8 @@ def set_core_data(config: ConfigType) -> ConfigType:
|
||||
return config
|
||||
|
||||
|
||||
def _resolve_toolchain(config: ConfigType) -> ConfigType:
|
||||
if CORE.toolchain is None:
|
||||
CORE.toolchain = config.get(CONF_TOOLCHAIN, Toolchain.SDK_NRF)
|
||||
return config
|
||||
_TOOLCHAINS = (Toolchain.PLATFORMIO, Toolchain.SDK_NRF)
|
||||
_resolve_toolchain = cv.resolve_toolchain("nRF52", _TOOLCHAINS, Toolchain.SDK_NRF)
|
||||
|
||||
|
||||
def set_framework(config: ConfigType) -> ConfigType:
|
||||
@@ -170,10 +168,7 @@ BOOTLOADERS = [
|
||||
]
|
||||
|
||||
|
||||
def _validate_toolchain(value) -> Toolchain:
|
||||
return Toolchain(
|
||||
cv.one_of(Toolchain.PLATFORMIO, Toolchain.SDK_NRF, lower=True)(value)
|
||||
)
|
||||
_validate_toolchain = cv.toolchain_enum(_TOOLCHAINS)
|
||||
|
||||
|
||||
def _detect_bootloader(config: ConfigType) -> ConfigType:
|
||||
|
||||
@@ -7,8 +7,7 @@ import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
import platformdirs
|
||||
|
||||
from esphome.build_helpers.tools_cache import SDK_NRF_TOOLS_CACHE, tools_cache_path
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION
|
||||
from esphome.core import CORE, EsphomeError
|
||||
@@ -21,7 +20,6 @@ from esphome.framework_helpers import (
|
||||
run_command_ok,
|
||||
str_to_lst_of_str,
|
||||
)
|
||||
from esphome.helpers import get_str_env
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -51,15 +49,9 @@ SDK_NG_MINIMAL_MIRRORS = str_to_lst_of_str(
|
||||
|
||||
|
||||
def get_sdk_nrf_tools_path() -> Path:
|
||||
# A blank ESPHOME_SDK_NRF_PREFIX must be treated as unset: Path("")
|
||||
# resolves to the CWD, which clean-all would then delete.
|
||||
if prefix := get_str_env("ESPHOME_SDK_NRF_PREFIX", "").strip():
|
||||
path = Path(prefix).expanduser()
|
||||
else:
|
||||
# Machine-global (OS user cache dir) so all projects share one install;
|
||||
# see espidf.framework.get_idf_tools_path for the location rationale.
|
||||
path = Path(platformdirs.user_cache_dir("esphome", appauthor=False)) / "sdk-nrf"
|
||||
return path.resolve()
|
||||
# Machine-global (OS user cache dir) so all projects share one install;
|
||||
# see espidf.framework.get_idf_tools_path for the location rationale.
|
||||
return tools_cache_path(*SDK_NRF_TOOLS_CACHE)
|
||||
|
||||
|
||||
def _needs_venv_rebuild(
|
||||
|
||||
@@ -312,6 +312,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
),
|
||||
cv.has_at_least_one_key(CONF_BOARD, CONF_VARIANT),
|
||||
_detect_variant,
|
||||
cv.require_platformio_toolchain("RP2"),
|
||||
set_core_data,
|
||||
)
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ from esphome.const import (
|
||||
CONF_SETUP_PRIORITY,
|
||||
CONF_STATE_TOPIC,
|
||||
CONF_SUBSCRIBE_QOS,
|
||||
CONF_TOOLCHAIN,
|
||||
CONF_TOPIC,
|
||||
CONF_TYPE,
|
||||
CONF_TYPE_ID,
|
||||
@@ -75,6 +76,7 @@ from esphome.const import (
|
||||
TYPE_GIT,
|
||||
TYPE_LOCAL,
|
||||
Framework,
|
||||
Toolchain,
|
||||
__version__ as ESPHOME_VERSION,
|
||||
)
|
||||
from esphome.core import (
|
||||
@@ -91,7 +93,13 @@ from esphome.core import (
|
||||
)
|
||||
from esphome.enum import StrEnum
|
||||
from esphome.expression import SUBSTITUTION_VARIABLE_PROG as VARIABLE_PROG
|
||||
from esphome.helpers import add_class_to_obj, docs_url, list_starts_with
|
||||
from esphome.helpers import (
|
||||
FALSY_BOOL_STRINGS,
|
||||
TRUTHY_BOOL_STRINGS,
|
||||
add_class_to_obj,
|
||||
docs_url,
|
||||
list_starts_with,
|
||||
)
|
||||
from esphome.schema_extractors import (
|
||||
SCHEMA_EXTRACT,
|
||||
schema_extractor,
|
||||
@@ -106,6 +114,9 @@ from esphome.util import parse_esphome_version # noqa: F401
|
||||
from esphome.voluptuous_schema import _Schema
|
||||
from esphome.yaml_util import SensitiveStr, make_data_base
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from esphome.types import ConfigType
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
@@ -576,9 +587,9 @@ def boolean(value):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
value = value.lower()
|
||||
if value in ("true", "yes", "on", "enable"):
|
||||
if value in TRUTHY_BOOL_STRINGS:
|
||||
return True
|
||||
if value in ("false", "no", "off", "disable"):
|
||||
if value in FALSY_BOOL_STRINGS:
|
||||
return False
|
||||
raise Invalid(
|
||||
f"Expected boolean value, but cannot convert {value} to a boolean. Please use 'true' or 'false'"
|
||||
@@ -2532,6 +2543,63 @@ def platformio_version_constraint(value):
|
||||
return constraints
|
||||
|
||||
|
||||
def _check_supported_toolchain(
|
||||
platform_name: str, supported: tuple[Toolchain, ...]
|
||||
) -> None:
|
||||
"""Raise when the resolved ``CORE.toolchain`` is not in ``supported``
|
||||
(one message shape for every platform)."""
|
||||
toolchain = CORE.toolchain
|
||||
if toolchain is None:
|
||||
# A caller ran the check before resolving; an ordering bug, not a
|
||||
# user error
|
||||
raise Invalid(f"Toolchain was not resolved before {platform_name} validation")
|
||||
if toolchain not in supported:
|
||||
names = ", ".join(f"'{tc.value}'" for tc in supported)
|
||||
raise Invalid(
|
||||
f"Unsupported toolchain "
|
||||
f"'{toolchain.value}' for "
|
||||
f"{platform_name}. Supported: {names}."
|
||||
)
|
||||
|
||||
|
||||
def toolchain_enum(supported: tuple[Toolchain, ...]) -> Callable[[str], Toolchain]:
|
||||
"""Schema validator for a platform's ``toolchain`` config key."""
|
||||
|
||||
def validator(value: str) -> Toolchain:
|
||||
return Toolchain(one_of(*supported, lower=True)(value))
|
||||
|
||||
return validator
|
||||
|
||||
|
||||
def resolve_toolchain(
|
||||
platform_name: str, supported: tuple[Toolchain, ...], default: Toolchain
|
||||
) -> Callable[[ConfigType], ConfigType]:
|
||||
"""Resolve ``CORE.toolchain`` (CLI > YAML > default) and reject one the
|
||||
platform cannot serve.
|
||||
|
||||
Add to the platform's validation chain before anything that reads
|
||||
``CORE.toolchain``.
|
||||
"""
|
||||
|
||||
def validator(config: ConfigType) -> ConfigType:
|
||||
if CORE.toolchain is None:
|
||||
CORE.toolchain = config.get(CONF_TOOLCHAIN, default)
|
||||
_check_supported_toolchain(platform_name, supported)
|
||||
return config
|
||||
|
||||
return validator
|
||||
|
||||
|
||||
def require_platformio_toolchain(
|
||||
platform_name: str,
|
||||
) -> Callable[[ConfigType], ConfigType]:
|
||||
"""Reject a CLI-selected toolchain other than PlatformIO, for platforms
|
||||
with only the PlatformIO backend."""
|
||||
return resolve_toolchain(
|
||||
platform_name, (Toolchain.PLATFORMIO,), Toolchain.PLATFORMIO
|
||||
)
|
||||
|
||||
|
||||
def require_framework_version(
|
||||
*,
|
||||
max_version=False,
|
||||
|
||||
@@ -21,6 +21,14 @@ class Toolchain(StrEnum):
|
||||
PLATFORMIO = "platformio"
|
||||
ESP_IDF = "esp-idf"
|
||||
SDK_NRF = "sdk-nrf"
|
||||
# ESP8266: the Arduino core built directly (no PlatformIO)
|
||||
ARDUINO = "arduino"
|
||||
|
||||
|
||||
# Toolchains that drive their build natively and never read platformio.ini.
|
||||
# SDK_NRF is absent on purpose: the zephyr backend keeps consuming
|
||||
# platformio_options.
|
||||
NATIVE_TOOLCHAINS = frozenset({Toolchain.ESP_IDF, Toolchain.ARDUINO})
|
||||
|
||||
|
||||
class Platform(StrEnum):
|
||||
|
||||
@@ -21,6 +21,7 @@ from esphome.const import (
|
||||
KEY_CORE,
|
||||
KEY_TARGET_FRAMEWORK,
|
||||
KEY_TARGET_PLATFORM,
|
||||
NATIVE_TOOLCHAINS,
|
||||
PLATFORM_BK72XX,
|
||||
PLATFORM_ESP32,
|
||||
PLATFORM_ESP8266,
|
||||
@@ -982,6 +983,19 @@ class EsphomeCore:
|
||||
def using_toolchain_sdk_nrf(self):
|
||||
return self.toolchain == Toolchain.SDK_NRF
|
||||
|
||||
@property
|
||||
def using_toolchain_arduino(self):
|
||||
"""The native ESP8266 Arduino build toolchain (unlike
|
||||
``using_arduino``, which is the target framework)."""
|
||||
return self.toolchain == Toolchain.ARDUINO
|
||||
|
||||
@property
|
||||
def using_native_toolchain(self):
|
||||
"""Whether the selected toolchain builds natively, without reading
|
||||
``platformio.ini`` (see ``NATIVE_TOOLCHAINS`` in ``esphome.const``;
|
||||
keep its membership in sync with ``write_cpp_file``'s dispatch)."""
|
||||
return self.toolchain in NATIVE_TOOLCHAINS
|
||||
|
||||
@property
|
||||
def using_zephyr(self):
|
||||
return self.target_framework == "zephyr"
|
||||
@@ -1095,6 +1109,8 @@ class EsphomeCore:
|
||||
return build_flag
|
||||
|
||||
def add_build_unflag(self, build_unflag: str) -> None:
|
||||
# No warning for using_toolchain_arduino: the native ESP8266 build
|
||||
# honors build_unflags (token-level, matching PlatformIO).
|
||||
if self.using_toolchain_esp_idf:
|
||||
# The native ESP-IDF build generator does not consume build_unflags
|
||||
_LOGGER.warning(
|
||||
|
||||
+40
-10
@@ -555,12 +555,24 @@ def _add_library_str(lib: str) -> None:
|
||||
cg.add_library(lib, None)
|
||||
|
||||
|
||||
# platformio_options keys the native ESP8266 Arduino generator (a later PR
|
||||
# in this chain) will honor; its ignored-option warning will consume the same
|
||||
# list so the two cannot drift
|
||||
NATIVE_ARDUINO_PIO_OPTIONS = frozenset({"board_build.f_cpu", "board_build.ldscript"})
|
||||
# The full set that survives into CORE.platformio_options under the native
|
||||
# arduino toolchain: lib_ignore is the only specially-translated key below
|
||||
# that is stored rather than translated away. Consumed by the esp8266 native
|
||||
# backend (later in this chain) for its ignored-option warning; defined here
|
||||
# so it stays adjacent to the routing.
|
||||
NATIVE_ARDUINO_CONSUMED_PIO_OPTIONS = NATIVE_ARDUINO_PIO_OPTIONS | {"lib_ignore"}
|
||||
|
||||
|
||||
@coroutine_with_priority(CoroPriority.FINAL)
|
||||
async def _add_platformio_options(pio_options: dict[str, str | list[str]]) -> None:
|
||||
if CORE.using_toolchain_esp_idf:
|
||||
# The native ESP-IDF build doesn't read platformio.ini; honor the
|
||||
# options with a native equivalent and warn about the rest, which
|
||||
# would otherwise be silently ignored.
|
||||
if CORE.using_native_toolchain:
|
||||
# The native builds don't read platformio.ini; honor the options
|
||||
# with a native equivalent and warn about the rest, which would
|
||||
# otherwise be silently ignored.
|
||||
for key, val in pio_options.items():
|
||||
vals = [val] if isinstance(val, str) else val
|
||||
if key == CONF_BUILD_FLAGS:
|
||||
@@ -573,23 +585,41 @@ async def _add_platformio_options(pio_options: dict[str, str | list[str]]) -> No
|
||||
)
|
||||
for flag in vals:
|
||||
cg.add_build_flag(flag)
|
||||
elif key == "build_unflags":
|
||||
# Native equivalent: add_build_unflag (honored token-level by
|
||||
# the arduino generator; the IDF generator warns there)
|
||||
for flag in vals:
|
||||
CORE.add_build_unflag(flag)
|
||||
elif key == "lib_deps":
|
||||
# Routed through the regular library mechanism so the libraries
|
||||
# are converted to IDF components like any other PIO library
|
||||
# Routed through the regular library mechanism so the
|
||||
# libraries reach the native backend's converter (IDF
|
||||
# components, or the ESP8266 native library resolution)
|
||||
for lib in vals:
|
||||
_add_library_str(lib)
|
||||
elif key == "lib_ignore":
|
||||
# Read by the PIO-library-to-IDF-component conversion
|
||||
# (generate_idf_components); filters both top-level libraries
|
||||
# and dependencies discovered during conversion
|
||||
# Read by the shared library conversion (lib_ignore_set in
|
||||
# platformio/library.py); filters top-level libraries and
|
||||
# discovered dependencies
|
||||
cg.add_platformio_option(key, vals)
|
||||
elif (
|
||||
key in NATIVE_ARDUINO_PIO_OPTIONS
|
||||
and CORE.using_toolchain_arduino
|
||||
and vals
|
||||
):
|
||||
# The esp8266 native generator reads these as scalars; the
|
||||
# schema also permits the list form, where the last value
|
||||
# wins like a later platformio.ini line (an empty list falls
|
||||
# through to the ignored-option warning). Other native
|
||||
# toolchains have no equivalent and fall through too.
|
||||
cg.add_platformio_option(key, vals[-1])
|
||||
elif key != "upload_speed":
|
||||
# upload_speed needs no handling: it is read from the raw
|
||||
# config at upload time (upload_using_esptool)
|
||||
_LOGGER.warning(
|
||||
"esphome->platformio_options->%s is ignored when building with "
|
||||
"the native ESP-IDF toolchain",
|
||||
"the native '%s' toolchain",
|
||||
key,
|
||||
CORE.toolchain.value,
|
||||
)
|
||||
return
|
||||
# Add includes at the very end, so that they override everything
|
||||
|
||||
+30
-46
@@ -11,9 +11,13 @@ import re
|
||||
import shutil
|
||||
from typing import Any, NoReturn
|
||||
|
||||
import platformdirs
|
||||
|
||||
from esphome.core import CORE, Version
|
||||
from esphome.build_helpers.ccache import (
|
||||
ccache_defaults_env,
|
||||
parse_enable_env,
|
||||
resolve_ccache_path,
|
||||
)
|
||||
from esphome.build_helpers.tools_cache import IDF_TOOLS_CACHE, tools_cache_path
|
||||
from esphome.core import Version
|
||||
from esphome.framework_helpers import (
|
||||
BatchDownloadProgress,
|
||||
PathType,
|
||||
@@ -29,7 +33,7 @@ from esphome.framework_helpers import (
|
||||
run_command_ok,
|
||||
str_to_lst_of_str,
|
||||
)
|
||||
from esphome.helpers import get_bool_env, get_str_env, write_file_if_changed
|
||||
from esphome.helpers import write_file_if_changed
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -90,22 +94,10 @@ def get_idf_tools_path() -> Path:
|
||||
Returns:
|
||||
Path object pointing to the ESP-IDF tools directory
|
||||
"""
|
||||
# Treat an empty/whitespace ESPHOME_ESP_IDF_PREFIX as unset: Path("")
|
||||
# resolves to the CWD, which would install into (and let clean-all delete)
|
||||
# the working directory by accident.
|
||||
if prefix := get_str_env("ESPHOME_ESP_IDF_PREFIX", "").strip():
|
||||
path = Path(prefix).expanduser()
|
||||
else:
|
||||
# Machine-global so all projects share the multi-GB install instead of
|
||||
# a per-config-directory copy. The user cache dir (not ~/.esphome)
|
||||
# avoids colliding with data_dir when configs live in the home dir.
|
||||
# appauthor=False drops the redundant <author>\ segment on Windows
|
||||
# (which otherwise repeats "esphome\esphome\") to keep the path short.
|
||||
path = Path(platformdirs.user_cache_dir("esphome", appauthor=False)) / "idf"
|
||||
# Resolve so an unnormalized config path (e.g. compiling ``../config/x.yaml``)
|
||||
# doesn't leave ``..`` segments in the IDF_TOOLS_PATH handed to idf.py, which
|
||||
# otherwise warns that the venv interpreter path doesn't match the install.
|
||||
return path.resolve()
|
||||
# Machine-global so all projects share the multi-GB install instead of
|
||||
# a per-config-directory copy; see build_helpers.tools_cache.tools_cache_path
|
||||
# for the env-override and normalization rules.
|
||||
return tools_cache_path(*IDF_TOOLS_CACHE)
|
||||
|
||||
|
||||
# Windows' default MAX_PATH is 260 characters. ESP-IDF toolchains nest deeply
|
||||
@@ -1179,8 +1171,10 @@ def check_esp_idf_install(
|
||||
def _ccache_env() -> dict[str, str]:
|
||||
"""Return ccache settings for ESP-IDF compiles.
|
||||
|
||||
Enabled by default whenever the ``ccache`` binary is on PATH; set
|
||||
``IDF_CCACHE_ENABLE=0`` in the environment to opt out. The cache lives under
|
||||
Enabled by default whenever a runnable ``ccache`` binary is on PATH.
|
||||
``IDF_CCACHE_ENABLE=0`` opts out and ``=1`` forces it on; when that knob
|
||||
is unset the shared ``ESPHOME_CCACHE_ENABLE`` applies (same 0/1 forms,
|
||||
unrecognized values warn and count as unset). The cache lives under
|
||||
the IDF tools path (the machine-global cache dir, or
|
||||
``ESPHOME_ESP_IDF_PREFIX``), so it is shared across all projects and removed
|
||||
by ``esphome clean-all`` along with the framework.
|
||||
@@ -1195,33 +1189,23 @@ def _ccache_env() -> dict[str, str]:
|
||||
Only values the user has not already set in the environment are returned, so
|
||||
a custom ``CCACHE_DIR`` / ``CCACHE_MAXSIZE`` / etc. is respected.
|
||||
"""
|
||||
# Honor an explicit choice already in the environment (opt-out or opt-in).
|
||||
if "IDF_CCACHE_ENABLE" in os.environ:
|
||||
if not get_bool_env("IDF_CCACHE_ENABLE"):
|
||||
return {}
|
||||
elif shutil.which("ccache") is None:
|
||||
# IDF_CCACHE_ENABLE (the backend-native knob) wins over the shared
|
||||
# ESPHOME_CCACHE_ENABLE.
|
||||
idf_knob = parse_enable_env("IDF_CCACHE_ENABLE")
|
||||
if idf_knob is False:
|
||||
# The raw value (e.g. "disable") is still inherited by idf.py via
|
||||
# os.environ, where a non-false-constant string reads as truthy;
|
||||
# export the canonical off spelling instead
|
||||
return {"IDF_CCACHE_ENABLE": "0"}
|
||||
if idf_knob is None and resolve_ccache_path() is None:
|
||||
# ESP-IDF silently skips ccache without the binary; don't enable it.
|
||||
return {}
|
||||
|
||||
# ccache is enabled past here. build_path is set during preload for every
|
||||
# config-loading command, so it being unset means a caller built the IDF env
|
||||
# too early -- fail loudly rather than silently drop CCACHE_BASEDIR (which
|
||||
# would quietly cost cross-device cache hits).
|
||||
if CORE.build_path is None:
|
||||
raise ValueError(
|
||||
"CORE.build_path must be set before constructing the ESP-IDF build "
|
||||
"environment"
|
||||
)
|
||||
|
||||
defaults = {
|
||||
"IDF_CCACHE_ENABLE": "1",
|
||||
"CCACHE_DIR": str(get_idf_tools_path() / "ccache"),
|
||||
"CCACHE_NOHASHDIR": "true",
|
||||
"CCACHE_DEPEND": "1",
|
||||
"CCACHE_BASEDIR": str(Path(CORE.build_path).resolve()),
|
||||
}
|
||||
# Don't override CCACHE_* values the user already set in their environment.
|
||||
return {k: v for k, v in defaults.items() if k not in os.environ}
|
||||
env = ccache_defaults_env(get_idf_tools_path() / "ccache")
|
||||
if idf_knob is None:
|
||||
# An unparsable IDF_CCACHE_ENABLE must not leak to idf.py as truthy
|
||||
env["IDF_CCACHE_ENABLE"] = "1"
|
||||
return env
|
||||
|
||||
|
||||
def get_framework_env(
|
||||
|
||||
@@ -199,6 +199,30 @@ def run_command(
|
||||
return False, None, None
|
||||
|
||||
|
||||
def tool_version_runs(binary: str, warning: str) -> bool:
|
||||
"""Probe ``binary --version``; on failure warn with ``warning`` % binary.
|
||||
|
||||
``shutil.which`` proves existence, not runnability (Windows .bat/.cmd
|
||||
shims, stale package-manager shims).
|
||||
"""
|
||||
try:
|
||||
subprocess.run(
|
||||
[binary, "--version"],
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=15,
|
||||
# Repo-wide convention (posix_spawn fast path)
|
||||
close_fds=False,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError) as err:
|
||||
# The cause (permission denied, missing DLL, timeout) is the one
|
||||
# detail the user needs to fix it
|
||||
_LOGGER.warning("%s (%s)", warning % binary, err)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def run_command_ok(*args, **kwargs) -> bool:
|
||||
"""
|
||||
Execute a command and return only the success status.
|
||||
@@ -1287,3 +1311,37 @@ def download_from_mirrors(
|
||||
f"No mirror URL template matched the provided substitutions:{details}"
|
||||
)
|
||||
raise ValueError("download_from_mirrors called with an empty mirrors list")
|
||||
|
||||
|
||||
def strip_win_long_path_prefix(path: str) -> str:
|
||||
r"""Strip the Windows extended-length path prefix from ``path``.
|
||||
|
||||
Handles both forms documented at
|
||||
https://learn.microsoft.com/windows/win32/fileio/naming-a-file:
|
||||
|
||||
* ``\\?\C:\path\to\file`` -> ``C:\path\to\file``
|
||||
* ``\\?\UNC\server\share\path`` -> ``\\server\share\path``
|
||||
|
||||
The NSIS-installed ``esphome.exe`` launcher on Windows starts Python with
|
||||
``sys.executable`` already prefixed with ``\\?\``. That prefix propagates
|
||||
into PlatformIO's ``$PYTHONEXE`` (PlatformIO reads ``PYTHONEXEPATH`` from
|
||||
the environment, falling back to ``os.path.normpath(sys.executable)``)
|
||||
and ends up baked into SCons-emitted command lines for build steps such
|
||||
as the esp8266 ``elf2bin`` invocation. ``cmd.exe`` does not understand
|
||||
the ``\\?\`` prefix, so the build fails with
|
||||
"The system cannot find the path specified." Stripping the prefix early
|
||||
keeps the path shell-quotable.
|
||||
|
||||
Also applied to the ccache path exported by the ccache helpers, which
|
||||
``shutil.which`` can return with the same prefix.
|
||||
|
||||
No-op on non-Windows platforms.
|
||||
"""
|
||||
if sys.platform != "win32":
|
||||
return path
|
||||
if path.startswith("\\\\?\\UNC\\"):
|
||||
# \\?\UNC\server\share\... -> \\server\share\...
|
||||
return "\\\\" + path[len("\\\\?\\UNC\\") :]
|
||||
if path.startswith("\\\\?\\"):
|
||||
return path[len("\\\\?\\") :]
|
||||
return path
|
||||
|
||||
@@ -31,6 +31,12 @@ SockAddr = IPv4SockAddr | IPv6SockAddr
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# 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"})
|
||||
|
||||
IS_MACOS = platform.system() == "Darwin"
|
||||
IS_WINDOWS = platform.system() == "Windows"
|
||||
IS_LINUX = platform.system() == "Linux"
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
"""Install packages from the PlatformIO registry without importing the
|
||||
platformio package (identical bits, esphome's own download machinery)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Collection
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import platform
|
||||
|
||||
from esphome.core import EsphomeError
|
||||
from esphome.framework_helpers import (
|
||||
BatchDownloadProgress,
|
||||
archive_extract_all,
|
||||
download_from_mirrors,
|
||||
download_with_resume,
|
||||
rmdir,
|
||||
run_batch_downloads,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_REGISTRY_URL = (
|
||||
"https://api.registry.platformio.org/v3/packages/platformio/tool/{package}"
|
||||
)
|
||||
|
||||
|
||||
def get_systype() -> str:
|
||||
"""The registry system tag for the current host.
|
||||
|
||||
Transliterates ``platformio.util.get_systype()`` (same
|
||||
``PLATFORMIO_SYSTEM_TYPE`` override). Deviation: windows-arm64 maps to
|
||||
``windows_amd64`` (no arm64 toolchains; x86 emulation).
|
||||
"""
|
||||
if systype := os.environ.get("PLATFORMIO_SYSTEM_TYPE"):
|
||||
return systype
|
||||
system = platform.system().lower()
|
||||
arch = platform.machine().lower()
|
||||
if system == "windows":
|
||||
if not arch: # same fallback as upstream (platformio issue #4353)
|
||||
arch = "x86_" + platform.architecture()[0]
|
||||
if "x86" in arch:
|
||||
arch = "amd64" if "64" in arch else "x86"
|
||||
elif arch == "arm64":
|
||||
arch = "amd64"
|
||||
if arch == "aarch64" and platform.architecture()[0] == "32bit":
|
||||
# 64-bit kernel with a 32-bit userland (e.g. 32-bit Raspberry Pi OS)
|
||||
arch = "armv7l"
|
||||
return f"{system}_{arch}" if arch else system
|
||||
|
||||
|
||||
def registry_download(package: str, version: str) -> tuple[str, str, int | None]:
|
||||
"""Resolve a package's download URL, sha256, and size via the registry.
|
||||
|
||||
The metadata fetch goes through ``download_from_mirrors`` so it shares
|
||||
the retry, backoff, and error reporting of every other download here.
|
||||
"""
|
||||
buf = io.BytesIO()
|
||||
download_from_mirrors([_REGISTRY_URL], {"package": package}, buf)
|
||||
try:
|
||||
data = json.loads(buf.getvalue())
|
||||
except ValueError as err:
|
||||
raise EsphomeError(
|
||||
f"The package registry returned invalid JSON for {package}: {err}"
|
||||
) from err
|
||||
if not isinstance(data, dict):
|
||||
raise EsphomeError(
|
||||
f"Unexpected package registry response for {package}: {str(data)[:200]}"
|
||||
)
|
||||
systype = get_systype()
|
||||
versions = data.get("versions")
|
||||
if not isinstance(versions, list):
|
||||
# A schema change or an error/captive-portal payload must not be
|
||||
# reported as "version not found"
|
||||
raise EsphomeError(
|
||||
f"Unexpected package registry response for {package}: {str(data)[:200]}"
|
||||
)
|
||||
for ver in versions:
|
||||
if not isinstance(ver, dict):
|
||||
raise EsphomeError(
|
||||
f"Unexpected package registry response for {package}: {str(data)[:200]}"
|
||||
)
|
||||
if ver.get("name") != version:
|
||||
continue
|
||||
files = ver.get("files")
|
||||
if not isinstance(files, list):
|
||||
raise EsphomeError(
|
||||
f"Unexpected package registry response for {package}: {str(ver)[:200]}"
|
||||
)
|
||||
for file in files:
|
||||
if not isinstance(file, dict):
|
||||
raise EsphomeError(
|
||||
f"Unexpected package registry response for {package}: "
|
||||
f"{str(ver)[:200]}"
|
||||
)
|
||||
# Only a missing key means "any system"; an empty list must not
|
||||
# match, and a bare string would make ``in`` a substring test.
|
||||
systems = file.get("system")
|
||||
if systems is None:
|
||||
systems = ["*"]
|
||||
elif isinstance(systems, str):
|
||||
systems = [systems]
|
||||
elif not isinstance(systems, list):
|
||||
# An int would make ``in`` a TypeError and a dict a key test
|
||||
raise EsphomeError(
|
||||
f"Unexpected package registry response for {package}: "
|
||||
f"{str(file)[:200]}"
|
||||
)
|
||||
if "*" in systems or systype in systems:
|
||||
sha256 = (file.get("checksum") or {}).get("sha256")
|
||||
if not sha256:
|
||||
# Never extract an unverified archive; the registry
|
||||
# publishes a checksum for every package file.
|
||||
raise EsphomeError(
|
||||
f"The package registry returned no sha256 for "
|
||||
f"{package} {version}; refusing the unverified download"
|
||||
)
|
||||
url = file.get("download_url")
|
||||
if not url:
|
||||
raise EsphomeError(
|
||||
f"The package registry returned no download URL for "
|
||||
f"{package} {version}"
|
||||
)
|
||||
return (url, sha256, file.get("size"))
|
||||
raise EsphomeError(
|
||||
f"No {package} {version} build for this platform ({systype})"
|
||||
)
|
||||
raise EsphomeError(f"{package} {version} not found in the package registry")
|
||||
|
||||
|
||||
def _check_layout(name: str, dest: Path, expect: Collection[str]) -> None:
|
||||
"""Raise when an install tree is missing an expected directory (runs on
|
||||
fresh extracts and on marker hits)."""
|
||||
for rel in expect:
|
||||
if not (dest / rel).is_dir():
|
||||
raise EsphomeError(
|
||||
f"{name} at {dest} is missing the expected {rel} "
|
||||
"directory; run 'esphome clean-all' and retry"
|
||||
)
|
||||
|
||||
|
||||
def prefetch_packages(
|
||||
packages: list[tuple[str, str, Path, list[str]]], downloads_dir: Path
|
||||
) -> None:
|
||||
"""Download pending package archives in parallel under one combined bar.
|
||||
|
||||
``packages`` holds ``(name, version, dest, mirrors)`` per package. Purely
|
||||
an optimization: ``install_package`` verifies every archive and
|
||||
re-downloads anything this pass left unfinished. Mirror overrides and
|
||||
registry entries without a size stay on the sequential path so its
|
||||
per-file bars remain trustworthy. Each fetch holds the same per-dest
|
||||
lock as ``install_package``: the archive's ``.part`` file is shared, and
|
||||
two concurrent writers would truncate each other's bytes.
|
||||
"""
|
||||
from filelock import FileLock
|
||||
|
||||
pending: list[tuple[str, str, Path, str, str, int]] = []
|
||||
seen: set[str] = set()
|
||||
for name, version, dest, mirrors in packages:
|
||||
if mirrors or (dest / ".esphome_extracted").is_file():
|
||||
continue
|
||||
archive_name = f"{name}-{version}"
|
||||
if archive_name in seen:
|
||||
# A duplicate entry would race itself between two workers
|
||||
continue
|
||||
seen.add(archive_name)
|
||||
try:
|
||||
url, sha256, size = registry_download(name, version)
|
||||
except EsphomeError as err:
|
||||
# The sequential install reports the real failure with context
|
||||
_LOGGER.debug("Prefetch resolve for %s failed: %s", name, err)
|
||||
continue
|
||||
if not size:
|
||||
continue
|
||||
archive = downloads_dir / archive_name
|
||||
if archive.is_file() and archive.stat().st_size == size:
|
||||
continue
|
||||
pending.append((name, version, dest, url, sha256, size))
|
||||
if len(pending) < 2:
|
||||
return
|
||||
downloads_dir.mkdir(parents=True, exist_ok=True)
|
||||
_LOGGER.info(
|
||||
"Downloading %d package archive(s): %s",
|
||||
len(pending),
|
||||
", ".join(name for name, *_ in pending),
|
||||
)
|
||||
|
||||
def _fetch(entry: tuple[str, str, Path, str, str, int]):
|
||||
name, version, dest, url, sha256, size = entry
|
||||
|
||||
def fetch(tracker):
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
with FileLock(f"{dest}.lock", fallback_to_soft=False):
|
||||
download_with_resume(
|
||||
url,
|
||||
downloads_dir / f"{name}-{version}",
|
||||
sha256=sha256,
|
||||
size=size,
|
||||
progress=tracker,
|
||||
)
|
||||
|
||||
return fetch
|
||||
|
||||
failures = run_batch_downloads(
|
||||
BatchDownloadProgress(
|
||||
"Downloading packages", sum(size for *_, size in pending)
|
||||
),
|
||||
[(entry[0], _fetch(entry)) for entry in pending],
|
||||
)
|
||||
for name, err in failures:
|
||||
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(
|
||||
name: str,
|
||||
version: str,
|
||||
dest: Path,
|
||||
mirrors: list[str],
|
||||
downloads_dir: Path,
|
||||
expect: Collection[str],
|
||||
) -> None:
|
||||
"""Download, verify, and extract one package if not already installed.
|
||||
|
||||
The registry path is integrity-checked against the sha256 the registry
|
||||
publishes; a mirror override (URL templates with ``{VERSION}``/``{SYSTEM}``
|
||||
substitution) is trusted as configured. ``downloads_dir`` holds the
|
||||
archive between runs so an interrupted download resumes.
|
||||
"""
|
||||
if not expect:
|
||||
# Layout validation before marker.touch() is the only guard against
|
||||
# caching a truncated mirror archive as a good install
|
||||
raise ValueError("install_package requires a non-empty expect")
|
||||
marker = dest / ".esphome_extracted"
|
||||
if marker.is_file():
|
||||
_check_layout(name, dest, expect)
|
||||
return
|
||||
from filelock import FileLock
|
||||
|
||||
# Serialize concurrent cold builds (same filelock pattern as git.py).
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
# A soft-lock fallback would turn a hard-killed run into a permanent
|
||||
# hang (see git.py).
|
||||
with FileLock(f"{dest}.lock", fallback_to_soft=False):
|
||||
if marker.is_file():
|
||||
# Another process finished the install while we waited
|
||||
return
|
||||
rmdir(dest, msg=f"Clean up incomplete {name} install")
|
||||
# Persistent location so an interrupted download resumes across runs.
|
||||
downloads_dir.mkdir(parents=True, exist_ok=True)
|
||||
archive = downloads_dir / f"{name}-{version}"
|
||||
_LOGGER.info("Downloading %s %s ...", name, version)
|
||||
if mirrors:
|
||||
_LOGGER.warning(
|
||||
"Downloading %s from a mirror override; checksum verification "
|
||||
"is skipped for mirrors",
|
||||
name,
|
||||
)
|
||||
download_from_mirrors(
|
||||
mirrors, {"VERSION": version, "SYSTEM": get_systype()}, archive
|
||||
)
|
||||
else:
|
||||
url, sha256, size = registry_download(name, version)
|
||||
download_with_resume(url, archive, sha256=sha256, size=size)
|
||||
_LOGGER.info("Extracting %s ...", name)
|
||||
archive_extract_all(archive, dest, progress_header="Extracting")
|
||||
# Validate the layout before recording success, so an unexpected
|
||||
# package is never cached as a working install.
|
||||
_check_layout(name, dest, expect)
|
||||
marker.touch()
|
||||
archive.unlink(missing_ok=True)
|
||||
@@ -4,19 +4,18 @@ import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import platformdirs
|
||||
|
||||
from esphome.build_helpers.ccache import resolve_ccache_path
|
||||
from esphome.const import CONF_COMPILE_PROCESS_LIMIT, CONF_ESPHOME, KEY_CORE
|
||||
from esphome.core import CORE, EsphomeError
|
||||
from esphome.framework_helpers import strip_win_long_path_prefix
|
||||
from esphome.helpers import (
|
||||
add_git_ceiling_directory,
|
||||
copy_file_if_changed,
|
||||
get_bool_env,
|
||||
rmtree,
|
||||
write_file,
|
||||
)
|
||||
@@ -41,40 +40,6 @@ _PIO_PYTHON_STAMP_LOCK = ".esphome.pio.stamp.lock"
|
||||
_PIO_PYTHON_STAMP_SCHEMA = "0"
|
||||
|
||||
|
||||
def _strip_win_long_path_prefix(path: str) -> str:
|
||||
r"""Strip the Windows extended-length path prefix from ``path``.
|
||||
|
||||
Handles both forms documented at
|
||||
https://learn.microsoft.com/windows/win32/fileio/naming-a-file:
|
||||
|
||||
* ``\\?\C:\path\to\file`` -> ``C:\path\to\file``
|
||||
* ``\\?\UNC\server\share\path`` -> ``\\server\share\path``
|
||||
|
||||
The NSIS-installed ``esphome.exe`` launcher on Windows starts Python with
|
||||
``sys.executable`` already prefixed with ``\\?\``. That prefix propagates
|
||||
into PlatformIO's ``$PYTHONEXE`` (PlatformIO reads ``PYTHONEXEPATH`` from
|
||||
the environment, falling back to ``os.path.normpath(sys.executable)``)
|
||||
and ends up baked into SCons-emitted command lines for build steps such
|
||||
as the esp8266 ``elf2bin`` invocation. ``cmd.exe`` does not understand
|
||||
the ``\\?\`` prefix, so the build fails with
|
||||
"The system cannot find the path specified." Stripping the prefix early
|
||||
keeps the path shell-quotable.
|
||||
|
||||
Also applied to the ccache path exported by ``_ccache_env()``, which
|
||||
``shutil.which`` can return with the same prefix.
|
||||
|
||||
No-op on non-Windows platforms.
|
||||
"""
|
||||
if sys.platform != "win32":
|
||||
return path
|
||||
if path.startswith("\\\\?\\UNC\\"):
|
||||
# \\?\UNC\server\share\... -> \\server\share\...
|
||||
return "\\\\" + path[len("\\\\?\\UNC\\") :]
|
||||
if path.startswith("\\\\?\\"):
|
||||
return path[len("\\\\?\\") :]
|
||||
return path
|
||||
|
||||
|
||||
def get_platformio_config() -> "ProjectConfig | None":
|
||||
"""Return PlatformIO's ``ProjectConfig``, or None when PlatformIO is absent."""
|
||||
try:
|
||||
@@ -238,35 +203,6 @@ def _check_platformio_python_stamp(config: "ProjectConfig") -> None:
|
||||
_write_pio_stamp_python(stamp_file, current)
|
||||
|
||||
|
||||
def _ccache_runs(ccache: str) -> bool:
|
||||
"""Return True when the ``ccache`` found on PATH actually runs.
|
||||
|
||||
``shutil.which`` proves existence, not runnability: on Windows it also
|
||||
matches ``.bat``/``.cmd`` wrappers and stale package-manager shims whose
|
||||
target is gone. Wrapping compiles around such a find fails every compile
|
||||
step with an opaque OS error, so probe once and fall back to compiling
|
||||
without ccache when the probe fails.
|
||||
"""
|
||||
try:
|
||||
subprocess.run(
|
||||
[ccache, "--version"],
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=15,
|
||||
# Repo-wide convention (posix_spawn fast path); see the
|
||||
# close_fds=False call sites across esphome/ and script/helpers.py
|
||||
close_fds=False,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
_LOGGER.warning(
|
||||
"Ignoring ccache at %s because it failed to run; compiling without ccache",
|
||||
ccache,
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _ccache_env() -> dict[str, str]:
|
||||
r"""Return ccache settings for PlatformIO builds.
|
||||
|
||||
@@ -285,7 +221,7 @@ def _ccache_env() -> dict[str, str]:
|
||||
runs fine through ``CreateProcess``, which is how ESP-IDF invokes it,
|
||||
but SCons runs every compile through ``cmd.exe``, which fails on it with
|
||||
"The system cannot find the path specified." (#18399), so the prefix is
|
||||
stripped here with ``_strip_win_long_path_prefix()`` before the
|
||||
stripped here with ``strip_win_long_path_prefix()`` before the
|
||||
runnability probe, which therefore validates the exact string the build
|
||||
will execute.
|
||||
``ESPHOME_CCACHE_PATH`` is an internal channel, not a user setting: the
|
||||
@@ -311,22 +247,8 @@ def _ccache_env() -> dict[str, str]:
|
||||
build dir. The other ``CCACHE_*`` values the user already set in the
|
||||
environment are respected.
|
||||
"""
|
||||
explicit = "ESPHOME_CCACHE_ENABLE" in os.environ
|
||||
if explicit and not get_bool_env("ESPHOME_CCACHE_ENABLE"):
|
||||
return {"ESPHOME_CCACHE_ENABLE": "0"}
|
||||
ccache_path = shutil.which("ccache")
|
||||
ccache_path = resolve_ccache_path()
|
||||
if ccache_path is None:
|
||||
if explicit:
|
||||
_LOGGER.warning(
|
||||
"ESPHOME_CCACHE_ENABLE is set but no ccache binary is on PATH; "
|
||||
"compiling without ccache"
|
||||
)
|
||||
return {"ESPHOME_CCACHE_ENABLE": "0"}
|
||||
# Strip before probing so the probe validates (and the failure warning
|
||||
# names) the exact string the build will execute through cmd.exe.
|
||||
ccache_path = _strip_win_long_path_prefix(ccache_path)
|
||||
# An explicit opt-in skips the runnability probe.
|
||||
if not explicit and not _ccache_runs(ccache_path):
|
||||
return {"ESPHOME_CCACHE_ENABLE": "0"}
|
||||
env = {
|
||||
"ESPHOME_CCACHE_ENABLE": "1",
|
||||
@@ -388,7 +310,7 @@ def run_platformio_cli(*args, **kwargs) -> str | int:
|
||||
# Strip the Windows extended-length path prefix from sys.executable so it
|
||||
# doesn't propagate into PlatformIO's $PYTHONEXE and break SCons-emitted
|
||||
# command lines run through cmd.exe.
|
||||
python_exe = _strip_win_long_path_prefix(sys.executable)
|
||||
python_exe = strip_win_long_path_prefix(sys.executable)
|
||||
if python_exe != sys.executable:
|
||||
# Only override PYTHONEXEPATH when we actually stripped a prefix.
|
||||
# PlatformIO's get_pythonexe_path() reads this and falls back to
|
||||
|
||||
+7
-4
@@ -690,14 +690,17 @@ def clean_all(configuration: list[str]):
|
||||
# the per-config loop above can't reach. Wipe the default cache root
|
||||
# (also catches leftovers from older install layouts), then the resolved
|
||||
# install paths for the ESPHOME_*_PREFIX overrides (docker/add-on/CI)
|
||||
# that live outside it.
|
||||
# that live outside it. Every backend's cache is listed in
|
||||
# TOOLS_CACHE_SPECS, so registering one there is the only step.
|
||||
import platformdirs
|
||||
|
||||
from esphome.components.nrf52.framework import get_sdk_nrf_tools_path
|
||||
from esphome.espidf.framework import get_idf_tools_path
|
||||
from esphome.build_helpers.tools_cache import TOOLS_CACHE_SPECS, tools_cache_path
|
||||
|
||||
cache_root = Path(platformdirs.user_cache_dir("esphome", appauthor=False)).resolve()
|
||||
for install_path in (cache_root, get_idf_tools_path(), get_sdk_nrf_tools_path()):
|
||||
install_paths = [cache_root] + [
|
||||
tools_cache_path(*spec) for spec in TOOLS_CACHE_SPECS
|
||||
]
|
||||
for install_path in install_paths:
|
||||
if install_path.is_dir():
|
||||
_LOGGER.info("Deleting %s", install_path)
|
||||
rmtree(install_path)
|
||||
|
||||
@@ -28,6 +28,7 @@ smpclient==7.2.0
|
||||
requests==2.34.2
|
||||
py7zr==1.1.3
|
||||
platformdirs==4.11.3 # native esp-idf toolchain global cache dir
|
||||
ninja==1.13.0 # native esp8266 arduino toolchain build driver
|
||||
filelock==3.32.3 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg
|
||||
|
||||
# esp-idf >= 5.0 requires this
|
||||
|
||||
@@ -131,6 +131,20 @@ def test_esp32_rejects_unsupported_toolchains(
|
||||
CONFIG_SCHEMA({"variant": VARIANT_ESP32, "toolchain": config_toolchain})
|
||||
|
||||
|
||||
def test_esp32_rejects_unsupported_cli_toolchain(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""A --toolchain the platform cannot serve fails instead of silently
|
||||
building with PlatformIO (the CLI path bypasses the YAML validator)."""
|
||||
set_core_config(PlatformFramework.ESP32_IDF)
|
||||
|
||||
from esphome.components.esp32 import CONFIG_SCHEMA
|
||||
|
||||
CORE.toolchain = Toolchain.ARDUINO
|
||||
with pytest.raises(cv.Invalid, match="Unsupported toolchain 'arduino'"):
|
||||
CONFIG_SCHEMA({"variant": VARIANT_ESP32})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("config", "error_match"),
|
||||
[
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Tests for the shared ccache policy in esphome.build_helpers.ccache."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.build_helpers import ccache
|
||||
|
||||
|
||||
def test_resolve_opt_out() -> None:
|
||||
with patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": "0"}):
|
||||
assert ccache.resolve_ccache_path() is None
|
||||
|
||||
|
||||
def test_resolve_no_binary(caplog: pytest.LogCaptureFixture) -> None:
|
||||
with (
|
||||
patch.dict(os.environ, {}, clear=True),
|
||||
patch("shutil.which", return_value=None),
|
||||
):
|
||||
assert ccache.resolve_ccache_path() is None
|
||||
assert "no ccache binary" not in caplog.text
|
||||
|
||||
|
||||
def test_resolve_probe_failure() -> None:
|
||||
with (
|
||||
patch.dict(os.environ, {}, clear=True),
|
||||
patch("shutil.which", return_value="/usr/bin/ccache"),
|
||||
patch("esphome.framework_helpers.subprocess.run", side_effect=OSError("boom")),
|
||||
):
|
||||
assert ccache.resolve_ccache_path() is None
|
||||
|
||||
|
||||
def test_resolve_explicit_skips_probe_and_warns_missing(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
with (
|
||||
patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": "1"}, clear=True),
|
||||
patch("shutil.which", return_value="/usr/bin/ccache"),
|
||||
patch.object(ccache, "_ccache_runs", side_effect=AssertionError),
|
||||
):
|
||||
assert ccache.resolve_ccache_path() == "/usr/bin/ccache"
|
||||
with (
|
||||
patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": "1"}, clear=True),
|
||||
patch("shutil.which", return_value=None),
|
||||
):
|
||||
assert ccache.resolve_ccache_path() is None
|
||||
assert "no ccache binary is on PATH" in caplog.text
|
||||
|
||||
|
||||
def test_probe_spawns_with_close_fds_false() -> None:
|
||||
with patch("esphome.framework_helpers.subprocess.run") as mock_run:
|
||||
assert ccache._ccache_runs("/usr/bin/ccache") is True
|
||||
assert mock_run.call_args.kwargs["close_fds"] is False
|
||||
|
||||
|
||||
def test_defaults_env(tmp_path: Path) -> None:
|
||||
with (
|
||||
patch("esphome.core.CORE", SimpleNamespace(build_path=tmp_path / "b")),
|
||||
patch.dict(os.environ, {"CCACHE_NOHASHDIR": "false"}, clear=True),
|
||||
):
|
||||
env = ccache.ccache_defaults_env(tmp_path / "cache")
|
||||
assert env["CCACHE_DIR"] == str(tmp_path / "cache")
|
||||
assert env["CCACHE_DEPEND"] == "1"
|
||||
assert "CCACHE_NOHASHDIR" not in env # user value respected
|
||||
|
||||
|
||||
def test_defaults_env_requires_build_path() -> None:
|
||||
with (
|
||||
patch("esphome.core.CORE", SimpleNamespace(build_path=None)),
|
||||
pytest.raises(ValueError, match="build_path"),
|
||||
):
|
||||
ccache.ccache_defaults_env(Path("/x"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["no", "off", "false", "0"])
|
||||
def test_resolve_opt_out_synonyms(value: str) -> None:
|
||||
"""Every recognized falsy spelling disables ccache."""
|
||||
with patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": value}):
|
||||
assert ccache.resolve_ccache_path() is None
|
||||
|
||||
|
||||
def test_resolve_unrecognized_value_warns_and_probes(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""An unparsable ESPHOME_CCACHE_ENABLE is treated as unset: it must not
|
||||
silently enable ccache or skip the runnability probe."""
|
||||
with (
|
||||
patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": "enabled"}),
|
||||
patch("shutil.which", return_value="/usr/bin/ccache"),
|
||||
patch.object(ccache, "_ccache_runs", return_value=False) as mock_probe,
|
||||
):
|
||||
assert ccache.resolve_ccache_path() is None
|
||||
mock_probe.assert_called_once()
|
||||
assert "unrecognized ESPHOME_CCACHE_ENABLE" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw", "expected"),
|
||||
[
|
||||
("1", True),
|
||||
("enable", True),
|
||||
("ON", True),
|
||||
("0", False),
|
||||
("disable", False),
|
||||
("Off", False),
|
||||
("maybe", None),
|
||||
],
|
||||
)
|
||||
def test_parse_enable_env_spelling_tables(
|
||||
monkeypatch: pytest.MonkeyPatch, raw: str, expected: bool | None
|
||||
) -> None:
|
||||
"""cv.boolean's spelling tables plus the 1/0 env convention."""
|
||||
monkeypatch.setenv("ESPHOME_CCACHE_ENABLE", raw)
|
||||
assert ccache.parse_enable_env("ESPHOME_CCACHE_ENABLE") is expected
|
||||
@@ -0,0 +1,143 @@
|
||||
"""Tests for esphome.build_helpers.ninja."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.build_helpers import ninja as ninja_helper
|
||||
from esphome.core import EsphomeError
|
||||
|
||||
|
||||
def test_find_ninja_prefers_path(tmp_path: Path) -> None:
|
||||
with (
|
||||
patch("shutil.which", return_value=str(tmp_path / "ninja")),
|
||||
patch.object(ninja_helper, "_ninja_runs", return_value=True),
|
||||
):
|
||||
assert ninja_helper.find_ninja() == tmp_path / "ninja"
|
||||
|
||||
|
||||
def test_find_ninja_falls_back_to_wheel(tmp_path: Path) -> None:
|
||||
"""Without a PATH entry, the ninja PyPI wheel's binary is used."""
|
||||
binary_name = "ninja.exe" if os.name == "nt" else "ninja"
|
||||
(tmp_path / binary_name).touch()
|
||||
wheel = MagicMock(BIN_DIR=str(tmp_path))
|
||||
with (
|
||||
patch("shutil.which", return_value=None),
|
||||
patch.dict(sys.modules, {"ninja": wheel}),
|
||||
):
|
||||
assert ninja_helper.find_ninja() == tmp_path / binary_name
|
||||
|
||||
|
||||
def test_find_ninja_package_not_installed() -> None:
|
||||
"""A missing ninja package raises the actionable message, not ImportError."""
|
||||
with (
|
||||
patch("shutil.which", return_value=None),
|
||||
patch.dict(sys.modules, {"ninja": None}),
|
||||
pytest.raises(EsphomeError, match="ninja not found"),
|
||||
):
|
||||
ninja_helper.find_ninja()
|
||||
|
||||
|
||||
def test_find_ninja_missing_everywhere(tmp_path: Path) -> None:
|
||||
wheel = MagicMock(BIN_DIR=str(tmp_path))
|
||||
with (
|
||||
patch("shutil.which", return_value=None),
|
||||
patch.dict(sys.modules, {"ninja": wheel}),
|
||||
pytest.raises(EsphomeError, match="ninja not found"),
|
||||
):
|
||||
ninja_helper.find_ninja()
|
||||
|
||||
|
||||
def test_escape_ninja_specials() -> None:
|
||||
assert ninja_helper.escape("a b:c$d") == "a$ b$:c$$d"
|
||||
|
||||
|
||||
def _q(tok: str) -> str:
|
||||
"""The platform's shell_token quote wrapper (argv rule on Windows)."""
|
||||
return f'"{tok}"' if os.name == "nt" else f"'{tok}'"
|
||||
|
||||
|
||||
def test_quote_arg_windows_argv_rule() -> None:
|
||||
# Backslash runs double only before a quote (subprocess.list2cmdline rule)
|
||||
assert ninja_helper.quote_arg('-DX=a\\"b c') == '"-DX=a\\\\\\"b c"'
|
||||
assert ninja_helper.quote_arg("a b\\") == '"a b\\\\"'
|
||||
|
||||
|
||||
def test_shell_token_quotes_only_when_needed() -> None:
|
||||
assert ninja_helper.shell_token("-Os") == "-Os"
|
||||
assert ninja_helper.shell_token("-DP=C:\\x y") == _q("-DP=C:\\x y")
|
||||
assert ninja_helper.shell_token("plain", force=True) == _q("plain")
|
||||
|
||||
|
||||
def test_shell_token_quotes_shell_metacharacters() -> None:
|
||||
"""Tokens like -DMASK=(1<<3) must not reach /bin/sh -c bare."""
|
||||
assert ninja_helper.shell_token("-DMASK=(1<<3)") == _q("-DMASK=(1<<3)")
|
||||
assert ninja_helper.shell_token("-DX=a;b") == _q("-DX=a;b")
|
||||
assert ninja_helper.shell_token("-DX=$HOME") == _q("-DX=$$HOME")
|
||||
|
||||
|
||||
def test_shell_token_posix_roundtrips_through_sh() -> None:
|
||||
"""Backslash runs, $, backticks, and quotes must reach the compiler
|
||||
exactly as lexed once ninja un-doubles $$ and /bin/sh strips quotes."""
|
||||
|
||||
if sys.platform == "win32":
|
||||
pytest.skip("POSIX sh quoting")
|
||||
for tok in ("-DP=a\\\\b", "-DX=$VAR", "-DY=`date`", "-DZ=it's", '-DC="q"'):
|
||||
quoted = ninja_helper.shell_token(tok).replace("$$", "$")
|
||||
out = subprocess.run(
|
||||
["/bin/sh", "-c", f'printf "%s" {quoted}'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
assert out.stdout == tok
|
||||
|
||||
|
||||
def test_quote_path_force_quotes() -> None:
|
||||
assert ninja_helper.quote_path(Path("a b")) == _q("a b")
|
||||
assert ninja_helper.quote_path("simple") == _q("simple")
|
||||
|
||||
|
||||
def test_shell_token_empty_token_is_quoted() -> None:
|
||||
"""An empty argv element must survive as an explicit pair of quotes."""
|
||||
assert ninja_helper.shell_token("") == _q("")
|
||||
|
||||
|
||||
def test_find_ninja_probes_path_hit(tmp_path: Path) -> None:
|
||||
"""A broken PATH shim falls back to the wheel instead of failing every
|
||||
build later."""
|
||||
binary_name = "ninja.exe" if os.name == "nt" else "ninja"
|
||||
(tmp_path / binary_name).touch()
|
||||
wheel = MagicMock(BIN_DIR=str(tmp_path))
|
||||
with (
|
||||
patch("shutil.which", return_value="/broken/ninja"),
|
||||
patch.object(ninja_helper, "_ninja_runs", return_value=False),
|
||||
patch.dict(sys.modules, {"ninja": wheel}),
|
||||
):
|
||||
assert ninja_helper.find_ninja() == tmp_path / binary_name
|
||||
|
||||
|
||||
def test_ninja_probe_failure_warns(caplog: pytest.LogCaptureFixture) -> None:
|
||||
with patch("esphome.framework_helpers.subprocess.run", side_effect=OSError("boom")):
|
||||
assert ninja_helper._ninja_runs("/broken/ninja") is False
|
||||
assert "failed to run" in caplog.text
|
||||
|
||||
|
||||
def test_ninja_probe_success() -> None:
|
||||
with patch("esphome.framework_helpers.subprocess.run") as mock_run:
|
||||
assert ninja_helper._ninja_runs("/usr/bin/ninja") is True
|
||||
assert mock_run.call_args.kwargs["close_fds"] is False
|
||||
|
||||
|
||||
def test_shell_token_windows_branch_uses_argv_rule() -> None:
|
||||
"""The nt branch quotes with the CreateProcess argv rule (the ubuntu
|
||||
coverage run never takes it naturally)."""
|
||||
with patch.object(os, "name", "nt"):
|
||||
assert ninja_helper.shell_token("a b") == '"a b"'
|
||||
assert ninja_helper.shell_token("", force=True) == '""'
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Tests for the linker-script surgery shared with the native toolchain."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components.esp8266 import build_surgery
|
||||
from esphome.components.esp8266.boards import BOARDS, ESP8266_BOARD_BUILD
|
||||
from esphome.components.esp8266.build_surgery import (
|
||||
RATETABLE_RULE,
|
||||
apply_testing_memory_patches,
|
||||
relocate_ratetable,
|
||||
segment_length,
|
||||
)
|
||||
|
||||
_COMMON_LD_SNIPPET = """\
|
||||
.dport0.data : ALIGN(4)
|
||||
{
|
||||
_dport0_data_start = ABSOLUTE(.);
|
||||
} >dport0_0_seg :dport0_0_phdr
|
||||
.data : ALIGN(4)
|
||||
{
|
||||
_data_start = ABSOLUTE(.);
|
||||
*(.data)
|
||||
} >dram0_0_seg :dram0_0_phdr
|
||||
"""
|
||||
|
||||
# Shaped like the real SDK flash ld scripts: no iram1_0_seg (that lives in
|
||||
# the generated common ld only)
|
||||
_FLASH_LD_SNIPPET = """\
|
||||
MEMORY
|
||||
{
|
||||
dport0_0_seg : org = 0x3FF00000, len = 0x10
|
||||
dram0_0_seg : org = 0x3FFE8000, len = 0x14000
|
||||
irom0_0_seg : org = 0x40201010, len = 0xfeff0
|
||||
}
|
||||
"""
|
||||
|
||||
# Shaped like the preprocessed common ld: MMU_IRAM_SIZE expands with a ul
|
||||
# suffix the patcher must leave in place
|
||||
_COMMON_LD_MEMORY_SNIPPET = """\
|
||||
MEMORY
|
||||
{
|
||||
iram1_0_seg : org = 0x40100000, len = 0x8000ul
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def test_relocate_ratetable_inserts_after_data_start() -> None:
|
||||
patched = relocate_ratetable(_COMMON_LD_SNIPPET)
|
||||
assert RATETABLE_RULE in patched
|
||||
# Inserted after the .data section's anchor, not the .dport0.data one
|
||||
# (whose closing brace bounds the decoy block)
|
||||
assert RATETABLE_RULE not in patched[: patched.index("} >dport0_0_seg")]
|
||||
assert patched.index(RATETABLE_RULE) < patched.index("*(.data)")
|
||||
# Idempotent on an already-patched script
|
||||
assert relocate_ratetable(patched) == patched
|
||||
|
||||
|
||||
def test_relocate_ratetable_requires_anchor() -> None:
|
||||
with pytest.raises(RuntimeError, match="_data_start"):
|
||||
relocate_ratetable("SECTIONS { }")
|
||||
|
||||
|
||||
def test_testing_memory_patches_enlarge_segments() -> None:
|
||||
patched = apply_testing_memory_patches(
|
||||
_FLASH_LD_SNIPPET, ("dram0_0_seg", "irom0_0_seg")
|
||||
)
|
||||
assert segment_length(patched, "dram0_0_seg") == 0x200000
|
||||
assert segment_length(patched, "irom0_0_seg") == 0x2000000
|
||||
# Untouched segments keep their sizes
|
||||
assert segment_length(patched, "dport0_0_seg") == 0x10
|
||||
|
||||
|
||||
def test_testing_memory_patches_keep_ul_suffix() -> None:
|
||||
"""The common ld's preprocessed sizes carry a ul suffix; the patch must
|
||||
replace only the hex digits, as testing_mode.py.script does."""
|
||||
patched = apply_testing_memory_patches(_COMMON_LD_MEMORY_SNIPPET, ("iram1_0_seg",))
|
||||
assert "len = 0x200000ul" in patched
|
||||
assert segment_length(patched, "iram1_0_seg") == 0x200000
|
||||
|
||||
|
||||
def test_segment_length_requires_whole_name() -> None:
|
||||
"""A name must match its own line, never inside a longer segment name."""
|
||||
assert segment_length(_FLASH_LD_SNIPPET, "ram0_0_seg") is None
|
||||
|
||||
|
||||
def test_testing_memory_patches_unknown_segment_raises() -> None:
|
||||
with pytest.raises(RuntimeError, match="Unknown testing-mode segment"):
|
||||
apply_testing_memory_patches("MEMORY { }", ("bogus_seg",))
|
||||
|
||||
|
||||
def test_segment_length() -> None:
|
||||
assert segment_length(_FLASH_LD_SNIPPET, "irom0_0_seg") == 0xFEFF0
|
||||
assert segment_length(_FLASH_LD_SNIPPET, "missing_seg") is None
|
||||
|
||||
|
||||
def test_testing_memory_patches_missing_segment_raises() -> None:
|
||||
"""A named segment the patch could not find raises instead of silently
|
||||
keeping the real memory limits."""
|
||||
with pytest.raises(RuntimeError, match="dram0_0_seg"):
|
||||
apply_testing_memory_patches("MEMORY { }", ("dram0_0_seg",))
|
||||
|
||||
|
||||
def test_board_build_covers_every_board() -> None:
|
||||
"""Every supported board has native build metadata (the table may carry
|
||||
extras that BOARDS does not expose)."""
|
||||
assert set(BOARDS) <= set(ESP8266_BOARD_BUILD)
|
||||
|
||||
|
||||
def test_surgery_fingerprint_is_stable_and_sensitive(tmp_path) -> None:
|
||||
"""The properties the linker-script cache depends on: the fingerprint is
|
||||
stable across calls and changes when the module's source changes."""
|
||||
|
||||
first = build_surgery.surgery_fingerprint()
|
||||
assert first == build_surgery.surgery_fingerprint()
|
||||
assert len(first) == 64
|
||||
int(first, 16) # sha256 hex digest
|
||||
|
||||
# A modified copy of the module must fingerprint differently
|
||||
copy = tmp_path / "build_surgery_variant.py"
|
||||
copy.write_text(
|
||||
Path(build_surgery.__file__).read_text(encoding="utf-8")
|
||||
+ "\nEXTRA_BEHAVIORAL_INPUT = 1\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
spec = importlib.util.spec_from_file_location("build_surgery_variant", copy)
|
||||
variant = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = variant
|
||||
try:
|
||||
spec.loader.exec_module(variant)
|
||||
assert variant.surgery_fingerprint() != first
|
||||
finally:
|
||||
del sys.modules[spec.name]
|
||||
|
||||
|
||||
def test_testing_memory_patches_present_but_unselected_raises() -> None:
|
||||
"""A known segment left off the caller's list must fail, not silently
|
||||
keep its real memory limit."""
|
||||
with pytest.raises(RuntimeError, match="not selected"):
|
||||
apply_testing_memory_patches(_FLASH_LD_SNIPPET, ("dram0_0_seg",))
|
||||
@@ -1285,6 +1285,7 @@ async def test_add_platformio_options_native_idf(
|
||||
await config._add_platformio_options(
|
||||
{
|
||||
"build_flags": "-DSINGLE_FLAG", # string and list forms both valid
|
||||
"build_unflags": ["-Os"],
|
||||
"lib_deps": ["bblanchon/ArduinoJson@7.4.2"],
|
||||
"lib_ignore": "libsodium",
|
||||
"upload_speed": "115200",
|
||||
@@ -1294,6 +1295,7 @@ async def test_add_platformio_options_native_idf(
|
||||
|
||||
assert "-DSINGLE_FLAG" in CORE.build_flags
|
||||
assert "ArduinoJson" in CORE.platformio_libraries
|
||||
assert "-Os" in CORE.build_unflags
|
||||
# lib_ignore is stored (listified) for generate_idf_components to read;
|
||||
# nothing else lands in platformio_options on the native toolchain.
|
||||
assert CORE.platformio_options == {"lib_ignore": ["libsodium"]}
|
||||
@@ -1389,3 +1391,50 @@ def test_esphome_build_internals_are_yaml_only() -> None:
|
||||
assert markers[field].visibility is cv.Visibility.ADVANCED, field
|
||||
# A regular device-config field stays on the main form.
|
||||
assert markers[CONF_NAME_ADD_MAC_SUFFIX].visibility is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_platformio_options_native_arduino(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""The native ESP8266 Arduino toolchain honors board_build.f_cpu (a
|
||||
real-world overclock knob) and warns about the rest like native IDF."""
|
||||
CORE.toolchain = Toolchain.ARDUINO
|
||||
CORE.data[KEY_CORE] = {
|
||||
KEY_TARGET_PLATFORM: "esp8266",
|
||||
KEY_TARGET_FRAMEWORK: "arduino",
|
||||
}
|
||||
|
||||
await config._add_platformio_options(
|
||||
{
|
||||
"board_build.f_cpu": "160000000L",
|
||||
# The schema also permits the list form; the last value wins
|
||||
# and reaches the generator as a scalar
|
||||
"board_build.ldscript": ["eagle.flash.2m.ld", "eagle.flash.4m2m.ld"],
|
||||
"board_build.filesystem": "littlefs",
|
||||
"upload_speed": "115200",
|
||||
}
|
||||
)
|
||||
|
||||
assert CORE.platformio_options["board_build.f_cpu"] == "160000000L"
|
||||
assert CORE.platformio_options["board_build.ldscript"] == "eagle.flash.4m2m.ld"
|
||||
assert "board_build.f_cpu is ignored" not in caplog.text
|
||||
assert "board_build.ldscript is ignored" not in caplog.text
|
||||
assert (
|
||||
"esphome->platformio_options->board_build.filesystem is ignored" in caplog.text
|
||||
)
|
||||
# An empty list for an honored key is not a scalar; it falls through
|
||||
# to the ignored-option warning instead of an IndexError
|
||||
await config._add_platformio_options({"board_build.ldscript": []})
|
||||
assert "board_build.ldscript is ignored" in caplog.text
|
||||
assert "'arduino' toolchain" in caplog.text
|
||||
assert "upload_speed" not in caplog.text
|
||||
|
||||
|
||||
def test_esp8266_rejects_unsupported_cli_toolchain() -> None:
|
||||
"""Until the native backend lands, ESP8266 serves only PlatformIO."""
|
||||
from esphome.components.esp8266 import CONFIG_SCHEMA
|
||||
|
||||
CORE.toolchain = Toolchain.ARDUINO
|
||||
with pytest.raises(cv.Invalid, match="Unsupported toolchain 'arduino'"):
|
||||
CONFIG_SCHEMA({"board": "nodemcuv2"})
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
"""Tests for esphome.arduino8266.framework (downloads and environment)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.arduino8266 import framework
|
||||
import esphome.config_validation as cv
|
||||
from esphome.core import CORE, EsphomeError
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _build_path(tmp_path: Path) -> None:
|
||||
CORE.build_path = tmp_path
|
||||
|
||||
|
||||
def test_framework_package_version() -> None:
|
||||
assert framework.framework_package_version(cv.Version(3, 1, 2)) == "3.30102.0"
|
||||
assert framework.framework_package_version(cv.Version(3, 2, 0)) == "3.30200.0"
|
||||
# 2.6.3+ cores use the same package-major-3 encoding (PlatformIO path)
|
||||
assert framework.framework_package_version(cv.Version(2, 7, 4)) == "3.20704.0"
|
||||
# A future major bump needs its own encoding, not a doomed registry lookup
|
||||
with pytest.raises(EsphomeError, match="not supported yet"):
|
||||
framework.framework_package_version(cv.Version(4, 0, 0))
|
||||
# The boundary matches the PlatformIO era guard; a 2.6.2 pre-release
|
||||
# keeps this encoding
|
||||
with pytest.raises(EsphomeError, match="older package encoding"):
|
||||
framework.framework_package_version(cv.Version(2, 6, 2))
|
||||
assert framework.framework_package_version(cv.Version(2, 6, 2, "b1")) == "3.20602.0"
|
||||
assert framework.framework_package_version(cv.Version(2, 6, 3)) == "3.20603.0"
|
||||
|
||||
|
||||
def test_format_framework_arduino_version_pins_all_series() -> None:
|
||||
"""The esp8266 component's PIO source formatter across every encoding
|
||||
era, including the 4.x rejection it now shares with the installer."""
|
||||
from esphome.components.esp8266 import _format_framework_arduino_version as fmt
|
||||
|
||||
assert fmt(cv.Version(2, 4, 1)) == "~1.20401.0"
|
||||
assert fmt(cv.Version(2, 6, 2)) == "~2.20602.0"
|
||||
assert fmt(cv.Version(2, 7, 4)) == "~3.20704.0"
|
||||
assert fmt(cv.Version(3, 1, 2)) == "~3.30102.0"
|
||||
# Anchored to the framework version line, not a bare EsphomeError
|
||||
with pytest.raises(cv.Invalid, match="not supported yet") as excinfo:
|
||||
fmt(cv.Version(4, 0, 0))
|
||||
assert excinfo.value.path == ["version"]
|
||||
|
||||
|
||||
def test_tools_path_default_and_prefix(tmp_path: Path) -> None:
|
||||
with patch.dict(os.environ, {"ESPHOME_ARDUINO8266_PREFIX": str(tmp_path)}):
|
||||
assert framework.get_arduino8266_tools_path() == tmp_path.resolve()
|
||||
# A blank prefix must be treated as unset, not as the CWD
|
||||
with patch.dict(os.environ, {"ESPHOME_ARDUINO8266_PREFIX": " "}):
|
||||
path = framework.get_arduino8266_tools_path()
|
||||
assert path.name == "arduino8266"
|
||||
assert path != Path.cwd()
|
||||
|
||||
|
||||
def test_check_and_install_returns_paths(tmp_path: Path) -> None:
|
||||
with (
|
||||
patch.dict(os.environ, {"ESPHOME_ARDUINO8266_PREFIX": str(tmp_path)}),
|
||||
patch.object(framework, "install_package") as mock_install,
|
||||
patch.object(framework, "prefetch_packages") as mock_prefetch,
|
||||
patch.object(framework, "find_ninja", return_value=tmp_path / "ninja"),
|
||||
):
|
||||
paths = framework.check_and_install(cv.Version(3, 1, 2))
|
||||
assert paths.framework == tmp_path / "frameworks" / "3.30102.0"
|
||||
assert paths.toolchain == tmp_path / "toolchains" / framework.TOOLCHAIN_VERSION
|
||||
assert paths.ninja == tmp_path / "ninja"
|
||||
assert mock_install.call_count == 2
|
||||
# Full argument pinning: a copy-paste swap between the two near-identical
|
||||
# calls (mirrors, destination) must not stay green
|
||||
fw_call, tc_call = mock_install.call_args_list
|
||||
assert fw_call.args == (
|
||||
framework.FRAMEWORK_PACKAGE,
|
||||
"3.30102.0",
|
||||
tmp_path / "frameworks" / "3.30102.0",
|
||||
framework.ESPHOME_ARDUINO8266_FRAMEWORK_MIRRORS,
|
||||
tmp_path / "downloads",
|
||||
)
|
||||
assert fw_call.kwargs["expect"] == ("cores/esp8266", "tools/sdk", "libraries")
|
||||
assert tc_call.args == (
|
||||
framework.TOOLCHAIN_PACKAGE,
|
||||
framework.TOOLCHAIN_VERSION,
|
||||
tmp_path / "toolchains" / framework.TOOLCHAIN_VERSION,
|
||||
framework.ESPHOME_ARDUINO8266_TOOLCHAIN_MIRRORS,
|
||||
tmp_path / "downloads",
|
||||
)
|
||||
assert tc_call.kwargs["expect"] == ("bin", "xtensa-lx106-elf")
|
||||
# The prefetch sees the same package specs as the installs
|
||||
assert mock_prefetch.call_args.args == (
|
||||
[
|
||||
(
|
||||
framework.FRAMEWORK_PACKAGE,
|
||||
"3.30102.0",
|
||||
tmp_path / "frameworks" / "3.30102.0",
|
||||
framework.ESPHOME_ARDUINO8266_FRAMEWORK_MIRRORS,
|
||||
),
|
||||
(
|
||||
framework.TOOLCHAIN_PACKAGE,
|
||||
framework.TOOLCHAIN_VERSION,
|
||||
tmp_path / "toolchains" / framework.TOOLCHAIN_VERSION,
|
||||
framework.ESPHOME_ARDUINO8266_TOOLCHAIN_MIRRORS,
|
||||
),
|
||||
],
|
||||
tmp_path / "downloads",
|
||||
)
|
||||
|
||||
|
||||
def test_get_build_env_prepends_toolchain_bin(tmp_path: Path) -> None:
|
||||
with patch.object(framework, "ccache_env", return_value={"CCACHE_DIR": "x"}):
|
||||
env = framework.get_build_env(tmp_path, None)
|
||||
assert env["PATH"].startswith(str(tmp_path / "bin") + os.pathsep)
|
||||
assert env["CCACHE_DIR"] == "x"
|
||||
|
||||
|
||||
def test_ccache_env(tmp_path: Path) -> None:
|
||||
assert framework.ccache_env(None) == {}
|
||||
with patch.dict(os.environ, {"CCACHE_NOHASHDIR": "false"}, clear=True):
|
||||
env = framework.ccache_env("/usr/bin/ccache")
|
||||
# User-set values are respected; the rest get defaults
|
||||
assert "CCACHE_NOHASHDIR" not in env
|
||||
assert env["CCACHE_DEPEND"] == "1"
|
||||
assert env["CCACHE_BASEDIR"] == str(Path(CORE.build_path).resolve())
|
||||
assert env["CCACHE_DIR"].endswith("ccache")
|
||||
|
||||
|
||||
def test_check_and_install_rejects_old_core(tmp_path: Path) -> None:
|
||||
"""Calling the installer below the floor fails before any download."""
|
||||
with pytest.raises(EsphomeError, match=">= 3.1.1"):
|
||||
framework.check_and_install(cv.Version(3, 0, 2))
|
||||
|
||||
|
||||
def test_get_build_env_without_path_has_no_empty_entry(tmp_path: Path) -> None:
|
||||
"""An absent PATH must not leave a trailing separator (an empty entry
|
||||
means the current directory to the shell)."""
|
||||
with (
|
||||
patch.dict(os.environ, {}, clear=True),
|
||||
patch.object(framework, "ccache_env", return_value={}),
|
||||
):
|
||||
env = framework.get_build_env(tmp_path, None)
|
||||
assert env["PATH"] == str(tmp_path / "bin")
|
||||
with (
|
||||
patch.dict(
|
||||
os.environ, {"PATH": f"/usr/bin{os.pathsep}{os.pathsep}/bin"}, clear=True
|
||||
),
|
||||
patch.object(framework, "ccache_env", return_value={}),
|
||||
):
|
||||
env = framework.get_build_env(tmp_path, None)
|
||||
assert env["PATH"].split(os.pathsep) == [str(tmp_path / "bin"), "/usr/bin", "/bin"]
|
||||
|
||||
|
||||
def test_ccache_env_accepts_a_preresolved_path() -> None:
|
||||
"""The caller resolves ccache once and threads it through; None means
|
||||
resolved-and-disabled."""
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
assert framework.ccache_env(None) == {}
|
||||
env = framework.ccache_env("/usr/bin/ccache")
|
||||
assert env["CCACHE_DIR"].endswith("ccache")
|
||||
|
||||
|
||||
def test_toolchain_tool_layout(tmp_path: Path) -> None:
|
||||
"""One owner for the bin/xtensa-lx106-elf-<name> layout."""
|
||||
tool = framework.toolchain_tool(tmp_path, "addr2line")
|
||||
assert tool.parent == tmp_path / "bin"
|
||||
assert tool.name.startswith("xtensa-lx106-elf-addr2line")
|
||||
assert (tool.suffix == ".exe") is (os.name == "nt")
|
||||
@@ -68,6 +68,7 @@ def _write_storage(
|
||||
esp_platform: str | None = "ESP32",
|
||||
core_platform: str | None = "esp32",
|
||||
build_path: str | None = "/build/lite_test",
|
||||
toolchain: str | None = None,
|
||||
) -> None:
|
||||
"""Write a vanilla StorageJSON sidecar for the cache tests."""
|
||||
storage_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -88,6 +89,7 @@ def _write_storage(
|
||||
"no_mdns": False,
|
||||
"framework": "arduino",
|
||||
"core_platform": core_platform,
|
||||
"toolchain": toolchain,
|
||||
}
|
||||
storage_path.write_text(json.dumps(data), encoding="utf-8")
|
||||
|
||||
@@ -629,6 +631,35 @@ def test_save_compiled_config_and_sidecar_builds_real_sidecar(tmp_path: Path) ->
|
||||
assert load_compiled_config(yaml_path) is not None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("sidecar_toolchain", "saved"),
|
||||
[
|
||||
("esp-idf", False),
|
||||
("platformio", True),
|
||||
(None, True), # legacy sidecar without the field: guard is inert
|
||||
],
|
||||
)
|
||||
def test_save_compiled_config_and_sidecar_toolchain_mismatch(
|
||||
tmp_path: Path, sidecar_toolchain: str | None, saved: bool
|
||||
) -> None:
|
||||
"""A config validated under a different toolchain than the compile's
|
||||
must not overwrite the cache."""
|
||||
yaml_path = _bare_yaml(tmp_path)
|
||||
_prime_core(tmp_path)
|
||||
CORE.config = {CONF_ESPHOME: {CONF_NAME: "lite_test"}}
|
||||
CORE.toolchain = Toolchain.PLATFORMIO
|
||||
_write_storage(
|
||||
tmp_path / ".esphome" / "storage" / "lite_test.yaml.json",
|
||||
toolchain=sidecar_toolchain,
|
||||
)
|
||||
|
||||
save_compiled_config_and_sidecar(CORE.config)
|
||||
|
||||
cache = tmp_path / ".esphome" / "storage" / "lite_test.yaml.validated.json"
|
||||
assert cache.exists() is saved
|
||||
assert (load_compiled_config(yaml_path) is not None) is saved
|
||||
|
||||
|
||||
@pytest.mark.parametrize("command", ["upload", "logs"])
|
||||
def test_run_esphome_upload_and_logs_refresh_cache_on_fallback(
|
||||
tmp_path: Path, command: str
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import importlib
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
@@ -48,6 +49,7 @@ from esphome.const import (
|
||||
TYPE_GIT,
|
||||
TYPE_LOCAL,
|
||||
Framework,
|
||||
Toolchain,
|
||||
)
|
||||
from esphome.core import (
|
||||
CORE,
|
||||
@@ -3165,3 +3167,46 @@ def test_file__remapped_path_is_directory_raises(setup_core: Path) -> None:
|
||||
|
||||
with pytest.raises(Invalid, match="is not a file"):
|
||||
cv.file_("/original/config/headers")
|
||||
|
||||
|
||||
def test_require_platformio_toolchain() -> None:
|
||||
"""Platforms with only the PlatformIO backend reject other toolchains."""
|
||||
validator = cv.require_platformio_toolchain("RP2")
|
||||
CORE.toolchain = None
|
||||
config: dict = {}
|
||||
assert validator(config) is config
|
||||
assert CORE.toolchain == Toolchain.PLATFORMIO
|
||||
|
||||
CORE.toolchain = Toolchain.ARDUINO
|
||||
with pytest.raises(Invalid, match="Unsupported toolchain 'arduino' for RP2"):
|
||||
validator(config)
|
||||
|
||||
|
||||
def test_check_supported_toolchain_unresolved_is_an_ordering_bug() -> None:
|
||||
"""Calling the check before resolution fails naming the ordering bug,
|
||||
not a user-facing unsupported-toolchain error."""
|
||||
CORE.toolchain = None
|
||||
with pytest.raises(Invalid, match="not resolved before RP2 validation"):
|
||||
cv._check_supported_toolchain("RP2", (Toolchain.PLATFORMIO,))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("platform", "minimal_config"),
|
||||
[
|
||||
("host", {}),
|
||||
("rp2", {"board": "rpipicow"}),
|
||||
("bk72xx", {"board": "generic-bk7231n-qfn32-tuya"}),
|
||||
("rtl87xx", {"board": "generic-rtl8710bn-2mb-788k"}),
|
||||
("ln882x", {"board": "generic-ln882h"}),
|
||||
# The legacy stub platform must reject too, not just the chip families
|
||||
("libretiny", {}),
|
||||
],
|
||||
)
|
||||
def test_every_platformio_only_platform_rejects_arduino_toolchain(
|
||||
platform: str, minimal_config: dict
|
||||
) -> None:
|
||||
"""A platform that cannot serve a CLI toolchain rejects it at validation."""
|
||||
module = importlib.import_module(f"esphome.components.{platform}")
|
||||
CORE.toolchain = Toolchain.ARDUINO
|
||||
with pytest.raises(Invalid, match="Unsupported toolchain 'arduino'"):
|
||||
module.CONFIG_SCHEMA(dict(minimal_config))
|
||||
|
||||
@@ -958,6 +958,24 @@ class TestEsphomeCore:
|
||||
target.toolchain = const.Toolchain.ESP_IDF
|
||||
assert target.using_toolchain_sdk_nrf is False
|
||||
|
||||
def test_using_toolchain_arduino(self, target):
|
||||
"""A toolchain choice, distinct from the arduino target framework."""
|
||||
target.toolchain = const.Toolchain.ARDUINO
|
||||
assert target.using_toolchain_arduino is True
|
||||
target.toolchain = const.Toolchain.PLATFORMIO
|
||||
assert target.using_toolchain_arduino is False
|
||||
|
||||
def test_using_native_toolchain(self, target):
|
||||
"""True exactly for the toolchains that never read platformio.ini."""
|
||||
target.toolchain = const.Toolchain.ESP_IDF
|
||||
assert target.using_native_toolchain is True
|
||||
target.toolchain = const.Toolchain.ARDUINO
|
||||
assert target.using_native_toolchain is True
|
||||
target.toolchain = const.Toolchain.PLATFORMIO
|
||||
assert target.using_native_toolchain is False
|
||||
target.toolchain = const.Toolchain.SDK_NRF
|
||||
assert target.using_native_toolchain is False
|
||||
|
||||
def test_add_library__extracts_short_name_from_path(self, target):
|
||||
"""Test add_library extracts short name from library paths like owner/lib."""
|
||||
target.data[const.KEY_CORE] = {
|
||||
|
||||
@@ -1553,13 +1553,14 @@ def test_get_framework_env_without_python_env_uses_os_path(tmp_path: Path) -> No
|
||||
|
||||
def _ccache_patches(tmp_path: Path, which: str | None, build_path: Path | None):
|
||||
return (
|
||||
patch("esphome.espidf.framework.shutil.which", return_value=which),
|
||||
patch("esphome.espidf.framework.resolve_ccache_path", return_value=which),
|
||||
patch(
|
||||
"esphome.espidf.framework.get_idf_tools_path",
|
||||
return_value=tmp_path / "tools",
|
||||
),
|
||||
# ccache_defaults_env (build_helpers.ccache) reads CORE at call time
|
||||
patch(
|
||||
"esphome.espidf.framework.CORE",
|
||||
"esphome.core.CORE",
|
||||
SimpleNamespace(build_path=build_path),
|
||||
),
|
||||
)
|
||||
@@ -1588,7 +1589,9 @@ def test_ccache_env_opt_out_via_env(tmp_path: Path) -> None:
|
||||
# short-circuits before build_path is needed.
|
||||
p1, p2, p3 = _ccache_patches(tmp_path, "/usr/bin/ccache", None)
|
||||
with patch.dict("os.environ", {"IDF_CCACHE_ENABLE": "0"}, clear=True), p1, p2, p3:
|
||||
assert _ccache_env() == {}
|
||||
# The canonical off spelling is exported: the raw value is inherited
|
||||
# by idf.py, where a spelling like "disable" would read as truthy
|
||||
assert _ccache_env() == {"IDF_CCACHE_ENABLE": "0"}
|
||||
|
||||
|
||||
def test_ccache_env_opt_in_without_binary(tmp_path: Path) -> None:
|
||||
@@ -1602,6 +1605,48 @@ def test_ccache_env_opt_in_without_binary(tmp_path: Path) -> None:
|
||||
assert env["CCACHE_DEPEND"] == "1"
|
||||
|
||||
|
||||
def test_ccache_env_honors_shared_esphome_opt_out(tmp_path: Path) -> None:
|
||||
"""ESPHOME_CCACHE_ENABLE=0 disables ccache here too; the shared policy
|
||||
must not apply to every backend except this one."""
|
||||
_p1, p2, p3 = _ccache_patches(tmp_path, "/usr/bin/ccache", tmp_path / "build")
|
||||
env_vars = {"ESPHOME_CCACHE_ENABLE": "0", "PATH": "/usr/bin"}
|
||||
with patch.dict("os.environ", env_vars, clear=True), p2, p3:
|
||||
# The real resolver runs so the opt-out parse is exercised
|
||||
assert _ccache_env() == {}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["off", "no"])
|
||||
def test_ccache_env_idf_knob_parses_strictly(tmp_path: Path, value: str) -> None:
|
||||
"""IDF_CCACHE_ENABLE uses the same strict table as the shared knob, so
|
||||
"off" disables instead of reading as truthy."""
|
||||
p1, p2, p3 = _ccache_patches(tmp_path, "/usr/bin/ccache", tmp_path / "build")
|
||||
with patch.dict("os.environ", {"IDF_CCACHE_ENABLE": value}, clear=True), p1, p2, p3:
|
||||
assert _ccache_env() == {"IDF_CCACHE_ENABLE": "0"}
|
||||
|
||||
|
||||
def test_ccache_env_idf_knob_unrecognized_warns_and_defers(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""An unparsable IDF_CCACHE_ENABLE warns, defers to the shared resolver,
|
||||
and is not forwarded to idf.py as truthy."""
|
||||
p1, p2, p3 = _ccache_patches(tmp_path, "/usr/bin/ccache", tmp_path / "build")
|
||||
env_vars = {"IDF_CCACHE_ENABLE": "enabled"}
|
||||
with patch.dict("os.environ", env_vars, clear=True), p1, p2, p3:
|
||||
env = _ccache_env()
|
||||
assert "unrecognized IDF_CCACHE_ENABLE" in caplog.text
|
||||
assert env["IDF_CCACHE_ENABLE"] == "1"
|
||||
|
||||
|
||||
def test_ccache_env_idf_knob_wins_over_shared_opt_out(tmp_path: Path) -> None:
|
||||
"""IDF_CCACHE_ENABLE=1 takes precedence over ESPHOME_CCACHE_ENABLE=0."""
|
||||
p1, p2, p3 = _ccache_patches(tmp_path, None, tmp_path / "build")
|
||||
env_vars = {"IDF_CCACHE_ENABLE": "1", "ESPHOME_CCACHE_ENABLE": "0"}
|
||||
with patch.dict("os.environ", env_vars, clear=True), p1, p2, p3:
|
||||
env = _ccache_env()
|
||||
assert env["CCACHE_DIR"] == str(tmp_path / "tools" / "ccache")
|
||||
assert "IDF_CCACHE_ENABLE" not in env
|
||||
|
||||
|
||||
def test_ccache_env_preserves_user_overrides(tmp_path: Path) -> None:
|
||||
# User-set CCACHE_* values must not be clobbered; unset ones still default.
|
||||
p1, p2, p3 = _ccache_patches(tmp_path, "/usr/bin/ccache", tmp_path / "build")
|
||||
|
||||
@@ -2194,3 +2194,37 @@ class TestGetProjectCxxCompileFlags:
|
||||
def test_empty_flags(self) -> None:
|
||||
with patch("esphome.core.CORE", _make_core_cxx(set())):
|
||||
assert get_project_cxx_compile_flags() == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("platform", "input_path", "expected"),
|
||||
[
|
||||
# win32: drive-letter extended-length prefix is stripped
|
||||
(
|
||||
"win32",
|
||||
"\\\\?\\C:\\Users\\jesse\\AppData\\Local\\ESPHome Builder\\python\\python.exe",
|
||||
"C:\\Users\\jesse\\AppData\\Local\\ESPHome Builder\\python\\python.exe",
|
||||
),
|
||||
# win32: UNC extended-length prefix is translated to a regular UNC path
|
||||
(
|
||||
"win32",
|
||||
"\\\\?\\UNC\\server\\share\\python.exe",
|
||||
"\\\\server\\share\\python.exe",
|
||||
),
|
||||
# win32: paths without the prefix are returned unchanged
|
||||
(
|
||||
"win32",
|
||||
"C:\\Users\\jesse\\AppData\\Local\\ESPHome Builder\\python\\python.exe",
|
||||
"C:\\Users\\jesse\\AppData\\Local\\ESPHome Builder\\python\\python.exe",
|
||||
),
|
||||
# non-win32: prefix is left alone (no-op)
|
||||
("linux", "\\\\?\\C:\\python.exe", "\\\\?\\C:\\python.exe"),
|
||||
("darwin", "/usr/bin/python3", "/usr/bin/python3"),
|
||||
],
|
||||
)
|
||||
def test_strip_win_long_path_prefix(
|
||||
platform: str, input_path: str, expected: str
|
||||
) -> None:
|
||||
r"""``\\?\`` and ``\\?\UNC\`` prefixes are stripped only on win32."""
|
||||
with patch("esphome.framework_helpers.sys.platform", platform):
|
||||
assert framework_helpers.strip_win_long_path_prefix(input_path) == expected
|
||||
|
||||
@@ -7195,6 +7195,42 @@ def test_compile_program_espidf_idedata_none_warns(
|
||||
assert "No idedata was generated" in caplog.text
|
||||
|
||||
|
||||
def test_cli_toolchain_skips_the_validated_config_cache(tmp_path: Path) -> None:
|
||||
"""An explicit --toolchain must run the per-platform validators, so the
|
||||
upload/logs fast path becomes a cache miss."""
|
||||
conf = tmp_path / "device.yaml"
|
||||
conf.write_text("esphome:\n name: t\n")
|
||||
argv = ["esphome", "--toolchain", "arduino", "logs", str(conf)]
|
||||
with (
|
||||
patch("esphome.compiled_config.load_compiled_config") as mock_cache,
|
||||
patch("esphome.config.read_config", return_value=None) as mock_read,
|
||||
):
|
||||
assert run_esphome(argv) == 2
|
||||
mock_cache.assert_not_called()
|
||||
mock_read.assert_called_once()
|
||||
|
||||
|
||||
def test_cli_toolchain_still_refreshes_the_validated_config_cache(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""An explicit --toolchain gates only the cache read; with a matching
|
||||
sidecar the freshly validated config is still saved."""
|
||||
conf = tmp_path / "device.yaml"
|
||||
conf.write_text("esphome:\n name: t\n")
|
||||
argv = ["esphome", "--toolchain", "platformio", "logs", str(conf)]
|
||||
with (
|
||||
patch("esphome.compiled_config.load_compiled_config") as mock_load,
|
||||
patch("esphome.config.read_config", return_value={CONF_ESPHOME: {}}),
|
||||
patch("esphome.compiled_config.save_compiled_config_and_sidecar") as mock_save,
|
||||
patch.dict(
|
||||
"esphome.__main__.POST_CONFIG_ACTIONS", {"logs": Mock(return_value=0)}
|
||||
),
|
||||
):
|
||||
assert run_esphome(argv) == 0
|
||||
mock_load.assert_not_called()
|
||||
mock_save.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wrap_to_code_comment_is_insertion_order_independent() -> None:
|
||||
"""The config comment dumps with sorted keys: voluptuous fills schema
|
||||
|
||||
@@ -7,8 +7,10 @@ import sys
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import platformdirs
|
||||
import pytest
|
||||
|
||||
from esphome.components.nrf52 import _resolve_toolchain
|
||||
from esphome.components.nrf52.framework import (
|
||||
_PLATFORMIO_PENV_REQUIREMENTS,
|
||||
_REQUIREMENTS,
|
||||
@@ -22,8 +24,9 @@ from esphome.components.nrf52.framework import (
|
||||
get_sdk_nrf_tools_path,
|
||||
setup_platformio_python_env,
|
||||
)
|
||||
import esphome.config_validation as cv
|
||||
from esphome.config_validation import Version
|
||||
from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION
|
||||
from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION, Toolchain
|
||||
from esphome.core import CORE, EsphomeError
|
||||
from esphome.framework_helpers import get_python_env_executable_path
|
||||
|
||||
@@ -558,7 +561,6 @@ def testget_tools_path_blank_env_falls_back_to_default(
|
||||
Path("") would resolve to the working directory, which clean-all could
|
||||
then delete by accident.
|
||||
"""
|
||||
import platformdirs
|
||||
|
||||
monkeypatch.setenv("ESPHOME_SDK_NRF_PREFIX", value)
|
||||
expected = (
|
||||
@@ -570,7 +572,6 @@ def testget_tools_path_blank_env_falls_back_to_default(
|
||||
def testget_tools_path_default_is_global_cache(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
import platformdirs
|
||||
|
||||
monkeypatch.delenv("ESPHOME_SDK_NRF_PREFIX", raising=False)
|
||||
expected = (
|
||||
@@ -619,3 +620,11 @@ def test_needs_venv_rebuild_on_dangling_interpreter_symlink(tmp_path: Path) -> N
|
||||
assert not python.exists()
|
||||
|
||||
assert _needs_venv_rebuild(python, sentinel, "abc123")
|
||||
|
||||
|
||||
def test_resolve_toolchain_rejects_unsupported() -> None:
|
||||
"""A --toolchain nRF52 cannot serve fails instead of degrading silently."""
|
||||
|
||||
CORE.toolchain = Toolchain.ARDUINO
|
||||
with pytest.raises(cv.Invalid, match="Unsupported toolchain 'arduino'"):
|
||||
_resolve_toolchain({})
|
||||
|
||||
@@ -0,0 +1,650 @@
|
||||
"""Tests for esphome.platformio.registry (PIO-registry package installs)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.core import EsphomeError
|
||||
from esphome.platformio import registry
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("system", "machine", "expected"),
|
||||
[
|
||||
("Darwin", "arm64", "darwin_arm64"),
|
||||
("Darwin", "x86_64", "darwin_x86_64"),
|
||||
("Windows", "AMD64", "windows_amd64"),
|
||||
# Deviation from upstream: auto-mapped to the emulated-x86 packages
|
||||
("Windows", "ARM64", "windows_amd64"),
|
||||
("Windows", "x86", "windows_x86"),
|
||||
("Linux", "x86_64", "linux_x86_64"),
|
||||
("Linux", "aarch64", "linux_aarch64"),
|
||||
("Linux", "i686", "linux_i686"),
|
||||
("Linux", "armv7l", "linux_armv7l"),
|
||||
# Unknown hosts pass through like upstream; the registry lookup
|
||||
# then fails naming the tag
|
||||
("FreeBSD", "amd64", "freebsd_amd64"),
|
||||
],
|
||||
)
|
||||
def test_get_systype(system: str, machine: str, expected: str) -> None:
|
||||
with (
|
||||
patch("platform.system", return_value=system),
|
||||
patch("platform.machine", return_value=machine),
|
||||
patch("platform.architecture", return_value=("64bit", "")),
|
||||
):
|
||||
assert registry.get_systype() == expected
|
||||
|
||||
|
||||
def test_get_systype_env_override() -> None:
|
||||
"""PLATFORMIO_SYSTEM_TYPE wins, exactly as in upstream get_systype()."""
|
||||
with patch.dict(os.environ, {"PLATFORMIO_SYSTEM_TYPE": "windows_amd64"}):
|
||||
assert registry.get_systype() == "windows_amd64"
|
||||
|
||||
|
||||
def test_get_systype_aarch64_32bit_userland() -> None:
|
||||
"""A 32-bit userland on a 64-bit arm kernel gets armv7l binaries."""
|
||||
with (
|
||||
patch("platform.system", return_value="Linux"),
|
||||
patch("platform.machine", return_value="aarch64"),
|
||||
patch("platform.architecture", return_value=("32bit", "")),
|
||||
):
|
||||
assert registry.get_systype() == "linux_armv7l"
|
||||
|
||||
|
||||
def test_get_systype_windows_empty_machine() -> None:
|
||||
"""An empty machine string falls back to the architecture bits."""
|
||||
with (
|
||||
patch("platform.system", return_value="Windows"),
|
||||
patch("platform.machine", return_value=""),
|
||||
patch("platform.architecture", return_value=("64bit", "")),
|
||||
):
|
||||
assert registry.get_systype() == "windows_amd64"
|
||||
|
||||
|
||||
def _registry_response(files: list[dict]):
|
||||
"""Patch the shared downloader to serve a canned registry response."""
|
||||
payload = {"versions": [{"name": "1.0.0", "files": files}]}
|
||||
|
||||
def fake_download(mirrors: list[str], substitutions: dict, target) -> str:
|
||||
target.write(json.dumps(payload).encode())
|
||||
return mirrors[0].format(**substitutions)
|
||||
|
||||
return patch.object(registry, "download_from_mirrors", side_effect=fake_download)
|
||||
|
||||
|
||||
def test_registry_download_uses_shared_downloader() -> None:
|
||||
"""The metadata fetch delegates its retries and error reporting to
|
||||
download_from_mirrors; failures surface unchanged."""
|
||||
with (
|
||||
patch.object(
|
||||
registry,
|
||||
"download_from_mirrors",
|
||||
side_effect=EsphomeError("Failed to download from all mirrors"),
|
||||
) as mock_download,
|
||||
pytest.raises(EsphomeError, match="Failed to download from all mirrors"),
|
||||
):
|
||||
registry.registry_download("pkg", "1.0.0")
|
||||
(mirrors, substitutions, _), _ = mock_download.call_args
|
||||
assert mirrors == [registry._REGISTRY_URL]
|
||||
assert substitutions == {"package": "pkg"}
|
||||
|
||||
|
||||
def test_registry_download_invalid_json_is_clean() -> None:
|
||||
def fake_download(mirrors: list[str], substitutions: dict, target) -> str:
|
||||
target.write(b"<html>not json</html>")
|
||||
return "http://x"
|
||||
|
||||
with (
|
||||
patch.object(registry, "download_from_mirrors", side_effect=fake_download),
|
||||
pytest.raises(EsphomeError, match="invalid JSON"),
|
||||
):
|
||||
registry.registry_download("pkg", "1.0.0")
|
||||
|
||||
|
||||
def test_registry_download_matches_system() -> None:
|
||||
with (
|
||||
_registry_response(
|
||||
[
|
||||
{"system": ["windows_amd64"], "download_url": "http://x/win"},
|
||||
{
|
||||
"system": ["linux_x86_64"],
|
||||
"download_url": "http://x/linux",
|
||||
"checksum": {"sha256": "abc123"},
|
||||
"size": 42,
|
||||
},
|
||||
]
|
||||
),
|
||||
patch.object(registry, "get_systype", return_value="linux_x86_64"),
|
||||
):
|
||||
assert registry.registry_download("pkg", "1.0.0") == (
|
||||
"http://x/linux",
|
||||
"abc123",
|
||||
42,
|
||||
)
|
||||
|
||||
|
||||
def test_registry_download_bare_string_system() -> None:
|
||||
"""A bare-string system tag is an exact match, not a substring test."""
|
||||
with (
|
||||
_registry_response(
|
||||
[
|
||||
{"system": "linux_x86", "download_url": "http://x/x86"},
|
||||
{
|
||||
"system": "linux_x86_64",
|
||||
"download_url": "http://x/x86_64",
|
||||
"checksum": {"sha256": "abc"},
|
||||
},
|
||||
]
|
||||
),
|
||||
patch.object(registry, "get_systype", return_value="linux_x86_64"),
|
||||
):
|
||||
assert registry.registry_download("pkg", "1.0.0")[0] == "http://x/x86_64"
|
||||
|
||||
|
||||
def test_registry_download_wildcard_system() -> None:
|
||||
with _registry_response(
|
||||
[
|
||||
{
|
||||
"system": "*",
|
||||
"download_url": "http://x/any",
|
||||
"checksum": {"sha256": "abc"},
|
||||
"size": 7,
|
||||
}
|
||||
]
|
||||
):
|
||||
assert registry.registry_download("pkg", "1.0.0") == (
|
||||
"http://x/any",
|
||||
"abc",
|
||||
7,
|
||||
)
|
||||
|
||||
|
||||
def test_registry_download_missing_checksum_raises() -> None:
|
||||
"""An unverifiable archive is refused, never silently extracted."""
|
||||
with (
|
||||
_registry_response([{"system": "*", "download_url": "http://x/any"}]),
|
||||
pytest.raises(EsphomeError, match="no sha256"),
|
||||
):
|
||||
registry.registry_download("pkg", "1.0.0")
|
||||
|
||||
|
||||
def test_registry_download_no_system_match() -> None:
|
||||
with (
|
||||
_registry_response(
|
||||
[{"system": ["windows_amd64"], "download_url": "http://x/win"}]
|
||||
),
|
||||
patch.object(registry, "get_systype", return_value="linux_x86_64"),
|
||||
pytest.raises(EsphomeError, match="No pkg 1.0.0 build"),
|
||||
):
|
||||
registry.registry_download("pkg", "1.0.0")
|
||||
|
||||
|
||||
def test_registry_download_version_not_found() -> None:
|
||||
def fake_download(mirrors: list[str], substitutions: dict, target) -> str:
|
||||
target.write(
|
||||
json.dumps({"versions": [{"name": "2.0.0", "files": []}]}).encode()
|
||||
)
|
||||
return "http://x"
|
||||
|
||||
with (
|
||||
patch.object(registry, "download_from_mirrors", side_effect=fake_download),
|
||||
pytest.raises(EsphomeError, match="not found"),
|
||||
):
|
||||
registry.registry_download("pkg", "1.0.0")
|
||||
|
||||
|
||||
def test_install_package_skips_when_marker_exists(tmp_path: Path) -> None:
|
||||
dest = tmp_path / "pkg"
|
||||
(dest / "payload").mkdir(parents=True)
|
||||
(dest / ".esphome_extracted").touch()
|
||||
with patch.object(registry, "download_from_mirrors") as mock_download:
|
||||
registry.install_package(
|
||||
"pkg", "1.0.0", dest, [], tmp_path / "dl", expect=("payload",)
|
||||
)
|
||||
mock_download.assert_not_called()
|
||||
|
||||
|
||||
def test_install_package_marker_hit_rechecks_layout(tmp_path: Path) -> None:
|
||||
"""A marked install that later lost files fails by name instead of
|
||||
surfacing as an opaque toolchain error."""
|
||||
dest = tmp_path / "pkg"
|
||||
dest.mkdir()
|
||||
(dest / ".esphome_extracted").touch()
|
||||
with pytest.raises(EsphomeError, match="missing the expected payload"):
|
||||
registry.install_package(
|
||||
"pkg", "1.0.0", dest, [], tmp_path / "dl", expect=("payload",)
|
||||
)
|
||||
|
||||
|
||||
def test_install_package_downloads_via_mirrors(tmp_path: Path) -> None:
|
||||
dest = tmp_path / "pkg"
|
||||
mirrors = ["http://mirror/{VERSION}/{SYSTEM}.tar.gz"]
|
||||
with (
|
||||
patch.object(registry, "download_from_mirrors") as mock_download,
|
||||
patch.object(registry, "archive_extract_all") as mock_extract,
|
||||
patch.object(registry, "get_systype", return_value="linux_x86_64"),
|
||||
):
|
||||
# Extraction is expected to create the directory
|
||||
mock_extract.side_effect = lambda *_a, **_kw: (dest / "payload").mkdir(
|
||||
parents=True
|
||||
)
|
||||
registry.install_package(
|
||||
"pkg", "1.0.0", dest, mirrors, tmp_path / "dl", expect=("payload",)
|
||||
)
|
||||
assert mock_download.call_args[0][0] is mirrors
|
||||
assert mock_download.call_args[0][1] == {
|
||||
"VERSION": "1.0.0",
|
||||
"SYSTEM": "linux_x86_64",
|
||||
}
|
||||
assert (dest / ".esphome_extracted").is_file()
|
||||
|
||||
|
||||
def test_install_package_downloads_via_registry(tmp_path: Path) -> None:
|
||||
"""The registry path downloads with the registry's sha256 and size."""
|
||||
dest = tmp_path / "pkg"
|
||||
with (
|
||||
patch.object(registry, "download_with_resume") as mock_download,
|
||||
patch.object(registry, "archive_extract_all") as mock_extract,
|
||||
patch.object(
|
||||
registry,
|
||||
"registry_download",
|
||||
return_value=("http://x/pkg.tar.gz", "abc123", 42),
|
||||
),
|
||||
):
|
||||
mock_extract.side_effect = lambda *_a, **_kw: (dest / "payload").mkdir(
|
||||
parents=True
|
||||
)
|
||||
registry.install_package(
|
||||
"pkg", "1.0.0", dest, [], tmp_path / "dl", expect=("payload",)
|
||||
)
|
||||
assert mock_download.call_args[0][0] == "http://x/pkg.tar.gz"
|
||||
assert mock_download.call_args[1] == {"sha256": "abc123", "size": 42}
|
||||
|
||||
|
||||
def test_install_package_validates_expected_layout(tmp_path: Path) -> None:
|
||||
"""The success marker is only written when the extracted tree is usable."""
|
||||
dest = tmp_path / "pkg"
|
||||
with (
|
||||
patch.object(registry, "download_from_mirrors"),
|
||||
patch.object(registry, "archive_extract_all") as mock_extract,
|
||||
patch.object(registry, "get_systype", return_value="linux_x86_64"),
|
||||
):
|
||||
mock_extract.side_effect = lambda *_a, **_kw: (dest / "bin").mkdir(parents=True)
|
||||
registry.install_package(
|
||||
"pkg", "1.0.0", dest, ["http://m"], tmp_path / "dl", expect=("bin",)
|
||||
)
|
||||
assert (dest / ".esphome_extracted").is_file()
|
||||
|
||||
|
||||
def test_install_package_unexpected_layout_raises(tmp_path: Path) -> None:
|
||||
dest = tmp_path / "pkg"
|
||||
with (
|
||||
patch.object(registry, "download_from_mirrors"),
|
||||
patch.object(registry, "archive_extract_all") as mock_extract,
|
||||
patch.object(registry, "get_systype", return_value="linux_x86_64"),
|
||||
pytest.raises(EsphomeError, match="missing the expected bin"),
|
||||
):
|
||||
mock_extract.side_effect = lambda *_a, **_kw: (dest / "payload").mkdir(
|
||||
parents=True
|
||||
)
|
||||
registry.install_package(
|
||||
"pkg", "1.0.0", dest, ["http://m"], tmp_path / "dl", expect=("bin",)
|
||||
)
|
||||
assert not (dest / ".esphome_extracted").exists()
|
||||
|
||||
|
||||
def test_install_package_marker_rechecked_under_lock(tmp_path: Path) -> None:
|
||||
"""A concurrent install finishing while we wait for the lock is detected."""
|
||||
dest = tmp_path / "pkg"
|
||||
marker = dest / ".esphome_extracted"
|
||||
|
||||
@contextmanager
|
||||
def _fake_lock(*_a, **_kw):
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
marker.touch()
|
||||
yield
|
||||
|
||||
with (
|
||||
patch("filelock.FileLock", _fake_lock),
|
||||
patch.object(registry, "download_from_mirrors") as mock_download,
|
||||
patch.object(registry, "rmdir") as mock_rmdir,
|
||||
):
|
||||
registry.install_package(
|
||||
"pkg", "1.0.0", dest, ["http://m"], tmp_path / "dl", expect=("payload",)
|
||||
)
|
||||
mock_download.assert_not_called()
|
||||
mock_rmdir.assert_not_called()
|
||||
|
||||
|
||||
def test_install_package_uses_hard_lock(tmp_path: Path) -> None:
|
||||
"""The install lock must never degrade to a soft (existence) lock."""
|
||||
dest = tmp_path / "pkg"
|
||||
with (
|
||||
patch("filelock.FileLock") as mock_lock,
|
||||
patch.object(registry, "download_from_mirrors"),
|
||||
patch.object(registry, "archive_extract_all") as mock_extract,
|
||||
patch.object(registry, "get_systype", return_value="linux_x86_64"),
|
||||
):
|
||||
mock_extract.side_effect = lambda *_a, **_kw: (dest / "payload").mkdir(
|
||||
parents=True, exist_ok=True
|
||||
)
|
||||
registry.install_package(
|
||||
"pkg", "1.0.0", dest, ["http://m"], tmp_path / "dl", expect=("payload",)
|
||||
)
|
||||
assert mock_lock.call_args.kwargs["fallback_to_soft"] is False
|
||||
|
||||
|
||||
def test_registry_download_empty_system_list_does_not_match() -> None:
|
||||
"""An explicitly empty system list must not act as a wildcard."""
|
||||
with (
|
||||
_registry_response([{"system": [], "download_url": "http://x/any"}]),
|
||||
patch.object(registry, "get_systype", return_value="linux_x86_64"),
|
||||
pytest.raises(EsphomeError, match="No pkg 1.0.0 build"),
|
||||
):
|
||||
registry.registry_download("pkg", "1.0.0")
|
||||
|
||||
|
||||
def test_registry_download_unexpected_payload_is_named() -> None:
|
||||
"""An error envelope without a versions list is not 'version not found'."""
|
||||
|
||||
def fake_download(mirrors: list[str], substitutions: dict, target) -> str:
|
||||
target.write(json.dumps({"message": "rate limited"}).encode())
|
||||
return "http://x"
|
||||
|
||||
with (
|
||||
patch.object(registry, "download_from_mirrors", side_effect=fake_download),
|
||||
pytest.raises(EsphomeError, match="Unexpected package registry response"),
|
||||
):
|
||||
registry.registry_download("pkg", "1.0.0")
|
||||
|
||||
|
||||
def test_registry_download_missing_system_key_matches_any() -> None:
|
||||
"""A file with no system key at all serves every host."""
|
||||
with _registry_response(
|
||||
[{"download_url": "http://x/any", "checksum": {"sha256": "abc"}, "size": 1}]
|
||||
):
|
||||
assert registry.registry_download("pkg", "1.0.0") == ("http://x/any", "abc", 1)
|
||||
|
||||
|
||||
def test_registry_download_missing_files_list_is_named() -> None:
|
||||
"""A version entry without a files list is an unexpected payload, not a
|
||||
missing platform build."""
|
||||
with (
|
||||
_registry_response(None),
|
||||
pytest.raises(EsphomeError, match="Unexpected package registry response"),
|
||||
):
|
||||
registry.registry_download("pkg", "1.0.0")
|
||||
|
||||
|
||||
def test_registry_download_missing_download_url_is_named() -> None:
|
||||
with (
|
||||
_registry_response([{"system": "*", "checksum": {"sha256": "abc"}, "size": 1}]),
|
||||
pytest.raises(EsphomeError, match="no download URL"),
|
||||
):
|
||||
registry.registry_download("pkg", "1.0.0")
|
||||
|
||||
|
||||
def test_install_package_empty_expect_rejected(tmp_path: Path) -> None:
|
||||
"""Layout validation is the only guard before marker.touch(), so an
|
||||
empty expect is a caller bug, not a lenient install."""
|
||||
with pytest.raises(ValueError, match="non-empty expect"):
|
||||
registry.install_package(
|
||||
"pkg", "1.0.0", tmp_path / "pkg", [], tmp_path / "dl", expect=()
|
||||
)
|
||||
|
||||
|
||||
def test_registry_download_non_dict_version_entry_is_named() -> None:
|
||||
"""A versions list of bare strings is an unexpected payload, not an
|
||||
AttributeError traceback."""
|
||||
|
||||
def fake_download(mirrors: list[str], substitutions: dict, target) -> str:
|
||||
target.write(json.dumps({"versions": ["1.0.0", "2.0.0"]}).encode())
|
||||
return "http://x"
|
||||
|
||||
with (
|
||||
patch.object(registry, "download_from_mirrors", side_effect=fake_download),
|
||||
pytest.raises(EsphomeError, match="Unexpected package registry response"),
|
||||
):
|
||||
registry.registry_download("pkg", "1.0.0")
|
||||
|
||||
|
||||
def test_registry_download_non_dict_file_entry_is_named() -> None:
|
||||
def fake_download(mirrors: list[str], substitutions: dict, target) -> str:
|
||||
target.write(
|
||||
json.dumps(
|
||||
{"versions": [{"name": "1.0.0", "files": ["a.tar.gz"]}]}
|
||||
).encode()
|
||||
)
|
||||
return "http://x"
|
||||
|
||||
with (
|
||||
patch.object(registry, "download_from_mirrors", side_effect=fake_download),
|
||||
pytest.raises(EsphomeError, match="Unexpected package registry response"),
|
||||
):
|
||||
registry.registry_download("pkg", "1.0.0")
|
||||
|
||||
|
||||
def test_registry_download_non_dict_payload_is_named() -> None:
|
||||
"""A JSON array answer is an unexpected payload at the outermost level."""
|
||||
|
||||
def fake_download(mirrors: list[str], substitutions: dict, target) -> str:
|
||||
target.write(json.dumps(["1.0.0"]).encode())
|
||||
return "http://x"
|
||||
|
||||
with (
|
||||
patch.object(registry, "download_from_mirrors", side_effect=fake_download),
|
||||
pytest.raises(EsphomeError, match="Unexpected package registry response"),
|
||||
):
|
||||
registry.registry_download("pkg", "1.0.0")
|
||||
|
||||
|
||||
def test_registry_download_non_list_system_is_named() -> None:
|
||||
"""A system field that is neither missing, str, nor list is an
|
||||
unexpected payload, not a TypeError from the ``in`` test."""
|
||||
with (
|
||||
_registry_response([{"system": 5, "checksum": {"sha256": "abc"}, "size": 1}]),
|
||||
pytest.raises(EsphomeError, match="Unexpected package registry response"),
|
||||
):
|
||||
registry.registry_download("pkg", "1.0.0")
|
||||
|
||||
|
||||
def _resolve_for(sizes: dict[str, int | None]):
|
||||
def resolve(name: str, version: str):
|
||||
size = sizes[name]
|
||||
if size == -1:
|
||||
raise EsphomeError("registry down")
|
||||
return (f"http://x/{name}.tar.gz", "abc123", size)
|
||||
|
||||
return resolve
|
||||
|
||||
|
||||
def test_prefetch_packages_downloads_pending_in_parallel(tmp_path: Path) -> None:
|
||||
"""Two uninstalled packages download together under one combined bar,
|
||||
with the registry's sha256 and size and a batch progress tracker."""
|
||||
with (
|
||||
patch.object(registry, "download_with_resume") as mock_download,
|
||||
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 mock_download.call_count == 2
|
||||
# Locking makes worker completion order nondeterministic
|
||||
calls = sorted(mock_download.call_args_list, key=lambda c: c[0][0])
|
||||
for call, (name, version, size) in zip(
|
||||
calls, [("a", "1.0", 10), ("b", "2.0", 20)], strict=True
|
||||
):
|
||||
assert call[0][0] == f"http://x/{name}.tar.gz"
|
||||
assert call[0][1] == tmp_path / "dl" / f"{name}-{version}"
|
||||
assert call[1]["sha256"] == "abc123"
|
||||
assert call[1]["size"] == size
|
||||
assert callable(call[1]["progress"])
|
||||
|
||||
|
||||
def test_prefetch_packages_dedupes_duplicate_entries(tmp_path: Path) -> None:
|
||||
"""Duplicate (name, version) entries would race each other between two
|
||||
workers; only one survives (and one is too few to parallelize)."""
|
||||
with (
|
||||
patch.object(registry, "download_with_resume") as mock_download,
|
||||
patch.object(
|
||||
registry, "registry_download", side_effect=_resolve_for({"a": 10})
|
||||
),
|
||||
):
|
||||
registry.prefetch_packages(
|
||||
[
|
||||
("a", "1.0", tmp_path / "a", []),
|
||||
("a", "1.0", tmp_path / "a", []),
|
||||
],
|
||||
tmp_path / "dl",
|
||||
)
|
||||
mock_download.assert_not_called()
|
||||
|
||||
|
||||
def test_prefetch_packages_single_pending_skips(tmp_path: Path) -> None:
|
||||
"""One pending package has nothing to parallelize; the sequential
|
||||
install keeps its own bar."""
|
||||
marker_dest = tmp_path / "a"
|
||||
marker_dest.mkdir()
|
||||
(marker_dest / ".esphome_extracted").touch()
|
||||
with (
|
||||
patch.object(registry, "download_with_resume") as mock_download,
|
||||
patch.object(
|
||||
registry, "registry_download", side_effect=_resolve_for({"b": 20})
|
||||
),
|
||||
):
|
||||
registry.prefetch_packages(
|
||||
[
|
||||
("a", "1.0", marker_dest, []),
|
||||
("b", "2.0", tmp_path / "b", []),
|
||||
],
|
||||
tmp_path / "dl",
|
||||
)
|
||||
mock_download.assert_not_called()
|
||||
|
||||
|
||||
def test_prefetch_packages_mirror_and_sizeless_stay_sequential(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Mirror overrides and size-less registry entries are left to the
|
||||
sequential path so its per-file bars stay trustworthy."""
|
||||
with (
|
||||
patch.object(registry, "download_with_resume") as mock_download,
|
||||
patch.object(
|
||||
registry,
|
||||
"registry_download",
|
||||
side_effect=_resolve_for({"b": None, "c": 30}),
|
||||
),
|
||||
):
|
||||
registry.prefetch_packages(
|
||||
[
|
||||
("a", "1.0", tmp_path / "a", ["http://mirror/{VERSION}"]),
|
||||
("b", "2.0", tmp_path / "b", []),
|
||||
("c", "3.0", tmp_path / "c", []),
|
||||
],
|
||||
tmp_path / "dl",
|
||||
)
|
||||
mock_download.assert_not_called()
|
||||
|
||||
|
||||
def test_prefetch_packages_resolve_failure_defers_to_install(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A registry failure only skips the prefetch; install_package reports
|
||||
the real error with context."""
|
||||
caplog.set_level("DEBUG")
|
||||
with (
|
||||
patch.object(registry, "download_with_resume") as mock_download,
|
||||
patch.object(
|
||||
registry, "registry_download", side_effect=_resolve_for({"a": -1, "b": 20})
|
||||
),
|
||||
):
|
||||
registry.prefetch_packages(
|
||||
[
|
||||
("a", "1.0", tmp_path / "a", []),
|
||||
("b", "2.0", tmp_path / "b", []),
|
||||
],
|
||||
tmp_path / "dl",
|
||||
)
|
||||
mock_download.assert_not_called()
|
||||
assert "Prefetch resolve for a failed" in caplog.text
|
||||
|
||||
|
||||
def test_prefetch_packages_complete_archive_skipped(tmp_path: Path) -> None:
|
||||
"""An archive already fully downloaded is not re-fetched."""
|
||||
dl = tmp_path / "dl"
|
||||
dl.mkdir()
|
||||
(dl / "a-1.0").write_bytes(b"x" * 10)
|
||||
with (
|
||||
patch.object(registry, "download_with_resume") as mock_download,
|
||||
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", []),
|
||||
],
|
||||
dl,
|
||||
)
|
||||
mock_download.assert_not_called()
|
||||
|
||||
|
||||
def test_prefetch_packages_download_failure_is_debug(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A failed prefetch download is logged and left for install_package."""
|
||||
caplog.set_level("DEBUG")
|
||||
with (
|
||||
patch.object(
|
||||
registry, "download_with_resume", side_effect=OSError("boom")
|
||||
) as mock_download,
|
||||
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 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
|
||||
@@ -431,8 +431,8 @@ def test_ccache_env_enabled_by_default(setup_core: Path) -> None:
|
||||
|
||||
with (
|
||||
patch.dict(os.environ, {}, clear=True),
|
||||
patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"),
|
||||
patch.object(toolchain.subprocess, "run"),
|
||||
patch("shutil.which", return_value="/usr/bin/ccache"),
|
||||
patch("esphome.framework_helpers.subprocess.run"),
|
||||
):
|
||||
env = toolchain._ccache_env()
|
||||
|
||||
@@ -469,7 +469,7 @@ def test_ccache_env_disabled_without_binary(
|
||||
|
||||
with (
|
||||
patch.dict(os.environ, env_vars, clear=True),
|
||||
patch.object(toolchain.shutil, "which", return_value=None),
|
||||
patch("shutil.which", return_value=None),
|
||||
caplog.at_level("WARNING"),
|
||||
):
|
||||
env = toolchain._ccache_env()
|
||||
@@ -494,8 +494,8 @@ def test_ccache_env_disabled_when_probe_fails(
|
||||
|
||||
with (
|
||||
patch.dict(os.environ, {}, clear=True),
|
||||
patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"),
|
||||
patch.object(toolchain.subprocess, "run", side_effect=probe_error),
|
||||
patch("shutil.which", return_value="/usr/bin/ccache"),
|
||||
patch("esphome.framework_helpers.subprocess.run", side_effect=probe_error),
|
||||
):
|
||||
env = toolchain._ccache_env()
|
||||
|
||||
@@ -508,8 +508,8 @@ def test_ccache_env_forced_on_skips_probe(setup_core: Path) -> None:
|
||||
|
||||
with (
|
||||
patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": "1"}, clear=True),
|
||||
patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"),
|
||||
patch.object(toolchain.subprocess, "run") as mock_probe,
|
||||
patch("shutil.which", return_value="/usr/bin/ccache"),
|
||||
patch("esphome.framework_helpers.subprocess.run") as mock_probe,
|
||||
):
|
||||
env = toolchain._ccache_env()
|
||||
|
||||
@@ -537,9 +537,9 @@ def test_ccache_env_strips_win_long_path_prefix(setup_core: Path) -> None:
|
||||
patch.dict(os.environ, {}, clear=True),
|
||||
# shutil.which is patched, so the win32 code path of the real
|
||||
# implementation (which crashes on a POSIX host) is never reached.
|
||||
patch("esphome.platformio.toolchain.sys.platform", "win32"),
|
||||
patch.object(toolchain.shutil, "which", return_value=prefixed),
|
||||
patch.object(toolchain.subprocess, "run") as mock_probe,
|
||||
patch("esphome.framework_helpers.sys.platform", "win32"),
|
||||
patch("shutil.which", return_value=prefixed),
|
||||
patch("esphome.framework_helpers.subprocess.run") as mock_probe,
|
||||
):
|
||||
env = toolchain._ccache_env()
|
||||
|
||||
@@ -555,7 +555,7 @@ def test_ccache_env_opt_out(setup_core: Path) -> None:
|
||||
|
||||
with (
|
||||
patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": "0"}, clear=True),
|
||||
patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"),
|
||||
patch("shutil.which", return_value="/usr/bin/ccache"),
|
||||
):
|
||||
env = toolchain._ccache_env()
|
||||
|
||||
@@ -568,7 +568,7 @@ def test_ccache_env_normalizes_enable_value(setup_core: Path) -> None:
|
||||
|
||||
with (
|
||||
patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": "yes"}, clear=True),
|
||||
patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"),
|
||||
patch("shutil.which", return_value="/usr/bin/ccache"),
|
||||
):
|
||||
env = toolchain._ccache_env()
|
||||
|
||||
@@ -587,8 +587,8 @@ def test_ccache_env_respects_user_values_and_refreshes_basedir(
|
||||
|
||||
with (
|
||||
patch.dict(os.environ, user_env, clear=True),
|
||||
patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"),
|
||||
patch.object(toolchain.subprocess, "run"),
|
||||
patch("shutil.which", return_value="/usr/bin/ccache"),
|
||||
patch("esphome.framework_helpers.subprocess.run"),
|
||||
):
|
||||
env = toolchain._ccache_env()
|
||||
|
||||
@@ -606,8 +606,8 @@ def test_run_platformio_cli_passes_ccache_env_to_subprocess_only(
|
||||
|
||||
with (
|
||||
patch.dict(os.environ, {}, clear=False),
|
||||
patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"),
|
||||
patch.object(toolchain.subprocess, "run"),
|
||||
patch("shutil.which", return_value="/usr/bin/ccache"),
|
||||
patch("esphome.framework_helpers.subprocess.run"),
|
||||
):
|
||||
os.environ.pop("ESPHOME_CCACHE_ENABLE", None)
|
||||
mock_run_external_process.return_value = 0
|
||||
@@ -628,8 +628,8 @@ def test_ccache_env_requires_build_path(setup_core: Path) -> None:
|
||||
|
||||
with (
|
||||
patch.dict(os.environ, {}, clear=True),
|
||||
patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"),
|
||||
patch.object(toolchain.subprocess, "run"),
|
||||
patch("shutil.which", return_value="/usr/bin/ccache"),
|
||||
patch("esphome.framework_helpers.subprocess.run"),
|
||||
pytest.raises(ValueError, match="CORE.build_path must be set"),
|
||||
):
|
||||
toolchain._ccache_env()
|
||||
@@ -642,8 +642,8 @@ def test_run_platformio_cli_merges_caller_env(
|
||||
CORE.build_path = str(setup_core / "build" / "test")
|
||||
|
||||
with (
|
||||
patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"),
|
||||
patch.object(toolchain.subprocess, "run"),
|
||||
patch("shutil.which", return_value="/usr/bin/ccache"),
|
||||
patch("esphome.framework_helpers.subprocess.run"),
|
||||
):
|
||||
mock_run_external_process.return_value = 0
|
||||
toolchain.run_platformio_cli(
|
||||
@@ -800,9 +800,7 @@ def test_ccache_env_real_probe_runs_stripped_path(setup_core: Path) -> None:
|
||||
|
||||
with (
|
||||
patch.dict(os.environ, {}, clear=False),
|
||||
patch.object(
|
||||
toolchain.shutil, "which", return_value="\\\\?\\" + sys.executable
|
||||
),
|
||||
patch("shutil.which", return_value="\\\\?\\" + sys.executable),
|
||||
):
|
||||
os.environ.pop("ESPHOME_CCACHE_ENABLE", None)
|
||||
env = toolchain._ccache_env()
|
||||
@@ -843,40 +841,6 @@ def test_ccache_wrapper_through_cmd_exe(
|
||||
assert marker.read_text() == "compiled"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("platform", "input_path", "expected"),
|
||||
[
|
||||
# win32: drive-letter extended-length prefix is stripped
|
||||
(
|
||||
"win32",
|
||||
"\\\\?\\C:\\Users\\jesse\\AppData\\Local\\ESPHome Builder\\python\\python.exe",
|
||||
"C:\\Users\\jesse\\AppData\\Local\\ESPHome Builder\\python\\python.exe",
|
||||
),
|
||||
# win32: UNC extended-length prefix is translated to a regular UNC path
|
||||
(
|
||||
"win32",
|
||||
"\\\\?\\UNC\\server\\share\\python.exe",
|
||||
"\\\\server\\share\\python.exe",
|
||||
),
|
||||
# win32: paths without the prefix are returned unchanged
|
||||
(
|
||||
"win32",
|
||||
"C:\\Users\\jesse\\AppData\\Local\\ESPHome Builder\\python\\python.exe",
|
||||
"C:\\Users\\jesse\\AppData\\Local\\ESPHome Builder\\python\\python.exe",
|
||||
),
|
||||
# non-win32: prefix is left alone (no-op)
|
||||
("linux", "\\\\?\\C:\\python.exe", "\\\\?\\C:\\python.exe"),
|
||||
("darwin", "/usr/bin/python3", "/usr/bin/python3"),
|
||||
],
|
||||
)
|
||||
def test_strip_win_long_path_prefix(
|
||||
platform: str, input_path: str, expected: str
|
||||
) -> None:
|
||||
r"""``\\?\`` and ``\\?\UNC\`` prefixes are stripped only on win32."""
|
||||
with patch("esphome.platformio.toolchain.sys.platform", platform):
|
||||
assert toolchain._strip_win_long_path_prefix(input_path) == expected
|
||||
|
||||
|
||||
def test_run_platformio_cli_strips_win_long_path_prefix(
|
||||
setup_core: Path, mock_run_external_process: Mock
|
||||
) -> None:
|
||||
@@ -900,7 +864,7 @@ def test_run_platformio_cli_strips_win_long_path_prefix(
|
||||
# so the stdlib sees it too) would send shutil.which down the Windows
|
||||
# code path, which crashes on a POSIX host.
|
||||
patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": "0"}, clear=False),
|
||||
patch("esphome.platformio.toolchain.sys.platform", "win32"),
|
||||
patch("esphome.framework_helpers.sys.platform", "win32"),
|
||||
patch("esphome.platformio.toolchain.sys.executable", prefixed_exe),
|
||||
):
|
||||
# Pop any pre-existing PYTHONEXEPATH so the assertion below reflects
|
||||
@@ -932,7 +896,7 @@ def test_run_platformio_cli_does_not_set_pythonexepath_without_strip(
|
||||
|
||||
with (
|
||||
patch.dict(os.environ, {}, clear=False),
|
||||
patch("esphome.platformio.toolchain.sys.platform", "linux"),
|
||||
patch("esphome.framework_helpers.sys.platform", "linux"),
|
||||
patch("esphome.platformio.toolchain.sys.executable", plain_exe),
|
||||
):
|
||||
os.environ.pop("PYTHONEXEPATH", None)
|
||||
@@ -1977,10 +1941,3 @@ def test_run_platformio_cli_invokes_heal(
|
||||
with patch.object(toolchain, "heal_platformio_python_env") as mock_heal:
|
||||
toolchain.run_platformio_cli("test")
|
||||
mock_heal.assert_called_once()
|
||||
|
||||
|
||||
def test_ccache_probe_spawns_with_close_fds_false() -> None:
|
||||
"""The probe follows the repo-wide posix_spawn convention."""
|
||||
with patch("subprocess.run") as mock_run:
|
||||
assert toolchain._ccache_runs("/usr/bin/ccache") is True
|
||||
assert mock_run.call_args.kwargs["close_fds"] is False
|
||||
|
||||
@@ -68,7 +68,8 @@ def _isolate_platformio_paths(tmp_path_factory: pytest.TempPathFactory) -> Any:
|
||||
test_clean_all_partial_exists) install their own inner patch which
|
||||
stacks on top of this one and wins for the duration of their block.
|
||||
|
||||
Also pin ``ESPHOME_ESP_IDF_PREFIX`` and ``ESPHOME_SDK_NRF_PREFIX`` to
|
||||
Also pin ``ESPHOME_ESP_IDF_PREFIX``, ``ESPHOME_SDK_NRF_PREFIX`` and
|
||||
``ESPHOME_ARDUINO8266_PREFIX`` to
|
||||
nonexistent tmp dirs, and patch ``platformdirs.user_cache_dir``, for the
|
||||
same reason: ``clean_all`` removes the machine-global toolchain installs
|
||||
and their default cache root, which otherwise resolve to the real
|
||||
@@ -77,6 +78,7 @@ def _isolate_platformio_paths(tmp_path_factory: pytest.TempPathFactory) -> Any:
|
||||
pio_root = tmp_path_factory.mktemp("isolated_pio") / "nonexistent"
|
||||
idf_root = tmp_path_factory.mktemp("isolated_idf") / "nonexistent"
|
||||
sdk_nrf_root = tmp_path_factory.mktemp("isolated_sdk_nrf") / "nonexistent"
|
||||
arduino8266_root = tmp_path_factory.mktemp("isolated_arduino8266") / "nonexistent"
|
||||
cache_root = tmp_path_factory.mktemp("isolated_cache") / "nonexistent"
|
||||
mock_cfg = MagicMock()
|
||||
mock_cfg.get.side_effect = lambda section, option: (
|
||||
@@ -92,6 +94,7 @@ def _isolate_platformio_paths(tmp_path_factory: pytest.TempPathFactory) -> Any:
|
||||
{
|
||||
"ESPHOME_ESP_IDF_PREFIX": str(idf_root),
|
||||
"ESPHOME_SDK_NRF_PREFIX": str(sdk_nrf_root),
|
||||
"ESPHOME_ARDUINO8266_PREFIX": str(arduino8266_root),
|
||||
},
|
||||
),
|
||||
patch("platformdirs.user_cache_dir", return_value=str(cache_root)),
|
||||
@@ -1033,6 +1036,28 @@ def test_clean_all_removes_global_idf_install(
|
||||
assert str(idf_install.resolve()) in caplog.text
|
||||
|
||||
|
||||
@patch("esphome.writer.CORE")
|
||||
def test_clean_all_removes_global_arduino8266_install(
|
||||
mock_core: MagicMock,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""clean_all removes the machine-global native arduino8266 install dir."""
|
||||
arduino8266_install = tmp_path / "arduino8266_install"
|
||||
(arduino8266_install / "frameworks").mkdir(parents=True)
|
||||
monkeypatch.setenv("ESPHOME_ARDUINO8266_PREFIX", str(arduino8266_install))
|
||||
|
||||
config_dir = tmp_path / "config"
|
||||
config_dir.mkdir()
|
||||
|
||||
with caplog.at_level("INFO"):
|
||||
clean_all([str(config_dir)])
|
||||
|
||||
assert not arduino8266_install.exists()
|
||||
assert str(arduino8266_install.resolve()) in caplog.text
|
||||
|
||||
|
||||
@patch("esphome.writer.CORE")
|
||||
def test_clean_all_removes_global_sdk_nrf_install(
|
||||
mock_core: MagicMock,
|
||||
|
||||
Reference in New Issue
Block a user