mirror of
https://github.com/esphome/esphome.git
synced 2026-10-28 00:42:11 +00:00
Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ea26ed65f | ||
|
|
1e8f084741 | ||
|
|
6595df0cab | ||
|
|
73894b2490 | ||
|
|
9bef20d8dc | ||
|
|
0e440500a2 | ||
|
|
badfa84fe0 | ||
|
|
e686ecf76e | ||
|
|
64fb4e06b5 | ||
|
|
cb82c9031a | ||
|
|
ca74ab9873 | ||
|
|
86fc740b65 | ||
|
|
450bb52f65 | ||
|
|
ed3615ec69 | ||
|
|
a5f25ebe2d | ||
|
|
7a4775de3d | ||
|
|
704551a6c9 | ||
|
|
f30e70acfa | ||
|
|
594a691d9f | ||
|
|
6c90b5c549 | ||
|
|
c8df23a9cb | ||
|
|
4fdce9d96b | ||
|
|
9d296a5748 | ||
|
|
f2e013d71a |
No files matched your search
@@ -0,0 +1,76 @@
|
|||||||
|
name: Install apt packages (cached)
|
||||||
|
description: >-
|
||||||
|
Installs libsdl2-dev, libmbedtls-dev and ccache from one shared cache. The cache key comes
|
||||||
|
only from the package list and version, so both live here and nowhere else.
|
||||||
|
runs:
|
||||||
|
using: "composite"
|
||||||
|
steps:
|
||||||
|
- name: Keep a failed install out of the cache
|
||||||
|
# cache-apt-pkgs ignores install failures and saved an empty cache when a stale package
|
||||||
|
# list got a 404 (upstream awalsh128/cache-apt-pkgs-action#221). It uses an apt-fast
|
||||||
|
# already on PATH: this one refreshes the lists and retries when the install fails and,
|
||||||
|
# if that fails too, removes the folder the action would save. Only a cache miss installs.
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
sudo tee /etc/apt/apt.conf.d/99ci-acquire-timeouts >/dev/null <<'EOF'
|
||||||
|
Acquire::Retries "1";
|
||||||
|
Acquire::http::Timeout "15";
|
||||||
|
Acquire::https::Timeout "15";
|
||||||
|
EOF
|
||||||
|
sudo tee /usr/local/bin/apt-fast > /dev/null <<'EOF'
|
||||||
|
#!/bin/bash
|
||||||
|
if [[ " $* " != *" install "* ]]; then
|
||||||
|
exec apt-get "$@"
|
||||||
|
fi
|
||||||
|
touch /tmp/apt-fast-standin-ran
|
||||||
|
apt-get "$@" && exit 0
|
||||||
|
apt-get update -qq >&2
|
||||||
|
# The action pins each package to the version the stale lists named
|
||||||
|
args=()
|
||||||
|
for arg in "$@"; do
|
||||||
|
if [[ "${arg}" == -* ]]; then args+=("${arg}"); else args+=("${arg%%=*}"); fi
|
||||||
|
done
|
||||||
|
apt-get "${args[@]}" && exit 0
|
||||||
|
# The action writes our output to install.log inside the folder it saves
|
||||||
|
log="$(readlink -f "/proc/$$/fd/1")"
|
||||||
|
if [ "$(basename "${log}")" = install.log ]; then
|
||||||
|
rm -rf "$(dirname "${log}")"
|
||||||
|
else
|
||||||
|
touch /tmp/apt-fast-cleanup-missed
|
||||||
|
fi
|
||||||
|
exit 1
|
||||||
|
EOF
|
||||||
|
sudo chmod +x /usr/local/bin/apt-fast
|
||||||
|
- name: Install apt packages (cached)
|
||||||
|
id: apt
|
||||||
|
# Pinned on purpose and not watched by Dependabot: the step above relies on how this
|
||||||
|
# release calls apt-fast and where it writes install.log.
|
||||||
|
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
|
||||||
|
with:
|
||||||
|
packages: libsdl2-dev libmbedtls-dev ccache
|
||||||
|
# 1.3 drops the empty caches saved before the step above existed
|
||||||
|
version: 1.3
|
||||||
|
- name: Check the packages are installed
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
CACHE_HIT: ${{ steps.apt.outputs.cache-hit }}
|
||||||
|
run: |
|
||||||
|
if [ "${CACHE_HIT}" != "true" ] && [ ! -e /tmp/apt-fast-standin-ran ]; then
|
||||||
|
echo "::error::cache-apt-pkgs did not use the apt-fast stand-in; check the pinned release"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ -e /tmp/apt-fast-cleanup-missed ]; then
|
||||||
|
echo "::error::the failed install may have been cached; check the pinned release and bump the version"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# The action restores files without registering them with dpkg
|
||||||
|
for cmd in ccache sdl2-config; do
|
||||||
|
if ! command -v "${cmd}" > /dev/null; then
|
||||||
|
echo "::error::${cmd} is missing after the cached install"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ ! -f /usr/include/mbedtls/version.h ]; then
|
||||||
|
echo "::error::libmbedtls-dev is missing after the cached install"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
+22
-23
@@ -73,16 +73,17 @@ jobs:
|
|||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
# PR-branch cache saves are invisible to other PRs, so dev/beta/release
|
# PR-branch cache saves are invisible to other PRs, so dev/beta/release
|
||||||
# pushes seed the one shared entry PR jobs restore. The key is derived
|
# pushes seed the one shared entry PR jobs restore. The key is derived
|
||||||
# only from the package list and version; keep both identical in every
|
# only from the package list and version, kept in the apt-packages action.
|
||||||
# step that restores it. In ci-status needs so a broken seed fails dev.
|
# In ci-status needs so a broken seed fails dev.
|
||||||
if: github.event_name == 'push'
|
if: github.event_name == 'push'
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- name: Install apt packages (cached)
|
- name: Check out code from GitHub
|
||||||
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
packages: libsdl2-dev ccache
|
sparse-checkout: .github
|
||||||
version: 1.1
|
- name: Install apt packages (cached)
|
||||||
|
uses: ./.github/actions/apt-packages
|
||||||
|
|
||||||
determine-jobs:
|
determine-jobs:
|
||||||
name: Determine which jobs to run
|
name: Determine which jobs to run
|
||||||
@@ -409,14 +410,10 @@ jobs:
|
|||||||
- name: Install apt packages (cached)
|
- name: Install apt packages (cached)
|
||||||
# ccache speeds up the host compiles. A cache hit never touches apt
|
# ccache speeds up the host compiles. A cache hit never touches apt
|
||||||
# (mirror outages cannot hang the job); the timeout bounds the cold
|
# (mirror outages cannot hang the job); the timeout bounds the cold
|
||||||
# path. Packages and version must match seed-apt-cache exactly.
|
# path. libsdl2-dev is needed by the headless display tests, which
|
||||||
# libsdl2-dev is needed by the headless display tests, which capture
|
# capture screenshots.
|
||||||
# screenshots.
|
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
|
uses: ./.github/actions/apt-packages
|
||||||
with:
|
|
||||||
packages: libsdl2-dev ccache
|
|
||||||
version: 1.1
|
|
||||||
- name: Set up Python 3.13
|
- name: Set up Python 3.13
|
||||||
id: python
|
id: python
|
||||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||||
@@ -707,6 +704,12 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
# Need history for HEAD~1 to work for checking changed files
|
# Need history for HEAD~1 to work for checking changed files
|
||||||
fetch-depth: 2
|
fetch-depth: 2
|
||||||
|
- name: Install apt packages (cached)
|
||||||
|
if: matrix.pio_cache_key == 'tidyhost'
|
||||||
|
# libsdl2-dev is needed by the sdl component on the host, libmbedtls-dev
|
||||||
|
# by dsmr and dlms_meter.
|
||||||
|
timeout-minutes: 10
|
||||||
|
uses: ./.github/actions/apt-packages
|
||||||
|
|
||||||
- name: Restore Python
|
- name: Restore Python
|
||||||
id: restore-python
|
id: restore-python
|
||||||
@@ -1093,20 +1096,16 @@ jobs:
|
|||||||
- name: List components
|
- name: List components
|
||||||
run: echo ${{ matrix.batch.components }}
|
run: echo ${{ matrix.batch.components }}
|
||||||
|
|
||||||
|
- name: Check out code from GitHub
|
||||||
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
|
||||||
- name: Install apt packages (cached)
|
- name: Install apt packages (cached)
|
||||||
# A cache hit (seeded on dev by seed-apt-cache) never touches apt,
|
# A cache hit (seeded on dev by seed-apt-cache) never touches apt,
|
||||||
# so mirror outages cannot hang this PR-only job; the timeout bounds
|
# so mirror outages cannot hang this PR-only job; the timeout bounds
|
||||||
# the cold path. Packages and version must match seed-apt-cache
|
# the cold path. The action has no --no-install-recommends; same
|
||||||
# exactly. The action has no --no-install-recommends; same package
|
# package set this job used before #17463.
|
||||||
# set this job used before #17463.
|
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
|
uses: ./.github/actions/apt-packages
|
||||||
with:
|
|
||||||
packages: libsdl2-dev ccache
|
|
||||||
version: 1.1
|
|
||||||
|
|
||||||
- name: Check out code from GitHub
|
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
||||||
- name: Restore Python
|
- name: Restore Python
|
||||||
uses: ./.github/actions/restore-python
|
uses: ./.github/actions/restore-python
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome
|
|||||||
# could be handy for archiving the generated documentation or if some version
|
# could be handy for archiving the generated documentation or if some version
|
||||||
# control system is used.
|
# control system is used.
|
||||||
|
|
||||||
PROJECT_NUMBER = 2026.10.0b5
|
PROJECT_NUMBER = 2026.10.1
|
||||||
|
|
||||||
# Using the PROJECT_BRIEF tag one can provide an optional one line description
|
# Using the PROJECT_BRIEF tag one can provide an optional one line description
|
||||||
# for a project that appears at the top of each page and should give viewer a
|
# for a project that appears at the top of each page and should give viewer a
|
||||||
|
|||||||
+1
-1
@@ -22,7 +22,7 @@ RUN \
|
|||||||
-r /requirements.txt
|
-r /requirements.txt
|
||||||
|
|
||||||
# Install the ESPHome Device Builder dashboard.
|
# Install the ESPHome Device Builder dashboard.
|
||||||
RUN uv pip install --no-cache-dir esphome-device-builder==1.24.0
|
RUN uv pip install --no-cache-dir esphome-device-builder==1.25.0
|
||||||
|
|
||||||
RUN \
|
RUN \
|
||||||
platformio settings set enable_telemetry No \
|
platformio settings set enable_telemetry No \
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ from esphome.platformio.library import (
|
|||||||
normalize_dependencies,
|
normalize_dependencies,
|
||||||
parse_library_json,
|
parse_library_json,
|
||||||
parse_library_properties,
|
parse_library_properties,
|
||||||
warn_properties_depends,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
@@ -283,7 +282,6 @@ def _bundled_library(framework_path: Path, name: str) -> ArduinoLibrary:
|
|||||||
"resolved automatically; add them with add_library() if needed",
|
"resolved automatically; add them with add_library() if needed",
|
||||||
name,
|
name,
|
||||||
)
|
)
|
||||||
warn_properties_depends(name, data)
|
|
||||||
build = data.get("build")
|
build = data.get("build")
|
||||||
if isinstance(build, dict) and build.get("extraScript"):
|
if isinstance(build, dict) and build.get("extraScript"):
|
||||||
# Scripts only run on the converted path; building without
|
# Scripts only run on the converted path; building without
|
||||||
|
|||||||
+30
-13
@@ -7,6 +7,7 @@ import re
|
|||||||
import textwrap
|
import textwrap
|
||||||
|
|
||||||
from esphome.build_helpers import pch
|
from esphome.build_helpers import pch
|
||||||
|
from esphome.build_helpers.ccache import CCACHE_PCH_MIN_VERSION
|
||||||
from esphome.components.esp32 import (
|
from esphome.components.esp32 import (
|
||||||
get_esp32_variant,
|
get_esp32_variant,
|
||||||
get_excluded_builtin_components,
|
get_excluded_builtin_components,
|
||||||
@@ -474,7 +475,7 @@ target_link_options(${{COMPONENT_LIB}} PUBLIC
|
|||||||
# Where CMake puts the .gch of the src component; ccache reads the checksum
|
# Where CMake puts the .gch of the src component; ccache reads the checksum
|
||||||
# next to it in place of the .gch
|
# next to it in place of the .gch
|
||||||
_PCH_SUM_PATH = "build/esp-idf/src/CMakeFiles/__idf_src.dir/cmake_pch.hxx.gch.sum"
|
_PCH_SUM_PATH = "build/esp-idf/src/CMakeFiles/__idf_src.dir/cmake_pch.hxx.gch.sum"
|
||||||
# Where the Windows gate records its choice
|
# Where the CMake gate records its choice
|
||||||
_PCH_CHOICE_VAR = "ESPHOME_PCH"
|
_PCH_CHOICE_VAR = "ESPHOME_PCH"
|
||||||
|
|
||||||
|
|
||||||
@@ -490,16 +491,32 @@ def _pch_cmake_block() -> str:
|
|||||||
block = f"""target_precompile_headers(${{COMPONENT_LIB}} PRIVATE
|
block = f"""target_precompile_headers(${{COMPONENT_LIB}} PRIVATE
|
||||||
{headers}
|
{headers}
|
||||||
)"""
|
)"""
|
||||||
if not pch.pch_needs_gcc_check():
|
if pch.pch_forced():
|
||||||
return f"\n# ESPHome precompiled header\n{block}\n"
|
return f"\n# ESPHome precompiled header\n{block}\n"
|
||||||
# Before the first configure only CMake knows the compiler version
|
# Only CMake knows the compiler version before the first configure, and
|
||||||
return f"""
|
# the ccache IDF found is the one the build runs
|
||||||
# ESPHome precompiled header, unless GCC bug 14940 keeps it from loading
|
gcc_gate = ""
|
||||||
if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU" AND ({pch.PCH_WINDOWS_CMAKE_OLD_GCC}))
|
if pch.pch_needs_gcc_check():
|
||||||
|
gcc_gate = f"""if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU" AND ({pch.PCH_WINDOWS_CMAKE_OLD_GCC}))
|
||||||
message(STATUS "ESPHome: GCC ${{CMAKE_CXX_COMPILER_VERSION}} cannot load a precompiled header on Windows; compiling without it")
|
message(STATUS "ESPHome: GCC ${{CMAKE_CXX_COMPILER_VERSION}} cannot load a precompiled header on Windows; compiling without it")
|
||||||
set({_PCH_CHOICE_VAR} OFF CACHE BOOL "ESPHome precompiled header in use" FORCE)
|
set(_esphome_pch OFF)
|
||||||
else()
|
endif()
|
||||||
set({_PCH_CHOICE_VAR} ON CACHE BOOL "ESPHome precompiled header in use" FORCE)
|
"""
|
||||||
|
min_ccache = ".".join(map(str, CCACHE_PCH_MIN_VERSION))
|
||||||
|
return f"""
|
||||||
|
# ESPHome precompiled header, unless a check below rules it out
|
||||||
|
set(_esphome_pch ON)
|
||||||
|
{gcc_gate}if(CCACHE_ENABLE AND CCACHE_FOUND)
|
||||||
|
execute_process(COMMAND "${{CCACHE_FOUND}}" --version OUTPUT_VARIABLE _esphome_ccache ERROR_QUIET)
|
||||||
|
if(_esphome_ccache MATCHES "ccache version ([0-9.]+)")
|
||||||
|
if(CMAKE_MATCH_1 VERSION_LESS {min_ccache})
|
||||||
|
message(STATUS "ESPHome: ccache ${{CMAKE_MATCH_1}} can reuse stale objects with a precompiled header; compiling without it (upgrade ccache to {min_ccache} or newer, or set ESPHOME_PCH_ENABLE=1 to force)")
|
||||||
|
set(_esphome_pch OFF)
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
|
set({_PCH_CHOICE_VAR} ${{_esphome_pch}} CACHE BOOL "ESPHome precompiled header in use" FORCE)
|
||||||
|
if(_esphome_pch)
|
||||||
{textwrap.indent(block, " ")}
|
{textwrap.indent(block, " ")}
|
||||||
endif()
|
endif()
|
||||||
"""
|
"""
|
||||||
@@ -516,10 +533,10 @@ def write_pch_checksum() -> None:
|
|||||||
|
|
||||||
if not pch.pch_enabled():
|
if not pch.pch_enabled():
|
||||||
return
|
return
|
||||||
# The gate's choice, cached by configure
|
# Written whatever the gate chose last time: a build tree without that
|
||||||
if pch.pch_needs_gcc_check() and get_cmake_cache_value(_PCH_CHOICE_VAR) != "ON":
|
# choice, or a reconfigure that turns the pch on, must not see a stale sum
|
||||||
return
|
if pch.pch_forced() or get_cmake_cache_value(_PCH_CHOICE_VAR) == "ON":
|
||||||
pch.log_pch_in_use()
|
pch.log_pch_in_use()
|
||||||
checksum = pch.pch_checksum(
|
checksum = pch.pch_checksum(
|
||||||
CORE.relative_src_path(),
|
CORE.relative_src_path(),
|
||||||
pch.PCH_DEFAULT_HEADERS,
|
pch.PCH_DEFAULT_HEADERS,
|
||||||
|
|||||||
@@ -3,21 +3,68 @@ resolution, and default ``CCACHE_*`` values."""
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import functools
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
|
||||||
from esphome.framework_helpers import strip_win_long_path_prefix, tool_version_runs
|
from esphome.framework_helpers import (
|
||||||
|
strip_win_long_path_prefix,
|
||||||
|
tool_version_probe,
|
||||||
|
tool_version_runs,
|
||||||
|
)
|
||||||
from esphome.helpers import FALSY_ENV_STRINGS, TRUTHY_ENV_STRINGS
|
from esphome.helpers import FALSY_ENV_STRINGS, TRUTHY_ENV_STRINGS
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# Before 4.7 ccache stores a pch consumer whose header is newer than one
|
||||||
|
# second without that header, so a later build reuses it after the header
|
||||||
|
# changed (#21621)
|
||||||
|
CCACHE_PCH_MIN_VERSION = (4, 7)
|
||||||
|
|
||||||
|
_VERSION_RE = re.compile(r"ccache version (\d+(?:\.\d+)*)")
|
||||||
|
|
||||||
|
|
||||||
|
@functools.cache
|
||||||
|
def _ccache_probe(ccache: str) -> str | Exception:
|
||||||
|
"""``ccache --version``, run once per binary per process."""
|
||||||
|
return tool_version_probe(ccache)
|
||||||
|
|
||||||
|
|
||||||
def _ccache_runs(ccache: str) -> bool:
|
def _ccache_runs(ccache: str) -> bool:
|
||||||
"""Return True when the ``ccache`` found on PATH actually runs."""
|
"""Return True when the ``ccache`` found on PATH actually runs."""
|
||||||
return tool_version_runs(
|
return tool_version_runs(
|
||||||
ccache,
|
ccache,
|
||||||
"Ignoring ccache at %s because it failed to run; compiling without ccache",
|
"Ignoring ccache at %s because it failed to run; compiling without ccache",
|
||||||
|
probe=_ccache_probe,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@functools.cache
|
||||||
|
def _ccache_too_old_for_pch(ccache: str) -> bool:
|
||||||
|
output = _ccache_probe(ccache)
|
||||||
|
if not isinstance(output, str) or (found := _VERSION_RE.search(output)) is None:
|
||||||
|
return False
|
||||||
|
if tuple(map(int, found[1].split("."))) >= CCACHE_PCH_MIN_VERSION:
|
||||||
|
return False
|
||||||
|
_LOGGER.info(
|
||||||
|
"ccache %s at %s can reuse stale objects with a precompiled header; "
|
||||||
|
"compiling without it (upgrade ccache to %s or newer, or set "
|
||||||
|
"ESPHOME_PCH_ENABLE=1 to force)",
|
||||||
|
found[1],
|
||||||
|
ccache,
|
||||||
|
".".join(map(str, CCACHE_PCH_MIN_VERSION)),
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def ccache_breaks_pch() -> bool:
|
||||||
|
"""Whether the ccache this build uses is too old for a precompiled header.
|
||||||
|
ESP-IDF builds check the ccache IDF finds in CMake instead (build_gen.espidf)."""
|
||||||
|
return (ccache := resolve_ccache_path()) is not None and _ccache_too_old_for_pch(
|
||||||
|
ccache
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,11 @@ import re
|
|||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from esphome.build_helpers.ccache import effective_ccache_basedir, parse_enable_env
|
from esphome.build_helpers.ccache import (
|
||||||
|
ccache_breaks_pch,
|
||||||
|
effective_ccache_basedir,
|
||||||
|
parse_enable_env,
|
||||||
|
)
|
||||||
from esphome.const import PLATFORM_NRF52
|
from esphome.const import PLATFORM_NRF52
|
||||||
from esphome.helpers import write_file_if_changed
|
from esphome.helpers import write_file_if_changed
|
||||||
|
|
||||||
@@ -117,9 +121,16 @@ def pch_needs_gcc_check() -> bool:
|
|||||||
return sys.platform == "win32" and parse_enable_env("ESPHOME_PCH_ENABLE") is None
|
return sys.platform == "win32" and parse_enable_env("ESPHOME_PCH_ENABLE") is None
|
||||||
|
|
||||||
|
|
||||||
|
def _ccache_rules_out_pch() -> bool:
|
||||||
|
"""An old ccache, unless ``ESPHOME_PCH_ENABLE=1`` forces the pch on.
|
||||||
|
ESP-IDF runs the same check in CMake (build_gen.espidf)."""
|
||||||
|
return not pch_forced() and ccache_breaks_pch()
|
||||||
|
|
||||||
|
|
||||||
def pch_usable(cxx: Sequence[Path | str]) -> bool:
|
def pch_usable(cxx: Sequence[Path | str]) -> bool:
|
||||||
"""The knob plus the host rule; ``ESPHOME_PCH_ENABLE=1`` skips the rule."""
|
"""The knob plus the ccache and host rules; ``ESPHOME_PCH_ENABLE=1``
|
||||||
if not pch_enabled():
|
skips the rules."""
|
||||||
|
if not pch_enabled() or _ccache_rules_out_pch():
|
||||||
return False
|
return False
|
||||||
if not pch_needs_gcc_check():
|
if not pch_needs_gcc_check():
|
||||||
return True
|
return True
|
||||||
@@ -162,7 +173,11 @@ def pch_script_enabled() -> bool:
|
|||||||
"""Whether this PlatformIO build takes the pch script."""
|
"""Whether this PlatformIO build takes the pch script."""
|
||||||
from esphome.core import CORE
|
from esphome.core import CORE
|
||||||
|
|
||||||
return pch_enabled() and CORE.target_platform not in PCH_SCRIPT_EXCLUDED_PLATFORMS
|
return (
|
||||||
|
pch_enabled()
|
||||||
|
and CORE.target_platform not in PCH_SCRIPT_EXCLUDED_PLATFORMS
|
||||||
|
and not _ccache_rules_out_pch()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def pch_header_text(include_headers: Iterable[str]) -> str:
|
def pch_header_text(include_headers: Iterable[str]) -> str:
|
||||||
|
|||||||
@@ -111,7 +111,9 @@ class ToolOutput(RedirectText):
|
|||||||
|
|
||||||
def _emit_line(self, line: str) -> None:
|
def _emit_line(self, line: str) -> None:
|
||||||
if self._progress and _PROGRESS.match(line):
|
if self._progress and _PROGRESS.match(line):
|
||||||
if not self._is_filtered(line):
|
if self._is_filtered(line):
|
||||||
|
self._drop_line(line)
|
||||||
|
else:
|
||||||
text = _fit_terminal(line.strip("\r\n"))
|
text = _fit_terminal(line.strip("\r\n"))
|
||||||
self._write_color_replace(f"\r{text}\x1b[K")
|
self._write_color_replace(f"\r{text}\x1b[K")
|
||||||
self._on_progress_line = True
|
self._on_progress_line = True
|
||||||
|
|||||||
@@ -82,6 +82,10 @@ class ClimateCall {
|
|||||||
ClimateCall &set_fan_mode(optional<std::string> fan_mode);
|
ClimateCall &set_fan_mode(optional<std::string> fan_mode);
|
||||||
/// Set the custom fan mode of the climate device.
|
/// Set the custom fan mode of the climate device.
|
||||||
ClimateCall &set_fan_mode(const char *custom_fan_mode);
|
ClimateCall &set_fan_mode(const char *custom_fan_mode);
|
||||||
|
/// Set the custom fan mode of the climate device (StringRef overload to resolve ambiguity, zero-copy).
|
||||||
|
ClimateCall &set_fan_mode(StringRef custom_fan_mode) {
|
||||||
|
return this->set_fan_mode(custom_fan_mode.c_str(), custom_fan_mode.size());
|
||||||
|
}
|
||||||
/// Set the custom fan mode of the climate device (zero-copy API path).
|
/// Set the custom fan mode of the climate device (zero-copy API path).
|
||||||
ClimateCall &set_fan_mode(const char *custom_fan_mode, size_t len);
|
ClimateCall &set_fan_mode(const char *custom_fan_mode, size_t len);
|
||||||
/// Set the swing mode of the climate device.
|
/// Set the swing mode of the climate device.
|
||||||
@@ -102,6 +106,10 @@ class ClimateCall {
|
|||||||
ClimateCall &set_preset(optional<std::string> preset);
|
ClimateCall &set_preset(optional<std::string> preset);
|
||||||
/// Set the custom preset of the climate device.
|
/// Set the custom preset of the climate device.
|
||||||
ClimateCall &set_preset(const char *custom_preset);
|
ClimateCall &set_preset(const char *custom_preset);
|
||||||
|
/// Set the custom preset of the climate device (StringRef overload to resolve ambiguity, zero-copy).
|
||||||
|
ClimateCall &set_preset(StringRef custom_preset) {
|
||||||
|
return this->set_preset(custom_preset.c_str(), custom_preset.size());
|
||||||
|
}
|
||||||
/// Set the custom preset of the climate device (zero-copy API path).
|
/// Set the custom preset of the climate device (zero-copy API path).
|
||||||
ClimateCall &set_preset(const char *custom_preset, size_t len);
|
ClimateCall &set_preset(const char *custom_preset, size_t len);
|
||||||
|
|
||||||
|
|||||||
@@ -3683,6 +3683,20 @@ def _write_sdkconfig():
|
|||||||
clean_build(clear_pio_cache=False)
|
clean_build(clear_pio_cache=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _arduino_provides(name: str) -> bool:
|
||||||
|
"""Whether arduino-esp32 bundles the library a manifest depends on, so it is
|
||||||
|
never fetched by name; it still has to be enabled to build."""
|
||||||
|
if name not in ARDUINO_DISABLED_LIBRARIES:
|
||||||
|
return False
|
||||||
|
if name not in CORE.data[KEY_ESP32][KEY_ARDUINO_LIBRARIES]:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"A library depends on the Arduino library %s, which is not enabled; "
|
||||||
|
"add it under esphome: libraries:",
|
||||||
|
name,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _write_idf_component_yml():
|
def _write_idf_component_yml():
|
||||||
yml_path = CORE.relative_build_path("src/idf_component.yml")
|
yml_path = CORE.relative_build_path("src/idf_component.yml")
|
||||||
dependencies: dict[str, dict] = {}
|
dependencies: dict[str, dict] = {}
|
||||||
@@ -3704,7 +3718,11 @@ def _write_idf_component_yml():
|
|||||||
# IDF sees the same requirement twice; converted components reach it through
|
# IDF sees the same requirement twice; converted components reach it through
|
||||||
# ${ESPHOME_PROJECT_MANAGED_COMPONENTS}.
|
# ${ESPHOME_PROJECT_MANAGED_COMPONENTS}.
|
||||||
managed = set(CORE.data[KEY_ESP32].get(KEY_COMPONENTS, {}))
|
managed = set(CORE.data[KEY_ESP32].get(KEY_COMPONENTS, {}))
|
||||||
converted = generate_idf_components(libraries, managed=managed)
|
converted = generate_idf_components(
|
||||||
|
libraries,
|
||||||
|
managed=managed,
|
||||||
|
provides=_arduino_provides if CORE.using_arduino else None,
|
||||||
|
)
|
||||||
# IDF names a component after its directory and a later registration of the
|
# IDF names a component after its directory and a later registration of the
|
||||||
# same name replaces the earlier one, so a stub beside a converted library of
|
# same name replaces the earlier one, so a stub beside a converted library of
|
||||||
# the same name (espressif/libsodium vs esphome/libsodium) would win or lose
|
# the same name (espressif/libsodium vs esphome/libsodium) would win or lose
|
||||||
|
|||||||
@@ -719,6 +719,7 @@ void ESP32BLE::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_pa
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Ignore these GAP events as they are not relevant for our use case
|
// Ignore these GAP events as they are not relevant for our use case
|
||||||
|
case ESP_GAP_BLE_KEY_EVT:
|
||||||
case ESP_GAP_BLE_SET_PKT_LENGTH_COMPLETE_EVT:
|
case ESP_GAP_BLE_SET_PKT_LENGTH_COMPLETE_EVT:
|
||||||
case ESP_GAP_BLE_PHY_UPDATE_COMPLETE_EVT: // BLE 5.0 PHY update complete
|
case ESP_GAP_BLE_PHY_UPDATE_COMPLETE_EVT: // BLE 5.0 PHY update complete
|
||||||
case ESP_GAP_BLE_CHANNEL_SELECT_ALGORITHM_EVT: // BLE 5.0 channel selection algorithm
|
case ESP_GAP_BLE_CHANNEL_SELECT_ALGORITHM_EVT: // BLE 5.0 channel selection algorithm
|
||||||
|
|||||||
@@ -26,9 +26,14 @@ static const char *const TAG = "esp32_hosted.update";
|
|||||||
|
|
||||||
// Older coprocessor firmware versions have a 1500-byte limit per RPC call
|
// Older coprocessor firmware versions have a 1500-byte limit per RPC call
|
||||||
constexpr size_t CHUNK_SIZE = 1500;
|
constexpr size_t CHUNK_SIZE = 1500;
|
||||||
// OTA begin blocks while the coprocessor erases its partition
|
// OTA begin blocks while the coprocessor erases its partition, end while it validates the image
|
||||||
constexpr uint32_t OTA_WDT_TIMEOUT_MS = 60000;
|
constexpr uint32_t OTA_WDT_TIMEOUT_MS = 60000;
|
||||||
|
|
||||||
|
static esp_err_t ota_end_guarded() {
|
||||||
|
watchdog::WatchdogManager wdt(OTA_WDT_TIMEOUT_MS);
|
||||||
|
return esp_hosted_slave_ota_end(); // NOLINT
|
||||||
|
}
|
||||||
|
|
||||||
#ifdef USE_ESP32_HOSTED_HTTP_UPDATE
|
#ifdef USE_ESP32_HOSTED_HTTP_UPDATE
|
||||||
// Interval/timeout IDs (uint32_t to avoid string comparison)
|
// Interval/timeout IDs (uint32_t to avoid string comparison)
|
||||||
constexpr uint32_t INITIAL_CHECK_INTERVAL_ID = 0;
|
constexpr uint32_t INITIAL_CHECK_INTERVAL_ID = 0;
|
||||||
@@ -380,7 +385,7 @@ bool Esp32HostedUpdate::stream_firmware_to_coprocessor_() {
|
|||||||
} else {
|
} else {
|
||||||
ESP_LOGE(TAG, "Error reading firmware data: %d", read_or_error);
|
ESP_LOGE(TAG, "Error reading firmware data: %d", read_or_error);
|
||||||
}
|
}
|
||||||
esp_hosted_slave_ota_end(); // NOLINT
|
ota_end_guarded();
|
||||||
container->end();
|
container->end();
|
||||||
this->status_set_error(LOG_STR("Download failed"));
|
this->status_set_error(LOG_STR("Download failed"));
|
||||||
return false;
|
return false;
|
||||||
@@ -390,7 +395,7 @@ bool Esp32HostedUpdate::stream_firmware_to_coprocessor_() {
|
|||||||
err = esp_hosted_slave_ota_write(buffer, read_or_error); // NOLINT
|
err = esp_hosted_slave_ota_write(buffer, read_or_error); // NOLINT
|
||||||
if (err != ESP_OK) {
|
if (err != ESP_OK) {
|
||||||
ESP_LOGE(TAG, "Failed to write OTA data: %s", esp_err_to_name(err));
|
ESP_LOGE(TAG, "Failed to write OTA data: %s", esp_err_to_name(err));
|
||||||
esp_hosted_slave_ota_end(); // NOLINT
|
ota_end_guarded();
|
||||||
container->end();
|
container->end();
|
||||||
this->status_set_error(LOG_STR("Failed to write OTA data"));
|
this->status_set_error(LOG_STR("Failed to write OTA data"));
|
||||||
return false;
|
return false;
|
||||||
@@ -402,7 +407,7 @@ bool Esp32HostedUpdate::stream_firmware_to_coprocessor_() {
|
|||||||
hasher.calculate();
|
hasher.calculate();
|
||||||
if (!hasher.equals_bytes(this->firmware_sha256_.data())) {
|
if (!hasher.equals_bytes(this->firmware_sha256_.data())) {
|
||||||
ESP_LOGE(TAG, "SHA256 mismatch");
|
ESP_LOGE(TAG, "SHA256 mismatch");
|
||||||
esp_hosted_slave_ota_end(); // NOLINT
|
ota_end_guarded();
|
||||||
this->status_set_error(LOG_STR("SHA256 verification failed"));
|
this->status_set_error(LOG_STR("SHA256 verification failed"));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -451,7 +456,7 @@ bool Esp32HostedUpdate::write_embedded_firmware_to_coprocessor_() {
|
|||||||
err = esp_hosted_slave_ota_write(chunk, chunk_size); // NOLINT
|
err = esp_hosted_slave_ota_write(chunk, chunk_size); // NOLINT
|
||||||
if (err != ESP_OK) {
|
if (err != ESP_OK) {
|
||||||
ESP_LOGE(TAG, "Failed to write OTA data: %s", esp_err_to_name(err));
|
ESP_LOGE(TAG, "Failed to write OTA data: %s", esp_err_to_name(err));
|
||||||
esp_hosted_slave_ota_end(); // NOLINT
|
ota_end_guarded();
|
||||||
this->status_set_error(LOG_STR("Failed to write OTA data"));
|
this->status_set_error(LOG_STR("Failed to write OTA data"));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -494,7 +499,7 @@ void Esp32HostedUpdate::perform(bool force) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// End OTA and activate new firmware
|
// End OTA and activate new firmware
|
||||||
esp_err_t end_err = esp_hosted_slave_ota_end(); // NOLINT
|
esp_err_t end_err = ota_end_guarded();
|
||||||
if (end_err != ESP_OK) {
|
if (end_err != ESP_OK) {
|
||||||
ESP_LOGE(TAG, "Failed to end OTA: %s", esp_err_to_name(end_err));
|
ESP_LOGE(TAG, "Failed to end OTA: %s", esp_err_to_name(end_err));
|
||||||
this->state_ = prev_state;
|
this->state_ = prev_state;
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ async def to_code(config: ConfigType) -> None:
|
|||||||
request_parent = await cg.get_variable(config[CONF_HTTP_REQUEST_ID])
|
request_parent = await cg.get_variable(config[CONF_HTTP_REQUEST_ID])
|
||||||
cg.add(var.set_request_parent(request_parent))
|
cg.add(var.set_request_parent(request_parent))
|
||||||
|
|
||||||
cg.add(var.set_source_url(config[CONF_SOURCE]))
|
cg.add(var.set_source_url_static(config[CONF_SOURCE]))
|
||||||
|
|
||||||
ota.request_ota_state_listeners()
|
ota.request_ota_state_listeners()
|
||||||
|
|
||||||
|
|||||||
@@ -24,12 +24,6 @@ namespace esphome::http_request {
|
|||||||
|
|
||||||
static const char *const TAG = "http_request.update";
|
static const char *const TAG = "http_request.update";
|
||||||
|
|
||||||
// Wraps UpdateInfo + error for the task→main-loop handoff.
|
|
||||||
struct TaskResult {
|
|
||||||
update::UpdateInfo info;
|
|
||||||
const LogString *error_str{nullptr};
|
|
||||||
};
|
|
||||||
|
|
||||||
static const size_t MAX_READ_SIZE = 256;
|
static const size_t MAX_READ_SIZE = 256;
|
||||||
static constexpr uint32_t INITIAL_CHECK_INTERVAL_ID = 0;
|
static constexpr uint32_t INITIAL_CHECK_INTERVAL_ID = 0;
|
||||||
static constexpr uint32_t INITIAL_CHECK_INTERVAL_MS = 10000;
|
static constexpr uint32_t INITIAL_CHECK_INTERVAL_MS = 10000;
|
||||||
@@ -68,6 +62,37 @@ void HttpRequestUpdate::on_ota_state(ota::OTAState state, float progress, uint8_
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void HttpRequestUpdate::set_source_url(const char *source_url, size_t len) {
|
||||||
|
// Copy before releasing, so a caller passing the current URL back in stays valid.
|
||||||
|
char *copy = new char[len + 1];
|
||||||
|
memcpy(copy, source_url, len);
|
||||||
|
copy[len] = '\0';
|
||||||
|
this->release_source_url_();
|
||||||
|
this->source_url_ = copy;
|
||||||
|
this->source_url_owned_ = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void HttpRequestUpdate::release_source_url_() {
|
||||||
|
if (this->source_url_owned_)
|
||||||
|
delete[] this->source_url_;
|
||||||
|
this->source_url_owned_ = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void HttpRequestUpdate::begin_check_(TaskResult *result) {
|
||||||
|
result->update = this;
|
||||||
|
result->source_url = this->source_url_;
|
||||||
|
result->owns_source_url = this->source_url_owned_;
|
||||||
|
this->source_url_owned_ = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void HttpRequestUpdate::end_check_(TaskResult *result) {
|
||||||
|
if (this->source_url_ == result->source_url) {
|
||||||
|
this->source_url_owned_ = result->owns_source_url;
|
||||||
|
} else if (result->owns_source_url) {
|
||||||
|
delete[] result->source_url; // replaced while the check ran
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void HttpRequestUpdate::update() {
|
void HttpRequestUpdate::update() {
|
||||||
if (!network::is_connected()) {
|
if (!network::is_connected()) {
|
||||||
ESP_LOGD(TAG, "Network not connected, skipping update check");
|
ESP_LOGD(TAG, "Network not connected, skipping update check");
|
||||||
@@ -75,28 +100,37 @@ void HttpRequestUpdate::update() {
|
|||||||
}
|
}
|
||||||
this->cancel_interval(INITIAL_CHECK_INTERVAL_ID);
|
this->cancel_interval(INITIAL_CHECK_INTERVAL_ID);
|
||||||
#ifdef USE_ESP32
|
#ifdef USE_ESP32
|
||||||
if (this->update_task_handle_ != nullptr) {
|
if (this->task_result_ != nullptr) {
|
||||||
ESP_LOGW(TAG, "Update check already in progress");
|
ESP_LOGW(TAG, "Update check already in progress");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
xTaskCreate(HttpRequestUpdate::update_task, "update_task", 8192, (void *) this, 1, &this->update_task_handle_);
|
#endif
|
||||||
|
// Allocated once per check; every path in the task returns via its single defer, which frees it.
|
||||||
|
auto *result = new TaskResult();
|
||||||
|
this->begin_check_(result);
|
||||||
|
#ifdef USE_ESP32
|
||||||
|
this->task_result_ = result;
|
||||||
|
if (xTaskCreate(HttpRequestUpdate::update_task, "update_task", 8192, result, 1, nullptr) != pdPASS) {
|
||||||
|
this->task_result_ = nullptr;
|
||||||
|
this->end_check_(result);
|
||||||
|
delete result;
|
||||||
|
}
|
||||||
#else
|
#else
|
||||||
this->update_task(this);
|
this->update_task(result);
|
||||||
|
this->end_check_(result);
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
void HttpRequestUpdate::update_task(void *params) {
|
void HttpRequestUpdate::update_task(void *params) {
|
||||||
HttpRequestUpdate *this_update = (HttpRequestUpdate *) params;
|
auto *result = static_cast<TaskResult *>(params);
|
||||||
|
HttpRequestUpdate *this_update = result->update;
|
||||||
// Allocate once — every path below returns via the single defer at the end.
|
|
||||||
// On failure, error_str is set; on success it is nullptr.
|
|
||||||
auto *result = new TaskResult();
|
|
||||||
auto *info = &result->info;
|
auto *info = &result->info;
|
||||||
|
const char *source_url = result->source_url;
|
||||||
|
|
||||||
auto container = this_update->request_parent_->get(this_update->source_url_);
|
auto container = this_update->request_parent_->get(source_url);
|
||||||
|
|
||||||
if (container == nullptr || container->status_code != HTTP_STATUS_OK) {
|
if (container == nullptr || container->status_code != HTTP_STATUS_OK) {
|
||||||
ESP_LOGE(TAG, "Failed to fetch manifest from %s", this_update->source_url_);
|
ESP_LOGE(TAG, "Failed to fetch manifest from %s", source_url);
|
||||||
if (container != nullptr)
|
if (container != nullptr)
|
||||||
container->end();
|
container->end();
|
||||||
result->error_str = LOG_STR("Failed to fetch manifest");
|
result->error_str = LOG_STR("Failed to fetch manifest");
|
||||||
@@ -176,14 +210,14 @@ void HttpRequestUpdate::update_task(void *params) {
|
|||||||
allocator.deallocate(data, content_length);
|
allocator.deallocate(data, content_length);
|
||||||
|
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
ESP_LOGE(TAG, "Failed to parse JSON from %s", this_update->source_url_);
|
ESP_LOGE(TAG, "Failed to parse JSON from %s", source_url);
|
||||||
result->error_str = LOG_STR("Failed to parse manifest JSON");
|
result->error_str = LOG_STR("Failed to parse manifest JSON");
|
||||||
goto defer; // NOLINT(cppcoreguidelines-avoid-goto)
|
goto defer; // NOLINT(cppcoreguidelines-avoid-goto)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Merge source_url_ and firmware_url
|
// Merge source_url_ and firmware_url
|
||||||
if (!info->firmware_url.empty() && info->firmware_url.find("http") == std::string::npos) {
|
if (!info->firmware_url.empty() && info->firmware_url.find("http") == std::string::npos) {
|
||||||
const char *source = this_update->source_url_;
|
const char *source = source_url;
|
||||||
const size_t source_len = strlen(source);
|
const size_t source_len = strlen(source);
|
||||||
size_t prefix_len;
|
size_t prefix_len;
|
||||||
if (info->firmware_url[0] == '/') {
|
if (info->firmware_url[0] == '/') {
|
||||||
@@ -215,7 +249,8 @@ defer:
|
|||||||
// Lambda captures only 2 pointers (8 bytes) — fits in std::function SBO on supported toolchains.
|
// Lambda captures only 2 pointers (8 bytes) — fits in std::function SBO on supported toolchains.
|
||||||
this_update->defer([this_update, result]() {
|
this_update->defer([this_update, result]() {
|
||||||
#ifdef USE_ESP32
|
#ifdef USE_ESP32
|
||||||
this_update->update_task_handle_ = nullptr;
|
this_update->task_result_ = nullptr;
|
||||||
|
this_update->end_check_(result);
|
||||||
#endif
|
#endif
|
||||||
if (result->error_str != nullptr) {
|
if (result->error_str != nullptr) {
|
||||||
this_update->status_set_error(result->error_str);
|
this_update->status_set_error(result->error_str);
|
||||||
|
|||||||
@@ -13,6 +13,21 @@
|
|||||||
|
|
||||||
namespace esphome::http_request {
|
namespace esphome::http_request {
|
||||||
|
|
||||||
|
namespace testing {
|
||||||
|
class HttpRequestUpdateTest;
|
||||||
|
} // namespace testing
|
||||||
|
|
||||||
|
class HttpRequestUpdate;
|
||||||
|
|
||||||
|
// One manifest check: handed from update() to the task and back to the main loop.
|
||||||
|
struct TaskResult {
|
||||||
|
HttpRequestUpdate *update{nullptr};
|
||||||
|
const char *source_url{nullptr}; // the URL this check reads, fixed before it starts
|
||||||
|
update::UpdateInfo info;
|
||||||
|
const LogString *error_str{nullptr}; // set on failure, nullptr on success
|
||||||
|
bool owns_source_url{false}; // the check holds the buffer until it ends, see begin_check_()
|
||||||
|
};
|
||||||
|
|
||||||
class HttpRequestUpdate final : public update::UpdateEntity, public PollingComponent, public ota::OTAStateListener {
|
class HttpRequestUpdate final : public update::UpdateEntity, public PollingComponent, public ota::OTAStateListener {
|
||||||
public:
|
public:
|
||||||
void setup() override;
|
void setup() override;
|
||||||
@@ -21,7 +36,18 @@ class HttpRequestUpdate final : public update::UpdateEntity, public PollingCompo
|
|||||||
void perform(bool force) override;
|
void perform(bool force) override;
|
||||||
void check() override { this->update(); }
|
void check() override { this->update(); }
|
||||||
|
|
||||||
void set_source_url(const char *source_url) { this->source_url_ = source_url; }
|
~HttpRequestUpdate() override { this->release_source_url_(); }
|
||||||
|
|
||||||
|
/// Codegen only: the config literal lives in flash for the life of the program, so it is kept by pointer.
|
||||||
|
void set_source_url_static(const char *source_url) {
|
||||||
|
this->release_source_url_();
|
||||||
|
this->source_url_ = source_url;
|
||||||
|
}
|
||||||
|
/// Set the manifest URL at run time; the string is copied, so any storage may be passed.
|
||||||
|
void set_source_url(const char *source_url) { this->set_source_url(source_url, strlen(source_url)); }
|
||||||
|
void set_source_url(const std::string &source_url) { this->set_source_url(source_url.data(), source_url.size()); }
|
||||||
|
void set_source_url(const char *source_url, size_t len);
|
||||||
|
const char *get_source_url() const { return this->source_url_; }
|
||||||
|
|
||||||
void set_request_parent(HttpRequestComponent *request_parent) { this->request_parent_ = request_parent; }
|
void set_request_parent(HttpRequestComponent *request_parent) { this->request_parent_ = request_parent; }
|
||||||
void set_ota_parent(OtaHttpRequestComponent *ota_parent) { this->ota_parent_ = ota_parent; }
|
void set_ota_parent(OtaHttpRequestComponent *ota_parent) { this->ota_parent_ = ota_parent; }
|
||||||
@@ -36,12 +62,21 @@ class HttpRequestUpdate final : public update::UpdateEntity, public PollingCompo
|
|||||||
|
|
||||||
static void update_task(void *params);
|
static void update_task(void *params);
|
||||||
#ifdef USE_ESP32
|
#ifdef USE_ESP32
|
||||||
TaskHandle_t update_task_handle_{nullptr};
|
TaskResult *task_result_{nullptr}; // set while a check runs on the update task
|
||||||
#endif
|
#endif
|
||||||
uint8_t initial_check_remaining_{0};
|
uint8_t initial_check_remaining_{0};
|
||||||
|
|
||||||
private:
|
private:
|
||||||
const char *source_url_{nullptr}; // literal from codegen
|
friend class testing::HttpRequestUpdateTest;
|
||||||
|
void release_source_url_();
|
||||||
|
// A check reads the URL it started with until it ends, on the update task or inline through an
|
||||||
|
// automation it triggers. Ownership of that buffer moves to the check, so a set_source_url in the
|
||||||
|
// meantime cannot free it; end_check_() hands it back or frees it on the main loop.
|
||||||
|
void begin_check_(TaskResult *result);
|
||||||
|
void end_check_(TaskResult *result);
|
||||||
|
|
||||||
|
bool source_url_owned_{false}; // source_url_ was copied by a run-time setter; sits in the padding above
|
||||||
|
const char *source_url_{nullptr};
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace esphome::http_request
|
} // namespace esphome::http_request
|
||||||
@@ -169,7 +169,9 @@ void LEDCOutput::setup() {
|
|||||||
#if !defined(CLANG_TIDY)
|
#if !defined(CLANG_TIDY)
|
||||||
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 1, 0)
|
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 1, 0)
|
||||||
PERIPH_RCC_ATOMIC() { ledc_ll_reset_register(0); }
|
PERIPH_RCC_ATOMIC() { ledc_ll_reset_register(0); }
|
||||||
#elif ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0)
|
#elif ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) || \
|
||||||
|
(ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 3, 0) && !defined(__PERIPH_CTRL_ALLOW_LEGACY_API))
|
||||||
|
// Also on the targets whose periph_module_reset() is an empty stub in ESP-IDF 5.x (P4, C5, C61, H4)
|
||||||
PERIPH_RCC_ATOMIC() {
|
PERIPH_RCC_ATOMIC() {
|
||||||
ledc_ll_enable_reset_reg(true);
|
ledc_ll_enable_reset_reg(true);
|
||||||
ledc_ll_enable_reset_reg(false);
|
ledc_ll_enable_reset_reg(false);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include "light_color_values.h"
|
#include "light_color_values.h"
|
||||||
|
#include "esphome/core/string_ref.h"
|
||||||
|
|
||||||
namespace esphome {
|
namespace esphome {
|
||||||
|
|
||||||
@@ -133,6 +134,8 @@ class LightCall {
|
|||||||
LightCall &set_effect(const std::string &effect) { return this->set_effect(effect.data(), effect.size()); }
|
LightCall &set_effect(const std::string &effect) { return this->set_effect(effect.data(), effect.size()); }
|
||||||
/// Set the effect of the light by its name (const char * overload to resolve ambiguity).
|
/// Set the effect of the light by its name (const char * overload to resolve ambiguity).
|
||||||
LightCall &set_effect(const char *effect) { return this->set_effect(effect, strlen(effect)); }
|
LightCall &set_effect(const char *effect) { return this->set_effect(effect, strlen(effect)); }
|
||||||
|
/// Set the effect of the light by its name (StringRef overload to resolve ambiguity, zero-copy).
|
||||||
|
LightCall &set_effect(StringRef effect) { return this->set_effect(effect.c_str(), effect.size()); }
|
||||||
/// Set the effect of the light by its name and length (zero-copy from API).
|
/// Set the effect of the light by its name and length (zero-copy from API).
|
||||||
LightCall &set_effect(const char *effect, size_t len);
|
LightCall &set_effect(const char *effect, size_t len);
|
||||||
/// Set the effect of the light by its internal index number (only for internal use).
|
/// Set the effect of the light by its internal index number (only for internal use).
|
||||||
|
|||||||
@@ -169,8 +169,7 @@ bool MQTTFanComponent::publish_state() {
|
|||||||
char topic_buf[MQTT_DEFAULT_TOPIC_MAX_LEN];
|
char topic_buf[MQTT_DEFAULT_TOPIC_MAX_LEN];
|
||||||
const char *state_s = this->state_->state ? "ON" : "OFF";
|
const char *state_s = this->state_->state ? "ON" : "OFF";
|
||||||
ESP_LOGD(TAG, "'%s' Sending state %s.", this->state_->get_name().c_str(), state_s);
|
ESP_LOGD(TAG, "'%s' Sending state %s.", this->state_->get_name().c_str(), state_s);
|
||||||
this->publish(this->get_state_topic_to_(topic_buf), state_s);
|
bool failed = !this->publish(this->get_state_topic_to_(topic_buf), state_s);
|
||||||
bool failed = false;
|
|
||||||
if (this->state_->get_traits().supports_direction()) {
|
if (this->state_->get_traits().supports_direction()) {
|
||||||
bool success = this->publish(this->get_direction_state_topic_to(topic_buf),
|
bool success = this->publish(this->get_direction_state_topic_to(topic_buf),
|
||||||
fan_direction_to_mqtt_str(this->state_->direction));
|
fan_direction_to_mqtt_str(this->state_->direction));
|
||||||
|
|||||||
@@ -98,8 +98,9 @@ enum OTAType : uint8_t {
|
|||||||
// The OTA backend method surface. Exactly one backend exists per build,
|
// The OTA backend method surface. Exactly one backend exists per build,
|
||||||
// selected in ota_backend_factory.h where this concept is asserted on
|
// selected in ota_backend_factory.h where this concept is asserted on
|
||||||
// make_ota_backend()'s return type. Semantics beyond the signatures:
|
// make_ota_backend()'s return type. Semantics beyond the signatures:
|
||||||
// - begin: prepare for an image of the given size; ota_type defaults to an
|
// - begin: prepare for an image of the given size; 0 means the size is unknown
|
||||||
// app update, so both call forms must be accepted.
|
// (web_server upload) and the backend allows the largest image it can take.
|
||||||
|
// ota_type defaults to an app update, so both call forms must be accepted.
|
||||||
// - set_update_md5: expected digest of the incoming image, hex string.
|
// - set_update_md5: expected digest of the incoming image, hex string.
|
||||||
// - write: consume the next chunk; end: finalize and mark bootable.
|
// - write: consume the next chunk; end: finalize and mark bootable.
|
||||||
// - abort: safe to call in any state, including after end().
|
// - abort: safe to call in any state, including after end().
|
||||||
|
|||||||
@@ -9,6 +9,12 @@
|
|||||||
|
|
||||||
#include <Updater.h>
|
#include <Updater.h>
|
||||||
|
|
||||||
|
// LittleFS region bounds, provided by the arduino-pico linker script.
|
||||||
|
// NOLINTNEXTLINE(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp)
|
||||||
|
extern "C" uint8_t _FS_start;
|
||||||
|
// NOLINTNEXTLINE(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp)
|
||||||
|
extern "C" uint8_t _FS_end;
|
||||||
|
|
||||||
namespace esphome::ota {
|
namespace esphome::ota {
|
||||||
|
|
||||||
static const char *const TAG = "ota";
|
static const char *const TAG = "ota";
|
||||||
@@ -19,8 +25,11 @@ OTAResponseTypes ArduinoRP2OTABackend::begin(size_t image_size, OTAType ota_type
|
|||||||
if (ota_type != OTA_TYPE_UPDATE_APP) {
|
if (ota_type != OTA_TYPE_UPDATE_APP) {
|
||||||
return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE;
|
return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE;
|
||||||
}
|
}
|
||||||
// OTA size of 0 is not currently handled, but
|
// 0 means the size is unknown (web_server upload). The updater only checks the size
|
||||||
// web_server is not supported for RP2040, so this is not an issue.
|
// against the LittleFS region it stages the image in, so ask for all of it.
|
||||||
|
if (image_size == 0) {
|
||||||
|
image_size = reinterpret_cast<uintptr_t>(&_FS_end) - reinterpret_cast<uintptr_t>(&_FS_start);
|
||||||
|
}
|
||||||
bool ret = Update.begin(image_size, U_FLASH);
|
bool ret = Update.begin(image_size, U_FLASH);
|
||||||
if (ret) {
|
if (ret) {
|
||||||
rp2::preferences_prevent_write(true);
|
rp2::preferences_prevent_write(true);
|
||||||
@@ -34,7 +43,7 @@ OTAResponseTypes ArduinoRP2OTABackend::begin(size_t image_size, OTAType ota_type
|
|||||||
return OTA_RESPONSE_ERROR_WRONG_NEW_FLASH_CONFIG;
|
return OTA_RESPONSE_ERROR_WRONG_NEW_FLASH_CONFIG;
|
||||||
if (error == UPDATE_ERROR_FLASH_CONFIG)
|
if (error == UPDATE_ERROR_FLASH_CONFIG)
|
||||||
return OTA_RESPONSE_ERROR_WRONG_CURRENT_FLASH_CONFIG;
|
return OTA_RESPONSE_ERROR_WRONG_CURRENT_FLASH_CONFIG;
|
||||||
if (error == UPDATE_ERROR_SPACE)
|
if (error == UPDATE_ERROR_SPACE || error == UPDATE_ERROR_SIZE)
|
||||||
return OTA_RESPONSE_ERROR_RP2040_NOT_ENOUGH_SPACE;
|
return OTA_RESPONSE_ERROR_RP2040_NOT_ENOUGH_SPACE;
|
||||||
|
|
||||||
ESP_LOGE(TAG, "Begin error: %d", error);
|
ESP_LOGE(TAG, "Begin error: %d", error);
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ namespace esphome::web_server_idf {
|
|||||||
|
|
||||||
static const char *const TAG = "web_server_idf";
|
static const char *const TAG = "web_server_idf";
|
||||||
|
|
||||||
// Only send_json_() may hold the JSON arena: every other frame in this file is capped below one
|
// Only prepare_json_() may hold the JSON arena: every other frame in this file is capped below one
|
||||||
// arena. Measured at -Os on GCC 14; newer toolchains stay checked on purpose, older ones skip it.
|
// arena. Measured at -Os on GCC 14; newer toolchains stay checked on purpose, older ones skip it.
|
||||||
#if defined(__GNUC__) && !defined(__clang__) && __GNUC__ >= 14 && defined(__OPTIMIZE_SIZE__)
|
#if defined(__GNUC__) && !defined(__clang__) && __GNUC__ >= 14 && defined(__OPTIMIZE_SIZE__)
|
||||||
#pragma GCC diagnostic error "-Wstack-usage=2048"
|
#pragma GCC diagnostic error "-Wstack-usage=2048"
|
||||||
@@ -148,10 +148,9 @@ void AsyncWebServer::begin() {
|
|||||||
if (this->server_) {
|
if (this->server_) {
|
||||||
this->end();
|
this->end();
|
||||||
}
|
}
|
||||||
// Default httpd stack is defined by ESP-IDF. Increase to accommodate SerializationBuffer's
|
// Allow room for SerializationBuffer's 640-byte stack buffer and HTTP request handling.
|
||||||
// 640-byte stack buffer used by web_server JSON request handlers.
|
|
||||||
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
|
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
|
||||||
config.stack_size = config.stack_size + 256;
|
config.stack_size = 4512;
|
||||||
config.server_port = this->port_;
|
config.server_port = this->port_;
|
||||||
config.uri_match_fn = [](const char * /*unused*/, const char * /*unused*/, size_t /*unused*/) { return true; };
|
config.uri_match_fn = [](const char * /*unused*/, const char * /*unused*/, size_t /*unused*/) { return true; };
|
||||||
// Always enable LRU purging to handle socket exhaustion gracefully.
|
// Always enable LRU purging to handle socket exhaustion gracefully.
|
||||||
@@ -1121,68 +1120,88 @@ void AsyncEventSourceResponse::loop() {
|
|||||||
|
|
||||||
#if defined(__GNUC__) && !defined(__clang__) && __GNUC__ >= 14 && defined(__OPTIMIZE_SIZE__)
|
#if defined(__GNUC__) && !defined(__clang__) && __GNUC__ >= 14 && defined(__OPTIMIZE_SIZE__)
|
||||||
#pragma GCC diagnostic push
|
#pragma GCC diagnostic push
|
||||||
#pragma GCC diagnostic ignored "-Wstack-usage=" // the one frame that holds the arena and the JSON buffer
|
#pragma GCC diagnostic ignored "-Wstack-usage=" // the one frame that holds the JSON arena
|
||||||
#endif
|
#endif
|
||||||
bool AsyncEventSourceResponse::send_json_(void *source, message_generator_t *generator) {
|
AsyncEventSourceResponse::JsonPrepareResult AsyncEventSourceResponse::prepare_json_(void *source,
|
||||||
// The arena lives only in this frame, so no call chain ever holds two of them
|
message_generator_t *generator,
|
||||||
|
char *buffer, size_t *size_out) {
|
||||||
|
// Log lines emitted while the generator runs are dropped for this session: on the large path they would fill
|
||||||
|
// tail_ before it is overwritten, and on the small path they would go out ahead of the event being built.
|
||||||
|
SendGuard guard{*this};
|
||||||
json::JsonArena<json::JSON_ARENA_SIZE> arena;
|
json::JsonArena<json::JSON_ARENA_SIZE> arena;
|
||||||
json::JsonBuilder builder(&arena);
|
json::JsonBuilder builder(&arena);
|
||||||
generator(this->web_server_, source, builder);
|
generator(this->web_server_, source, builder);
|
||||||
char buf[JSON_BUF_SIZE];
|
*size_out = builder.serialize_to(buffer, JSON_BUF_SIZE);
|
||||||
const size_t len = builder.serialize_to(buf, sizeof(buf));
|
if (*size_out < JSON_BUF_SIZE) {
|
||||||
if (len < sizeof(buf)) {
|
return JsonPrepareResult::JSON_PREPARE_RESULT_BUFFER;
|
||||||
return this->try_send_nodefer(buf, len, "state");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Too large for the stack: the tail holds the whole chunk and loop() drains it. Serialized
|
char prefix[PREFIX_BUF_SIZE];
|
||||||
// JSON has no raw line break, so the body is one data line.
|
const size_t prefix_len = build_chunk_prefix(prefix, sizeof(prefix), "state", 0, 0, true);
|
||||||
if (!this->ready_to_send_()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
{
|
|
||||||
SendGuard guard{*this};
|
|
||||||
char prefix[PREFIX_BUF_SIZE];
|
|
||||||
const size_t prefix_len = build_chunk_prefix(prefix, sizeof(prefix), "state", 0, 0, true);
|
|
||||||
|
|
||||||
// Grow the tail until the document fits. Nothing has reached the wire, so a document that
|
// Nothing reaches the wire until the complete document and its framing are owned by the caller.
|
||||||
// cannot be held costs only this event, and the tail grown for it is released.
|
size_t json_len = 0;
|
||||||
size_t json_len = 0;
|
size_t cap = std::max<size_t>(JSON_BUF_SIZE * 2, this->tail_cap_);
|
||||||
size_t cap = std::max<size_t>(JSON_BUF_SIZE * 2, this->tail_cap_);
|
for (;;) {
|
||||||
for (;;) {
|
if (!this->reserve_tail_(cap)) {
|
||||||
if (!this->reserve_tail_(cap)) {
|
*size_out = cap;
|
||||||
this->tail_alloc_failed_(cap);
|
return JsonPrepareResult::JSON_PREPARE_RESULT_NO_MEMORY;
|
||||||
return false; // stays deferred, retried on a later pass
|
|
||||||
}
|
|
||||||
const size_t room = cap - prefix_len - SSE_SUFFIX_LEN;
|
|
||||||
json_len = builder.serialize_to(reinterpret_cast<char *>(this->tail_.get()) + prefix_len, room);
|
|
||||||
if (json_len < room) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (cap >= TAIL_MAX_SIZE) {
|
|
||||||
ESP_LOGW(TAG, "State event does not fit %zu bytes, dropped", room);
|
|
||||||
this->tail_.reset();
|
|
||||||
this->tail_cap_ = 0;
|
|
||||||
this->send_failure_started_ms_ = 0;
|
|
||||||
return true; // would never fit, reported as sent
|
|
||||||
}
|
|
||||||
cap = std::min<size_t>(cap * 2, TAIL_MAX_SIZE);
|
|
||||||
}
|
}
|
||||||
|
const size_t room = cap - prefix_len - SSE_SUFFIX_LEN;
|
||||||
const size_t total = prefix_len + json_len + SSE_SUFFIX_LEN;
|
json_len = builder.serialize_to(reinterpret_cast<char *>(this->tail_.get()) + prefix_len, room);
|
||||||
write_chunk_header(prefix, total - CHUNK_HDR_LEN - CHUNK_END_LEN);
|
if (json_len < room) {
|
||||||
uint8_t *dst = this->tail_.get();
|
break;
|
||||||
std::memcpy(dst, prefix, prefix_len);
|
}
|
||||||
std::memcpy(dst + prefix_len + json_len, SSE_SUFFIX, SSE_SUFFIX_LEN);
|
if (cap >= TAIL_MAX_SIZE) {
|
||||||
this->tail_len_ = total;
|
*size_out = room;
|
||||||
this->tail_sent_ = 0;
|
return JsonPrepareResult::JSON_PREPARE_RESULT_TOO_LARGE;
|
||||||
|
}
|
||||||
|
cap = std::min<size_t>(cap * 2, TAIL_MAX_SIZE);
|
||||||
}
|
}
|
||||||
drain_tail_();
|
|
||||||
return true;
|
const size_t total = prefix_len + json_len + SSE_SUFFIX_LEN;
|
||||||
|
write_chunk_header(prefix, total - CHUNK_HDR_LEN - CHUNK_END_LEN);
|
||||||
|
uint8_t *dst = this->tail_.get();
|
||||||
|
std::memcpy(dst, prefix, prefix_len);
|
||||||
|
std::memcpy(dst + prefix_len + json_len, SSE_SUFFIX, SSE_SUFFIX_LEN);
|
||||||
|
this->tail_len_ = total;
|
||||||
|
this->tail_sent_ = 0;
|
||||||
|
return JsonPrepareResult::JSON_PREPARE_RESULT_TAIL;
|
||||||
}
|
}
|
||||||
#if defined(__GNUC__) && !defined(__clang__) && __GNUC__ >= 14 && defined(__OPTIMIZE_SIZE__)
|
#if defined(__GNUC__) && !defined(__clang__) && __GNUC__ >= 14 && defined(__OPTIMIZE_SIZE__)
|
||||||
#pragma GCC diagnostic pop
|
#pragma GCC diagnostic pop
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
bool AsyncEventSourceResponse::send_json_(void *source, message_generator_t *generator) {
|
||||||
|
if (!this->ready_to_send_()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
char buffer[JSON_BUF_SIZE];
|
||||||
|
size_t size;
|
||||||
|
const auto result = this->prepare_json_(source, generator, buffer, &size);
|
||||||
|
switch (result) {
|
||||||
|
case JsonPrepareResult::JSON_PREPARE_RESULT_BUFFER:
|
||||||
|
return this->try_send_nodefer(buffer, size, "state");
|
||||||
|
case JsonPrepareResult::JSON_PREPARE_RESULT_TAIL:
|
||||||
|
this->drain_tail_();
|
||||||
|
return true;
|
||||||
|
case JsonPrepareResult::JSON_PREPARE_RESULT_NO_MEMORY: {
|
||||||
|
SendGuard guard{*this};
|
||||||
|
this->tail_alloc_failed_(size);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
case JsonPrepareResult::JSON_PREPARE_RESULT_TOO_LARGE: {
|
||||||
|
SendGuard guard{*this};
|
||||||
|
ESP_LOGW(TAG, "State event does not fit %zu bytes, dropped", size);
|
||||||
|
this->tail_.reset();
|
||||||
|
this->tail_cap_ = 0;
|
||||||
|
this->send_failure_started_ms_ = 0;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
void AsyncEventSourceResponse::tail_alloc_failed_(size_t cap) {
|
void AsyncEventSourceResponse::tail_alloc_failed_(size_t cap) {
|
||||||
// Same stall clock as a socket that stops draining, so a session cannot retry forever
|
// Same stall clock as a socket that stops draining, so a session cannot retry forever
|
||||||
const uint32_t now = App.get_loop_component_start_time();
|
const uint32_t now = App.get_loop_component_start_time();
|
||||||
|
|||||||
@@ -317,6 +317,17 @@ class AsyncEventSourceResponse {
|
|||||||
size_t sent);
|
size_t sent);
|
||||||
// Send a state event; JSON too large for the stack buffer is serialized into tail_ instead
|
// Send a state event; JSON too large for the stack buffer is serialized into tail_ instead
|
||||||
bool send_json_(void *source, message_generator_t *generator);
|
bool send_json_(void *source, message_generator_t *generator);
|
||||||
|
enum class JsonPrepareResult : uint8_t {
|
||||||
|
JSON_PREPARE_RESULT_BUFFER,
|
||||||
|
JSON_PREPARE_RESULT_TAIL,
|
||||||
|
JSON_PREPARE_RESULT_NO_MEMORY,
|
||||||
|
JSON_PREPARE_RESULT_TOO_LARGE,
|
||||||
|
};
|
||||||
|
// Keep the arena out of the caller's frame so it is gone before the state send.
|
||||||
|
// size_out: BUFFER = JSON length, NO_MEMORY = requested tail capacity, TOO_LARGE = available JSON room;
|
||||||
|
// unused for TAIL.
|
||||||
|
JsonPrepareResult __attribute__((noinline))
|
||||||
|
prepare_json_(void *source, message_generator_t *generator, char *buffer, size_t *size_out);
|
||||||
// Warn once, and close the session once the stall timeout passes with no memory for the tail
|
// Warn once, and close the session once the stall timeout passes with no memory for the tail
|
||||||
void tail_alloc_failed_(size_t cap);
|
void tail_alloc_failed_(size_t cap);
|
||||||
void request_close_();
|
void request_close_();
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ from enum import Enum
|
|||||||
|
|
||||||
from esphome.enum import StrEnum
|
from esphome.enum import StrEnum
|
||||||
|
|
||||||
__version__ = "2026.10.0b5"
|
__version__ = "2026.10.1"
|
||||||
|
|
||||||
ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_"
|
ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_"
|
||||||
VALID_SUBSTITUTIONS_CHARACTERS = (
|
VALID_SUBSTITUTIONS_CHARACTERS = (
|
||||||
|
|||||||
+18
-11
@@ -648,6 +648,8 @@ class EsphomeCore:
|
|||||||
self.main_statements: list[Statement] = []
|
self.main_statements: list[Statement] = []
|
||||||
# A list of statements to insert in the global block (includes and global variables)
|
# A list of statements to insert in the global block (includes and global variables)
|
||||||
self.global_statements: list[Statement] = []
|
self.global_statements: list[Statement] = []
|
||||||
|
# Generated code emitted after the user includes, so it can use what they declare
|
||||||
|
self.global_statements_after_includes: list[Statement] = []
|
||||||
# A map of platformio libraries to add to the project (shortname: (name, version, repository))
|
# A map of platformio libraries to add to the project (shortname: (name, version, repository))
|
||||||
self.platformio_libraries: dict[str, Library] = {}
|
self.platformio_libraries: dict[str, Library] = {}
|
||||||
# A set of build flags to set in the platformio project
|
# A set of build flags to set in the platformio project
|
||||||
@@ -716,6 +718,7 @@ class EsphomeCore:
|
|||||||
self.variables = {}
|
self.variables = {}
|
||||||
self.main_statements = []
|
self.main_statements = []
|
||||||
self.global_statements = []
|
self.global_statements = []
|
||||||
|
self.global_statements_after_includes = []
|
||||||
self.platformio_libraries = {}
|
self.platformio_libraries = {}
|
||||||
self.build_flags = set()
|
self.build_flags = set()
|
||||||
self.cmake_args = {}
|
self.cmake_args = {}
|
||||||
@@ -1032,8 +1035,8 @@ class EsphomeCore:
|
|||||||
except RuntimeError as e:
|
except RuntimeError as e:
|
||||||
raise EsphomeError(str(e)) from e
|
raise EsphomeError(str(e)) from e
|
||||||
|
|
||||||
def add(self, expression, prepend=False) -> "Statement":
|
@staticmethod
|
||||||
"""Add an expression or statement to the main setup() block."""
|
def _as_statement(expression) -> "Statement":
|
||||||
from esphome.cpp_generator import Expression, Statement, statement
|
from esphome.cpp_generator import Expression, Statement, statement
|
||||||
|
|
||||||
if isinstance(expression, Expression):
|
if isinstance(expression, Expression):
|
||||||
@@ -1042,7 +1045,11 @@ class EsphomeCore:
|
|||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Add '{expression}' must be expression or statement, not {type(expression)}"
|
f"Add '{expression}' must be expression or statement, not {type(expression)}"
|
||||||
)
|
)
|
||||||
|
return expression
|
||||||
|
|
||||||
|
def add(self, expression, prepend=False) -> "Statement":
|
||||||
|
"""Add an expression or statement to the main setup() block."""
|
||||||
|
expression = self._as_statement(expression)
|
||||||
if prepend:
|
if prepend:
|
||||||
self.main_statements.insert(0, expression)
|
self.main_statements.insert(0, expression)
|
||||||
else:
|
else:
|
||||||
@@ -1051,14 +1058,7 @@ class EsphomeCore:
|
|||||||
return expression
|
return expression
|
||||||
|
|
||||||
def add_global(self, expression, prepend=False) -> "Statement":
|
def add_global(self, expression, prepend=False) -> "Statement":
|
||||||
from esphome.cpp_generator import Expression, Statement, statement
|
expression = self._as_statement(expression)
|
||||||
|
|
||||||
if isinstance(expression, Expression):
|
|
||||||
expression = statement(expression)
|
|
||||||
if not isinstance(expression, Statement):
|
|
||||||
raise ValueError(
|
|
||||||
f"Add '{expression}' must be expression or statement, not {type(expression)}"
|
|
||||||
)
|
|
||||||
if prepend:
|
if prepend:
|
||||||
self.global_statements.insert(0, expression)
|
self.global_statements.insert(0, expression)
|
||||||
else:
|
else:
|
||||||
@@ -1066,6 +1066,13 @@ class EsphomeCore:
|
|||||||
_LOGGER.debug("Adding global: %s", expression)
|
_LOGGER.debug("Adding global: %s", expression)
|
||||||
return expression
|
return expression
|
||||||
|
|
||||||
|
def add_global_after_includes(self, expression) -> "Statement":
|
||||||
|
"""Add a global after the user includes, for generated code that may use them."""
|
||||||
|
expression = self._as_statement(expression)
|
||||||
|
self.global_statements_after_includes.append(expression)
|
||||||
|
_LOGGER.debug("Adding global after includes: %s", expression)
|
||||||
|
return expression
|
||||||
|
|
||||||
def add_library(self, library: Library):
|
def add_library(self, library: Library):
|
||||||
if not isinstance(library, Library):
|
if not isinstance(library, Library):
|
||||||
raise TypeError(
|
raise TypeError(
|
||||||
@@ -1245,7 +1252,7 @@ class EsphomeCore:
|
|||||||
from esphome.cpp_generator import statement
|
from esphome.cpp_generator import statement
|
||||||
|
|
||||||
global_code = []
|
global_code = []
|
||||||
for exp in self.global_statements:
|
for exp in (*self.global_statements, *self.global_statements_after_includes):
|
||||||
text = str(statement(exp))
|
text = str(statement(exp))
|
||||||
text = text.rstrip()
|
text = text.rstrip()
|
||||||
global_code.append(text)
|
global_code.append(text)
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ struct Color {
|
|||||||
uint8_t r = rand >> 16;
|
uint8_t r = rand >> 16;
|
||||||
uint8_t g = rand >> 8;
|
uint8_t g = rand >> 8;
|
||||||
uint8_t b = rand >> 0;
|
uint8_t b = rand >> 0;
|
||||||
const uint16_t max_rgb = std::max({r, g, b});
|
const uint16_t max_rgb = std::max({r, g, b, uint8_t(1)});
|
||||||
return Color(uint8_t((uint16_t(r) * 255U / max_rgb)), uint8_t((uint16_t(g) * 255U / max_rgb)),
|
return Color(uint8_t((uint16_t(r) * 255U / max_rgb)), uint8_t((uint16_t(g) * 255U / max_rgb)),
|
||||||
uint8_t((uint16_t(b) * 255U / max_rgb)), w);
|
uint8_t((uint16_t(b) * 255U / max_rgb)), w);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -577,7 +577,8 @@ async def add_arduino_global_workaround():
|
|||||||
|
|
||||||
@coroutine_with_priority(CoroPriority.FINAL)
|
@coroutine_with_priority(CoroPriority.FINAL)
|
||||||
async def add_includes(includes: list[str], is_c_header: bool = False) -> None:
|
async def add_includes(includes: list[str], is_c_header: bool = False) -> None:
|
||||||
# Add includes at the very end, so that the included files can access global variables
|
# After the id declarations, so the headers can use id(x); the generated function bodies
|
||||||
|
# and flash tables come later still, so their lambdas can use the headers.
|
||||||
for include in includes:
|
for include in includes:
|
||||||
path = CORE.relative_config_path(include)
|
path = CORE.relative_config_path(include)
|
||||||
if path.is_dir():
|
if path.is_dir():
|
||||||
|
|||||||
+20
-10
@@ -318,6 +318,8 @@ class FloatLiteral(Literal):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
if math.isnan(self.f):
|
if math.isnan(self.f):
|
||||||
return "NAN"
|
return "NAN"
|
||||||
|
if math.isinf(self.f):
|
||||||
|
return "INFINITY" if self.f > 0 else "-INFINITY"
|
||||||
return f"{self.f}f"
|
return f"{self.f}f"
|
||||||
|
|
||||||
|
|
||||||
@@ -506,8 +508,8 @@ def shared_progmem_array(
|
|||||||
used |= {str(i) for i in CORE.variables}
|
used |= {str(i) for i in CORE.variables}
|
||||||
used |= set(RESERVED_IDS) | CORE.loaded_integrations
|
used |= set(RESERVED_IDS) | CORE.loaded_integrations
|
||||||
id_ = ID(ensure_unique_string(name, used), is_declaration=True, type=type_)
|
id_ = ID(ensure_unique_string(name, used), is_declaration=True, type=type_)
|
||||||
# Global, so any scope can use it; anything a lambda references is already declared.
|
# After the user includes, so a lambda in the table can use what their headers declare.
|
||||||
CORE.add_global(ProgmemAssignmentExpression(type_, id_, rhs))
|
CORE.add_global_after_includes(ProgmemAssignmentExpression(type_, id_, rhs))
|
||||||
array = MockObj(id_, ".")
|
array = MockObj(id_, ".")
|
||||||
CORE.register_variable(id_, array)
|
CORE.register_variable(id_, array)
|
||||||
if share:
|
if share:
|
||||||
@@ -731,15 +733,16 @@ def static_function(
|
|||||||
"""Emit ``static <return_type> <name>(parameters) { body }`` at global scope and return an
|
"""Emit ``static <return_type> <name>(parameters) { body }`` at global scope and return an
|
||||||
expression naming it, for use as a template argument or a function pointer.
|
expression naming it, for use as a template argument or a function pointer.
|
||||||
|
|
||||||
Every id the body names must already be declared, which holds when the statements were
|
The declaration goes with the other globals, so a template argument can name the function
|
||||||
rendered through ``get_variable`` or ``process_lambda``.
|
at once; the body goes after the user includes, where every id and everything those
|
||||||
|
headers declare is in scope.
|
||||||
"""
|
"""
|
||||||
params = ParameterListExpression(*parameters)
|
signature = (
|
||||||
add_global(
|
f"static {safe_exp(return_type)} {name}({ParameterListExpression(*parameters)})"
|
||||||
RawStatement(
|
)
|
||||||
f"static {safe_exp(return_type)} {name}({params}) {{\n"
|
add_global(RawStatement(f"{signature};"))
|
||||||
f"{indent(chr(10).join(body))}\n}}"
|
add_global_after_includes(
|
||||||
)
|
RawStatement(f"{signature} {{\n{indent(chr(10).join(body))}\n}}")
|
||||||
)
|
)
|
||||||
return RawExpression(name)
|
return RawExpression(name)
|
||||||
|
|
||||||
@@ -758,6 +761,13 @@ def add_global(expression: SafeExpType | Statement, prepend: bool = False):
|
|||||||
CORE.add_global(expression, prepend)
|
CORE.add_global(expression, prepend)
|
||||||
|
|
||||||
|
|
||||||
|
def add_global_after_includes(expression: SafeExpType | Statement):
|
||||||
|
"""Add a global after the user's ``includes:`` headers, for generated code whose
|
||||||
|
lambdas may use what those headers declare.
|
||||||
|
"""
|
||||||
|
CORE.add_global_after_includes(expression)
|
||||||
|
|
||||||
|
|
||||||
def add_library(name: str, version: str | None, repository: str | None = None):
|
def add_library(name: str, version: str | None, repository: str | None = None):
|
||||||
"""Add a library to the codegen library storage.
|
"""Add a library to the codegen library storage.
|
||||||
|
|
||||||
|
|||||||
@@ -266,14 +266,24 @@ def _convert_pio_libs(
|
|||||||
in the real esp32 build; converting them too would give IDF the same requirement
|
in the real esp32 build; converting them too would give IDF the same requirement
|
||||||
twice. Arduino below IDF 6.0 has them rule-gated off, so there they convert.
|
twice. Arduino below IDF 6.0 has them rule-gated off, so there they convert.
|
||||||
"""
|
"""
|
||||||
from esphome.components.esp32 import arduino_bundles_libsodium
|
from esphome.components.esp32 import (
|
||||||
|
ARDUINO_DISABLED_LIBRARIES,
|
||||||
|
arduino_bundles_libsodium,
|
||||||
|
)
|
||||||
from esphome.espidf.component import generate_idf_components
|
from esphome.espidf.component import generate_idf_components
|
||||||
|
|
||||||
libraries = _parse_lib_deps(platformio_ini, framework)
|
libraries = _parse_lib_deps(platformio_ini, framework)
|
||||||
# Same rule as noise._use_managed_components and the manifest
|
# Same rule as noise._use_managed_components and the manifest
|
||||||
managed = set() if arduino_bundles_libsodium() else _esphome_manifest_deps()
|
managed = set() if arduino_bundles_libsodium() else _esphome_manifest_deps()
|
||||||
deps: dict[str, dict[str, str]] = {}
|
deps: dict[str, dict[str, str]] = {}
|
||||||
for component in generate_idf_components(libraries, managed=managed):
|
# As in the real esp32 build, a dependency on an Arduino-bundled library is
|
||||||
|
# left to arduino-esp32 instead of being fetched from the registry
|
||||||
|
provides = (
|
||||||
|
ARDUINO_DISABLED_LIBRARIES.__contains__ if framework == "arduino" else None
|
||||||
|
)
|
||||||
|
for component in generate_idf_components(
|
||||||
|
libraries, managed=managed, provides=provides
|
||||||
|
):
|
||||||
deps[component.get_sanitized_name()] = {"override_path": str(component.path)}
|
deps[component.get_sanitized_name()] = {"override_path": str(component.path)}
|
||||||
return deps
|
return deps
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ for each resolved library, running any PlatformIO ``extraScript``, and the
|
|||||||
ESP-IDF platform/framework compatibility defaults.
|
ESP-IDF platform/framework compatibility defaults.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -288,18 +289,23 @@ def _emit_idf_component(component: IDFComponent) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def generate_idf_components(
|
def generate_idf_components(
|
||||||
libraries: list[Library], managed: set[str] | None = None
|
libraries: list[Library],
|
||||||
|
managed: set[str] | None = None,
|
||||||
|
provides: Callable[[str], bool] | None = None,
|
||||||
) -> list[IDFComponent]:
|
) -> list[IDFComponent]:
|
||||||
"""Resolve and convert a batch of PlatformIO libraries to IDF components.
|
"""Resolve and convert a batch of PlatformIO libraries to IDF components.
|
||||||
|
|
||||||
``managed`` names registry components already in the project manifest; they
|
``managed`` names registry components already in the project manifest; they
|
||||||
are not converted, since IDF rejects the same requirement from two components.
|
are not converted, since IDF rejects the same requirement from two components.
|
||||||
Converted components reach them through ``${ESPHOME_PROJECT_MANAGED_COMPONENTS}``.
|
Converted components reach them through ``${ESPHOME_PROJECT_MANAGED_COMPONENTS}``.
|
||||||
|
``provides`` names the libraries the framework bundles, so a manifest dependency on
|
||||||
|
one is never fetched from the registry.
|
||||||
"""
|
"""
|
||||||
backend = LibraryBackend(
|
backend = LibraryBackend(
|
||||||
platform=ESP32_PLATFORM,
|
platform=ESP32_PLATFORM,
|
||||||
framework=_idf_framework(),
|
framework=_idf_framework(),
|
||||||
emit=_emit_idf_component,
|
emit=_emit_idf_component,
|
||||||
cache_key="idf",
|
cache_key="idf",
|
||||||
|
provides=provides,
|
||||||
)
|
)
|
||||||
return convert_libraries(libraries, backend, provided=managed)
|
return convert_libraries(libraries, backend, provided=managed)
|
||||||
@@ -205,28 +205,42 @@ def run_command(
|
|||||||
return False, None, None
|
return False, None, None
|
||||||
|
|
||||||
|
|
||||||
def tool_version_runs(binary: str, warning: str) -> bool:
|
def tool_version_probe(binary: str) -> str | Exception:
|
||||||
"""Probe ``binary --version``; on failure warn with ``warning`` % binary.
|
"""Run ``binary --version``: its stdout, or the error when it fails to run.
|
||||||
|
|
||||||
``shutil.which`` proves existence, not runnability (Windows .bat/.cmd
|
``shutil.which`` proves existence, not runnability (Windows .bat/.cmd
|
||||||
shims, stale package-manager shims).
|
shims, stale package-manager shims).
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
subprocess.run(
|
result = subprocess.run(
|
||||||
[binary, "--version"],
|
[binary, "--version"],
|
||||||
check=True,
|
check=True,
|
||||||
stdout=subprocess.DEVNULL,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.DEVNULL,
|
stderr=subprocess.DEVNULL,
|
||||||
|
text=True,
|
||||||
|
errors="replace",
|
||||||
timeout=15,
|
timeout=15,
|
||||||
# Repo-wide convention (posix_spawn fast path)
|
# Repo-wide convention (posix_spawn fast path)
|
||||||
close_fds=False,
|
close_fds=False,
|
||||||
)
|
)
|
||||||
except (OSError, subprocess.SubprocessError) as err:
|
except (OSError, subprocess.SubprocessError) as err:
|
||||||
# The cause (permission denied, missing DLL, timeout) is the one
|
return err
|
||||||
# detail the user needs to fix it
|
return result.stdout
|
||||||
_LOGGER.warning("%s (%s)", warning % binary, err)
|
|
||||||
return False
|
|
||||||
return True
|
def tool_version_runs(
|
||||||
|
binary: str,
|
||||||
|
warning: str,
|
||||||
|
probe: Callable[[str], str | Exception] = tool_version_probe,
|
||||||
|
) -> bool:
|
||||||
|
"""Probe ``binary --version``; on failure warn with ``warning`` % binary.
|
||||||
|
``probe`` lets a caller supply a cached probe."""
|
||||||
|
if not isinstance(result := probe(binary), Exception):
|
||||||
|
return True
|
||||||
|
# The cause (permission denied, missing DLL, timeout) is the one detail
|
||||||
|
# the user needs to fix it
|
||||||
|
_LOGGER.warning("%s (%s)", warning % binary, result)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def run_command_ok(*args, **kwargs) -> bool:
|
def run_command_ok(*args, **kwargs) -> bool:
|
||||||
|
|||||||
+251
-101
@@ -609,9 +609,34 @@ def parse_library_properties(library_properties_path: PathType):
|
|||||||
if not value.strip():
|
if not value.strip():
|
||||||
continue
|
continue
|
||||||
data[key.strip()] = value.strip()
|
data[key.strip()] = value.strip()
|
||||||
|
if depends := data.pop("depends", None):
|
||||||
|
data["dependencies"] = parse_properties_depends(depends)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def parse_properties_depends(depends: str) -> list[dict[str, str]]:
|
||||||
|
"""The Arduino ``depends=Name, Other (>=1.0)`` line as name-only dependency entries.
|
||||||
|
|
||||||
|
An Arduino constraint is not PlatformIO syntax, so it is dropped; the name is
|
||||||
|
satisfied by a listed library or the registry's current version, as the walk
|
||||||
|
does for every version-less dependency.
|
||||||
|
"""
|
||||||
|
entries: list[dict[str, str]] = []
|
||||||
|
for item in depends.split(","):
|
||||||
|
name, _, constraint = item.strip().partition("(")
|
||||||
|
if not (name := name.strip()):
|
||||||
|
continue
|
||||||
|
if constraint:
|
||||||
|
# A plain lower bound is met by the registry's current version; an
|
||||||
|
# exact or capped one may not be, so say so
|
||||||
|
log = (
|
||||||
|
_LOGGER.debug if constraint.lstrip().startswith(">=") else _LOGGER.info
|
||||||
|
)
|
||||||
|
log("Ignoring version constraint on depends=%s", item.strip())
|
||||||
|
entries.append({"name": name})
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
def _make_registry_client() -> Any:
|
def _make_registry_client() -> Any:
|
||||||
"""Create a minimal PlatformIO registry client with no system filtering.
|
"""Create a minimal PlatformIO registry client with no system filtering.
|
||||||
|
|
||||||
@@ -730,20 +755,6 @@ def join_flag_args(tokens: Iterable[str], owner: str) -> list[str]:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def warn_properties_depends(name: str, data: object) -> None:
|
|
||||||
"""Warn for ``depends=``-only manifests; the walk reads only the JSON
|
|
||||||
``dependencies`` key, so they would otherwise drop silently."""
|
|
||||||
if isinstance(data, dict) and not data.get("dependencies") and data.get("depends"):
|
|
||||||
# INFO: common and unactionable for transitive libraries; a WARNING
|
|
||||||
# on every build would train users to ignore the stream
|
|
||||||
_LOGGER.info(
|
|
||||||
"Library %s declares dependencies via library.properties "
|
|
||||||
"depends=, which are not resolved automatically; add them with "
|
|
||||||
"add_library() if needed",
|
|
||||||
name,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def dependency_is_usable(
|
def dependency_is_usable(
|
||||||
dep: dict, platform: str | None, framework: str, requester: str
|
dep: dict, platform: str | None, framework: str, requester: str
|
||||||
) -> bool:
|
) -> bool:
|
||||||
@@ -762,6 +773,20 @@ def dependency_is_usable(
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _split_owner(entry: dict) -> dict:
|
||||||
|
"""``owner/Name`` written as one name becomes owner and name, as in the dict form,
|
||||||
|
so the name matches the package the registry resolves it to."""
|
||||||
|
name, owner = entry.get("name"), entry.get("owner")
|
||||||
|
if isinstance(name, str) and owner is None and "://" not in name:
|
||||||
|
head, sep, tail = name.partition("/")
|
||||||
|
# A registry owner is a plain identifier; anything else is left to the
|
||||||
|
# backend's own checks
|
||||||
|
owner_ok = head.replace("-", "").replace("_", "").isalnum()
|
||||||
|
if sep and owner_ok and tail and "/" not in tail:
|
||||||
|
return {**entry, "owner": head, "name": tail}
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
def _valid_dependency_entry(entry: dict, manifest_name: str) -> bool:
|
def _valid_dependency_entry(entry: dict, manifest_name: str) -> bool:
|
||||||
"""Whether a normalized entry carries a usable name (non-empty string),
|
"""Whether a normalized entry carries a usable name (non-empty string),
|
||||||
version (string, if present), and owner (string, if present); invalid
|
version (string, if present), and owner (string, if present); invalid
|
||||||
@@ -790,16 +815,12 @@ def normalize_dependencies(
|
|||||||
so callers see a uniform list. ``manifest_name`` names the manifest in the
|
so callers see a uniform list. ``manifest_name`` names the manifest in the
|
||||||
warning for entries that cannot be normalized.
|
warning for entries that cannot be normalized.
|
||||||
|
|
||||||
Bare-name spellings carry no version; the dependency walk later drops
|
Bare-name spellings carry no version; the walk settles them once the graph is known.
|
||||||
version-less entries at DEBUG, since they are usually names of bundled
|
|
||||||
framework libraries (Wire, SPI) that need no registry install.
|
|
||||||
"""
|
"""
|
||||||
if dependencies is None:
|
if dependencies is None:
|
||||||
return []
|
return []
|
||||||
if isinstance(dependencies, str):
|
if isinstance(dependencies, str):
|
||||||
# A plain string is one or more comma-separated names; iterating it
|
return [_split_owner(entry) for entry in parse_properties_depends(dependencies)]
|
||||||
# as a list would shred it into one-character "libraries"
|
|
||||||
return [{"name": n.strip()} for n in dependencies.split(",") if n.strip()]
|
|
||||||
if isinstance(dependencies, dict):
|
if isinstance(dependencies, dict):
|
||||||
normalized = []
|
normalized = []
|
||||||
for raw_name, spec in dependencies.items():
|
for raw_name, spec in dependencies.items():
|
||||||
@@ -826,10 +847,10 @@ def normalize_dependencies(
|
|||||||
for entry in dependencies:
|
for entry in dependencies:
|
||||||
if isinstance(entry, dict):
|
if isinstance(entry, dict):
|
||||||
if _valid_dependency_entry(entry, manifest_name):
|
if _valid_dependency_entry(entry, manifest_name):
|
||||||
normalized.append(entry)
|
normalized.append(_split_owner(entry))
|
||||||
elif isinstance(entry, str) and entry:
|
elif isinstance(entry, str) and entry:
|
||||||
# PIO also accepts a bare list of names ("dependencies": ["Wire"])
|
# PIO also accepts a bare list of names ("dependencies": ["Wire"])
|
||||||
normalized.append({"name": entry})
|
normalized.append(_split_owner({"name": entry}))
|
||||||
else:
|
else:
|
||||||
_LOGGER.warning(
|
_LOGGER.warning(
|
||||||
"Ignoring unrecognized dependency entry %r of %s",
|
"Ignoring unrecognized dependency entry %r of %s",
|
||||||
@@ -971,54 +992,61 @@ def is_lib_ignored(name: str | None, lib_ignore: set[str]) -> bool:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _reconcile_versionless_skips(
|
def _short_name(node: _LibNode) -> str:
|
||||||
skipped_versionless: list[tuple[Any, Any, str]],
|
"""The package name a node goes by: the registry name, else the last segment
|
||||||
|
of its key (the repository or directory name of a git or local source)."""
|
||||||
|
return node.pkgname or node.key.rpartition("/")[2]
|
||||||
|
|
||||||
|
|
||||||
|
def _satisfying_key(
|
||||||
|
name: str,
|
||||||
|
owner: str | None,
|
||||||
|
requester: str,
|
||||||
|
nodes: dict[str, _LibNode],
|
||||||
components: dict[str, ConvertedLibrary],
|
components: dict[str, ConvertedLibrary],
|
||||||
backend: LibraryBackend,
|
) -> tuple[str, bool] | None:
|
||||||
) -> None:
|
"""The key of a resolved library a version-less dependency names, and whether
|
||||||
"""Warn for version-less deps nothing satisfied, and record the
|
only its manifest name matched: the registry package name (with the owner,
|
||||||
backend-provided ones in ``backend.provided_requests`` for its
|
when the dependency gives one) is checked first; never the requester itself."""
|
||||||
post-emit reconciliation; a silent drop surfaces as link errors far
|
wanted = name.lower()
|
||||||
from the cause."""
|
|
||||||
resolved_manifest_names = {c.data.get("name") for c in components.values()}
|
def owner_of(key: str, component: ConvertedLibrary) -> str:
|
||||||
# A treeless backend can never supply a bundled name; noise for it
|
# The owner as requested, else the one the registry (or a git path) gave
|
||||||
log = _LOGGER.warning if backend.provides is not None else _LOGGER.debug
|
head, sep, _ = component.name.partition("/")
|
||||||
warned: set[str] = set()
|
return nodes[key].owner or (head if sep else "")
|
||||||
for dep_name, dep_owner, requester in skipped_versionless:
|
|
||||||
if not isinstance(dep_name, str) or not dep_name or dep_name in warned:
|
candidates = [
|
||||||
continue
|
(key, component)
|
||||||
if dep_name in components:
|
for key, component in components.items()
|
||||||
# A version-less dep's request key is the name itself
|
if key != requester
|
||||||
continue
|
and (not owner or owner_of(key, component).lower() == owner.lower())
|
||||||
if (
|
]
|
||||||
not dep_owner
|
for key, _ in candidates:
|
||||||
and backend.provides is not None
|
if _short_name(nodes[key]).lower() == wanted:
|
||||||
and backend.provides(dep_name)
|
return key, False
|
||||||
):
|
if owner:
|
||||||
# provides() only satisfies owner-less names (same guard as
|
return None
|
||||||
# the walk's skip); record for the post-emit reconciliation.
|
for key, component in candidates:
|
||||||
# Checked before the manifest-name evidence so the overlap
|
manifest_name = component.data.get("name")
|
||||||
# case warns once, in the backend's own suppression loop
|
if isinstance(manifest_name, str) and manifest_name.lower() == wanted:
|
||||||
backend.provided_requests.add(dep_name)
|
return key, True
|
||||||
continue
|
return None
|
||||||
if dep_name in resolved_manifest_names:
|
|
||||||
# Name-only evidence: a coincidental collision must stay
|
|
||||||
# visible where the user could pin it
|
def _own_names(
|
||||||
warned.add(dep_name)
|
key: str, nodes: dict[str, _LibNode], components: dict[str, ConvertedLibrary]
|
||||||
log(
|
) -> set[str]:
|
||||||
"Version-less dependency %s of %s assumed satisfied by a "
|
"""The lower-case key, requested and resolved package names and the manifest
|
||||||
"resolved library's manifest name only",
|
name a resolved library goes by."""
|
||||||
dep_name,
|
names = {
|
||||||
requester,
|
key.lower(),
|
||||||
)
|
_short_name(nodes[key]).lower(),
|
||||||
continue
|
components[key].name.lower(),
|
||||||
warned.add(dep_name)
|
}
|
||||||
log(
|
manifest_name = components[key].data.get("name")
|
||||||
"Dependency %s of %s has no version to resolve and nothing "
|
if isinstance(manifest_name, str):
|
||||||
"provides it; skipping",
|
names.add(manifest_name.lower())
|
||||||
dep_name,
|
return names
|
||||||
requester,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _fetch_source(
|
def _fetch_source(
|
||||||
@@ -1147,7 +1175,12 @@ def convert_libraries(
|
|||||||
managed components from ``add_idf_component``); unlike ``backend.provides`` they
|
managed components from ``add_idf_component``); unlike ``backend.provides`` they
|
||||||
carry an owner. They are excluded like ``lib_ignore`` so nothing is both
|
carry an owner. They are excluded like ``lib_ignore`` so nothing is both
|
||||||
converted and managed, which ESP-IDF refuses to build.
|
converted and managed, which ESP-IDF refuses to build.
|
||||||
|
|
||||||
|
A dependency named without a version is settled once the graph is known: wired
|
||||||
|
to a library already in it, left to ``backend.provides``, or fetched by name.
|
||||||
"""
|
"""
|
||||||
|
from platformio.package.exception import UnknownPackageError
|
||||||
|
|
||||||
nodes: dict[str, _LibNode] = {}
|
nodes: dict[str, _LibNode] = {}
|
||||||
|
|
||||||
# Folded into one set so every is_lib_ignored() call site honors both.
|
# Folded into one set so every is_lib_ignored() call site honors both.
|
||||||
@@ -1165,10 +1198,16 @@ def convert_libraries(
|
|||||||
else ""
|
else ""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
resolved_requirements: dict[str, frozenset[str]] = {}
|
||||||
|
|
||||||
def add_spec(name: str | None, version: str | None, repository: str | None) -> str:
|
def add_spec(name: str | None, version: str | None, repository: str | None) -> str:
|
||||||
key, kind, locator = _node_key(name, version, repository)
|
key, kind, locator = _node_key(name, version, repository)
|
||||||
node = nodes.get(key) or _LibNode(key=key, is_git=kind == "git")
|
node = nodes.get(key) or _LibNode(key=key, is_git=kind == "git")
|
||||||
nodes[key] = node
|
nodes[key] = node
|
||||||
|
if kind != "registry" and not (node.is_git or node.is_local):
|
||||||
|
# A registry node (perhaps one the registry did not know) becomes a
|
||||||
|
# git or local source: resolve it again from that source
|
||||||
|
resolved_requirements.pop(key, None)
|
||||||
# The same key requested from two different kinds of source (or two
|
# The same key requested from two different kinds of source (or two
|
||||||
# different local paths) is a config mistake: one silently wins. Warn so
|
# different local paths) is a config mistake: one silently wins. Warn so
|
||||||
# it isn't a surprise. (git-vs-registry is reported separately below.)
|
# it isn't a surprise. (git-vs-registry is reported separately below.)
|
||||||
@@ -1220,10 +1259,73 @@ def convert_libraries(
|
|||||||
# requirement set has grown since the last time, so every requirement in the
|
# requirement set has grown since the last time, so every requirement in the
|
||||||
# graph is accounted for before conversion.
|
# graph is accounted for before conversion.
|
||||||
components: dict[str, ConvertedLibrary] = {}
|
components: dict[str, ConvertedLibrary] = {}
|
||||||
resolved_requirements: dict[str, frozenset[str]] = {}
|
|
||||||
top_level_keys = set(top_level)
|
top_level_keys = set(top_level)
|
||||||
# (name, owner, requester) reconciled against the final resolution set
|
|
||||||
skipped_versionless: list[tuple[Any, Any, str]] = []
|
def fetched_by_name(key: str) -> bool:
|
||||||
|
"""A registry node only version-less manifest dependencies asked for: a
|
||||||
|
miss skips it, and an explicit library of the same name replaces it."""
|
||||||
|
node = nodes[key]
|
||||||
|
return (
|
||||||
|
key not in top_level_keys
|
||||||
|
and not (node.is_git or node.is_local)
|
||||||
|
and not node.requirements
|
||||||
|
)
|
||||||
|
|
||||||
|
def backend_provides(name: str, owner: str | None) -> bool:
|
||||||
|
"""Whether the framework bundles an owner-less name; asked once per name,
|
||||||
|
since the backend may warn, and recorded in ``provided_requests``."""
|
||||||
|
if owner or backend.provides is None:
|
||||||
|
return False
|
||||||
|
if name in backend.provided_requests or backend.provides(name):
|
||||||
|
backend.provided_requests.add(name)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Version-less dependencies per requester key, kept for the whole walk and
|
||||||
|
# wired once the graph is final; a re-walked requester replaces its entries
|
||||||
|
pending_versionless: dict[str, list[dict]] = {}
|
||||||
|
# Names fetched by name so far: each is asked for once
|
||||||
|
fetched: dict[tuple[str, str], str] = {}
|
||||||
|
|
||||||
|
def spelling_of(name: str, owner: str | None) -> tuple[str, str]:
|
||||||
|
"""Registry names fold case; a URL names its source as written."""
|
||||||
|
return (name if "://" in name else name.lower(), (owner or "").lower())
|
||||||
|
|
||||||
|
def settle_versionless() -> list[str]:
|
||||||
|
"""Fetch by name the deferred dependencies nothing in the graph or the
|
||||||
|
framework satisfies yet; return the new keys to walk. Only a dependency
|
||||||
|
on the requester itself or with a bad name needs no wiring later."""
|
||||||
|
new_keys: list[str] = []
|
||||||
|
for requester, dependencies in pending_versionless.items():
|
||||||
|
kept: list[dict] = []
|
||||||
|
for dependency in dependencies:
|
||||||
|
name, owner = dependency["name"], dependency.get("owner")
|
||||||
|
if _owner_pkgname_to_name(owner, name).lower() in _own_names(
|
||||||
|
requester, nodes, components
|
||||||
|
):
|
||||||
|
_LOGGER.debug("Dependency %s of %s names itself", name, requester)
|
||||||
|
continue
|
||||||
|
# A library in the graph beats a bundled copy, as on the Arduino backend
|
||||||
|
if _satisfying_key(
|
||||||
|
name, owner, requester, nodes, components
|
||||||
|
) is None and not backend_provides(name, owner):
|
||||||
|
spelling = spelling_of(name, owner)
|
||||||
|
if spelling not in fetched:
|
||||||
|
try:
|
||||||
|
dep_key = add_spec(
|
||||||
|
_owner_pkgname_to_name(owner, name), None, None
|
||||||
|
)
|
||||||
|
except RuntimeError as e:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Skipping dependency %s of %s: %s", name, requester, e
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
fetched[spelling] = dep_key
|
||||||
|
new_keys.append(dep_key)
|
||||||
|
kept.append(dependency)
|
||||||
|
dependencies[:] = kept
|
||||||
|
return new_keys
|
||||||
|
|
||||||
worklist = deque(dict.fromkeys(top_level))
|
worklist = deque(dict.fromkeys(top_level))
|
||||||
while worklist:
|
while worklist:
|
||||||
# Drain the frontier sequentially (spec resolution mutates shared
|
# Drain the frontier sequentially (spec resolution mutates shared
|
||||||
@@ -1245,9 +1347,17 @@ def convert_libraries(
|
|||||||
elif node.is_local:
|
elif node.is_local:
|
||||||
component = ConvertedLibrary(key, "*", LocalSource(node.local_path))
|
component = ConvertedLibrary(key, "*", LocalSource(node.local_path))
|
||||||
else:
|
else:
|
||||||
owner, name, version, url, size = _resolve_registry_version(
|
try:
|
||||||
node.owner, node.pkgname, node.requirements
|
owner, name, version, url, size = _resolve_registry_version(
|
||||||
)
|
node.owner, node.pkgname, node.requirements
|
||||||
|
)
|
||||||
|
except UnknownPackageError as e:
|
||||||
|
# A requirement conflict is a RuntimeError and always raises
|
||||||
|
if not fetched_by_name(key):
|
||||||
|
raise
|
||||||
|
# Reported per requester once the graph is final
|
||||||
|
_LOGGER.debug("Library %s is not in the registry: %s", key, e)
|
||||||
|
continue
|
||||||
component = ConvertedLibrary(
|
component = ConvertedLibrary(
|
||||||
_owner_pkgname_to_name(owner, name), version, URLSource(url, size)
|
_owner_pkgname_to_name(owner, name), version, URLSource(url, size)
|
||||||
)
|
)
|
||||||
@@ -1255,9 +1365,9 @@ def convert_libraries(
|
|||||||
_prefetch_wave(wave, salt, backend.cache_key)
|
_prefetch_wave(wave, salt, backend.cache_key)
|
||||||
for key, component in wave:
|
for key, component in wave:
|
||||||
node = nodes[key]
|
node = nodes[key]
|
||||||
if frozenset(node.requirements) != resolved_requirements[key]:
|
if frozenset(node.requirements) != resolved_requirements.get(key):
|
||||||
# Requirements grew mid-wave: skip parsing a manifest the
|
# Requirements grew (or the source changed) mid-wave: skip parsing
|
||||||
# next wave will re-resolve and replace
|
# a manifest the next wave will re-resolve and replace
|
||||||
worklist.append(key)
|
worklist.append(key)
|
||||||
continue
|
continue
|
||||||
component.download(salt=salt, namespace=backend.cache_key)
|
component.download(salt=salt, namespace=backend.cache_key)
|
||||||
@@ -1313,8 +1423,6 @@ def convert_libraries(
|
|||||||
raise EsphomeError(f"Library {key} has a malformed manifest")
|
raise EsphomeError(f"Library {key} has a malformed manifest")
|
||||||
_LOGGER.warning("Skipping dependency %s: malformed manifest", key)
|
_LOGGER.warning("Skipping dependency %s: malformed manifest", key)
|
||||||
continue
|
continue
|
||||||
warn_properties_depends(component.name, component.data)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
check_library_data(component.data, backend.platform, backend.framework)
|
check_library_data(component.data, backend.platform, backend.framework)
|
||||||
except InvalidLibrary as e:
|
except InvalidLibrary as e:
|
||||||
@@ -1337,27 +1445,19 @@ def convert_libraries(
|
|||||||
# Requirements changed (we got past the short-circuit above), so
|
# Requirements changed (we got past the short-circuit above), so
|
||||||
# (re)walk this component's dependencies.
|
# (re)walk this component's dependencies.
|
||||||
node.edges = set()
|
node.edges = set()
|
||||||
|
pending = pending_versionless[key] = []
|
||||||
for dependency in normalize_dependencies(
|
for dependency in normalize_dependencies(
|
||||||
component.data.get("dependencies"), component.name
|
component.data.get("dependencies"), component.name
|
||||||
):
|
):
|
||||||
if "version" not in dependency:
|
if not dependency.get("version"):
|
||||||
# Cannot resolve from the registry; the post-emit
|
# An empty version is as good as none; it settles the same way
|
||||||
# reconciliation owns the drop warning
|
|
||||||
dep_name = dependency.get("name")
|
|
||||||
_LOGGER.debug(
|
|
||||||
"Skip version-less dependency %r of %s",
|
|
||||||
dep_name,
|
|
||||||
component.name,
|
|
||||||
)
|
|
||||||
if not is_lib_ignored(
|
if not is_lib_ignored(
|
||||||
dep_name, lib_ignore
|
dependency.get("name"), lib_ignore
|
||||||
) and dependency_is_usable(
|
) and dependency_is_usable(
|
||||||
dependency, backend.platform, backend.framework, component.name
|
dependency, backend.platform, backend.framework, component.name
|
||||||
):
|
):
|
||||||
# Filtered or ignored deps are deliberately absent
|
# Filtered or ignored deps are deliberately absent
|
||||||
skipped_versionless.append(
|
pending.append(dependency)
|
||||||
(dep_name, dependency.get("owner"), component.name)
|
|
||||||
)
|
|
||||||
continue
|
continue
|
||||||
if not dependency_is_usable(
|
if not dependency_is_usable(
|
||||||
dependency, backend.platform, backend.framework, component.name
|
dependency, backend.platform, backend.framework, component.name
|
||||||
@@ -1375,11 +1475,7 @@ def convert_libraries(
|
|||||||
dep_url = _url_or_none(dep_version)
|
dep_url = _url_or_none(dep_version)
|
||||||
if dep_url is not None:
|
if dep_url is not None:
|
||||||
dep_version = None
|
dep_version = None
|
||||||
elif (
|
elif backend_provides(dep_name, dependency.get("owner")):
|
||||||
backend.provides is not None
|
|
||||||
and not dependency.get("owner")
|
|
||||||
and backend.provides(dep_name)
|
|
||||||
):
|
|
||||||
# The backend adds it from its own tree; resolving here
|
# The backend adds it from its own tree; resolving here
|
||||||
# would fetch a same-named registry package
|
# would fetch a same-named registry package
|
||||||
if dep_version and dep_version != "*":
|
if dep_version and dep_version != "*":
|
||||||
@@ -1392,11 +1488,12 @@ def convert_libraries(
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
_LOGGER.debug("Skip backend-provided dependency %s", dep_name)
|
_LOGGER.debug("Skip backend-provided dependency %s", dep_name)
|
||||||
backend.provided_requests.add(dep_name)
|
|
||||||
continue
|
continue
|
||||||
dep_key = add_spec(dep_name, dep_version, dep_url)
|
dep_key = add_spec(dep_name, dep_version, dep_url)
|
||||||
node.edges.add(dep_key)
|
node.edges.add(dep_key)
|
||||||
worklist.append(dep_key)
|
worklist.append(dep_key)
|
||||||
|
if not worklist:
|
||||||
|
worklist.extend(settle_versionless())
|
||||||
|
|
||||||
# A git or local source wins over the same component requested from the
|
# A git or local source wins over the same component requested from the
|
||||||
# registry. That's intentional, but warn so the dropped registry spec isn't
|
# registry. That's intentional, but warn so the dropped registry spec isn't
|
||||||
@@ -1420,6 +1517,61 @@ def convert_libraries(
|
|||||||
source,
|
source,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Every version-less request resolves to the explicit library that goes by
|
||||||
|
# its name if the final graph has one, else to the node fetched by name for
|
||||||
|
# that package, else to the framework's bundled copy, else to nothing
|
||||||
|
explicit = {k: c for k, c in components.items() if not fetched_by_name(k)}
|
||||||
|
by_name = {k: c for k, c in components.items() if fetched_by_name(k)}
|
||||||
|
unresolved: dict[tuple[str, str], None] = {}
|
||||||
|
for requester, dependencies in pending_versionless.items():
|
||||||
|
for dependency in dependencies:
|
||||||
|
name, owner = dependency["name"], dependency.get("owner")
|
||||||
|
found = _satisfying_key(
|
||||||
|
name, owner, requester, nodes, explicit
|
||||||
|
) or _satisfying_key(name, owner, requester, nodes, by_name)
|
||||||
|
if found is None:
|
||||||
|
# A dependency written as a URL goes by the key its source gave it
|
||||||
|
key = fetched.get(spelling_of(name, owner))
|
||||||
|
if key in components and key != requester:
|
||||||
|
found = (key, False)
|
||||||
|
if found is None:
|
||||||
|
if not backend_provides(name, owner):
|
||||||
|
unresolved[(requester, name)] = None
|
||||||
|
continue
|
||||||
|
key, by_manifest_name = found
|
||||||
|
if by_manifest_name:
|
||||||
|
# Visible, so a chance name collision can be pinned to the right package
|
||||||
|
_LOGGER.info(
|
||||||
|
"Dependency %s of %s satisfied by %s through its manifest name",
|
||||||
|
name,
|
||||||
|
requester,
|
||||||
|
key,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Dependency %s of %s satisfied by %s", name, requester, key
|
||||||
|
)
|
||||||
|
nodes[requester].edges.add(key)
|
||||||
|
|
||||||
|
# What no listed library needs any more is not built: a node fetched by name
|
||||||
|
# that an explicit library replaced, and whatever only it pulled in
|
||||||
|
reachable: set[str] = set()
|
||||||
|
stack = [key for key in top_level if key in components]
|
||||||
|
while stack:
|
||||||
|
if (key := stack.pop()) in reachable:
|
||||||
|
continue
|
||||||
|
reachable.add(key)
|
||||||
|
stack.extend(edge for edge in nodes[key].edges if edge in components)
|
||||||
|
for key in [key for key in components if key not in reachable]:
|
||||||
|
_LOGGER.debug("Library %s is not needed; dropping it", key)
|
||||||
|
del components[key]
|
||||||
|
|
||||||
|
for requester, name in unresolved:
|
||||||
|
if requester in components:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Dependency %s of %s was not resolved; skipping", name, requester
|
||||||
|
)
|
||||||
|
|
||||||
# Two graph nodes that resolve to the same component name (e.g. a package
|
# Two graph nodes that resolve to the same component name (e.g. a package
|
||||||
# referenced both bare and as ``owner/name``) are not deduplicated and can
|
# referenced both bare and as ``owner/name``) are not deduplicated and can
|
||||||
# produce conflicting component definitions. Warn so it's not silent.
|
# produce conflicting component definitions. Warn so it's not silent.
|
||||||
@@ -1447,6 +1599,4 @@ def convert_libraries(
|
|||||||
for component in components.values():
|
for component in components.values():
|
||||||
backend.emit(component)
|
backend.emit(component)
|
||||||
|
|
||||||
_reconcile_versionless_skips(skipped_versionless, components, backend)
|
|
||||||
|
|
||||||
return [components[key] for key in top_level if key in components]
|
return [components[key] for key in top_level if key in components]
|
||||||
@@ -190,8 +190,19 @@ class RedirectText:
|
|||||||
"""Whether output is handled line by line rather than passed through."""
|
"""Whether output is handled line by line rather than passed through."""
|
||||||
return self._filter_pattern is not None or bool(self._line_callbacks)
|
return self._filter_pattern is not None or bool(self._line_callbacks)
|
||||||
|
|
||||||
|
def _drop_line(self, line: str) -> None:
|
||||||
|
"""Leave out a filtered line, keeping the color codes it carried.
|
||||||
|
|
||||||
|
cmake ends a warning with the reset code on a line of its own; dropped
|
||||||
|
with the blank lines, it would leave everything after it in the
|
||||||
|
warning's color.
|
||||||
|
"""
|
||||||
|
if codes := "".join(ANSI_ESCAPE.findall(line)):
|
||||||
|
self._write_color_replace(codes)
|
||||||
|
|
||||||
def _emit_line(self, line: str) -> None:
|
def _emit_line(self, line: str) -> None:
|
||||||
if self._is_filtered(line):
|
if self._is_filtered(line):
|
||||||
|
self._drop_line(line)
|
||||||
return
|
return
|
||||||
line_without_end = ANSI_ESCAPE.sub("", line).rstrip()
|
line_without_end = ANSI_ESCAPE.sub("", line).rstrip()
|
||||||
|
|
||||||
|
|||||||
@@ -100,6 +100,14 @@ button:
|
|||||||
id: climate_test_thermostat
|
id: climate_test_thermostat
|
||||||
custom_fan_mode: !lambda return "quiet";
|
custom_fan_mode: !lambda return "quiet";
|
||||||
custom_preset: !lambda return "Default";
|
custom_preset: !lambda return "Default";
|
||||||
|
# Inlined lambdas returning a StringRef, the shape an api action string variable takes (issue #21327)
|
||||||
|
- platform: template
|
||||||
|
name: "Climate Control StringRef Lambdas"
|
||||||
|
on_press:
|
||||||
|
- climate.control:
|
||||||
|
id: climate_test_thermostat
|
||||||
|
custom_fan_mode: !lambda return id(climate_test_thermostat).get_custom_fan_mode();
|
||||||
|
custom_preset: !lambda return id(climate_test_thermostat).get_custom_preset();
|
||||||
|
|
||||||
# Exercise climate.control inside a trigger with non-empty Ts (number on_value
|
# Exercise climate.control inside a trigger with non-empty Ts (number on_value
|
||||||
# passes float).
|
# passes float).
|
||||||
|
|||||||
@@ -43,6 +43,21 @@ button:
|
|||||||
url: http://my.ha.net:8123/local/esphome/firmware.bin
|
url: http://my.ha.net:8123/local/esphome/firmware.bin
|
||||||
|
|
||||||
- logger.log: "This message should be not displayed (reboot)"
|
- logger.log: "This message should be not displayed (reboot)"
|
||||||
|
# Device firmware switches the manifest from lambdas; every shape below must compile and
|
||||||
|
# the stored URL must outlive the lambda (EverythingSmartHome/everything-presence-one#341).
|
||||||
|
- platform: template
|
||||||
|
name: Switch manifest
|
||||||
|
on_press:
|
||||||
|
then:
|
||||||
|
- lambda: |-
|
||||||
|
id(ota_update).set_source_url("http://my.ha.net:8123/local/esphome/beta-manifest.json");
|
||||||
|
std::string url = "http://my.ha.net:8123/local/esphome/";
|
||||||
|
url += "manifest.json";
|
||||||
|
id(ota_update).set_source_url(url);
|
||||||
|
id(ota_update).set_source_url(url.c_str());
|
||||||
|
id(ota_update).set_source_url(std::string("http://my.ha.net:8123/local/esphome/manifest.json"));
|
||||||
|
ESP_LOGI("test", "Manifest is now %s", id(ota_update).get_source_url());
|
||||||
|
- component.update: ota_update
|
||||||
|
|
||||||
update:
|
update:
|
||||||
- platform: http_request
|
- platform: http_request
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import esphome.config_validation as cv
|
||||||
|
from tests.testing_helpers import ComponentManifestOverride
|
||||||
|
|
||||||
|
|
||||||
|
def override_manifest(manifest: ComponentManifestOverride) -> None:
|
||||||
|
# The real schema rejects the host target by framework version; the update platform's
|
||||||
|
# unit tests only need this platform's sources on the host.
|
||||||
|
manifest.config_schema = cv.Schema({}, extra=cv.ALLOW_EXTRA)
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
#include <gtest/gtest.h>
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include "esphome/components/http_request/update/http_request_update.h"
|
||||||
|
|
||||||
|
#if defined(__SANITIZE_ADDRESS__)
|
||||||
|
#define HAS_ASAN
|
||||||
|
#elif defined(__has_feature)
|
||||||
|
#if __has_feature(address_sanitizer)
|
||||||
|
#define HAS_ASAN
|
||||||
|
#endif
|
||||||
|
#endif
|
||||||
|
#ifdef HAS_ASAN
|
||||||
|
#include <sanitizer/asan_interface.h>
|
||||||
|
#endif
|
||||||
|
|
||||||
|
namespace esphome::http_request::testing {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
constexpr char LITERAL[] = "http://example.invalid/manifest.json";
|
||||||
|
|
||||||
|
// Freed heap memory is poisoned under AddressSanitizer, which CI builds with; without it the
|
||||||
|
// freed-exactly-once checks cannot be observed and are skipped.
|
||||||
|
void expect_freed(const char *buffer) {
|
||||||
|
#ifdef HAS_ASAN
|
||||||
|
EXPECT_TRUE(__asan_address_is_poisoned(buffer));
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
void expect_live(const char *buffer, const char *content) {
|
||||||
|
#ifdef HAS_ASAN
|
||||||
|
EXPECT_FALSE(__asan_address_is_poisoned(buffer));
|
||||||
|
#endif
|
||||||
|
EXPECT_STREQ(buffer, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
// Friend of HttpRequestUpdate, so a check can be begun and ended without an HTTP fetch.
|
||||||
|
class HttpRequestUpdateTest : public ::testing::Test {
|
||||||
|
protected:
|
||||||
|
static void begin_check(HttpRequestUpdate &update, TaskResult &result) { update.begin_check_(&result); }
|
||||||
|
static void end_check(HttpRequestUpdate &update, TaskResult &result) { update.end_check_(&result); }
|
||||||
|
};
|
||||||
|
|
||||||
|
TEST(HttpRequestUpdateSourceUrl, ConfigLiteralIsKeptByPointer) {
|
||||||
|
HttpRequestUpdate update;
|
||||||
|
update.set_source_url_static(LITERAL);
|
||||||
|
EXPECT_EQ(update.get_source_url(), LITERAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(HttpRequestUpdateSourceUrl, RuntimeStringIsCopied) {
|
||||||
|
HttpRequestUpdate update;
|
||||||
|
std::string url = "http://example.invalid/from-string-manifest.json";
|
||||||
|
update.set_source_url(url);
|
||||||
|
EXPECT_NE(update.get_source_url(), url.c_str());
|
||||||
|
EXPECT_STREQ(update.get_source_url(), url.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Device firmware passes base_url.c_str() of a local and runs the update after the lambda returned.
|
||||||
|
TEST(HttpRequestUpdateSourceUrl, PointerIntoDeadLocalStaysReadable) {
|
||||||
|
HttpRequestUpdate update;
|
||||||
|
{
|
||||||
|
std::string local = "http://example.invalid/from-local-manifest.json";
|
||||||
|
update.set_source_url(local.c_str());
|
||||||
|
}
|
||||||
|
EXPECT_STREQ(update.get_source_url(), "http://example.invalid/from-local-manifest.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(HttpRequestUpdateSourceUrl, PassingTheCurrentUrlBackIsSafe) {
|
||||||
|
HttpRequestUpdate update;
|
||||||
|
update.set_source_url("http://example.invalid/current-manifest.json");
|
||||||
|
const char *previous = update.get_source_url();
|
||||||
|
update.set_source_url(update.get_source_url());
|
||||||
|
EXPECT_STREQ(update.get_source_url(), "http://example.invalid/current-manifest.json");
|
||||||
|
expect_freed(previous);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(HttpRequestUpdateSourceUrl, ConfigLiteralAfterRuntimeUrlDropsTheCopy) {
|
||||||
|
HttpRequestUpdate update;
|
||||||
|
update.set_source_url("http://example.invalid/runtime-manifest.json");
|
||||||
|
const char *copy = update.get_source_url();
|
||||||
|
update.set_source_url_static(LITERAL);
|
||||||
|
EXPECT_EQ(update.get_source_url(), LITERAL);
|
||||||
|
expect_freed(copy);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The URL a check started with stays readable until the check ends, however often it is replaced in
|
||||||
|
// the meantime; the replacements it never saw are freed at once and the original exactly once.
|
||||||
|
TEST_F(HttpRequestUpdateTest, UrlReplacedDuringACheckOutlivesIt) {
|
||||||
|
HttpRequestUpdate update;
|
||||||
|
update.set_source_url("http://example.invalid/first-manifest.json");
|
||||||
|
const char *first = update.get_source_url();
|
||||||
|
TaskResult result;
|
||||||
|
begin_check(update, result);
|
||||||
|
EXPECT_EQ(result.source_url, first);
|
||||||
|
|
||||||
|
update.set_source_url("http://example.invalid/second-manifest.json");
|
||||||
|
const char *second = update.get_source_url();
|
||||||
|
expect_live(first, "http://example.invalid/first-manifest.json");
|
||||||
|
|
||||||
|
update.set_source_url("http://example.invalid/third-manifest.json");
|
||||||
|
const char *third = update.get_source_url();
|
||||||
|
expect_live(first, "http://example.invalid/first-manifest.json");
|
||||||
|
expect_freed(second);
|
||||||
|
|
||||||
|
end_check(update, result);
|
||||||
|
expect_freed(first);
|
||||||
|
expect_live(third, "http://example.invalid/third-manifest.json");
|
||||||
|
// The entity owns the URL set during the check: switching away frees it.
|
||||||
|
update.set_source_url_static(LITERAL);
|
||||||
|
expect_freed(third);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_F(HttpRequestUpdateTest, UrlUnchangedDuringACheckReturnsToTheEntity) {
|
||||||
|
HttpRequestUpdate update;
|
||||||
|
update.set_source_url("http://example.invalid/kept-manifest.json");
|
||||||
|
const char *kept = update.get_source_url();
|
||||||
|
TaskResult result;
|
||||||
|
begin_check(update, result);
|
||||||
|
end_check(update, result);
|
||||||
|
expect_live(kept, "http://example.invalid/kept-manifest.json");
|
||||||
|
// Ownership came back, so the next change frees it instead of leaking it.
|
||||||
|
update.set_source_url_static(LITERAL);
|
||||||
|
expect_freed(kept);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_F(HttpRequestUpdateTest, ConfigLiteralDuringACheckIsNeverFreed) {
|
||||||
|
HttpRequestUpdate update;
|
||||||
|
update.set_source_url_static(LITERAL);
|
||||||
|
TaskResult result;
|
||||||
|
begin_check(update, result);
|
||||||
|
update.set_source_url("http://example.invalid/runtime-manifest.json");
|
||||||
|
end_check(update, result);
|
||||||
|
EXPECT_STREQ(LITERAL, "http://example.invalid/manifest.json");
|
||||||
|
EXPECT_STREQ(update.get_source_url(), "http://example.invalid/runtime-manifest.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace esphome::http_request::testing
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import esphome.codegen as cg
|
||||||
|
from tests.testing_helpers import ComponentManifestOverride
|
||||||
|
|
||||||
|
|
||||||
|
def override_manifest(manifest: ComponentManifestOverride) -> None:
|
||||||
|
# setup() registers the entity as an OTA state listener, which the real to_code
|
||||||
|
# requests through ota.request_ota_state_listeners().
|
||||||
|
async def to_code_testing(config):
|
||||||
|
cg.add_define("USE_OTA_STATE_LISTENER")
|
||||||
|
|
||||||
|
manifest.to_code = to_code_testing
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
packages:
|
||||||
|
ledc: !include common.yaml
|
||||||
@@ -138,6 +138,10 @@ esphome:
|
|||||||
- light.turn_on:
|
- light.turn_on:
|
||||||
id: test_monochromatic_light
|
id: test_monochromatic_light
|
||||||
effect: !lambda 'return "Strobe";'
|
effect: !lambda 'return "Strobe";'
|
||||||
|
# Inlined lambda returning a StringRef, the shape an api action string variable takes (issue #21327)
|
||||||
|
- light.turn_on:
|
||||||
|
id: test_monochromatic_light
|
||||||
|
effect: !lambda 'return light::EFFECT_NONE_REF;'
|
||||||
# Test effect lambda with non-empty args (repeat passes uint32_t iteration)
|
# Test effect lambda with non-empty args (repeat passes uint32_t iteration)
|
||||||
- repeat:
|
- repeat:
|
||||||
count: 3
|
count: 3
|
||||||
|
|||||||
@@ -6,3 +6,6 @@ web_server:
|
|||||||
username: admin
|
username: admin
|
||||||
password: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
password: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||||
type: basic
|
type: basic
|
||||||
|
|
||||||
|
ota:
|
||||||
|
- platform: web_server
|
||||||
@@ -236,6 +236,10 @@ async def yaml_config(request: pytest.FixtureRequest, unused_tcp_port: int) -> s
|
|||||||
external_components_path = str(FIXTURES_DIR / "external_components")
|
external_components_path = str(FIXTURES_DIR / "external_components")
|
||||||
content = content.replace("EXTERNAL_COMPONENT_PATH", external_components_path)
|
content = content.replace("EXTERNAL_COMPONENT_PATH", external_components_path)
|
||||||
|
|
||||||
|
# Replace the user includes path placeholder if present
|
||||||
|
if "INCLUDES_PATH" in content:
|
||||||
|
content = content.replace("INCLUDES_PATH", str(FIXTURES_DIR / "includes"))
|
||||||
|
|
||||||
if shared_name is not None:
|
if shared_name is not None:
|
||||||
# _compile verifies the marked test compiles this content unmodified
|
# _compile verifies the marked test compiles this content unmodified
|
||||||
request.node._shared_yaml_content = content
|
request.node._shared_yaml_content = content
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
static constexpr int HEADER_CONSTANT = 42;
|
||||||
|
|
||||||
|
// Names the global by its id, so the header must come after the id declarations.
|
||||||
|
inline int header_reads_global() { return header_value->value() * 2; }
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
esphome:
|
||||||
|
name: includes-lambda-order
|
||||||
|
includes:
|
||||||
|
- INCLUDES_PATH/lambda_order.h
|
||||||
|
on_boot:
|
||||||
|
# An apply action value is rendered at file scope.
|
||||||
|
- globals.set:
|
||||||
|
id: header_value
|
||||||
|
value: !lambda return HEADER_CONSTANT;
|
||||||
|
|
||||||
|
host:
|
||||||
|
api:
|
||||||
|
logger:
|
||||||
|
|
||||||
|
globals:
|
||||||
|
- id: header_value
|
||||||
|
type: int
|
||||||
|
initial_value: "0"
|
||||||
|
|
||||||
|
sensor:
|
||||||
|
- platform: template
|
||||||
|
name: Header Value
|
||||||
|
update_interval: 100ms
|
||||||
|
lambda: return header_reads_global();
|
||||||
|
filters:
|
||||||
|
# A lambda in a flash table.
|
||||||
|
- filter_out: !lambda return HEADER_CONSTANT - 1;
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""Lambdas rendered at file scope can use the user's includes, which can name ids."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from aioesphomeapi import SensorInfo, SensorState
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from .state_utils import require_entity, wait_for_state
|
||||||
|
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_includes_lambda_order(
|
||||||
|
yaml_config: str,
|
||||||
|
run_compiled: RunCompiledFunction,
|
||||||
|
api_client_connected: APIClientConnectedFactory,
|
||||||
|
) -> None:
|
||||||
|
"""The apply action and the sensor filter use the header, the header reads the global."""
|
||||||
|
async with run_compiled(yaml_config), api_client_connected() as client:
|
||||||
|
entities, _ = await client.list_entities_services()
|
||||||
|
sensor = require_entity(entities, "header_value", SensorInfo)
|
||||||
|
state = await wait_for_state(
|
||||||
|
client,
|
||||||
|
lambda s: (
|
||||||
|
isinstance(s, SensorState)
|
||||||
|
and s.key == sensor.key
|
||||||
|
and not s.missing_state
|
||||||
|
),
|
||||||
|
)
|
||||||
|
# globals.set stored HEADER_CONSTANT and the header read it back doubled.
|
||||||
|
assert state.state == 84.0
|
||||||
@@ -5,10 +5,11 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from esphome.build_helpers import pch
|
||||||
from esphome.components.esp32 import (
|
from esphome.components.esp32 import (
|
||||||
KEY_COMPONENTS,
|
KEY_COMPONENTS,
|
||||||
KEY_ESP32,
|
KEY_ESP32,
|
||||||
@@ -609,11 +610,39 @@ def test_component_cmakelists_pch_block(monkeypatch: pytest.MonkeyPatch) -> None
|
|||||||
assert "target_precompile_headers" not in get_component_cmakelists()
|
assert "target_precompile_headers" not in get_component_cmakelists()
|
||||||
|
|
||||||
|
|
||||||
def test_component_cmakelists_pch_gate_on_windows(
|
def test_component_cmakelists_pch_ccache_gate(
|
||||||
windows_gcc_rule: None, monkeypatch: pytest.MonkeyPatch
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""The block carries the rule and records its choice; the knob drops
|
"""With the knob unset, CMake checks the ccache IDF found and records its
|
||||||
the gate."""
|
choice; the knob drops the gate."""
|
||||||
|
from esphome.build_gen.espidf import get_component_cmakelists
|
||||||
|
|
||||||
|
monkeypatch.delenv("ESPHOME_PCH_ENABLE")
|
||||||
|
# Off Windows, so the GCC rule stays out of the block
|
||||||
|
monkeypatch.setattr(pch.sys, "platform", "linux")
|
||||||
|
# Code generation must not run ccache or need an installed toolchain
|
||||||
|
monkeypatch.setattr(pch, "ccache_breaks_pch", Mock(side_effect=AssertionError))
|
||||||
|
content = get_component_cmakelists()
|
||||||
|
assert (
|
||||||
|
"if(CCACHE_ENABLE AND CCACHE_FOUND)\n"
|
||||||
|
' execute_process(COMMAND "${CCACHE_FOUND}" --version '
|
||||||
|
"OUTPUT_VARIABLE _esphome_ccache ERROR_QUIET)\n"
|
||||||
|
' if(_esphome_ccache MATCHES "ccache version ([0-9.]+)")\n'
|
||||||
|
" if(CMAKE_MATCH_1 VERSION_LESS 4.7)\n"
|
||||||
|
) in content
|
||||||
|
assert (
|
||||||
|
'set(ESPHOME_PCH ${_esphome_pch} CACHE BOOL "ESPHome precompiled header in use" FORCE)\n'
|
||||||
|
"if(_esphome_pch)\n target_precompile_headers(${COMPONENT_LIB} PRIVATE\n"
|
||||||
|
) in content
|
||||||
|
assert "CMAKE_CXX_COMPILER_VERSION VERSION_LESS" not in content
|
||||||
|
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "1")
|
||||||
|
content = get_component_cmakelists()
|
||||||
|
assert "CCACHE_FOUND" not in content
|
||||||
|
assert "\ntarget_precompile_headers(${COMPONENT_LIB} PRIVATE\n" in content
|
||||||
|
|
||||||
|
|
||||||
|
def test_component_cmakelists_pch_gate_on_windows(windows_gcc_rule: None) -> None:
|
||||||
|
"""On Windows the GCC rule joins the ccache check."""
|
||||||
from esphome.build_gen.espidf import get_component_cmakelists
|
from esphome.build_gen.espidf import get_component_cmakelists
|
||||||
|
|
||||||
content = get_component_cmakelists()
|
content = get_component_cmakelists()
|
||||||
@@ -622,39 +651,42 @@ def test_component_cmakelists_pch_gate_on_windows(
|
|||||||
"(CMAKE_CXX_COMPILER_VERSION VERSION_LESS 14.4 OR "
|
"(CMAKE_CXX_COMPILER_VERSION VERSION_LESS 14.4 OR "
|
||||||
"(CMAKE_CXX_COMPILER_VERSION VERSION_GREATER_EQUAL 15 AND "
|
"(CMAKE_CXX_COMPILER_VERSION VERSION_GREATER_EQUAL 15 AND "
|
||||||
"CMAKE_CXX_COMPILER_VERSION VERSION_LESS 15.3)))\n"
|
"CMAKE_CXX_COMPILER_VERSION VERSION_LESS 15.3)))\n"
|
||||||
" message(STATUS " in content
|
" message(STATUS "
|
||||||
)
|
) in content
|
||||||
assert (
|
assert (
|
||||||
' set(ESPHOME_PCH OFF CACHE BOOL "ESPHome precompiled header in use" FORCE)\nelse()\n'
|
" set(_esphome_pch OFF)\nendif()\nif(CCACHE_ENABLE AND CCACHE_FOUND)"
|
||||||
in content
|
in content
|
||||||
)
|
)
|
||||||
assert (
|
|
||||||
' set(ESPHOME_PCH ON CACHE BOOL "ESPHome precompiled header in use" FORCE)\n target_precompile_headers(${COMPONENT_LIB} PRIVATE\n'
|
|
||||||
in content
|
|
||||||
)
|
|
||||||
assert "endif()" in content
|
|
||||||
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "1")
|
|
||||||
content = get_component_cmakelists()
|
|
||||||
assert "if(CMAKE_CXX_COMPILER_VERSION" not in content
|
|
||||||
assert "\ntarget_precompile_headers(${COMPONENT_LIB} PRIVATE\n" in content
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(("choice", "written"), [("OFF", False), ("ON", True)])
|
@pytest.mark.parametrize(
|
||||||
def test_pch_checksum_follows_the_cmake_choice_on_windows(
|
("choice", "logged"), [("OFF", False), ("ON", True), (None, False)]
|
||||||
windows_gcc_rule: None, tmp_path: Path, choice: str, written: bool
|
)
|
||||||
|
def test_pch_checksum_ignores_the_cmake_choice(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
choice: str | None,
|
||||||
|
logged: bool,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
"""The sum is always refreshed, so a reconfigure that turns the pch on,
|
||||||
|
or a build tree from before the gate, never pairs a new .gch with an old
|
||||||
|
sum; only the log line follows the choice."""
|
||||||
from esphome.build_gen.espidf import _PCH_SUM_PATH, write_pch_checksum
|
from esphome.build_gen.espidf import _PCH_SUM_PATH, write_pch_checksum
|
||||||
|
|
||||||
|
monkeypatch.delenv("ESPHOME_PCH_ENABLE")
|
||||||
_make_pch_project(tmp_path)
|
_make_pch_project(tmp_path)
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"esphome.espidf.toolchain.get_cmake_cache_value", return_value=choice
|
"esphome.espidf.toolchain.get_cmake_cache_value", return_value=choice
|
||||||
) as asked,
|
) as asked,
|
||||||
patch.object(CORE, "name", "test"),
|
patch.object(CORE, "name", "test"),
|
||||||
|
caplog.at_level("INFO"),
|
||||||
):
|
):
|
||||||
write_pch_checksum()
|
write_pch_checksum()
|
||||||
assert asked.call_args.args == ("ESPHOME_PCH",)
|
assert asked.call_args.args == ("ESPHOME_PCH",)
|
||||||
assert CORE.relative_build_path(_PCH_SUM_PATH).exists() is written
|
assert CORE.relative_build_path(_PCH_SUM_PATH).exists()
|
||||||
|
assert ("Compiling with a precompiled header" in caplog.text) is logged
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
|
|||||||
@@ -2,10 +2,12 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from contextlib import contextmanager
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -195,3 +197,57 @@ def test_ccache_env_includes_pch_settings(tmp_path: Path) -> None:
|
|||||||
env = _pch_ccache_env(tmp_path, {})
|
env = _pch_ccache_env(tmp_path, {})
|
||||||
assert env["CCACHE_SLOPPINESS"] == "pch_defines,time_macros"
|
assert env["CCACHE_SLOPPINESS"] == "pch_defines,time_macros"
|
||||||
assert env["CCACHE_PCH_EXTSUM"] == "true"
|
assert env["CCACHE_PCH_EXTSUM"] == "true"
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _fake_ccache(
|
||||||
|
banner: str | Exception, env: dict[str, str] | None = None
|
||||||
|
) -> Iterator[MagicMock]:
|
||||||
|
"""A ccache on PATH whose ``--version`` prints ``banner``."""
|
||||||
|
with (
|
||||||
|
patch.dict(os.environ, env or {}, clear=True),
|
||||||
|
patch("shutil.which", return_value="/usr/bin/ccache"),
|
||||||
|
patch.object(ccache, "tool_version_probe", return_value=banner) as probe,
|
||||||
|
):
|
||||||
|
yield probe
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("banner", "env", "expected"),
|
||||||
|
[
|
||||||
|
("ccache version 4.2\n", {}, True),
|
||||||
|
("ccache version 4.6.3\nFeatures: file-storage\n", {}, True),
|
||||||
|
# An explicit enable skips the runnability probe, not the version
|
||||||
|
("ccache version 4.6.3\n", {"ESPHOME_CCACHE_ENABLE": "1"}, True),
|
||||||
|
("ccache version 4.6.3\n", {"ESPHOME_CCACHE_ENABLE": "0"}, False),
|
||||||
|
("ccache version 4.7\n", {}, False),
|
||||||
|
("ccache version 4.13.6\n", {}, False),
|
||||||
|
("something else\n", {}, False),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_ccache_breaks_pch(banner: str, env: dict[str, str], expected: bool) -> None:
|
||||||
|
with _fake_ccache(banner, env):
|
||||||
|
assert ccache.ccache_breaks_pch() is expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_ccache_breaks_pch_probes_and_logs_once(
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
"""The runnability probe and the version check share one spawn."""
|
||||||
|
with _fake_ccache("ccache version 4.5.1\n") as probe, caplog.at_level("INFO"):
|
||||||
|
assert ccache.resolve_ccache_path() == "/usr/bin/ccache"
|
||||||
|
assert ccache.ccache_breaks_pch()
|
||||||
|
assert ccache.ccache_breaks_pch()
|
||||||
|
probe.assert_called_once()
|
||||||
|
assert caplog.text.count("ccache 4.5.1 at /usr/bin/ccache") == 1
|
||||||
|
assert "upgrade ccache to 4.7 or newer" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_ccache_breaks_pch_forced_broken_binary_is_quiet(
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
"""Forced on, a ccache that fails to run is still used, so the version
|
||||||
|
check must not claim the build compiles without it."""
|
||||||
|
with _fake_ccache(OSError("boom"), {"ESPHOME_CCACHE_ENABLE": "1"}):
|
||||||
|
assert not ccache.ccache_breaks_pch()
|
||||||
|
assert "compiling without ccache" not in caplog.text
|
||||||
@@ -37,6 +37,21 @@ def test_pch_enabled(value: str | None, expected: bool) -> None:
|
|||||||
assert pch.pch_enabled() is expected
|
assert pch.pch_enabled() is expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("value", "expected"), [(None, False), ("1", True)])
|
||||||
|
def test_old_ccache_rules_out_pch(value: str | None, expected: bool) -> None:
|
||||||
|
"""An old ccache turns the pch off unless ESPHOME_PCH_ENABLE=1 forces it."""
|
||||||
|
env = {} if value is None else {"ESPHOME_PCH_ENABLE": value}
|
||||||
|
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "rp2040"}
|
||||||
|
with (
|
||||||
|
patch.dict(os.environ, env, clear=True),
|
||||||
|
patch.object(pch, "ccache_breaks_pch", return_value=True),
|
||||||
|
patch.object(pch.sys, "platform", "linux"),
|
||||||
|
):
|
||||||
|
assert pch.pch_enabled()
|
||||||
|
assert pch.pch_usable(("g++",)) is expected
|
||||||
|
assert pch.pch_script_enabled() is expected
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("version", "expected"),
|
("version", "expected"),
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -176,6 +176,14 @@ def test_tool_output_filters_a_matching_progress_line() -> None:
|
|||||||
assert out.getvalue() == "\r[2/2] shown\x1b[K"
|
assert out.getvalue() == "\r[2/2] shown\x1b[K"
|
||||||
|
|
||||||
|
|
||||||
|
def test_tool_output_keeps_the_color_codes_of_a_filtered_progress_line() -> None:
|
||||||
|
"""The hidden text and its newline go, the reset it carried stays."""
|
||||||
|
out = io.StringIO()
|
||||||
|
output = ToolOutput(out, [r"\[1/2\]"], True)
|
||||||
|
output.write("[1/2] hidden\x1b[0m\n[2/2] shown\n")
|
||||||
|
assert out.getvalue() == "\x1b[0m\r[2/2] shown\x1b[K"
|
||||||
|
|
||||||
|
|
||||||
def test_tool_output_without_progress_keeps_status_lines() -> None:
|
def test_tool_output_without_progress_keeps_status_lines() -> None:
|
||||||
output, out = _tool_output(progress=False)
|
output, out = _tool_output(progress=False)
|
||||||
output.write("[1/2] Building a.o\n")
|
output.write("[1/2] Building a.o\n")
|
||||||
|
|||||||
@@ -62,8 +62,9 @@ def test_write_idf_component_yml_passes_managed_components(
|
|||||||
converted.get_sanitized_name.return_value = "esphome/other-lib"
|
converted.get_sanitized_name.return_value = "esphome/other-lib"
|
||||||
converted.path = tmp_path / "pio_components" / "other-lib"
|
converted.path = tmp_path / "pio_components" / "other-lib"
|
||||||
|
|
||||||
def fake_generate_idf_components(libraries, managed=None):
|
def fake_generate_idf_components(libraries, managed=None, provides=None):
|
||||||
captured["managed"] = managed
|
captured["managed"] = managed
|
||||||
|
captured["provides"] = provides
|
||||||
return [converted]
|
return [converted]
|
||||||
|
|
||||||
monkeypatch.setattr(esp32, "generate_idf_components", fake_generate_idf_components)
|
monkeypatch.setattr(esp32, "generate_idf_components", fake_generate_idf_components)
|
||||||
@@ -71,6 +72,8 @@ def test_write_idf_component_yml_passes_managed_components(
|
|||||||
esp32._write_idf_component_yml()
|
esp32._write_idf_component_yml()
|
||||||
|
|
||||||
assert captured["managed"] == {"esphome/noise-c"}
|
assert captured["managed"] == {"esphome/noise-c"}
|
||||||
|
# ESP-IDF bundles no Arduino libraries, so every name may be fetched
|
||||||
|
assert captured["provides"] is None
|
||||||
# The managed component itself is still written into the manifest deps
|
# The managed component itself is still written into the manifest deps
|
||||||
# directly (from KEY_COMPONENTS), just not converted a second time.
|
# directly (from KEY_COMPONENTS), just not converted a second time.
|
||||||
yml_path = tmp_path / "src" / "idf_component.yml"
|
yml_path = tmp_path / "src" / "idf_component.yml"
|
||||||
@@ -93,7 +96,7 @@ def test_write_idf_component_yml_empty_managed_when_no_components(
|
|||||||
|
|
||||||
captured: dict[str, set[str] | None] = {}
|
captured: dict[str, set[str] | None] = {}
|
||||||
|
|
||||||
def fake_generate_idf_components(libraries, managed=None):
|
def fake_generate_idf_components(libraries, managed=None, provides=None):
|
||||||
captured["managed"] = managed
|
captured["managed"] = managed
|
||||||
return []
|
return []
|
||||||
|
|
||||||
@@ -104,6 +107,50 @@ def test_write_idf_component_yml_empty_managed_when_no_components(
|
|||||||
assert captured["managed"] == set()
|
assert captured["managed"] == set()
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_idf_component_yml_marks_arduino_bundled_libraries_provided(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Under Arduino the converter is told which names the core bundles, so a
|
||||||
|
manifest dependency on Wire or SPI is never fetched from the registry."""
|
||||||
|
_setup_core(tmp_path)
|
||||||
|
CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = str(Framework.ARDUINO)
|
||||||
|
CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] = cv.Version(3, 3, 12)
|
||||||
|
CORE.data[esp32.KEY_ESP32] = {
|
||||||
|
esp32.KEY_COMPONENTS: {},
|
||||||
|
esp32.KEY_IDF_VERSION: cv.Version(5, 5, 4),
|
||||||
|
esp32.KEY_ARDUINO_LIBRARIES: {"Wire", "SPI"},
|
||||||
|
}
|
||||||
|
|
||||||
|
captured: dict[str, object] = {}
|
||||||
|
|
||||||
|
def fake_generate_idf_components(libraries, managed=None, provides=None):
|
||||||
|
captured["provides"] = provides
|
||||||
|
return []
|
||||||
|
|
||||||
|
monkeypatch.setattr(esp32, "generate_idf_components", fake_generate_idf_components)
|
||||||
|
|
||||||
|
esp32._write_idf_component_yml()
|
||||||
|
|
||||||
|
provides = captured["provides"]
|
||||||
|
assert provides("Wire") and provides("SPI")
|
||||||
|
assert not provides("Adafruit BusIO")
|
||||||
|
|
||||||
|
|
||||||
|
def test_arduino_provides_warns_for_a_bundled_library_not_enabled(
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
"""A manifest dependency on a bundled Arduino library is never fetched, but
|
||||||
|
the library still has to be enabled, so an unenabled one is called out."""
|
||||||
|
CORE.reset()
|
||||||
|
CORE.data[esp32.KEY_ESP32] = {esp32.KEY_ARDUINO_LIBRARIES: {"Wire"}}
|
||||||
|
assert esp32._arduino_provides("Wire")
|
||||||
|
assert "Wire" not in caplog.text
|
||||||
|
assert esp32._arduino_provides("SPI")
|
||||||
|
assert "Arduino library SPI, which is not enabled" in caplog.text
|
||||||
|
assert not esp32._arduino_provides("Adafruit BusIO")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("version", "libsodium_stubbed"),
|
("version", "libsodium_stubbed"),
|
||||||
[
|
[
|
||||||
@@ -200,7 +247,9 @@ def test_write_idf_component_yml_stub_yields_to_converted_library_of_same_name(
|
|||||||
converted.get_sanitized_name.return_value = "esphome/libsodium"
|
converted.get_sanitized_name.return_value = "esphome/libsodium"
|
||||||
converted.path = tmp_path / "pio_components" / "esphome" / "libsodium"
|
converted.path = tmp_path / "pio_components" / "esphome" / "libsodium"
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
esp32, "generate_idf_components", lambda libraries, managed=None: [converted]
|
esp32,
|
||||||
|
"generate_idf_components",
|
||||||
|
lambda libraries, managed=None, provides=None: [converted],
|
||||||
)
|
)
|
||||||
|
|
||||||
esp32._write_idf_component_yml()
|
esp32._write_idf_component_yml()
|
||||||
|
|||||||
@@ -199,6 +199,17 @@ def _default_pch_knobs(monkeypatch: pytest.MonkeyPatch) -> None:
|
|||||||
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "1")
|
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "1")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _no_ambient_ccache(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""The ccache on this machine does not decide the pch, and no cached
|
||||||
|
ccache probe crosses tests."""
|
||||||
|
from esphome.build_helpers import ccache, pch
|
||||||
|
|
||||||
|
monkeypatch.setattr(pch, "ccache_breaks_pch", lambda: False)
|
||||||
|
ccache._ccache_probe.cache_clear()
|
||||||
|
ccache._ccache_too_old_for_pch.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def windows_gcc_rule(monkeypatch: pytest.MonkeyPatch) -> None:
|
def windows_gcc_rule(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
"""Windows host, knob unset: the compiler version decides."""
|
"""Windows host, knob unset: the compiler version decides."""
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ from esphome.const import (
|
|||||||
KEY_TARGET_PLATFORM,
|
KEY_TARGET_PLATFORM,
|
||||||
Toolchain,
|
Toolchain,
|
||||||
)
|
)
|
||||||
from esphome.core import CORE, KEY_CONTROLLER_REGISTRY_CONTROLLERS, config
|
from esphome.core import CORE, ID, KEY_CONTROLLER_REGISTRY_CONTROLLERS, config
|
||||||
from esphome.core.config import (
|
from esphome.core.config import (
|
||||||
CONF_SUSPEND_LOOP,
|
CONF_SUSPEND_LOOP,
|
||||||
Area,
|
Area,
|
||||||
@@ -1006,6 +1006,40 @@ async def test_add_includes_with_single_file(
|
|||||||
assert any('#include "my_header.h"' in inc for inc in includes_added)
|
assert any('#include "my_header.h"' in inc for inc in includes_added)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_add_includes_sit_between_declarations_and_generated_code(
|
||||||
|
tmp_path: Path, mock_copy_file_if_changed: Mock
|
||||||
|
) -> None:
|
||||||
|
"""A header can name an id, and generated functions and tables can use the header."""
|
||||||
|
CORE.config_path = tmp_path / "config.yaml"
|
||||||
|
CORE.build_path = tmp_path / "build"
|
||||||
|
CORE.build_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
header = tmp_path / "my_header.h"
|
||||||
|
header.write_text("static constexpr int MY_CONSTANT = 42;")
|
||||||
|
CORE.config = {}
|
||||||
|
|
||||||
|
value = cg.new_Pvariable(ID("my_value", is_declaration=True, type=cg.int32))
|
||||||
|
cg.static_function(
|
||||||
|
"esphome__my_action__fn", cg.void, [], [f"*{value} = MY_CONSTANT;"]
|
||||||
|
)
|
||||||
|
cg.shared_progmem_array("my_table", cg.int32, [cg.RawExpression("MY_CONSTANT")])
|
||||||
|
await config.add_includes([str(header)])
|
||||||
|
# A job that runs after the includes still lands before the generated code.
|
||||||
|
cg.add_global(cg.RawStatement("static constexpr int FINAL_JOB = 1;"))
|
||||||
|
|
||||||
|
section = CORE.cpp_global_section
|
||||||
|
expected_order = [
|
||||||
|
"*const my_value",
|
||||||
|
"static void esphome__my_action__fn();",
|
||||||
|
'#include "my_header.h"',
|
||||||
|
"static constexpr int FINAL_JOB = 1;",
|
||||||
|
"static void esphome__my_action__fn() {",
|
||||||
|
"my_table[] PROGMEM",
|
||||||
|
]
|
||||||
|
positions = [section.index(text) for text in expected_order]
|
||||||
|
assert positions == sorted(positions), section
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@pytest.mark.skipif(os.name == "nt", reason="Unix-specific test")
|
@pytest.mark.skipif(os.name == "nt", reason="Unix-specific test")
|
||||||
async def test_add_includes_with_directory_unix(
|
async def test_add_includes_with_directory_unix(
|
||||||
|
|||||||
@@ -846,15 +846,14 @@ def test_library_info_dot_a_linkage_malformed_raises(tmp_path: Path) -> None:
|
|||||||
def test_bundled_library_properties_depends_warns(
|
def test_bundled_library_properties_depends_warns(
|
||||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||||
) -> None:
|
) -> None:
|
||||||
"""The library.properties depends= spelling reaches the visibility
|
"""The library.properties depends= spelling is a dependency list too, so a
|
||||||
warning too; the shared parser returns it raw."""
|
bundled manifest using it gets the same visibility warning."""
|
||||||
framework = _make_framework(tmp_path)
|
framework = _make_framework(tmp_path)
|
||||||
wire = framework / "libraries" / "Wire"
|
wire = framework / "libraries" / "Wire"
|
||||||
(wire / "library.properties").write_text("name=Wire\nversion=1.0\ndepends=SPI\n")
|
(wire / "library.properties").write_text("name=Wire\nversion=1.0\ndepends=SPI\n")
|
||||||
_add_library("Wire", None)
|
_add_library("Wire", None)
|
||||||
caplog.set_level("INFO")
|
|
||||||
_resolve(framework)
|
_resolve(framework)
|
||||||
assert "Library Wire declares dependencies via library.properties" in caplog.text
|
assert "Bundled library Wire declares dependencies" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
def test_bundled_library_extra_script_raises(tmp_path: Path) -> None:
|
def test_bundled_library_extra_script_raises(tmp_path: Path) -> None:
|
||||||
|
|||||||
@@ -512,7 +512,9 @@ class MockCodegen(NamedTuple):
|
|||||||
new_pvariable: MagicMock
|
new_pvariable: MagicMock
|
||||||
register_parented: AsyncMock
|
register_parented: AsyncMock
|
||||||
add_global: MagicMock
|
add_global: MagicMock
|
||||||
calls: MagicMock # new_pvariable and add_global attached, to check their order
|
add_global_after_includes: MagicMock
|
||||||
|
add: MagicMock
|
||||||
|
calls: MagicMock # the global emitters and new_pvariable attached, to check their order
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -525,14 +527,25 @@ def mock_cg() -> Generator[MockCodegen]:
|
|||||||
"esphome.codegen.register_parented", new_callable=AsyncMock
|
"esphome.codegen.register_parented", new_callable=AsyncMock
|
||||||
) as register_parented,
|
) as register_parented,
|
||||||
patch("esphome.cpp_generator.add_global") as add_global,
|
patch("esphome.cpp_generator.add_global") as add_global,
|
||||||
|
patch(
|
||||||
|
"esphome.cpp_generator.add_global_after_includes"
|
||||||
|
) as add_global_after_includes,
|
||||||
|
patch("esphome.codegen.add") as add,
|
||||||
):
|
):
|
||||||
get_variable.return_value = PARENT_OBJ
|
get_variable.return_value = PARENT_OBJ
|
||||||
new_pvariable.return_value = NEW_OBJ
|
new_pvariable.return_value = NEW_OBJ
|
||||||
calls = MagicMock()
|
calls = MagicMock()
|
||||||
calls.attach_mock(new_pvariable, "new_pvariable")
|
calls.attach_mock(new_pvariable, "new_pvariable")
|
||||||
calls.attach_mock(add_global, "add_global")
|
calls.attach_mock(add_global, "add_global")
|
||||||
|
calls.attach_mock(add_global_after_includes, "add_global_after_includes")
|
||||||
yield MockCodegen(
|
yield MockCodegen(
|
||||||
get_variable, new_pvariable, register_parented, add_global, calls
|
get_variable,
|
||||||
|
new_pvariable,
|
||||||
|
register_parented,
|
||||||
|
add_global,
|
||||||
|
add_global_after_includes,
|
||||||
|
add,
|
||||||
|
calls,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -660,7 +673,7 @@ async def _run_apply_condition(
|
|||||||
|
|
||||||
|
|
||||||
def _apply_definition(mock_cg: MockCodegen) -> str:
|
def _apply_definition(mock_cg: MockCodegen) -> str:
|
||||||
return str(mock_cg.add_global.call_args.args[0])
|
return str(mock_cg.add_global_after_includes.call_args.args[0])
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -674,8 +687,16 @@ async def test_register_apply_action_entry(
|
|||||||
action_id, template_arg = mock_cg.new_pvariable.call_args.args
|
action_id, template_arg = mock_cg.new_pvariable.call_args.args
|
||||||
assert action_id == ID("obj_1")
|
assert action_id == ID("obj_1")
|
||||||
assert str(template_arg) == "<esphome__obj_1__fn, int32_t>"
|
assert str(template_arg) == "<esphome__obj_1__fn, int32_t>"
|
||||||
# The definition must precede the storage line that names the function.
|
# The declaration must precede the storage line that names the function; the body
|
||||||
assert [c[0] for c in mock_cg.calls.mock_calls] == ["add_global", "new_pvariable"]
|
# goes after the user includes.
|
||||||
|
assert [c[0] for c in mock_cg.calls.mock_calls] == [
|
||||||
|
"add_global",
|
||||||
|
"add_global_after_includes",
|
||||||
|
"new_pvariable",
|
||||||
|
]
|
||||||
|
assert str(mock_cg.add_global.call_args.args[0]) == (
|
||||||
|
"static void esphome__obj_1__fn(const std::remove_cvref_t<int32_t> & x);"
|
||||||
|
)
|
||||||
assert _apply_definition(mock_cg).startswith(
|
assert _apply_definition(mock_cg).startswith(
|
||||||
"static void esphome__obj_1__fn(const std::remove_cvref_t<int32_t> & x) {"
|
"static void esphome__obj_1__fn(const std::remove_cvref_t<int32_t> & x) {"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -346,6 +346,9 @@ class TestLiterals:
|
|||||||
(cg.FloatLiteral(4.2), "4.2f"),
|
(cg.FloatLiteral(4.2), "4.2f"),
|
||||||
(cg.FloatLiteral(1.23456789), "1.23456789f"),
|
(cg.FloatLiteral(1.23456789), "1.23456789f"),
|
||||||
(cg.FloatLiteral(math.nan), "NAN"),
|
(cg.FloatLiteral(math.nan), "NAN"),
|
||||||
|
(cg.FloatLiteral(math.inf), "INFINITY"),
|
||||||
|
(cg.FloatLiteral(-math.inf), "-INFINITY"),
|
||||||
|
(cg.FloatLiteral(float("1e400")), "INFINITY"),
|
||||||
(cg.FlashStringLiteral("hello"), 'ESPHOME_F("hello")'),
|
(cg.FlashStringLiteral("hello"), 'ESPHOME_F("hello")'),
|
||||||
(cg.FlashStringLiteral(""), 'ESPHOME_F("")'),
|
(cg.FlashStringLiteral(""), 'ESPHOME_F("")'),
|
||||||
(
|
(
|
||||||
@@ -819,7 +822,8 @@ class TestSharedProgmemArray:
|
|||||||
c = cg.shared_progmem_array("table", ct.uint8, [4])
|
c = cg.shared_progmem_array("table", ct.uint8, [4])
|
||||||
assert a is b
|
assert a is b
|
||||||
assert str(a) != str(c)
|
assert str(a) != str(c)
|
||||||
assert sum("PROGMEM" in str(st) for st in CORE.global_statements) == 2
|
late = CORE.global_statements_after_includes
|
||||||
|
assert sum("PROGMEM" in str(st) for st in late) == 2
|
||||||
assert not any("PROGMEM" in str(st) for st in CORE.main_statements)
|
assert not any("PROGMEM" in str(st) for st in CORE.main_statements)
|
||||||
|
|
||||||
def test_share_false_always_emits_a_new_array(self) -> None:
|
def test_share_false_always_emits_a_new_array(self) -> None:
|
||||||
@@ -827,7 +831,8 @@ class TestSharedProgmemArray:
|
|||||||
a = cg.shared_progmem_array("table", ct.uint8, [1], share=False)
|
a = cg.shared_progmem_array("table", ct.uint8, [1], share=False)
|
||||||
b = cg.shared_progmem_array("table", ct.uint8, [1], share=False)
|
b = cg.shared_progmem_array("table", ct.uint8, [1], share=False)
|
||||||
assert str(a) != str(b)
|
assert str(a) != str(b)
|
||||||
assert sum("PROGMEM" in str(st) for st in CORE.global_statements) == 2
|
late = CORE.global_statements_after_includes
|
||||||
|
assert sum("PROGMEM" in str(st) for st in late) == 2
|
||||||
|
|
||||||
def test_same_contents_different_type_are_separate(self) -> None:
|
def test_same_contents_different_type_are_separate(self) -> None:
|
||||||
CORE.config = {}
|
CORE.config = {}
|
||||||
|
|||||||
@@ -215,8 +215,9 @@ def test_convert_pio_libs_arduino_framework_passes_empty_managed(
|
|||||||
path=tmp_path / "other-lib",
|
path=tmp_path / "other-lib",
|
||||||
)
|
)
|
||||||
|
|
||||||
def fake_generate_idf_components(libraries, managed=None):
|
def fake_generate_idf_components(libraries, managed=None, provides=None):
|
||||||
captured["managed"] = managed
|
captured["managed"] = managed
|
||||||
|
captured["provides"] = provides
|
||||||
return [converted]
|
return [converted]
|
||||||
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
@@ -226,6 +227,8 @@ def test_convert_pio_libs_arduino_framework_passes_empty_managed(
|
|||||||
result = _convert_pio_libs(tmp_path / "platformio.ini", "arduino")
|
result = _convert_pio_libs(tmp_path / "platformio.ini", "arduino")
|
||||||
|
|
||||||
assert captured["managed"] == set()
|
assert captured["managed"] == set()
|
||||||
|
# Bundled Arduino names are left to arduino-esp32, as in the real build
|
||||||
|
assert captured["provides"]("Wire") and not captured["provides"]("Adafruit BusIO")
|
||||||
assert result == {
|
assert result == {
|
||||||
"esphome/other-lib": {"override_path": str(tmp_path / "other-lib")}
|
"esphome/other-lib": {"override_path": str(tmp_path / "other-lib")}
|
||||||
}
|
}
|
||||||
@@ -242,7 +245,7 @@ def test_convert_pio_libs_arduino_idf_6_passes_manifest_deps(
|
|||||||
|
|
||||||
captured: dict[str, set[str] | None] = {}
|
captured: dict[str, set[str] | None] = {}
|
||||||
|
|
||||||
def fake_generate_idf_components(libraries, managed=None):
|
def fake_generate_idf_components(libraries, managed=None, provides=None):
|
||||||
captured["managed"] = managed
|
captured["managed"] = managed
|
||||||
return []
|
return []
|
||||||
|
|
||||||
@@ -265,8 +268,9 @@ def test_convert_pio_libs_espidf_framework_passes_manifest_deps(
|
|||||||
|
|
||||||
captured: dict[str, set[str] | None] = {}
|
captured: dict[str, set[str] | None] = {}
|
||||||
|
|
||||||
def fake_generate_idf_components(libraries, managed=None):
|
def fake_generate_idf_components(libraries, managed=None, provides=None):
|
||||||
captured["managed"] = managed
|
captured["managed"] = managed
|
||||||
|
captured["provides"] = provides
|
||||||
return []
|
return []
|
||||||
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
@@ -276,6 +280,8 @@ def test_convert_pio_libs_espidf_framework_passes_manifest_deps(
|
|||||||
result = _convert_pio_libs(tmp_path / "platformio.ini", "espidf")
|
result = _convert_pio_libs(tmp_path / "platformio.ini", "espidf")
|
||||||
|
|
||||||
assert captured["managed"] == _esphome_manifest_deps()
|
assert captured["managed"] == _esphome_manifest_deps()
|
||||||
|
# ESP-IDF bundles no Arduino libraries
|
||||||
|
assert captured["provides"] is None
|
||||||
assert "esphome/noise-c" in captured["managed"]
|
assert "esphome/noise-c" in captured["managed"]
|
||||||
assert result == {}
|
assert result == {}
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ Covers the shared download/parse/resolve/dependency-walk paths in
|
|||||||
``esphome.platformio.library`` directly (the ESP-IDF and Zephyr backends are
|
``esphome.platformio.library`` directly (the ESP-IDF and Zephyr backends are
|
||||||
exercised in their own test modules)."""
|
exercised in their own test modules)."""
|
||||||
|
|
||||||
|
from collections.abc import Collection
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import Mock
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
from platformio.package.exception import UnknownPackageError
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from esphome.core import CORE, EsphomeError, Library
|
from esphome.core import CORE, EsphomeError, Library
|
||||||
@@ -261,23 +263,37 @@ def test_make_registry_client_creates_http_cache_dir(
|
|||||||
lib._make_registry_client()
|
lib._make_registry_client()
|
||||||
|
|
||||||
|
|
||||||
def _patch_registry_resolve(monkeypatch: pytest.MonkeyPatch) -> None:
|
def _patch_registry_resolve(
|
||||||
"""Stub the registry lookup so tests never touch the network."""
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
monkeypatch.setattr(
|
*,
|
||||||
lib,
|
unknown: Collection[str] = (),
|
||||||
"_resolve_registry_version",
|
owner: str | None = None,
|
||||||
lambda owner, pkgname, requirements: (
|
) -> list[tuple[str | None, str]]:
|
||||||
owner,
|
"""Stub the registry lookup so tests never touch the network; returns the
|
||||||
pkgname,
|
(owner, name) pairs looked up. ``unknown`` names miss; ``owner`` is what the
|
||||||
"1.0.0",
|
registry answers for an owner-less name."""
|
||||||
f"http://x/{pkgname}.tar.gz",
|
asked: list[tuple[str | None, str]] = []
|
||||||
None,
|
|
||||||
),
|
def resolve(requested_owner, pkgname, requirements):
|
||||||
)
|
asked.append((requested_owner, pkgname))
|
||||||
|
if pkgname in unknown:
|
||||||
|
raise UnknownPackageError(pkgname)
|
||||||
|
return _resolution(requested_owner or owner, pkgname)
|
||||||
|
|
||||||
|
monkeypatch.setattr(lib, "_resolve_registry_version", resolve)
|
||||||
|
return asked
|
||||||
|
|
||||||
|
|
||||||
def _patch_download_with_manifests(monkeypatch, tmp_path, manifests, *, properties=()):
|
def _resolution(owner, pkgname):
|
||||||
"""Fake ConvertedLibrary.download to materialize canned manifests on disk."""
|
"""What the stubbed registry answers for a package."""
|
||||||
|
return owner, pkgname, "1.0.0", f"http://x/{pkgname}.tar.gz", None
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_download_with_manifests(
|
||||||
|
monkeypatch, tmp_path, manifests, *, properties=(), unknown=(), owner=None
|
||||||
|
):
|
||||||
|
"""Fake ConvertedLibrary.download to materialize canned manifests on disk;
|
||||||
|
returns the registry lookups made (see _patch_registry_resolve)."""
|
||||||
|
|
||||||
def fake_download(self, force=False, salt="", namespace=""):
|
def fake_download(self, force=False, salt="", namespace=""):
|
||||||
self.path = tmp_path / self.get_require_name()
|
self.path = tmp_path / self.get_require_name()
|
||||||
@@ -288,7 +304,10 @@ def _patch_download_with_manifests(monkeypatch, tmp_path, manifests, *, properti
|
|||||||
(self.path / "library.json").write_text(json.dumps(manifests[self.name]))
|
(self.path / "library.json").write_text(json.dumps(manifests[self.name]))
|
||||||
|
|
||||||
monkeypatch.setattr(ConvertedLibrary, "download", fake_download)
|
monkeypatch.setattr(ConvertedLibrary, "download", fake_download)
|
||||||
_patch_registry_resolve(monkeypatch)
|
# The wave prefetch would clone git sources for real; the walk's own
|
||||||
|
# download() above is what these tests exercise
|
||||||
|
monkeypatch.setattr(lib, "_prefetch_wave", lambda *args: None)
|
||||||
|
return _patch_registry_resolve(monkeypatch, unknown=unknown, owner=owner)
|
||||||
|
|
||||||
|
|
||||||
def test_wave_requirement_growth_defers_the_superseded_download(tmp_path, monkeypatch):
|
def test_wave_requirement_growth_defers_the_superseded_download(tmp_path, monkeypatch):
|
||||||
@@ -343,18 +362,22 @@ def test_convert_libraries_parses_library_properties(tmp_path, monkeypatch):
|
|||||||
assert emitted[0].data["version"] == "1.0"
|
assert emitted[0].data["version"] == "1.0"
|
||||||
|
|
||||||
|
|
||||||
def test_convert_libraries_skips_dependency_without_version(tmp_path, monkeypatch):
|
def test_convert_libraries_fetches_dependency_without_version(tmp_path, monkeypatch):
|
||||||
# A dependency entry lacking a version is malformed and silently skipped.
|
# A dependency entry lacking a version resolves by name alone, as PlatformIO did.
|
||||||
_patch_download_with_manifests(
|
asked = _patch_download_with_manifests(
|
||||||
monkeypatch,
|
monkeypatch,
|
||||||
tmp_path,
|
tmp_path,
|
||||||
{"esphome/A": {"name": "A", "dependencies": [{"name": "C"}]}},
|
{
|
||||||
|
"esphome/A": {"name": "A", "dependencies": [{"name": "C"}]},
|
||||||
|
"C": {"name": "C"},
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
# No version on the top-level spec exercises the "no requirement" path too.
|
# No version on the top-level spec exercises the "no requirement" path too.
|
||||||
top = convert_libraries([Library("esphome/A", None, None)], _backend())
|
top = convert_libraries([Library("esphome/A", None, None)], _backend())
|
||||||
|
|
||||||
assert top[0].dependencies == []
|
assert [d.name for d in top[0].dependencies] == ["C"]
|
||||||
|
assert asked == [("esphome", "A"), (None, "C")]
|
||||||
|
|
||||||
|
|
||||||
def test_convert_libraries_handles_unparsable_dependency_version(tmp_path, monkeypatch):
|
def test_convert_libraries_handles_unparsable_dependency_version(tmp_path, monkeypatch):
|
||||||
@@ -1013,20 +1036,24 @@ def test_convert_libraries_malformed_transitive_dep_skips(
|
|||||||
assert "Skipping dependency esphome/B: malformed manifest" in caplog.text
|
assert "Skipping dependency esphome/B: malformed manifest" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
def test_walk_warns_for_properties_only_depends(
|
def test_walk_resolves_properties_only_depends(tmp_path, monkeypatch) -> None:
|
||||||
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
|
"""A library.properties depends= line is a dependency list: a bundled name
|
||||||
) -> None:
|
goes to the backend, the rest resolve by name."""
|
||||||
"""A manifest declaring dependencies only as library.properties depends=
|
asked = _patch_download_with_manifests(
|
||||||
warns in the shared walk, so every backend reports the drop."""
|
|
||||||
_patch_download_with_manifests(
|
|
||||||
monkeypatch,
|
monkeypatch,
|
||||||
tmp_path,
|
tmp_path,
|
||||||
{"esphome/A": "name=A\nversion=1.0\ndepends=Wire, SPI\n"},
|
{
|
||||||
|
"esphome/A": "name=A\nversion=1.0\ndepends=Wire, SPI\n",
|
||||||
|
"SPI": {"name": "SPI"},
|
||||||
|
},
|
||||||
properties=("esphome/A",),
|
properties=("esphome/A",),
|
||||||
)
|
)
|
||||||
caplog.set_level("INFO")
|
backend = _backend(provides=lambda name: name == "Wire")
|
||||||
convert_libraries([Library("esphome/A", "1.0.0", None)], _backend())
|
top = convert_libraries([Library("esphome/A", "1.0.0", None)], backend)
|
||||||
assert "declares dependencies via library.properties" in caplog.text
|
assert [d.name for d in top[0].dependencies] == ["SPI"]
|
||||||
|
assert "depends" not in top[0].data
|
||||||
|
assert backend.provided_requests == {"Wire"}
|
||||||
|
assert (None, "SPI") in asked
|
||||||
|
|
||||||
|
|
||||||
def test_walk_warns_for_nonplatform_invalid_library(
|
def test_walk_warns_for_nonplatform_invalid_library(
|
||||||
@@ -1093,12 +1120,12 @@ def test_source_kind_map_shape() -> None:
|
|||||||
assert SOURCE_KIND_FOR_SUFFIX[".C++"] == "cxx"
|
assert SOURCE_KIND_FOR_SUFFIX[".C++"] == "cxx"
|
||||||
|
|
||||||
|
|
||||||
def test_versionless_platform_filtered_dependency_stays_quiet(
|
def test_versionless_platform_filtered_dependency_is_not_fetched(
|
||||||
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
|
tmp_path, monkeypatch
|
||||||
) -> None:
|
) -> None:
|
||||||
"""A version-less dependency the platform filter excludes is
|
"""A version-less dependency the platform filter excludes is deliberately
|
||||||
deliberately absent, not a drop to warn about."""
|
absent, not fetched by name."""
|
||||||
_patch_download_with_manifests(
|
asked = _patch_download_with_manifests(
|
||||||
monkeypatch,
|
monkeypatch,
|
||||||
tmp_path,
|
tmp_path,
|
||||||
{
|
{
|
||||||
@@ -1108,59 +1135,52 @@ def test_versionless_platform_filtered_dependency_stays_quiet(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
convert_libraries([Library("esphome/A", None, None)], _backend())
|
top = convert_libraries([Library("esphome/A", None, None)], _backend())
|
||||||
assert "has no version to resolve" not in caplog.text
|
assert top[0].dependencies == []
|
||||||
|
assert (None, "Hash") not in asked
|
||||||
|
|
||||||
|
|
||||||
def test_versionless_ignored_dependency_stays_quiet(
|
def test_versionless_ignored_dependency_is_not_fetched(tmp_path, monkeypatch) -> None:
|
||||||
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
|
|
||||||
) -> None:
|
|
||||||
"""A lib_ignore'd version-less dependency is deliberately excluded, not
|
"""A lib_ignore'd version-less dependency is deliberately excluded, not
|
||||||
a drop; no reconciliation warning."""
|
fetched by name."""
|
||||||
_patch_download_with_manifests(
|
asked = _patch_download_with_manifests(
|
||||||
monkeypatch,
|
monkeypatch,
|
||||||
tmp_path,
|
tmp_path,
|
||||||
{"esphome/A": {"name": "A", "dependencies": [{"name": "Hash"}]}},
|
{"esphome/A": {"name": "A", "dependencies": [{"name": "Hash"}]}},
|
||||||
)
|
)
|
||||||
CORE.platformio_options = {"lib_ignore": ["Hash"]}
|
CORE.platformio_options = {"lib_ignore": ["Hash"]}
|
||||||
convert_libraries([Library("esphome/A", None, None)], _backend())
|
top = convert_libraries([Library("esphome/A", None, None)], _backend())
|
||||||
assert "has no version to resolve" not in caplog.text
|
assert top[0].dependencies == []
|
||||||
|
assert (None, "Hash") not in asked
|
||||||
|
|
||||||
|
|
||||||
def test_versionless_dependency_without_provider_warns(
|
def test_versionless_dependency_registry_miss_is_skipped(
|
||||||
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
|
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
|
||||||
) -> None:
|
) -> None:
|
||||||
"""A backend whose tree could supply the name warns on the drop; one
|
"""A name-only dependency the registry does not know is skipped with one
|
||||||
without provides() can never act on it, so it stays at debug."""
|
warning naming the requester, and the requester still converts; a listed
|
||||||
|
library the registry does not know still fails the build."""
|
||||||
_patch_download_with_manifests(
|
_patch_download_with_manifests(
|
||||||
monkeypatch,
|
monkeypatch,
|
||||||
tmp_path,
|
tmp_path,
|
||||||
{
|
{
|
||||||
"esphome/A": {
|
"esphome/A": {
|
||||||
"name": "A",
|
"name": "A",
|
||||||
# The duplicate entry warns once (reconciliation dedup)
|
# The duplicate entry is one node, so it warns once
|
||||||
"dependencies": [{"name": "Hash"}, {"name": "Hash"}],
|
"dependencies": [{"name": "Hash"}, {"name": "Hash"}],
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
unknown={"Hash"},
|
||||||
)
|
)
|
||||||
convert_libraries(
|
|
||||||
[Library("esphome/A", None, None)], _backend(provides=lambda name: False)
|
top = convert_libraries([Library("esphome/A", None, None)], _backend())
|
||||||
)
|
assert top[0].dependencies == []
|
||||||
assert (
|
assert (
|
||||||
caplog.text.count(
|
caplog.text.count("Dependency Hash of esphome/A was not resolved; skipping")
|
||||||
"Hash of esphome/A has no version to resolve and nothing provides it"
|
|
||||||
)
|
|
||||||
== 1
|
== 1
|
||||||
)
|
)
|
||||||
caplog.clear()
|
with pytest.raises(UnknownPackageError):
|
||||||
with caplog.at_level(logging.DEBUG):
|
convert_libraries([Library("Hash", None, None)], _backend())
|
||||||
convert_libraries([Library("esphome/A", None, None)], _backend())
|
|
||||||
records = [
|
|
||||||
r
|
|
||||||
for r in caplog.records
|
|
||||||
if "has no version to resolve and nothing provides it" in r.message
|
|
||||||
]
|
|
||||||
assert records and all(r.levelno == logging.DEBUG for r in records)
|
|
||||||
|
|
||||||
|
|
||||||
def test_url_version_dependency_is_not_substituted_by_provides(
|
def test_url_version_dependency_is_not_substituted_by_provides(
|
||||||
@@ -1191,102 +1211,616 @@ def test_url_version_dependency_is_not_substituted_by_provides(
|
|||||||
assert any("o/hash" in n.lower() for n in emitted)
|
assert any("o/hash" in n.lower() for n in emitted)
|
||||||
|
|
||||||
|
|
||||||
def test_versionless_owner_qualified_dependency_warns_despite_provides(
|
def test_versionless_owner_qualified_dependency_is_fetched_despite_provides(
|
||||||
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
|
tmp_path, monkeypatch
|
||||||
) -> None:
|
) -> None:
|
||||||
"""An owner-qualified version-less dependency is not satisfied by
|
"""An owner-qualified version-less dependency names a registry package,
|
||||||
provides(); it must still warn."""
|
so provides() does not stand in for it."""
|
||||||
_patch_download_with_manifests(
|
asked = _patch_download_with_manifests(
|
||||||
monkeypatch,
|
monkeypatch,
|
||||||
tmp_path,
|
tmp_path,
|
||||||
{
|
{
|
||||||
"esphome/A": {
|
"esphome/A": {
|
||||||
"name": "A",
|
"name": "A",
|
||||||
"dependencies": [{"name": "Wire", "owner": "Foo"}],
|
"dependencies": [{"name": "Wire", "owner": "Foo"}],
|
||||||
}
|
},
|
||||||
|
"Foo/Wire": {"name": "Wire"},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
convert_libraries(
|
backend = _backend(provides=lambda name: name == "Wire")
|
||||||
[Library("esphome/A", None, None)],
|
top = convert_libraries([Library("esphome/A", None, None)], backend)
|
||||||
_backend(provides=lambda name: name == "Wire"),
|
assert [d.name for d in top[0].dependencies] == ["Foo/Wire"]
|
||||||
)
|
assert ("Foo", "Wire") in asked
|
||||||
assert "Wire of esphome/A has no version to resolve" in caplog.text
|
assert not backend.provided_requests
|
||||||
|
|
||||||
|
|
||||||
def test_versionless_provided_dependency_stays_quiet(
|
|
||||||
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
|
|
||||||
) -> None:
|
|
||||||
"""An owner-less version-less dependency the backend provides is added
|
|
||||||
by the backend after emit; no reconciliation warning."""
|
|
||||||
_patch_download_with_manifests(
|
|
||||||
monkeypatch,
|
|
||||||
tmp_path,
|
|
||||||
{"esphome/A": {"name": "A", "dependencies": [{"name": "Wire"}]}},
|
|
||||||
)
|
|
||||||
convert_libraries(
|
|
||||||
[Library("esphome/A", None, None)],
|
|
||||||
_backend(provides=lambda name: name == "Wire"),
|
|
||||||
)
|
|
||||||
assert "has no version to resolve" not in caplog.text
|
|
||||||
|
|
||||||
|
|
||||||
def test_versionless_dependency_requested_top_level_stays_quiet(
|
|
||||||
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
|
|
||||||
) -> None:
|
|
||||||
"""A version-less dependency the config also requests top-level is in
|
|
||||||
the build; no drop warning even without a provides backend."""
|
|
||||||
_patch_download_with_manifests(
|
|
||||||
monkeypatch,
|
|
||||||
tmp_path,
|
|
||||||
{
|
|
||||||
"esphome/A": {"name": "A", "dependencies": [{"name": "Hash"}]},
|
|
||||||
"Hash": {"name": "Hash"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
convert_libraries(
|
|
||||||
[Library("esphome/A", None, None), Library("Hash", None, None)],
|
|
||||||
_backend(),
|
|
||||||
)
|
|
||||||
assert "has no version to resolve" not in caplog.text
|
|
||||||
|
|
||||||
|
|
||||||
def test_versionless_url_ish_dependency_name_warns_cleanly(
|
def test_versionless_url_ish_dependency_name_warns_cleanly(
|
||||||
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
|
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
|
||||||
) -> None:
|
) -> None:
|
||||||
"""A malformed URL-ish dependency name falls to the drop warning, never
|
"""A malformed URL-ish dependency name is skipped with a warning, never
|
||||||
a RuntimeError out of the key parser."""
|
a RuntimeError out of the key parser."""
|
||||||
_patch_download_with_manifests(
|
_patch_download_with_manifests(
|
||||||
monkeypatch,
|
monkeypatch,
|
||||||
tmp_path,
|
tmp_path,
|
||||||
{"esphome/A": {"name": "A", "dependencies": [{"name": "file://"}]}},
|
{"esphome/A": {"name": "A", "dependencies": [{"name": "file://"}]}},
|
||||||
)
|
)
|
||||||
convert_libraries(
|
top = convert_libraries([Library("esphome/A", None, None)], _backend())
|
||||||
[Library("esphome/A", None, None)], _backend(provides=lambda name: False)
|
assert top[0].dependencies == []
|
||||||
|
assert "Skipping dependency file:// of esphome/A" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_versionless_dependency_matching_resolved_manifest_name_is_wired(
|
||||||
|
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
|
||||||
|
) -> None:
|
||||||
|
"""A bare name no package name matches is satisfied by a listed component's
|
||||||
|
manifest name (a fork published under another package name)."""
|
||||||
|
asked = _patch_download_with_manifests(
|
||||||
|
monkeypatch,
|
||||||
|
tmp_path,
|
||||||
|
{
|
||||||
|
"esphome/A": {"name": "A", "dependencies": [{"name": "B"}]},
|
||||||
|
"esphome/B-fork": {"name": "B"},
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
with caplog.at_level(logging.INFO):
|
||||||
|
top = convert_libraries(
|
||||||
|
[Library("esphome/A", None, None), Library("esphome/B-fork", None, None)],
|
||||||
|
_backend(),
|
||||||
|
)
|
||||||
|
assert [d.name for d in top[0].dependencies] == ["esphome/B-fork"]
|
||||||
|
assert asked == [("esphome", "A"), ("esphome", "B-fork")]
|
||||||
assert (
|
assert (
|
||||||
"file:// of esphome/A has no version to resolve and nothing provides it"
|
"Dependency B of esphome/A satisfied by esphome/B-fork through its manifest name"
|
||||||
in caplog.text
|
in caplog.text
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_versionless_dependency_matching_resolved_manifest_name_stays_quiet(
|
def test_dependency_naming_the_requester_itself_is_ignored(
|
||||||
|
tmp_path, monkeypatch
|
||||||
|
) -> None:
|
||||||
|
"""A manifest that lists its own name as a dependency neither fetches a
|
||||||
|
second copy of itself nor gains a self edge."""
|
||||||
|
asked = _patch_download_with_manifests(
|
||||||
|
monkeypatch,
|
||||||
|
tmp_path,
|
||||||
|
{
|
||||||
|
"esphome/A": "name=A\ndepends=a, esphome/A\n",
|
||||||
|
# No manifest name: the requested and resolved package names still
|
||||||
|
# identify it, with or without the owner
|
||||||
|
"esphome/C": {
|
||||||
|
"dependencies": [{"name": "C"}, {"name": "C", "owner": "esphome"}]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
properties=("esphome/A",),
|
||||||
|
)
|
||||||
|
top = convert_libraries(
|
||||||
|
[Library("esphome/A", "1.0.0", None), Library("esphome/C", "1.0.0", None)],
|
||||||
|
_backend(),
|
||||||
|
)
|
||||||
|
assert [c.dependencies for c in top] == [[], []]
|
||||||
|
assert asked == [("esphome", "A"), ("esphome", "C")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_versionless_dependency_settles_after_the_graph(
|
||||||
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
|
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
|
||||||
) -> None:
|
) -> None:
|
||||||
"""A bare name satisfied by an owner-qualified component's manifest
|
"""The issue 21308 shape: a library.properties depends= naming a library the
|
||||||
name is not a drop."""
|
config lists owner-qualified is wired to that one package, case-insensitively
|
||||||
|
and whatever constraint it carries, without a second node for the bare name
|
||||||
|
and ahead of a same-named bundled copy, as on the Arduino backend."""
|
||||||
|
asked = _patch_download_with_manifests(
|
||||||
|
monkeypatch,
|
||||||
|
tmp_path,
|
||||||
|
{
|
||||||
|
"adafruit/Adafruit GFX Library": (
|
||||||
|
"name=Adafruit GFX Library\ndepends=adafruit busio (>=1.0)\n"
|
||||||
|
),
|
||||||
|
"adafruit/Adafruit BusIO": {"name": "Adafruit BusIO"},
|
||||||
|
},
|
||||||
|
properties=("adafruit/Adafruit GFX Library",),
|
||||||
|
)
|
||||||
|
backend = _backend(provides=lambda name: name == "adafruit busio")
|
||||||
|
top = convert_libraries(
|
||||||
|
[
|
||||||
|
Library("adafruit/Adafruit GFX Library", "^1.13.0", None),
|
||||||
|
Library("adafruit/Adafruit BusIO", "^1.17.4", None),
|
||||||
|
],
|
||||||
|
backend,
|
||||||
|
)
|
||||||
|
assert [d.name for d in top[0].dependencies] == ["adafruit/Adafruit BusIO"]
|
||||||
|
assert len(asked) == 2
|
||||||
|
assert not backend.provided_requests
|
||||||
|
assert "multiple names" not in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_by_name_node_folds_into_a_later_pin_of_the_same_package(
|
||||||
|
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
|
||||||
|
) -> None:
|
||||||
|
"""A library fetched by name alone may pull in a manifest that pins the same
|
||||||
|
package owner-qualified; the by-name node folds into that one instead of
|
||||||
|
converting the package twice."""
|
||||||
|
# Manifests are keyed by the resolved name, so both B nodes read the same one:
|
||||||
|
# B pins C, and C pins owner/B, which arrives after B was fetched by name
|
||||||
|
manifests = {
|
||||||
|
"esphome/A": {"name": "A", "dependencies": [{"name": "B"}]},
|
||||||
|
"esphome/B": {"name": "B", "dependencies": {"esphome/C": "1.0.0"}},
|
||||||
|
"esphome/C": {"name": "C", "dependencies": {"esphome/B": "1.0.0"}},
|
||||||
|
}
|
||||||
|
_patch_download_with_manifests(monkeypatch, tmp_path, manifests, owner="esphome")
|
||||||
|
|
||||||
|
emitted: list[str] = []
|
||||||
|
top = convert_libraries(
|
||||||
|
[Library("esphome/A", None, None)],
|
||||||
|
_backend(emit=lambda c: emitted.append(c.name)),
|
||||||
|
)
|
||||||
|
assert [d.name for d in top[0].dependencies] == ["esphome/B"]
|
||||||
|
assert emitted.count("esphome/B") == 1
|
||||||
|
assert "multiple names" not in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_two_spellings_of_a_name_in_one_round_share_a_node(
|
||||||
|
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
|
||||||
|
) -> None:
|
||||||
|
"""Requesters spelling one unlisted name differently get a single by-name
|
||||||
|
fetch and the same node."""
|
||||||
|
asked = _patch_download_with_manifests(
|
||||||
|
monkeypatch,
|
||||||
|
tmp_path,
|
||||||
|
{
|
||||||
|
"esphome/A": {"name": "A", "dependencies": [{"name": "Foo"}]},
|
||||||
|
"esphome/B": {"name": "B", "dependencies": [{"name": "foo"}]},
|
||||||
|
"Foo": {"name": "Foo"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
top = convert_libraries(
|
||||||
|
[Library("esphome/A", None, None), Library("esphome/B", None, None)],
|
||||||
|
_backend(),
|
||||||
|
)
|
||||||
|
assert [d.name for d in top[0].dependencies] == ["Foo"]
|
||||||
|
assert [d.name for d in top[1].dependencies] == ["Foo"]
|
||||||
|
assert asked.count((None, "Foo")) + asked.count((None, "foo")) == 1
|
||||||
|
assert "multiple names" not in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_properties_depends_reads_names_and_drops_constraints(
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
"""Arduino constraints are not PlatformIO syntax, so only the names survive;
|
||||||
|
an unclosed parenthesis loses nothing more than the constraint."""
|
||||||
|
with caplog.at_level(logging.DEBUG):
|
||||||
|
entries = lib.parse_properties_depends(
|
||||||
|
"Adafruit BusIO, Foo (>=1.2), ,Bar( <2 ), Baz (=1.0, Qux ()"
|
||||||
|
)
|
||||||
|
levels = {
|
||||||
|
r.message.removeprefix("Ignoring version constraint on depends="): r.levelno
|
||||||
|
for r in caplog.records
|
||||||
|
if "Ignoring version constraint" in r.message
|
||||||
|
}
|
||||||
|
# A lower bound is met by the current version; a cap or pin may not be
|
||||||
|
assert levels == {
|
||||||
|
"Foo (>=1.2)": logging.DEBUG,
|
||||||
|
"Bar( <2 )": logging.INFO,
|
||||||
|
"Baz (=1.0": logging.INFO,
|
||||||
|
"Qux ()": logging.INFO,
|
||||||
|
}
|
||||||
|
assert entries == [
|
||||||
|
{"name": "Adafruit BusIO"},
|
||||||
|
{"name": "Foo"},
|
||||||
|
{"name": "Bar"},
|
||||||
|
{"name": "Baz"},
|
||||||
|
{"name": "Qux"},
|
||||||
|
]
|
||||||
|
assert "Ignoring version constraint on depends=Foo (>=1.2)" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_by_name_node_pinned_later_reports_a_requirement_conflict(
|
||||||
|
tmp_path, monkeypatch
|
||||||
|
) -> None:
|
||||||
|
"""A node fetched by name that a later manifest pins under the same spelling
|
||||||
|
is resolved again against the pin; a conflict is an error, not a miss."""
|
||||||
_patch_download_with_manifests(
|
_patch_download_with_manifests(
|
||||||
monkeypatch,
|
monkeypatch,
|
||||||
tmp_path,
|
tmp_path,
|
||||||
{
|
{
|
||||||
"esphome/A": {"name": "A", "dependencies": [{"name": "B"}]},
|
"esphome/A": {"name": "A", "dependencies": [{"name": "B"}]},
|
||||||
"esphome/B": {"name": "B"},
|
"B": {"name": "B", "dependencies": {"C": "1.0.0"}},
|
||||||
|
"C": {"name": "C", "dependencies": {"B": "^2.0"}},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def resolve(owner, pkgname, requirements):
|
||||||
|
if pkgname == "B" and requirements:
|
||||||
|
raise RuntimeError(f"No version of {pkgname} satisfies {requirements}")
|
||||||
|
return _resolution(owner, pkgname)
|
||||||
|
|
||||||
|
monkeypatch.setattr(lib, "_resolve_registry_version", resolve)
|
||||||
|
with pytest.raises(RuntimeError, match="No version of B satisfies"):
|
||||||
|
convert_libraries([Library("esphome/A", None, None)], _backend())
|
||||||
|
|
||||||
|
|
||||||
|
def test_by_name_fetch_yields_to_a_fork_pulled_in_later(
|
||||||
|
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
|
||||||
|
) -> None:
|
||||||
|
"""A name fetched from the registry yields to a library another round
|
||||||
|
pins explicitly under the same manifest name, so only the fork is built."""
|
||||||
|
_patch_download_with_manifests(
|
||||||
|
monkeypatch,
|
||||||
|
tmp_path,
|
||||||
|
{
|
||||||
|
"esphome/A": {"name": "A", "dependencies": [{"name": "B"}]},
|
||||||
|
"esphome/X": {"name": "X", "dependencies": [{"name": "C"}]},
|
||||||
|
# The upstream B pulls in D, which nothing else needs; D asks for a
|
||||||
|
# name the registry does not know
|
||||||
|
"B": {"name": "B", "dependencies": {"esphome/D": "1.0.0"}},
|
||||||
|
"esphome/D": {"name": "D", "dependencies": [{"name": "Zed"}]},
|
||||||
|
"C": {"name": "C", "dependencies": {"custom/B-fork": "1.0.0"}},
|
||||||
|
"custom/B-fork": {"name": "B"},
|
||||||
|
},
|
||||||
|
unknown={"Zed"},
|
||||||
|
)
|
||||||
|
|
||||||
|
emitted: list[str] = []
|
||||||
|
top = convert_libraries(
|
||||||
|
[Library("esphome/A", None, None), Library("esphome/X", None, None)],
|
||||||
|
_backend(emit=lambda c: emitted.append(c.name)),
|
||||||
|
)
|
||||||
|
assert [d.name for d in top[0].dependencies] == ["custom/B-fork"]
|
||||||
|
assert sorted(emitted) == ["C", "custom/B-fork", "esphome/A", "esphome/X"]
|
||||||
|
# The miss belongs to a library that is not built, so it is not reported
|
||||||
|
assert "Zed" not in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_git_source_replacing_a_registry_key_in_the_same_wave(
|
||||||
|
tmp_path, monkeypatch
|
||||||
|
) -> None:
|
||||||
|
"""A manifest in one wave naming a key of the same wave as a git source
|
||||||
|
defers that key to the next wave instead of tripping over its stale record."""
|
||||||
|
_patch_download_with_manifests(
|
||||||
|
monkeypatch,
|
||||||
|
tmp_path,
|
||||||
|
{
|
||||||
|
"o/A": {"name": "A", "dependencies": {"B": "https://github.com/o/B.git"}},
|
||||||
|
"o/B": {"name": "B"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
emitted: dict[str, ConvertedLibrary] = {}
|
||||||
|
top = convert_libraries(
|
||||||
|
[Library("o/A", "1.0.0", None), Library("o/B", "1.0.0", None)],
|
||||||
|
_backend(emit=lambda c: emitted.__setitem__(c.name, c)),
|
||||||
|
)
|
||||||
|
assert [c.name for c in top] == ["o/A", "o/B"]
|
||||||
|
assert isinstance(emitted["o/B"].source, GitSource)
|
||||||
|
assert [d.name for d in top[0].dependencies] == ["o/B"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_owner_qualified_name_matches_the_resolved_owner(tmp_path, monkeypatch) -> None:
|
||||||
|
"""A version-less ``o/B`` is satisfied by a bare ``B`` pin the registry
|
||||||
|
resolved to owner ``o``, so the package is not fetched a second time."""
|
||||||
|
asked = _patch_download_with_manifests(
|
||||||
|
monkeypatch,
|
||||||
|
tmp_path,
|
||||||
|
{
|
||||||
|
"esphome/A": {
|
||||||
|
"name": "A",
|
||||||
|
"dependencies": [
|
||||||
|
{"name": "B", "version": "1.0.0"},
|
||||||
|
{"name": "B", "owner": "o"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"o/B": {"name": "B"},
|
||||||
|
},
|
||||||
|
owner="o",
|
||||||
|
)
|
||||||
|
top = convert_libraries([Library("esphome/A", None, None)], _backend())
|
||||||
|
assert [d.name for d in top[0].dependencies] == ["o/B"]
|
||||||
|
assert asked.count((None, "B")) == 1
|
||||||
|
assert ("o", "B") not in asked
|
||||||
|
|
||||||
|
|
||||||
|
def test_two_spellings_fetched_by_name_fold_into_one_node(
|
||||||
|
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
|
||||||
|
) -> None:
|
||||||
|
"""Version-less ``B`` and ``o/B`` from two requesters resolve to one
|
||||||
|
package; only one copy is built and both requesters depend on it."""
|
||||||
|
_patch_download_with_manifests(
|
||||||
|
monkeypatch,
|
||||||
|
tmp_path,
|
||||||
|
{
|
||||||
|
"esphome/A": {"name": "A", "dependencies": [{"name": "B"}]},
|
||||||
|
"esphome/X": {"name": "X", "dependencies": [{"name": "B", "owner": "o"}]},
|
||||||
|
"o/B": {"name": "B"},
|
||||||
|
},
|
||||||
|
owner="o",
|
||||||
|
)
|
||||||
|
emitted: list[str] = []
|
||||||
|
top = convert_libraries(
|
||||||
|
[Library("esphome/A", None, None), Library("esphome/X", None, None)],
|
||||||
|
_backend(emit=lambda c: emitted.append(c.name)),
|
||||||
|
)
|
||||||
|
assert emitted.count("o/B") == 1
|
||||||
|
kept = top[0].dependencies[0]
|
||||||
|
assert top[1].dependencies == [kept]
|
||||||
|
assert "multiple names" not in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_registry_miss_resolves_again_as_a_git_source(tmp_path, monkeypatch) -> None:
|
||||||
|
"""A key the registry missed is resolved again when a later manifest names
|
||||||
|
the same key as a git source."""
|
||||||
|
_patch_download_with_manifests(
|
||||||
|
monkeypatch,
|
||||||
|
tmp_path,
|
||||||
|
{
|
||||||
|
"esphome/A": {"name": "A", "dependencies": [{"name": "B", "owner": "o"}]},
|
||||||
|
"esphome/X": {"name": "X", "dependencies": [{"name": "C"}]},
|
||||||
|
"C": {"name": "C", "dependencies": {"B": "https://github.com/o/B.git"}},
|
||||||
|
"o/B": {"name": "B"},
|
||||||
|
},
|
||||||
|
unknown={"B"},
|
||||||
|
)
|
||||||
|
|
||||||
|
emitted: dict[str, ConvertedLibrary] = {}
|
||||||
|
top = convert_libraries(
|
||||||
|
[Library("esphome/A", None, None), Library("esphome/X", None, None)],
|
||||||
|
_backend(emit=lambda c: emitted.__setitem__(c.name, c)),
|
||||||
|
)
|
||||||
|
assert [d.name for d in top[0].dependencies] == ["o/B"]
|
||||||
|
assert isinstance(emitted["o/B"].source, GitSource)
|
||||||
|
assert [d.name for d in emitted["C"].dependencies] == ["o/B"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_dependencies_splits_an_embedded_owner() -> None:
|
||||||
|
"""``owner/Name`` as one string, in a list or a dict entry, normalizes like
|
||||||
|
the dict form; a URL or a deeper path is left alone."""
|
||||||
|
assert lib.normalize_dependencies(
|
||||||
|
["o/B", {"name": "o/C", "version": "1.0"}, "x://y", "a/b/c", "../escape"],
|
||||||
|
"libx",
|
||||||
|
) == [
|
||||||
|
{"name": "B", "owner": "o"},
|
||||||
|
{"name": "C", "owner": "o", "version": "1.0"},
|
||||||
|
{"name": "x://y"},
|
||||||
|
{"name": "a/b/c"},
|
||||||
|
{"name": "../escape"},
|
||||||
|
]
|
||||||
|
assert lib.normalize_dependencies("o/B, D", "libx") == [
|
||||||
|
{"name": "B", "owner": "o"},
|
||||||
|
{"name": "D"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_owner_qualified_string_dependency_is_fetched_and_wired(
|
||||||
|
tmp_path, monkeypatch
|
||||||
|
) -> None:
|
||||||
|
"""``"dependencies": ["o/B"]`` fetches ``o/B`` and wires the requester to it."""
|
||||||
|
asked = _patch_download_with_manifests(
|
||||||
|
monkeypatch,
|
||||||
|
tmp_path,
|
||||||
|
{
|
||||||
|
"esphome/A": {"name": "A", "dependencies": ["o/B"]},
|
||||||
|
"o/B": {"name": "B"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
top = convert_libraries([Library("esphome/A", None, None)], _backend())
|
||||||
|
assert [d.name for d in top[0].dependencies] == ["o/B"]
|
||||||
|
assert asked == [("esphome", "A"), ("o", "B")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_versionless_url_dependency_is_wired_to_its_source(
|
||||||
|
tmp_path, monkeypatch
|
||||||
|
) -> None:
|
||||||
|
"""A dependency written as a git or ``file://`` URL goes by the key its
|
||||||
|
source gave it: the requester is wired to it and it is built."""
|
||||||
|
asked = _patch_download_with_manifests(
|
||||||
|
monkeypatch,
|
||||||
|
tmp_path,
|
||||||
|
{
|
||||||
|
"esphome/A": {
|
||||||
|
"name": "A",
|
||||||
|
"dependencies": [
|
||||||
|
"https://github.com/vendor/B.git",
|
||||||
|
(tmp_path / "local" / "C").as_uri(),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"vendor/B": {"name": "B"},
|
||||||
|
"C": {"name": "C"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
emitted: dict[str, ConvertedLibrary] = {}
|
||||||
|
top = convert_libraries(
|
||||||
|
[Library("esphome/A", None, None)],
|
||||||
|
_backend(emit=lambda c: emitted.__setitem__(c.name, c)),
|
||||||
|
)
|
||||||
|
assert [d.name for d in top[0].dependencies] == ["C", "vendor/B"]
|
||||||
|
assert isinstance(emitted["vendor/B"].source, GitSource)
|
||||||
|
assert isinstance(emitted["C"].source, LocalSource)
|
||||||
|
assert asked == [("esphome", "A")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_url_dependencies_differing_only_in_case_stay_distinct(
|
||||||
|
tmp_path, monkeypatch
|
||||||
|
) -> None:
|
||||||
|
"""Two local sources whose paths differ only in case are both fetched and
|
||||||
|
each requester is wired to its own; only registry names fold case."""
|
||||||
|
_patch_download_with_manifests(
|
||||||
|
monkeypatch,
|
||||||
|
tmp_path,
|
||||||
|
{
|
||||||
|
"esphome/A": {
|
||||||
|
"name": "A",
|
||||||
|
"dependencies": [(tmp_path / "local" / "Upper").as_uri()],
|
||||||
|
},
|
||||||
|
"esphome/X": {
|
||||||
|
"name": "X",
|
||||||
|
"dependencies": [(tmp_path / "local" / "upper").as_uri()],
|
||||||
|
},
|
||||||
|
"Upper": {"name": "Lib"},
|
||||||
|
"upper": {"name": "Lib"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
emitted: dict[str, ConvertedLibrary] = {}
|
||||||
|
top = convert_libraries(
|
||||||
|
[Library("esphome/A", None, None), Library("esphome/X", None, None)],
|
||||||
|
_backend(emit=lambda c: emitted.__setitem__(c.name, c)),
|
||||||
|
)
|
||||||
|
assert [d.name for d in top[0].dependencies] == ["Upper"]
|
||||||
|
assert [d.name for d in top[1].dependencies] == ["upper"]
|
||||||
|
assert all(
|
||||||
|
isinstance(emitted[key].source, LocalSource) for key in ("Upper", "upper")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_listed_git_source_satisfies_a_bare_dependency_by_repository_name(
|
||||||
|
tmp_path, monkeypatch
|
||||||
|
) -> None:
|
||||||
|
"""A git source goes by its repository name: a bare ``B`` is satisfied by
|
||||||
|
the listed ``vendor/B`` even when its manifest says otherwise, so the
|
||||||
|
registry is never asked for a second copy."""
|
||||||
|
asked = _patch_download_with_manifests(
|
||||||
|
monkeypatch,
|
||||||
|
tmp_path,
|
||||||
|
{
|
||||||
|
"esphome/A": {"name": "A", "dependencies": ["B"]},
|
||||||
|
"vendor/B": {"name": "Other"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
emitted: list[str] = []
|
||||||
|
top = convert_libraries(
|
||||||
|
[
|
||||||
|
Library("esphome/A", None, None),
|
||||||
|
Library("https://github.com/vendor/B.git", None, None),
|
||||||
|
],
|
||||||
|
_backend(emit=lambda c: emitted.append(c.name)),
|
||||||
|
)
|
||||||
|
assert [d.name for d in top[0].dependencies] == ["vendor/B"]
|
||||||
|
assert sorted(emitted) == ["esphome/A", "vendor/B"]
|
||||||
|
assert asked == [("esphome", "A")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_version_is_settled_like_a_missing_one(
|
||||||
|
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
|
||||||
|
) -> None:
|
||||||
|
"""``{"Foo": ""}`` carries no version either, so it is matched, fetched by
|
||||||
|
name or reported like a version-less entry rather than failing or vanishing."""
|
||||||
|
asked = _patch_download_with_manifests(
|
||||||
|
monkeypatch,
|
||||||
|
tmp_path,
|
||||||
|
{
|
||||||
|
"esphome/A": {"name": "A", "dependencies": {"Foo": "", "Bar": ""}},
|
||||||
|
"Bar": {"name": "Bar"},
|
||||||
|
},
|
||||||
|
unknown={"Foo"},
|
||||||
|
)
|
||||||
|
top = convert_libraries([Library("esphome/A", None, None)], _backend())
|
||||||
|
assert [d.name for d in top[0].dependencies] == ["Bar"]
|
||||||
|
assert (None, "Foo") in asked
|
||||||
|
assert "Dependency Foo of esphome/A was not resolved; skipping" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_bundled_name_is_satisfied_by_a_replacement_pulled_in_later(
|
||||||
|
tmp_path, monkeypatch
|
||||||
|
) -> None:
|
||||||
|
"""A name the framework bundles is still wired to an explicit replacement
|
||||||
|
of the same manifest name that a later round pulls in, as it would be had
|
||||||
|
the replacement been listed up front."""
|
||||||
|
_patch_download_with_manifests(
|
||||||
|
monkeypatch,
|
||||||
|
tmp_path,
|
||||||
|
{
|
||||||
|
"esphome/A": {"name": "A", "dependencies": [{"name": "Ethernet"}]},
|
||||||
|
"esphome/X": {"name": "X", "dependencies": [{"name": "C"}]},
|
||||||
|
"C": {"name": "C", "dependencies": {"vendor/Ethernet": "1.0.0"}},
|
||||||
|
"vendor/Ethernet": {"name": "Ethernet"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
backend = _backend(provides=lambda name: name == "Ethernet")
|
||||||
|
top = convert_libraries(
|
||||||
|
[Library("esphome/A", None, None), Library("esphome/X", None, None)],
|
||||||
|
backend,
|
||||||
|
)
|
||||||
|
assert [d.name for d in top[0].dependencies] == ["vendor/Ethernet"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_missed_name_is_satisfied_by_a_library_pulled_in_later(
|
||||||
|
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
|
||||||
|
) -> None:
|
||||||
|
"""A bare name the registry does not know is wired to a library a later
|
||||||
|
round pulls in under another package name but the same manifest name."""
|
||||||
|
asked = _patch_download_with_manifests(
|
||||||
|
monkeypatch,
|
||||||
|
tmp_path,
|
||||||
|
{
|
||||||
|
"esphome/A": {"name": "A", "dependencies": [{"name": "B"}]},
|
||||||
|
"esphome/X": {"name": "X", "dependencies": [{"name": "C"}]},
|
||||||
|
# Fetched by name in the same round as the miss; pins the fork
|
||||||
|
"C": {"name": "C", "dependencies": {"owner/B-fork": "1.0.0"}},
|
||||||
|
"owner/B-fork": {"name": "B"},
|
||||||
|
},
|
||||||
|
unknown={"B"},
|
||||||
|
)
|
||||||
|
with caplog.at_level(logging.INFO):
|
||||||
|
top = convert_libraries(
|
||||||
|
[Library("esphome/A", None, None), Library("esphome/X", None, None)],
|
||||||
|
_backend(),
|
||||||
|
)
|
||||||
|
assert [d.name for d in top[0].dependencies] == ["owner/B-fork"]
|
||||||
|
assert asked.count((None, "B")) == 1
|
||||||
|
assert (
|
||||||
|
"Dependency B of esphome/A satisfied by owner/B-fork through its manifest name"
|
||||||
|
in caplog.text
|
||||||
|
)
|
||||||
|
assert "skipping" not in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_by_name_request_does_not_make_an_existing_node_optional(
|
||||||
|
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
|
||||||
|
) -> None:
|
||||||
|
"""A version-less request for a node the walk already created (here one
|
||||||
|
skipped for its platform) neither re-fetches it nor turns a later miss
|
||||||
|
into a warning."""
|
||||||
|
asked = _patch_download_with_manifests(
|
||||||
|
monkeypatch,
|
||||||
|
tmp_path,
|
||||||
|
{
|
||||||
|
"esphome/A": {
|
||||||
|
"name": "A",
|
||||||
|
"dependencies": [{"name": "X", "version": "1.0"}, {"name": "X"}],
|
||||||
|
},
|
||||||
|
"X": {"name": "X", "platforms": "espressif8266"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
top = convert_libraries([Library("esphome/A", None, None)], _backend())
|
||||||
|
assert top[0].dependencies == []
|
||||||
|
assert asked == [("esphome", "A"), (None, "X")]
|
||||||
|
assert "not in the registry" not in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_provided_dependency_asks_the_backend_once_per_name(
|
||||||
|
tmp_path, monkeypatch
|
||||||
|
) -> None:
|
||||||
|
"""A bundled name is recorded for the backend and never fetched; two
|
||||||
|
requesters cost one provides() call, so a backend warning there is not
|
||||||
|
repeated. A pinned request on the same name shares the memo."""
|
||||||
|
asked = _patch_download_with_manifests(
|
||||||
|
monkeypatch,
|
||||||
|
tmp_path,
|
||||||
|
{
|
||||||
|
"esphome/A": {"name": "A", "dependencies": [{"name": "Wire"}]},
|
||||||
|
"esphome/B": {"name": "B", "dependencies": {"Wire": "*"}},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
calls: list[str] = []
|
||||||
|
|
||||||
|
def provides(name: str) -> bool:
|
||||||
|
calls.append(name)
|
||||||
|
return name == "Wire"
|
||||||
|
|
||||||
|
backend = _backend(provides=provides)
|
||||||
convert_libraries(
|
convert_libraries(
|
||||||
[Library("esphome/A", None, None), Library("esphome/B", None, None)],
|
[Library("esphome/A", None, None), Library("esphome/B", None, None)],
|
||||||
_backend(),
|
backend,
|
||||||
)
|
)
|
||||||
assert "has no version to resolve" not in caplog.text
|
assert calls == ["Wire"]
|
||||||
|
assert backend.provided_requests == {"Wire"}
|
||||||
|
assert (None, "Wire") not in asked
|
||||||
|
|
||||||
|
|
||||||
def test_convert_libraries_symlink_url_resolves_as_local(setup_core: Path) -> None:
|
def test_convert_libraries_symlink_url_resolves_as_local(setup_core: Path) -> None:
|
||||||
|
|||||||
@@ -467,6 +467,25 @@ def test_redirect_text_keeps_output_after_an_exotic_break_character(
|
|||||||
assert buf.getvalue() == f"first{break_char}second\nthird\n"
|
assert buf.getvalue() == f"first{break_char}second\nthird\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_redirect_text_keeps_the_color_codes_of_a_filtered_line() -> None:
|
||||||
|
"""A dropped line still delivers its color codes.
|
||||||
|
|
||||||
|
With ``CLICOLOR_FORCE`` cmake prints a warning in yellow and puts the
|
||||||
|
reset on a line of its own after a blank line. The IDF build filters
|
||||||
|
blank lines, matched without ANSI codes, so the reset used to be dropped
|
||||||
|
and the output after the warning stayed yellow.
|
||||||
|
"""
|
||||||
|
redirect, buf = _make_redirect(filter_lines=[r"\s*$"])
|
||||||
|
|
||||||
|
redirect.write(
|
||||||
|
"\x1b[33mCMake Warning at a.cmake:3 (message):\n w\n\n\x1b[0m\n-- b\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert buf.getvalue() == (
|
||||||
|
"\x1b[33mCMake Warning at a.cmake:3 (message):\n w\n\x1b[0m-- b\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_redirect_text_treats_crlf_as_one_terminator() -> None:
|
def test_redirect_text_treats_crlf_as_one_terminator() -> None:
|
||||||
r"""``\r\n``, a lone ``\r`` and a lone ``\n`` each end exactly one line."""
|
r"""``\r\n``, a lone ``\r`` and a lone ``\n`` each end exactly one line."""
|
||||||
redirect, buf = _make_redirect(filter_lines=["ignore me"])
|
redirect, buf = _make_redirect(filter_lines=["ignore me"])
|
||||||
|
|||||||
Reference in new issue
Block a user