mirror of
https://github.com/esphome/esphome.git
synced 2026-09-09 14:28:46 +00:00
Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4ce8cc6b4 | ||
|
|
1ce0bed3f6 | ||
|
|
552fcebbba | ||
|
|
e90b219e00 | ||
|
|
cff5cf7ad5 | ||
|
|
a9b1fc361b | ||
|
|
b7a5056f3e | ||
|
|
a19893b168 | ||
|
|
2344929dae | ||
|
|
46de7335b2 | ||
|
|
1cb2136c38 | ||
|
|
d1f7e71efb | ||
|
|
cf97e4c4e8 | ||
|
|
4e2ff94588 | ||
|
|
c2118778ee | ||
|
|
c655a2a442 | ||
|
|
b8a79a26fb | ||
|
|
34caf86839 | ||
|
|
1eb4cff6b6 | ||
|
|
b60950da76 | ||
|
|
7e6db4d335 |
@@ -1,39 +0,0 @@
|
||||
name: Cache Arduino ESP8266
|
||||
description: >
|
||||
Resolve the pinned Arduino core and xtensa toolchain versions and cache the
|
||||
native ESP8266 install (~110 MB framework + toolchain; no ccache store, the
|
||||
seed job saves before any compile runs). Exports
|
||||
ESPHOME_ARDUINO8266_PREFIX to the job so every later step installs into
|
||||
the cached path; the Python venv must already be restored. Mirrors
|
||||
cache-esp-idf: only dev-branch pushes write the shared cache, everything
|
||||
else restores.
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Resolve the native toolchain cache key
|
||||
# Versions are pinned in code, not a hashable file; resolve them so a
|
||||
# bump changes the cache key. Assignment form so errexit catches a
|
||||
# resolver failure.
|
||||
id: version
|
||||
shell: bash
|
||||
run: |
|
||||
# One owner for the install prefix: exported here and referenced by
|
||||
# the cache steps below via env, so the caller's install and the
|
||||
# cached path cannot diverge.
|
||||
echo "ESPHOME_ARDUINO8266_PREFIX=$HOME/.esphome-arduino8266" >> "$GITHUB_ENV"
|
||||
. venv/bin/activate
|
||||
key=$(python -c 'from esphome.components.esp8266 import RECOMMENDED_ARDUINO_FRAMEWORK_VERSION as f; from esphome.arduino8266.framework import TOOLCHAIN_VERSION as t; print(f"{f}-{t}")')
|
||||
[ -n "$key" ] || exit 1
|
||||
echo "key=$key" >> "$GITHUB_OUTPUT"
|
||||
- name: Cache the native toolchain (write on dev)
|
||||
if: github.ref == 'refs/heads/dev'
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ${{ env.ESPHOME_ARDUINO8266_PREFIX }}
|
||||
key: ${{ runner.os }}-esp8266-native-${{ steps.version.outputs.key }}
|
||||
- name: Restore the native toolchain (off dev)
|
||||
if: github.ref != 'refs/heads/dev'
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ${{ env.ESPHOME_ARDUINO8266_PREFIX }}
|
||||
key: ${{ runner.os }}-esp8266-native-${{ steps.version.outputs.key }}
|
||||
@@ -21,7 +21,6 @@ on:
|
||||
- "esphome/core/**"
|
||||
- "esphome/writer.py"
|
||||
- "esphome/build_gen/**"
|
||||
- "esphome/build_helpers/**"
|
||||
- "esphome/espidf/**"
|
||||
- "esphome/platformio/**"
|
||||
- "esphome/components/bk72xx/**"
|
||||
@@ -198,7 +197,6 @@ jobs:
|
||||
# the default.
|
||||
id:
|
||||
- esp8266-arduino
|
||||
- esp8266-arduino-native
|
||||
- esp32-arduino-platformio
|
||||
- esp32-arduino-esp-idf
|
||||
- esp32-idf-platformio
|
||||
@@ -209,16 +207,6 @@ jobs:
|
||||
- ln882x-arduino
|
||||
- nrf52
|
||||
- host
|
||||
# Strict by default so a new matrix id cannot silently join in the
|
||||
# degrade-quietly mode the knob exists to catch.
|
||||
# Opt-outs: libretiny GCC rejects its own pch until a toolchain bump.
|
||||
include:
|
||||
- id: bk72xx-arduino
|
||||
pch_strict: "0"
|
||||
- id: rtl87xx-arduino
|
||||
pch_strict: "0"
|
||||
- id: ln882x-arduino
|
||||
pch_strict: "0"
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Download image artifact
|
||||
@@ -230,7 +218,6 @@ jobs:
|
||||
- name: Compile ${{ matrix.id }}
|
||||
run: |
|
||||
docker run --rm \
|
||||
-e ESPHOME_PCH_STRICT="${{ matrix.pch_strict || '1' }}" \
|
||||
-v "${{ github.workspace }}/docker/test_configs:/config" \
|
||||
"ghcr.io/esphome/esphome-amd64:${{ needs.check-docker.outputs.tag }}" \
|
||||
compile "${{ matrix.id }}.yaml"
|
||||
|
||||
@@ -102,8 +102,6 @@ jobs:
|
||||
device-builder: ${{ steps.determine.outputs.device-builder }}
|
||||
esp32-platformio: ${{ steps.determine.outputs.esp32-platformio }}
|
||||
esp32-platformio-components: ${{ steps.determine.outputs.esp32-platformio-components }}
|
||||
esp8266-native: ${{ steps.determine.outputs.esp8266-native }}
|
||||
esp8266-native-components: ${{ steps.determine.outputs.esp8266-native-components }}
|
||||
changed-components: ${{ steps.determine.outputs.changed-components }}
|
||||
changed-components-with-tests: ${{ steps.determine.outputs.changed-components-with-tests }}
|
||||
directly-changed-components-with-tests: ${{ steps.determine.outputs.directly-changed-components-with-tests }}
|
||||
@@ -167,8 +165,6 @@ jobs:
|
||||
echo "device-builder=$(echo "$output" | jq -r '.device_builder')" >> $GITHUB_OUTPUT
|
||||
echo "esp32-platformio=$(echo "$output" | jq -r '.esp32_platformio')" >> $GITHUB_OUTPUT
|
||||
echo "esp32-platformio-components=$(echo "$output" | jq -r '.esp32_platformio_components')" >> $GITHUB_OUTPUT
|
||||
echo "esp8266-native=$(echo "$output" | jq -r '.esp8266_native')" >> $GITHUB_OUTPUT
|
||||
echo "esp8266-native-components=$(echo "$output" | jq -r '.esp8266_native_components')" >> $GITHUB_OUTPUT
|
||||
echo "changed-components=$(echo "$output" | jq -c '.changed_components')" >> $GITHUB_OUTPUT
|
||||
echo "changed-components-with-tests=$(echo "$output" | jq -c '.changed_components_with_tests')" >> $GITHUB_OUTPUT
|
||||
echo "directly-changed-components-with-tests=$(echo "$output" | jq -c '.directly_changed_components_with_tests')" >> $GITHUB_OUTPUT
|
||||
@@ -187,32 +183,6 @@ jobs:
|
||||
path: .temp/components_graph.json
|
||||
key: components-graph-${{ hashFiles('esphome/components/**/*.py') }}
|
||||
|
||||
seed-esp8266-native-cache:
|
||||
name: Seed the esp8266 native toolchain cache
|
||||
runs-on: ubuntu-24.04
|
||||
needs:
|
||||
- common
|
||||
# PR-branch cache saves are invisible to other PRs, so dev pushes seed
|
||||
# the shared entry test-esp8266-native restores. Only dev: the composite
|
||||
# action saves nowhere else, so a beta/release push would download the
|
||||
# toolchain and discard it.
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Restore Python
|
||||
uses: ./.github/actions/restore-python
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
cache-key: ${{ needs.common.outputs.cache-key }}
|
||||
- name: Cache the native toolchain
|
||||
uses: ./.github/actions/cache-arduino8266
|
||||
- name: Install the native toolchain
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
python -c "from esphome.arduino8266.framework import check_and_install; from esphome.components.esp8266 import RECOMMENDED_ARDUINO_FRAMEWORK_VERSION; check_and_install(RECOMMENDED_ARDUINO_FRAMEWORK_VERSION)"
|
||||
|
||||
ci-custom:
|
||||
name: Run script/ci-custom
|
||||
runs-on: ubuntu-24.04
|
||||
@@ -1267,7 +1237,7 @@ jobs:
|
||||
|
||||
# compile validates config first, so a separate config pass is
|
||||
# redundant for this smoke test. ESP-IDF framework via PlatformIO:
|
||||
python3 script/test_build_components.py -e compile -t esp32-idf -c "$TEST_COMPONENTS" -f --toolchain platformio --fail-on-no-tests
|
||||
python3 script/test_build_components.py -e compile -t esp32-idf -c "$TEST_COMPONENTS" -f --toolchain platformio
|
||||
|
||||
echo ""
|
||||
echo "ESP-IDF-via-PlatformIO build passed! Starting Arduino smoke test..."
|
||||
@@ -1276,42 +1246,6 @@ jobs:
|
||||
# Arduino framework via PlatformIO (only components with an esp32-ard test are built):
|
||||
python3 script/test_build_components.py -e compile -t esp32-ard -c "$TEST_COMPONENTS" -f --toolchain platformio
|
||||
|
||||
test-esp8266-native:
|
||||
name: Test esp8266 components with the native toolchain
|
||||
runs-on: ubuntu-24.04
|
||||
needs:
|
||||
- common
|
||||
- determine-jobs
|
||||
if: github.event_name == 'pull_request' && needs.determine-jobs.outputs.esp8266-native == 'true'
|
||||
env:
|
||||
# Computed by script/determine-jobs.py (ESP8266_NATIVE_TEST_COMPONENTS)
|
||||
TEST_COMPONENTS: ${{ needs.determine-jobs.outputs.esp8266-native-components }}
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Restore Python
|
||||
uses: ./.github/actions/restore-python
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
cache-key: ${{ needs.common.outputs.cache-key }}
|
||||
|
||||
- name: Cache the native toolchain
|
||||
uses: ./.github/actions/cache-arduino8266
|
||||
|
||||
- name: Run native toolchain compile test
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
|
||||
echo "Testing components: $TEST_COMPONENTS"
|
||||
echo ""
|
||||
|
||||
# ESP8266 Arduino built directly (no PlatformIO); compile validates
|
||||
# config first, so a separate config pass is redundant. Strict pch:
|
||||
# exercises the native ninja pch (and its probe edge) against real
|
||||
# component configs; the docker matrix smoke-tests both toolchains.
|
||||
ESPHOME_PCH_STRICT=1 python3 script/test_build_components.py -e compile -t esp8266-ard -c "$TEST_COMPONENTS" -f --toolchain arduino --fail-on-no-tests
|
||||
|
||||
device-builder:
|
||||
name: Test downstream esphome/device-builder
|
||||
runs-on: ubuntu-24.04
|
||||
@@ -1674,7 +1608,6 @@ jobs:
|
||||
needs:
|
||||
- common
|
||||
- seed-apt-cache
|
||||
- seed-esp8266-native-cache
|
||||
- determine-jobs
|
||||
- ci-custom
|
||||
- pylint
|
||||
@@ -1690,7 +1623,6 @@ jobs:
|
||||
- clang-tidy-esp32-variants
|
||||
- test-build-components-split
|
||||
- test-esp32-platformio
|
||||
- test-esp8266-native
|
||||
- device-builder
|
||||
- memory-impact-target-branch
|
||||
- memory-impact-pr-branch
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
esphome:
|
||||
name: docker-test-esp8266-native
|
||||
|
||||
esp8266:
|
||||
board: d1_mini
|
||||
toolchain: arduino
|
||||
|
||||
logger:
|
||||
+31
-83
@@ -817,9 +817,7 @@ def write_cpp_file() -> int:
|
||||
from esphome.build_gen import espidf
|
||||
|
||||
espidf.write_project()
|
||||
elif not CORE.using_native_toolchain:
|
||||
# Other native builds generate their project at compile time;
|
||||
# never write a platformio.ini for them
|
||||
else:
|
||||
from esphome.build_gen import platformio
|
||||
|
||||
platformio.write_project()
|
||||
@@ -861,14 +859,20 @@ def compile_program(args: ArgsProtocol, config: ConfigType) -> int:
|
||||
toolchain.create_factory_bin()
|
||||
toolchain.create_ota_bin()
|
||||
toolchain.create_elf_copy()
|
||||
from esphome.build_helpers.idedata import warn_if_idedata_missing
|
||||
from esphome.build_helpers.idedata import IDEDATA_BEST_EFFORT_ERRORS
|
||||
|
||||
warn_if_idedata_missing(toolchain.get_idedata)
|
||||
elif CORE.using_native_toolchain:
|
||||
raise EsphomeError(
|
||||
f"Toolchain '{CORE.toolchain.value}' resolved but no platform "
|
||||
"backend claimed the build"
|
||||
)
|
||||
try:
|
||||
if toolchain.get_idedata() is None:
|
||||
_LOGGER.warning("No idedata was generated for this build")
|
||||
except IDEDATA_BEST_EFFORT_ERRORS as err:
|
||||
# The firmware already built; an idedata failure must not fail
|
||||
# a successful build.
|
||||
_LOGGER.warning(
|
||||
"Could not generate idedata: %s (IDE, clang-tidy, and "
|
||||
"memory-analysis data will be unavailable for this build)",
|
||||
err,
|
||||
)
|
||||
_LOGGER.debug("Idedata failure detail", exc_info=True)
|
||||
else:
|
||||
from esphome.platformio import toolchain
|
||||
|
||||
@@ -971,15 +975,12 @@ def upload_using_esptool(
|
||||
|
||||
if file is not None:
|
||||
flash_images = [FlashImage(path=file, offset="0x0")]
|
||||
elif (native := _native_toolchain_module()) is not None:
|
||||
# Every native backend supplies its own 0x0 flash image (bootloader
|
||||
# and partitions included where the target needs them)
|
||||
image = native.get_factory_firmware_path()
|
||||
if not image.is_file():
|
||||
raise EsphomeError(
|
||||
f"{image} does not exist; compile the configuration first"
|
||||
)
|
||||
flash_images = [FlashImage(path=image, offset="0x0")]
|
||||
elif CORE.using_toolchain_esp_idf:
|
||||
from esphome.espidf import toolchain
|
||||
|
||||
flash_images = [
|
||||
FlashImage(path=toolchain.get_factory_firmware_path(), offset="0x0")
|
||||
]
|
||||
else:
|
||||
from esphome.platformio import toolchain
|
||||
|
||||
@@ -1961,39 +1962,15 @@ def command_update_all(args: ArgsProtocol) -> int | None:
|
||||
return run_multiple_configs(files, build_command)
|
||||
|
||||
|
||||
# Native build backend per (target platform, toolchain). Keyed here rather
|
||||
# than through a platform hook so the serial upload/logs fast path never
|
||||
# imports the platform component package (see the esp32 variant comment in
|
||||
# upload_using_esptool); the platform half comes from CORE.data the same way.
|
||||
_NATIVE_TOOLCHAIN_MODULES = {
|
||||
("esp32", Toolchain.ESP_IDF): "esphome.espidf.toolchain",
|
||||
("esp8266", Toolchain.ARDUINO): "esphome.arduino8266.toolchain",
|
||||
}
|
||||
|
||||
|
||||
def _native_toolchain_module():
|
||||
"""The native build backend module for the resolved toolchain."""
|
||||
if not CORE.using_native_toolchain:
|
||||
return None
|
||||
key = (CORE.target_platform, CORE.toolchain)
|
||||
if (module_path := _NATIVE_TOOLCHAIN_MODULES.get(key)) is None:
|
||||
# Degrading to the PlatformIO path would build with the wrong backend
|
||||
raise EsphomeError(
|
||||
f"Toolchain '{CORE.toolchain.value}' has no native build backend "
|
||||
f"module for platform {CORE.target_platform}"
|
||||
)
|
||||
return importlib.import_module(module_path)
|
||||
|
||||
|
||||
def command_idedata(args: ArgsProtocol, config: ConfigType) -> int:
|
||||
import json
|
||||
|
||||
native_toolchain = _native_toolchain_module()
|
||||
if CORE.using_toolchain_esp_idf:
|
||||
# Native ESP-IDF derives idedata from the build's compile_commands.json,
|
||||
# so the configuration must already be compiled.
|
||||
from esphome.espidf import toolchain as espidf_toolchain
|
||||
|
||||
if native_toolchain is not None:
|
||||
# Native toolchains derive idedata from the build's
|
||||
# compile_commands.json, so the configuration must already be compiled.
|
||||
idedata = native_toolchain.get_idedata()
|
||||
idedata = espidf_toolchain.get_idedata()
|
||||
if idedata is None:
|
||||
_LOGGER.error(
|
||||
"No idedata available; compile the configuration first",
|
||||
@@ -2032,17 +2009,6 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int:
|
||||
from esphome.analyze_memory.cli import MemoryAnalyzerCLI
|
||||
from esphome.analyze_memory.ram_strings import RamStringsAnalyzer
|
||||
|
||||
# Refuse an unsupported toolchain before paying for a full compile
|
||||
native_toolchain = _native_toolchain_module()
|
||||
if native_toolchain is None and not CORE.using_toolchain_platformio:
|
||||
_LOGGER.error(
|
||||
"analyze-memory is not supported with the '%s' toolchain on %s; "
|
||||
"re-run with --toolchain platformio",
|
||||
CORE.toolchain.value if CORE.toolchain else "unresolved",
|
||||
CORE.target_platform,
|
||||
)
|
||||
return 1
|
||||
|
||||
# Always compile to ensure fresh data (fast if no changes - just relinks)
|
||||
exit_code = write_cpp(config)
|
||||
if exit_code != 0:
|
||||
@@ -2054,31 +2020,13 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int:
|
||||
|
||||
# Get idedata for analysis
|
||||
idedata = None
|
||||
if native_toolchain is not None:
|
||||
objdump = native_toolchain.get_objdump_path()
|
||||
readelf = native_toolchain.get_readelf_path()
|
||||
for tool in (objdump, readelf):
|
||||
if not tool.is_file():
|
||||
# The analyzer would silently fall back to host binutils,
|
||||
# which cannot read the target ELF. clean-all is heavy for
|
||||
# ESP-IDF, so suggest a recompile first.
|
||||
_LOGGER.error(
|
||||
"%s is missing; the toolchain install may be incomplete "
|
||||
"(recompile, or run 'esphome clean-all' if it persists)",
|
||||
tool,
|
||||
)
|
||||
return 1
|
||||
objdump_path = str(objdump)
|
||||
readelf_path = str(readelf)
|
||||
if CORE.using_toolchain_esp_idf:
|
||||
from esphome.espidf import toolchain
|
||||
|
||||
firmware_elf = native_toolchain.get_elf_path()
|
||||
if not firmware_elf.is_file():
|
||||
# The analyzer swallows tool failures, so a missing ELF would
|
||||
# produce an exit-0 zeroed report
|
||||
_LOGGER.error(
|
||||
"%s is missing; compile the configuration first", firmware_elf
|
||||
)
|
||||
return 1
|
||||
objdump_path = str(toolchain.get_objdump_path())
|
||||
readelf_path = str(toolchain.get_readelf_path())
|
||||
|
||||
firmware_elf = toolchain.get_elf_path()
|
||||
else:
|
||||
from esphome.platformio import toolchain
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ from typing import NamedTuple
|
||||
|
||||
from esphome.build_helpers.ccache import ccache_defaults_env
|
||||
from esphome.build_helpers.ninja import find_ninja
|
||||
from esphome.build_helpers.pch import ccache_pch_env
|
||||
from esphome.build_helpers.tools_cache import ARDUINO8266_TOOLS_CACHE, tools_cache_path
|
||||
from esphome.core import EsphomeError, Version
|
||||
from esphome.framework_helpers import str_to_lst_of_str
|
||||
@@ -157,6 +156,4 @@ def ccache_env(ccache: str | None) -> dict[str, str]:
|
||||
"""
|
||||
if ccache is None:
|
||||
return {}
|
||||
env = ccache_defaults_env(get_arduino8266_tools_path() / "ccache")
|
||||
env.update(ccache_pch_env())
|
||||
return env
|
||||
return ccache_defaults_env(get_arduino8266_tools_path() / "ccache")
|
||||
|
||||
@@ -1,329 +0,0 @@
|
||||
"""Native Arduino ESP8266 build driver (the PlatformIO ``run`` equivalent)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
from typing import Any
|
||||
|
||||
from esphome.arduino8266 import framework
|
||||
from esphome.build_helpers.ccache import resolve_ccache_path
|
||||
from esphome.const import (
|
||||
CONF_COMPILE_PROCESS_LIMIT,
|
||||
CONF_ESPHOME,
|
||||
KEY_CORE,
|
||||
KEY_FRAMEWORK_VERSION,
|
||||
)
|
||||
from esphome.core import CORE, EsphomeError
|
||||
from esphome.helpers import write_file_if_changed
|
||||
from esphome.types import ConfigType
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# ESP8266 user RAM (matches upload.maximum_ram_size in every board manifest)
|
||||
_MAX_RAM_SIZE = 81920
|
||||
|
||||
|
||||
def _warn_ignored_platformio_options() -> None:
|
||||
"""Warn for component-added platformio options the native build drops.
|
||||
|
||||
The consumed set is exported by core/config.py next to the routing that
|
||||
stores these options, so the two cannot drift; YAML upload_speed never
|
||||
reaches CORE.platformio_options here.
|
||||
"""
|
||||
from esphome.core.config import NATIVE_ARDUINO_CONSUMED_PIO_OPTIONS
|
||||
|
||||
consumed = NATIVE_ARDUINO_CONSUMED_PIO_OPTIONS
|
||||
for key in sorted(CORE.platformio_options or {}):
|
||||
if key not in consumed:
|
||||
_LOGGER.warning(
|
||||
"platformio_options->%s is ignored when building with the "
|
||||
"native 'arduino' toolchain",
|
||||
key,
|
||||
)
|
||||
|
||||
|
||||
_RAM_SECTIONS = (".data", ".rodata", ".bss")
|
||||
_FLASH_SECTIONS = (".irom0.text", ".text", ".text1", ".data", ".rodata")
|
||||
|
||||
|
||||
def get_build_dir() -> Path:
|
||||
return CORE.relative_pioenvs_path(CORE.name)
|
||||
|
||||
|
||||
def get_elf_path() -> Path:
|
||||
return get_build_dir() / "firmware.elf"
|
||||
|
||||
|
||||
def _toolchain_tool(name: str) -> Path:
|
||||
return framework.toolchain_tool(framework.get_toolchain_path(), name)
|
||||
|
||||
|
||||
def get_factory_firmware_path() -> Path:
|
||||
"""The image to serial-flash at 0x0 (same bytes as firmware.bin: the
|
||||
8266 factory copy exists for artifact-contract parity, not content)."""
|
||||
return get_build_dir() / "firmware.factory.bin"
|
||||
|
||||
|
||||
def get_addr2line_path() -> Path:
|
||||
return _toolchain_tool("addr2line")
|
||||
|
||||
|
||||
def get_objdump_path() -> Path:
|
||||
return _toolchain_tool("objdump")
|
||||
|
||||
|
||||
def get_readelf_path() -> Path:
|
||||
return _toolchain_tool("readelf")
|
||||
|
||||
|
||||
def run_compile(config: ConfigType, verbose: bool) -> int:
|
||||
from esphome.build_gen import arduino8266 as build_gen
|
||||
|
||||
_warn_ignored_platformio_options()
|
||||
paths = framework.check_and_install(CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION])
|
||||
# Resolved once per build: the resolution probes PATH and spawns the
|
||||
# runnability check, and three consumers need the same answer
|
||||
ccache = resolve_ccache_path()
|
||||
ninja_changed = build_gen.write_project(paths, ccache)
|
||||
|
||||
build_dir = get_build_dir()
|
||||
env = framework.get_build_env(paths.toolchain, ccache)
|
||||
|
||||
# Regenerate the compile DB before the build (a pure function of
|
||||
# build.ninja); skip only when it is at least as fresh as build.ninja
|
||||
# (an interrupted previous run may have rewritten the manifest without
|
||||
# regenerating the DB).
|
||||
compdb = build_dir / "compile_commands.json"
|
||||
compdb_stamp = build_dir / ".compile_commands.stamp"
|
||||
ninja_file = build_dir / "build.ninja"
|
||||
# Freshness rides a stamp: the DB itself is written through
|
||||
# write_file_if_changed (its mtime feeds get_idedata's cache), so a
|
||||
# regeneration with identical content would stay "stale" forever
|
||||
if (
|
||||
ninja_changed
|
||||
or not compdb.is_file()
|
||||
or not compdb_stamp.is_file()
|
||||
or compdb_stamp.stat().st_mtime < ninja_file.stat().st_mtime
|
||||
):
|
||||
_write_compile_commands(paths.ninja, build_dir, env)
|
||||
compdb_stamp.touch()
|
||||
|
||||
cmd = [str(paths.ninja)]
|
||||
if verbose:
|
||||
cmd.append("-v")
|
||||
if jobs := config[CONF_ESPHOME].get(CONF_COMPILE_PROCESS_LIMIT):
|
||||
cmd += ["-j", str(jobs)]
|
||||
# Explicit targets, not the default statement: a generator defect that
|
||||
# drops them fails loudly with "unknown target" instead of a green
|
||||
# no-op run that leaves stale artifacts in place
|
||||
targets = ["firmware.factory.bin", "firmware.ota.bin"]
|
||||
cmd += targets
|
||||
|
||||
# A dry-run probe keeps a no-op rebuild quiet: ninja would only print
|
||||
# "no work to do". A freshly rewritten manifest all but guarantees work,
|
||||
# so skip the probe (and its full stat pass) on that path. cwd instead
|
||||
# of -C also drops the "Entering directory" banner on real builds.
|
||||
skip_build = False
|
||||
if not ninja_changed:
|
||||
probe = subprocess.run(
|
||||
[str(paths.ninja), "-n", *targets],
|
||||
cwd=build_dir,
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
close_fds=False,
|
||||
)
|
||||
if probe.stderr.strip():
|
||||
# A load-time diagnostic (e.g. "multiple rules generate X")
|
||||
# flags a generator bug; the skip branch would otherwise
|
||||
# swallow it forever
|
||||
_LOGGER.warning("ninja: %s", probe.stderr.strip())
|
||||
if probe.returncode != 0:
|
||||
# An unknown target here is the defective-manifest case; fall
|
||||
# through to the real build so the error prints attributably
|
||||
_LOGGER.debug("ninja probe failed; running the full build")
|
||||
skip_build = probe.returncode == 0 and "no work to do" in probe.stdout
|
||||
if skip_build:
|
||||
_LOGGER.debug("ninja: nothing to rebuild")
|
||||
else:
|
||||
_LOGGER.debug("Running: %s", " ".join(cmd))
|
||||
rc = subprocess.run(
|
||||
cmd, cwd=build_dir, env=env, check=False, close_fds=False
|
||||
).returncode
|
||||
if rc != 0:
|
||||
return rc
|
||||
|
||||
# ninja already refused a manifest missing the explicit targets above;
|
||||
# existence covers the remaining hole (a rule that ran but wrote
|
||||
# elsewhere). The factory/ota copies are what upload and OTA consume.
|
||||
build_dir_artifacts = (
|
||||
get_elf_path(),
|
||||
build_dir / "firmware.bin",
|
||||
get_factory_firmware_path(),
|
||||
build_dir / "firmware.ota.bin",
|
||||
)
|
||||
for artifact in build_dir_artifacts:
|
||||
if not artifact.is_file():
|
||||
_LOGGER.error("Build produced no %s", artifact)
|
||||
return 1
|
||||
|
||||
if not _print_size_summary(build_dir, paths):
|
||||
# The cause was already warned; name the consequence so a build
|
||||
# contributing no RAM/Flash metric is visible to CI harnesses
|
||||
_LOGGER.warning("Firmware size summary unavailable for this build")
|
||||
from esphome.build_helpers.idedata import warn_if_idedata_missing
|
||||
|
||||
warn_if_idedata_missing(lambda: get_idedata(ccache))
|
||||
return 0
|
||||
|
||||
|
||||
def _write_compile_commands(
|
||||
ninja_path: Path, build_dir: Path, env: dict[str, str]
|
||||
) -> None:
|
||||
compdb = build_dir / "compile_commands.json"
|
||||
result = subprocess.run(
|
||||
[str(ninja_path), "-C", str(build_dir), "-t", "compdb", "c", "cxx", "asm"],
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
close_fds=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
# Drop any stale database so consumers (IDE integration, clang-tidy,
|
||||
# the memory analyzer) can't silently read outdated data.
|
||||
compdb.unlink(missing_ok=True)
|
||||
raise EsphomeError(f"Could not generate compile_commands.json: {result.stderr}")
|
||||
try:
|
||||
entries = json.loads(result.stdout)
|
||||
except ValueError as err:
|
||||
compdb.unlink(missing_ok=True)
|
||||
raise EsphomeError(
|
||||
f"ninja produced an unparsable compile database: {err} "
|
||||
f"(output starts {result.stdout[:120]!r})"
|
||||
) from err
|
||||
if not entries:
|
||||
# compdb exits 0 with [] for unknown rule names; a renamed compile
|
||||
# rule must fail the build, not silently strand every consumer
|
||||
compdb.unlink(missing_ok=True)
|
||||
raise EsphomeError(
|
||||
"ninja produced an empty compile database; the generator's rule "
|
||||
"names no longer match"
|
||||
)
|
||||
# write_file_if_changed keeps the mtime stable on no-op builds so the
|
||||
# idedata cache in get_idedata() stays valid.
|
||||
write_file_if_changed(compdb, result.stdout)
|
||||
|
||||
|
||||
def _parse_app_size(build_dir: Path, paths: framework.InstalledPaths) -> int | None:
|
||||
"""Read the app flash budget (irom0_0_seg length) from the linker script."""
|
||||
from esphome.build_gen.arduino8266 import get_flash_ld_path
|
||||
from esphome.components.esp8266.build_surgery import segment_length
|
||||
|
||||
# Warnings, not debug: without the app size the Flash summary line is
|
||||
# dropped and CI's memory-impact extraction loses its flash metric.
|
||||
ld_path = get_flash_ld_path(build_dir, paths)
|
||||
try:
|
||||
ld_text = ld_path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError) as err:
|
||||
# UnicodeDecodeError: a truncated/corrupt script must degrade to
|
||||
# the same warning, never abort an already-linked build
|
||||
_LOGGER.warning("Cannot read linker script for the Flash summary: %s", err)
|
||||
return None
|
||||
app_size = segment_length(ld_text, "irom0_0_seg")
|
||||
if app_size is None:
|
||||
_LOGGER.warning("irom0_0_seg not found in %s; skipping Flash summary", ld_path)
|
||||
return None
|
||||
if app_size == 0:
|
||||
_LOGGER.warning(
|
||||
"irom0_0_seg has zero length in %s; skipping Flash summary", ld_path
|
||||
)
|
||||
return None
|
||||
return app_size
|
||||
|
||||
|
||||
def _print_size_summary(build_dir: Path, paths: framework.InstalledPaths) -> bool:
|
||||
"""Print the PlatformIO-shaped RAM/Flash lines; False when skipped.
|
||||
|
||||
The exact shape (including the bar) is parsed by
|
||||
``script/ci_memory_impact_extract.py``; ``print_size_line`` matches it.
|
||||
"""
|
||||
from esphome.build_helpers.size_summary import print_size_line
|
||||
|
||||
size_tool = _toolchain_tool("size")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[str(size_tool), "-A", "-d", str(get_elf_path())],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
close_fds=False,
|
||||
)
|
||||
except OSError as err:
|
||||
# The summary is a bonus artifact like idedata; a truncated
|
||||
# toolchain extraction must not discard an already-linked build
|
||||
_LOGGER.warning("Could not summarize firmware size: %s", err)
|
||||
return False
|
||||
if result.returncode != 0:
|
||||
_LOGGER.warning("Could not summarize firmware size: %s", result.stderr)
|
||||
return False
|
||||
sections: dict[str, int] = {}
|
||||
for line in result.stdout.splitlines():
|
||||
parts = line.split()
|
||||
if len(parts) >= 2 and parts[0].startswith("."):
|
||||
try:
|
||||
sections[parts[0]] = int(parts[1])
|
||||
except ValueError:
|
||||
# An unparsed RAM/Flash section trips the missing-sections
|
||||
# guard below, so no total is built on a dropped value
|
||||
_LOGGER.warning("Unparsable size output for section %s", parts[0])
|
||||
if missing := set(_RAM_SECTIONS + _FLASH_SECTIONS) - set(sections):
|
||||
# A defaulted 0 would print a confidently wrong total for CI's metric
|
||||
_LOGGER.warning(
|
||||
"Size output is missing section(s) %s; skipping the size summary",
|
||||
", ".join(sorted(missing)),
|
||||
)
|
||||
return False
|
||||
# Resolve the flash budget before printing anything: a RAM line without
|
||||
# its Flash line would let CI's memory-impact extraction sum the two
|
||||
# metrics over different build counts (_parse_app_size already warned).
|
||||
app_size = _parse_app_size(build_dir, paths)
|
||||
if not app_size:
|
||||
return False
|
||||
ram = sum(sections[s] for s in _RAM_SECTIONS)
|
||||
flash = sum(sections[s] for s in _FLASH_SECTIONS)
|
||||
print_size_line("RAM", ram, _MAX_RAM_SIZE)
|
||||
print_size_line("Flash", flash, app_size)
|
||||
return True
|
||||
|
||||
|
||||
# Sentinel: "resolve for me"; None is a real value meaning disabled.
|
||||
_CCACHE_UNRESOLVED: Any = object()
|
||||
|
||||
|
||||
def get_idedata(ccache: str | None = _CCACHE_UNRESOLVED) -> dict | None:
|
||||
"""Derive idedata from the build's compile_commands.json.
|
||||
|
||||
Same contract as ``espidf.toolchain.get_idedata``: the fields IDE
|
||||
integrations, clang-tidy, and the memory analyzer expect.
|
||||
"""
|
||||
from esphome.build_helpers.idedata import load_or_build_idedata
|
||||
|
||||
if ccache is _CCACHE_UNRESOLVED:
|
||||
# Deliberately uncached: env/PATH can change between builds in a
|
||||
# long-lived host process
|
||||
ccache = resolve_ccache_path()
|
||||
return load_or_build_idedata(
|
||||
get_build_dir() / "compile_commands.json",
|
||||
get_elf_path(),
|
||||
# Suffixed so a platformio->arduino->platformio round trip on one
|
||||
# config never serves the other toolchain's cache shape
|
||||
CORE.relative_internal_path("idedata", f"{CORE.name}.arduino.json"),
|
||||
# The compile DB's commands carry the same ccache prefix the ninja
|
||||
# rules were generated with
|
||||
launcher=str(ccache) if ccache else None,
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,6 @@ started esphome and must not depend on the package being importable.
|
||||
Subcommands:
|
||||
ar <ar-binary> <archive> <rspfile> remove stale archive, then ``ar rcs``
|
||||
copy <src> <dst> copy a file
|
||||
touch <path> create/update a stamp file
|
||||
|
||||
The ar rspfile carries one object path per line (the generating rule must
|
||||
use ``$in_newline``, never ``$in``).
|
||||
@@ -84,18 +83,9 @@ def _run_copy(src: str, dst: str) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def _run_touch(path: str) -> int:
|
||||
try:
|
||||
Path(path).touch()
|
||||
except OSError as err:
|
||||
print(f"touch: {path} failed: {err}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
# mode -> (handler, expected operand count); surplus argv means a
|
||||
# mis-specified ninja rule and must error, not silently drop operands
|
||||
_MODES = {"ar": (_run_ar, 3), "copy": (_run_copy, 2), "touch": (_run_touch, 1)}
|
||||
_MODES = {"ar": (_run_ar, 3), "copy": (_run_copy, 2)}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
|
||||
@@ -4,14 +4,6 @@ import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from esphome.build_helpers import pch
|
||||
from esphome.build_helpers.pch import (
|
||||
PCH_DEFAULT_HEADERS,
|
||||
PCH_HEADER_NAME,
|
||||
mark_pch_emitted,
|
||||
pch_enabled,
|
||||
pch_header_text,
|
||||
)
|
||||
from esphome.components.esp32 import (
|
||||
get_esp32_variant,
|
||||
get_excluded_builtin_components,
|
||||
@@ -287,47 +279,7 @@ idf_component_register(
|
||||
target_link_options(${{COMPONENT_LIB}} PUBLIC
|
||||
{link_opts_str}
|
||||
)
|
||||
{_pch_cmake()}"""
|
||||
|
||||
|
||||
def _pch_cmake() -> str:
|
||||
"""Consumer block for the component CMakeLists. Baked at generation:
|
||||
a strict-knob flip takes effect on the next esphome compile; a
|
||||
hand-run idf.py keeps the old one."""
|
||||
return pch.pch_cmake_consumer("${COMPONENT_LIB}", "${app_sources}")
|
||||
|
||||
|
||||
def prepare_pch() -> None:
|
||||
"""Build the .gch right before ninja, after every reconfigure, so the
|
||||
compile_commands.json flags and the sdkconfig are the settled ones."""
|
||||
if not pch_enabled():
|
||||
# Self-cleaning escape hatch: drop any previously built .gch
|
||||
pch.discard_pch(CORE.relative_build_path("build"))
|
||||
pch.pch_disabled_degraded()
|
||||
return
|
||||
sdkconfig_path = CORE.relative_build_path(f"sdkconfig.{CORE.name}")
|
||||
try:
|
||||
sdkconfig = sdkconfig_path.read_text(encoding="utf-8")
|
||||
except OSError as err:
|
||||
# Fail closed: the sdkconfig is the .sum's only config identity for
|
||||
# sdkconfig.h-only options; a stand-in marker would collide
|
||||
_LOGGER.warning(
|
||||
"Could not read %s; compiling without the pch: %s", sdkconfig_path, err
|
||||
)
|
||||
pch.discard_pch(CORE.relative_build_path("build"))
|
||||
pch.pch_degraded(f"sdkconfig unreadable: {err}")
|
||||
return
|
||||
pch.prepare_pch(
|
||||
CORE.relative_build_path("build"),
|
||||
PCH_DEFAULT_HEADERS,
|
||||
(
|
||||
str(idf_version()),
|
||||
CORE.cpp_standard or "",
|
||||
sdkconfig,
|
||||
*get_project_compile_flags(),
|
||||
*get_project_cxx_compile_flags(),
|
||||
),
|
||||
)
|
||||
"""
|
||||
|
||||
|
||||
def write_project(
|
||||
@@ -349,14 +301,6 @@ def write_project(
|
||||
get_component_cmakelists(),
|
||||
)
|
||||
|
||||
if pch_enabled():
|
||||
write_file_if_changed(
|
||||
CORE.relative_build_path("build", PCH_HEADER_NAME),
|
||||
pch_header_text(PCH_DEFAULT_HEADERS),
|
||||
)
|
||||
# Consumers carry the -include; gate the ccache relaxation on it
|
||||
mark_pch_emitted()
|
||||
|
||||
# Snapshot the exclusion set so has_outdated_files() can trigger a
|
||||
# discovery reconfigure when it changes. Excluded components never
|
||||
# register in project_description.json, so re-including one (e.g. a
|
||||
|
||||
@@ -21,13 +21,12 @@ def _ccache_runs(ccache: str) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def parse_enable_env(name: str, strict: bool = False) -> bool | None:
|
||||
def parse_enable_env(name: str) -> bool | None:
|
||||
"""Strictly parse an on/off environment knob; None when unset or invalid.
|
||||
|
||||
``bool(str)`` truthiness would flip ``no``/``off`` to enabled, so only
|
||||
1/true/yes/on and 0/false/no/off count; anything else warns and reads
|
||||
as unset so the caller's default policy applies — or raises when
|
||||
``strict`` (a typo must not silently disable a CI gate).
|
||||
as unset so the caller's default policy applies.
|
||||
"""
|
||||
raw = os.environ.get(name)
|
||||
if raw is None:
|
||||
@@ -40,10 +39,6 @@ def parse_enable_env(name: str, strict: bool = False) -> bool | None:
|
||||
return True
|
||||
if lowered in FALSY_ENV_STRINGS:
|
||||
return False
|
||||
if strict:
|
||||
from esphome.core import EsphomeError
|
||||
|
||||
raise EsphomeError(f"Unrecognized {name}={raw!r}; use 1 or 0")
|
||||
_LOGGER.warning("Ignoring unrecognized %s=%r; use 1 or 0", name, raw)
|
||||
return None
|
||||
|
||||
@@ -92,19 +87,6 @@ def ccache_defaults_env(cache_dir: Path) -> dict[str, str]:
|
||||
"CCACHE_DIR": str(cache_dir),
|
||||
"CCACHE_NOHASHDIR": "true",
|
||||
"CCACHE_DEPEND": "1",
|
||||
# A user value wins via the filter below
|
||||
"CCACHE_BASEDIR": effective_ccache_basedir(),
|
||||
"CCACHE_BASEDIR": str(Path(CORE.build_path).resolve()),
|
||||
}
|
||||
return {k: v for k, v in defaults.items() if k not in os.environ}
|
||||
|
||||
|
||||
def effective_ccache_basedir() -> str:
|
||||
"""The prefix ccache rewrites out of hashed paths: a user CCACHE_BASEDIR
|
||||
wins, else the resolved build path (matching ccache_defaults_env)."""
|
||||
from esphome.core import CORE
|
||||
|
||||
raw = os.environ.get("CCACHE_BASEDIR")
|
||||
if raw is not None and Path(raw).is_absolute() and len(Path(raw).parts) > 1:
|
||||
return raw
|
||||
# Unset or degenerate ("", "/", relative): fall back to the build path
|
||||
return str(Path(CORE.build_path).resolve())
|
||||
|
||||
@@ -11,7 +11,6 @@ consumers (IDE integration, clang-tidy) expect:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -22,8 +21,6 @@ import subprocess
|
||||
from esphome.core import EsphomeError
|
||||
from esphome.helpers import write_file
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Everything idedata generation may raise after a successful link; idedata
|
||||
# is a bonus artifact, so consumers warn instead of failing the build
|
||||
IDEDATA_BEST_EFFORT_ERRORS = (
|
||||
@@ -34,36 +31,13 @@ IDEDATA_BEST_EFFORT_ERRORS = (
|
||||
ValueError,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
def warn_if_idedata_missing(get_idedata: Callable[[], dict | None]) -> None:
|
||||
"""Run an idedata generator, downgrading any failure to a warning.
|
||||
|
||||
Shared by the native backends: the firmware already built, so a missing
|
||||
or broken idedata must not fail a successful build.
|
||||
"""
|
||||
try:
|
||||
if get_idedata() is None:
|
||||
_LOGGER.warning("No idedata was generated for this build")
|
||||
except IDEDATA_BEST_EFFORT_ERRORS as err:
|
||||
_LOGGER.warning(
|
||||
"Could not generate idedata: %s (IDE, clang-tidy, and "
|
||||
"memory-analysis data will be unavailable for this build)",
|
||||
err,
|
||||
)
|
||||
if isinstance(err, (EsphomeError, OSError)):
|
||||
# Routine environmental failures keep the detail at debug
|
||||
_LOGGER.debug("Idedata failure detail", exc_info=True)
|
||||
else:
|
||||
# LookupError/ValueError/RuntimeError smell like a parsing bug;
|
||||
# a permanently masked traceback would hide it on every build
|
||||
_LOGGER.warning("Idedata failure detail", exc_info=True)
|
||||
|
||||
|
||||
# C++ translation-unit suffixes.
|
||||
CXX_SOURCE_SUFFIXES = (".cpp", ".cc", ".cxx")
|
||||
# C++ translation-unit suffixes used to identify ESPHome source files.
|
||||
_CXX_SUFFIXES = (".cpp", ".cc")
|
||||
# Suffixes of input/output files that appear bare on the command line (and so
|
||||
# must not be mistaken for compiler flags).
|
||||
_INPUT_FILE_SUFFIXES = (*CXX_SOURCE_SUFFIXES, ".c", ".o", ".S", ".s")
|
||||
_INPUT_FILE_SUFFIXES = (*_CXX_SUFFIXES, ".c", ".o", ".S", ".s")
|
||||
# Path marker identifying an ESPHome source translation unit.
|
||||
_ESPHOME_SRC_MARKER = "/src/esphome/"
|
||||
|
||||
@@ -72,11 +46,11 @@ def _is_esphome_src(file: str) -> bool:
|
||||
"""Whether ``file`` is an ESPHome C++ translation unit; normalized to
|
||||
``/`` first since Windows compile DBs use backslashes."""
|
||||
return _ESPHOME_SRC_MARKER in file.replace("\\", "/") and file.endswith(
|
||||
CXX_SOURCE_SUFFIXES
|
||||
_CXX_SUFFIXES
|
||||
)
|
||||
|
||||
|
||||
def split_command(command: str) -> list[str]:
|
||||
def _split_command(command: str) -> list[str]:
|
||||
r"""Tokenize a compile_commands.json / response-file command string.
|
||||
|
||||
On Windows, tokenize per Windows ``argv`` rules via ``CommandLineToArgvW``.
|
||||
@@ -112,7 +86,7 @@ def split_command(command: str) -> list[str]:
|
||||
ctypes.windll.kernel32.LocalFree(argv)
|
||||
|
||||
|
||||
def expand_response_files(tokens: list[str], directory: Path) -> list[str]:
|
||||
def _expand_response_files(tokens: list[str], directory: Path) -> list[str]:
|
||||
"""Inline any ``@response-file`` arguments (paths relative to ``directory``).
|
||||
|
||||
GCC response files embed flags that must be expanded so GCC-only flags
|
||||
@@ -127,8 +101,8 @@ def expand_response_files(tokens: list[str], directory: Path) -> list[str]:
|
||||
rf = directory / rf
|
||||
try:
|
||||
out.extend(
|
||||
expand_response_files(
|
||||
split_command(rf.read_text(encoding="utf-8")), directory
|
||||
_expand_response_files(
|
||||
_split_command(rf.read_text(encoding="utf-8")), directory
|
||||
)
|
||||
)
|
||||
continue
|
||||
@@ -147,7 +121,7 @@ def _pick_entry(entries: list[dict]) -> dict:
|
||||
if _is_esphome_src(entry["file"]):
|
||||
return entry
|
||||
for entry in entries:
|
||||
if entry["file"].endswith(CXX_SOURCE_SUFFIXES):
|
||||
if entry["file"].endswith(_CXX_SUFFIXES):
|
||||
return entry
|
||||
raise ValueError("no C++ translation unit found in compile_commands.json")
|
||||
|
||||
@@ -157,25 +131,16 @@ def _pick_entry(entries: list[dict]) -> dict:
|
||||
_LAUNCHER_STEMS = frozenset({"ccache", "sccache", "distcc", "icecc", "buildcache"})
|
||||
|
||||
|
||||
def is_launcher(token: str) -> bool:
|
||||
def _is_launcher(token: str) -> bool:
|
||||
return Path(token).stem.lower() in _LAUNCHER_STEMS
|
||||
|
||||
|
||||
def is_joined_include(tok: str) -> bool:
|
||||
"""The joined ``-includefoo.h`` spelling; excludes clang's -include-pch."""
|
||||
return (
|
||||
tok.startswith("-include")
|
||||
and tok != "-include"
|
||||
and not tok.startswith("-include-")
|
||||
)
|
||||
|
||||
|
||||
def parse_entry(
|
||||
entry: dict, launcher: str | None = None
|
||||
) -> tuple[str, list[str], list[str], list[str]]:
|
||||
"""Parse one compile_commands entry -> (cxx_path, defines, includes, cxx_flags)."""
|
||||
directory = Path(entry["directory"])
|
||||
tokens = expand_response_files(split_command(entry["command"]), directory)
|
||||
tokens = _expand_response_files(_split_command(entry["command"]), directory)
|
||||
|
||||
def _include(raw: str) -> str:
|
||||
# Resolve against the entry's ``directory`` so cached idedata works
|
||||
@@ -191,7 +156,7 @@ def parse_entry(
|
||||
if not tokens:
|
||||
# An empty command, or one that was only the launcher; fail by name
|
||||
raise ValueError(f"empty compile command for {entry.get('file')}")
|
||||
if is_launcher(tokens[0]) and len(tokens) > 1 and not tokens[1].startswith("-"):
|
||||
if _is_launcher(tokens[0]) and len(tokens) > 1 and not tokens[1].startswith("-"):
|
||||
# Stale DB built with a launcher this run no longer configures; the
|
||||
# real compiler is the next token
|
||||
_LOGGER.warning("Stripping unconfigured launcher %s", tokens[0])
|
||||
@@ -204,23 +169,11 @@ def parse_entry(
|
||||
defines: list[str] = []
|
||||
includes: list[str] = []
|
||||
cxx_flags: list[str] = []
|
||||
unresolved_force_includes: list[str] = []
|
||||
|
||||
it = iter(tokens[1:])
|
||||
for tok in it:
|
||||
if tok in ("-c", "-o"):
|
||||
next(it, None) # drop the flag and its argument (input/output)
|
||||
elif tok == "-include" or is_joined_include(tok):
|
||||
# Re-anchor only names next to the compile (the pch); a name
|
||||
# meant for the -I chain must stay untouched
|
||||
raw = next(it, "") if tok == "-include" else tok[len("-include") :]
|
||||
if not raw:
|
||||
_LOGGER.warning("Dropping -include with no argument")
|
||||
elif Path(resolved := _include(raw)).is_file():
|
||||
cxx_flags.extend(("-include", resolved))
|
||||
else:
|
||||
unresolved_force_includes.append(raw)
|
||||
cxx_flags.extend(("-include", raw))
|
||||
elif tok.startswith("-D"):
|
||||
# ``.strip()`` handles tokens like ``-D CONFIGURED=1`` (a single
|
||||
# quoted arg with a space after -D) that some flags arrive as.
|
||||
@@ -239,14 +192,6 @@ def parse_entry(
|
||||
pass # input/output files
|
||||
else:
|
||||
cxx_flags.append(tok)
|
||||
for raw in unresolved_force_includes:
|
||||
# A deleted build artifact would otherwise surface only downstream
|
||||
if not any((Path(inc) / raw).is_file() for inc in includes):
|
||||
_LOGGER.warning(
|
||||
"-include %s found neither next to the compile nor on the "
|
||||
"include path; cached idedata may not resolve it",
|
||||
raw,
|
||||
)
|
||||
return cxx_path, defines, includes, cxx_flags
|
||||
|
||||
|
||||
@@ -311,7 +256,7 @@ def _cache_usable(cached: object) -> bool:
|
||||
if not isinstance(cached, dict) or "cc_path" not in cached:
|
||||
return False
|
||||
cxx_path = cached.get("cxx_path")
|
||||
if not isinstance(cxx_path, str) or is_launcher(cxx_path):
|
||||
if not isinstance(cxx_path, str) or _is_launcher(cxx_path):
|
||||
return False
|
||||
includes = cached.get("includes")
|
||||
return isinstance(includes, dict) and isinstance(includes.get("build"), list)
|
||||
@@ -359,7 +304,7 @@ def load_or_build_idedata(
|
||||
def reject_launcher_compiler(cxx_path: str) -> None:
|
||||
"""Reject a compile DB naming a launcher (ccache) as the compiler; it
|
||||
must never be probed, cached, or consumed."""
|
||||
if is_launcher(cxx_path):
|
||||
if _is_launcher(cxx_path):
|
||||
raise EsphomeError(
|
||||
f"compile_commands.json names the launcher {cxx_path} as the "
|
||||
"compiler; the compile database is unusable"
|
||||
|
||||
@@ -1,603 +0,0 @@
|
||||
"""Shared precompiled-header policy for the build backends.
|
||||
|
||||
The prefix either mirrors the TUs' own force-includes (ESP8266) or is a
|
||||
curated core-header set (ESP-IDF). ``esphome: includes:`` sources receive
|
||||
it too; Arduino.h visibility there is intended (esphome#8693).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Iterable
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import posixpath
|
||||
import re
|
||||
import stat
|
||||
import subprocess
|
||||
|
||||
from esphome.build_helpers.ccache import effective_ccache_basedir, parse_enable_env
|
||||
from esphome.build_helpers.idedata import (
|
||||
CXX_SOURCE_SUFFIXES,
|
||||
expand_response_files,
|
||||
is_launcher,
|
||||
split_command,
|
||||
)
|
||||
|
||||
_DOMAIN = "pch"
|
||||
|
||||
|
||||
@dataclass
|
||||
class _PCHData:
|
||||
emitted: bool = False
|
||||
|
||||
|
||||
def _pch_data() -> _PCHData:
|
||||
from esphome.core import CORE
|
||||
|
||||
if _DOMAIN not in CORE.data:
|
||||
CORE.data[_DOMAIN] = _PCHData()
|
||||
return CORE.data[_DOMAIN]
|
||||
|
||||
|
||||
def mark_pch_emitted() -> None:
|
||||
"""Record that this build's consumers reference the pch."""
|
||||
_pch_data().emitted = True
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# The header and its .gch/.sum sidecars live in the build directory.
|
||||
PCH_HEADER_NAME = "esphome_pch.h"
|
||||
|
||||
# Every artifact the pch machinery can leave behind, for cleanup.
|
||||
PCH_ARTIFACT_NAMES = (
|
||||
PCH_HEADER_NAME,
|
||||
f"{PCH_HEADER_NAME}.gch",
|
||||
f"{PCH_HEADER_NAME}.gch.sum",
|
||||
f"{PCH_HEADER_NAME}.gch.failed",
|
||||
)
|
||||
|
||||
# The core defines header every backend anchors its prefix on.
|
||||
PCH_CORE_HEADER = "esphome/core/defines.h"
|
||||
|
||||
# Guarded curated-prefix wrapper for PlatformIO backends without framework
|
||||
# force-includes (host, esp32); folded by the pch script via build_src_flags.
|
||||
PCH_PREFIX_HEADER = "esphome/core/pch_prefix.h"
|
||||
|
||||
# Prefix-header contents for backends that inject a curated set (rather
|
||||
# than mirroring the TUs' own force-includes), defines.h first so USE_*
|
||||
# macros exist for the rest. Deliberately hard-coded: frequency-derived
|
||||
# sets measured no better and kept selecting headers that cannot compile
|
||||
# standalone (X-macro, platform-variant). Every entry must be safe to
|
||||
# include first in an empty TU. Caveat: application.h/automation.h become
|
||||
# ambiently visible, so a TU missing those #includes still builds on such
|
||||
# backends; ESPHOME_PCH_ENABLE=0 restores the strict view.
|
||||
PCH_DEFAULT_HEADERS = (
|
||||
PCH_CORE_HEADER,
|
||||
"esphome/core/component.h",
|
||||
"esphome/core/helpers.h",
|
||||
"esphome/core/log.h",
|
||||
"esphome/core/application.h",
|
||||
"esphome/core/automation.h",
|
||||
)
|
||||
|
||||
# ccache cannot hash through a .gch; CCACHE_PCH_EXTSUM makes it hash the
|
||||
# .sum sidecar instead of the .gch bytes, which are not reproducible.
|
||||
# Keep in sync with the literals in platformio/pch.py.script.
|
||||
_CCACHE_PCH_ENV = {
|
||||
"CCACHE_SLOPPINESS": "pch_defines,time_macros",
|
||||
"CCACHE_PCH_EXTSUM": "true",
|
||||
}
|
||||
|
||||
# Both include forms: an angle include resolving under src/ must enter the
|
||||
# digest too; ones that do not resolve simply end the walk
|
||||
# Compiler failures that clear on their own must not latch the .failed marker
|
||||
_TRANSIENT_ERRORS = ("No space left", "Cannot allocate", "Resource temporarily")
|
||||
|
||||
_INCLUDE_RE = re.compile(rb'^\s*#\s*include\s+["<]([^">]+)[">]', re.MULTILINE)
|
||||
|
||||
|
||||
def pch_enabled() -> bool:
|
||||
"""Precompiled-header knob: default on, ``ESPHOME_PCH_ENABLE=0`` opts out."""
|
||||
return parse_enable_env("ESPHOME_PCH_ENABLE") is not False
|
||||
|
||||
|
||||
def pch_strict() -> bool:
|
||||
"""CI knob: ``ESPHOME_PCH_STRICT=1`` turns pch degrade paths fatal.
|
||||
|
||||
A set-but-unrecognized value raises: a typo must not silently turn
|
||||
the gate into a no-op that proves nothing.
|
||||
"""
|
||||
return parse_enable_env("ESPHOME_PCH_STRICT", strict=True) is True
|
||||
|
||||
|
||||
def pch_degraded(reason: str) -> None:
|
||||
"""Every degrade path funnels through here; strict mode raises."""
|
||||
if pch_strict():
|
||||
from esphome.core import EsphomeError
|
||||
|
||||
raise EsphomeError(f"ESPHOME_PCH_STRICT: {reason}")
|
||||
|
||||
|
||||
def pch_disabled_degraded() -> None:
|
||||
"""Strict CI must not read "no pch at all" as success."""
|
||||
pch_degraded("pch disabled by ESPHOME_PCH_ENABLE")
|
||||
|
||||
|
||||
def pch_probe_tail(source: str = "-") -> list[str]:
|
||||
"""The syntax-only compile shared by the probe and its baseline."""
|
||||
return ["-fsyntax-only", "-x", "c++", source]
|
||||
|
||||
|
||||
def pch_probe_args(header: str, source: str = "-") -> list[str]:
|
||||
"""Flags that load-check a built .gch via a syntax-only compile.
|
||||
|
||||
Rejection must be a nonzero exit (never just a wording match), so the
|
||||
invalid-pch class is always escalated. ``source`` defaults to stdin
|
||||
(host independent); the ninja probe edge passes a real file.
|
||||
"""
|
||||
return [
|
||||
"-Winvalid-pch",
|
||||
"-Werror=invalid-pch",
|
||||
"-include",
|
||||
header,
|
||||
*pch_probe_tail(source),
|
||||
]
|
||||
|
||||
|
||||
def pch_consumer_escalation() -> str:
|
||||
"""Consumer-side invalid-pch flag: strict reds the build on rejection
|
||||
(per-process, so the probe alone cannot prove the consumers)."""
|
||||
return "-Werror=invalid-pch" if pch_strict() else "-Wno-error=invalid-pch"
|
||||
|
||||
|
||||
def pch_cmake_consumer(target: str, sources_var: str) -> str:
|
||||
"""Emit the CMake block making ``target``'s C++ sources consume the
|
||||
pch; empty when disabled. OBJECT_DEPENDS is on the header, not the
|
||||
.gch (pch-baked headers drop out of TU depfiles); the -include stays
|
||||
relative — an absolute path would poison ccache keys."""
|
||||
if not pch_enabled():
|
||||
return ""
|
||||
escalation = pch_consumer_escalation()
|
||||
return f"""
|
||||
# ESPHome precompiled header (see esphome/build_helpers/pch.py).
|
||||
# The touch keeps OBJECT_DEPENDS satisfiable when the build system itself
|
||||
# wiped the build dir after the header was written (west --pristine)
|
||||
if(NOT EXISTS "${{CMAKE_BINARY_DIR}}/{PCH_HEADER_NAME}")
|
||||
file(TOUCH "${{CMAKE_BINARY_DIR}}/{PCH_HEADER_NAME}")
|
||||
endif()
|
||||
target_compile_options({target} PRIVATE
|
||||
"$<$<COMPILE_LANGUAGE:CXX>:-Winvalid-pch>"
|
||||
"$<$<COMPILE_LANGUAGE:CXX>:{escalation}>"
|
||||
"$<$<COMPILE_LANGUAGE:CXX>:-include>"
|
||||
"$<$<COMPILE_LANGUAGE:CXX>:{PCH_HEADER_NAME}>"
|
||||
)
|
||||
set_source_files_properties({sources_var} PROPERTIES
|
||||
OBJECT_DEPENDS "${{CMAKE_BINARY_DIR}}/{PCH_HEADER_NAME}")
|
||||
"""
|
||||
|
||||
|
||||
def ccache_pch_env() -> dict[str, str]:
|
||||
"""Settings ccache needs to cache compiles that consume the .gch;
|
||||
empty unless this build actually emitted one. User-set values win.
|
||||
Native backends export these process-wide; only time_macros affects
|
||||
non-pch TUs."""
|
||||
if not (pch_enabled() and _pch_data().emitted):
|
||||
return {}
|
||||
extsum = os.environ.get("CCACHE_PCH_EXTSUM")
|
||||
if extsum is not None and extsum.strip().lower() not in ("1", "true", "yes", "on"):
|
||||
# ccache then hashes the non-reproducible .gch bytes: permanent misses
|
||||
_LOGGER.warning("CCACHE_PCH_EXTSUM=%s disables pch caching", extsum)
|
||||
env = {k: v for k, v in _CCACHE_PCH_ENV.items() if k not in os.environ}
|
||||
user_sloppiness = os.environ.get("CCACHE_SLOPPINESS")
|
||||
if user_sloppiness is not None and (
|
||||
missing := [
|
||||
t
|
||||
for t in ("pch_defines", "time_macros")
|
||||
if t not in {tok.strip() for tok in user_sloppiness.split(",")}
|
||||
]
|
||||
):
|
||||
# Without these ccache declines every pch-consuming compile
|
||||
env["CCACHE_SLOPPINESS"] = ",".join((user_sloppiness, *missing))
|
||||
_LOGGER.warning(
|
||||
"Adding %s to CCACHE_SLOPPINESS so ccache can cache compiles "
|
||||
"that use the precompiled header",
|
||||
",".join(missing),
|
||||
)
|
||||
return env
|
||||
|
||||
|
||||
def guarded_prepare(build_dir: Path, prepare: Callable[[], None]) -> None:
|
||||
"""Run a backend's pch preparation; an optional speedup must never
|
||||
abort the build. Strict is read first so its own knob error cannot
|
||||
mask the real failure; discard_pch raises if a stale .gch survives;
|
||||
the header is ensured so OBJECT_DEPENDS stays satisfiable."""
|
||||
try:
|
||||
prepare()
|
||||
except Exception: # noqa: BLE001 # pylint: disable=broad-exception-caught
|
||||
strict = pch_strict()
|
||||
discard_pch(build_dir)
|
||||
if strict:
|
||||
raise
|
||||
header = build_dir / PCH_HEADER_NAME
|
||||
if not header.exists():
|
||||
try:
|
||||
header.touch()
|
||||
except OSError as err:
|
||||
# The coming OBJECT_DEPENDS error would hide the real cause
|
||||
_LOGGER.warning("Could not create the pch placeholder: %s", err)
|
||||
_LOGGER.warning(
|
||||
"Precompiled header setup failed; compiling without it", exc_info=True
|
||||
)
|
||||
|
||||
|
||||
def pch_extra_scripts() -> list[str]:
|
||||
"""The extra_scripts entries a PlatformIO platform registers for the
|
||||
pch; empty when disabled (the script itself has no enable check)."""
|
||||
if not pch_enabled():
|
||||
pch_disabled_degraded()
|
||||
return []
|
||||
return ["post:pch.py"]
|
||||
|
||||
|
||||
def pch_header_text(include_headers: Iterable[str]) -> str:
|
||||
"""The prefix-header source: exactly these includes, in order."""
|
||||
return "".join(f'#include "{name}"\n' for name in include_headers)
|
||||
|
||||
|
||||
def _resolves(path: Path) -> bool:
|
||||
"""False when missing; other stat failures propagate (identity unknown,
|
||||
unlike is_file(), which would silently drop the header)."""
|
||||
try:
|
||||
return stat.S_ISREG(path.stat().st_mode)
|
||||
except (FileNotFoundError, NotADirectoryError):
|
||||
return False
|
||||
|
||||
|
||||
def _include_closure(src_dir: Path, roots: Iterable[str]) -> dict[str, bytes]:
|
||||
"""Include closure of ``roots``: src-relative name -> contents.
|
||||
|
||||
Resolution mirrors the compiler (includer's dir, then src root); names
|
||||
outside ``src_dir`` end the walk and are versioned by the caller. No
|
||||
#ifdef evaluation: over-approximating is the safe direction.
|
||||
"""
|
||||
seen: dict[str, bytes] = {}
|
||||
stack: list[tuple[str, str]] = [(name, "") for name in roots]
|
||||
while stack:
|
||||
name, from_dir = stack.pop()
|
||||
for candidate in (f"{from_dir}/{name}" if from_dir else name, name):
|
||||
rel = posixpath.normpath(candidate)
|
||||
if not rel.startswith("..") and _resolves(src_dir / rel):
|
||||
break
|
||||
else:
|
||||
continue
|
||||
if rel in seen:
|
||||
continue
|
||||
try:
|
||||
data = (src_dir / rel).read_bytes()
|
||||
except OSError as err:
|
||||
# A marker would truncate the transitive walk; fail closed
|
||||
_LOGGER.warning("Could not read %s for the pch checksum: %s", rel, err)
|
||||
raise
|
||||
seen[rel] = data
|
||||
parent = posixpath.dirname(rel)
|
||||
stack.extend(
|
||||
# surrogateescape: a non-UTF-8 name just fails to resolve
|
||||
(inc.decode(errors="surrogateescape"), parent)
|
||||
for inc in _INCLUDE_RE.findall(data)
|
||||
)
|
||||
return seen
|
||||
|
||||
|
||||
def pch_checksum(
|
||||
src_dir: Path, include_headers: Iterable[str], extra: Iterable[str]
|
||||
) -> str:
|
||||
"""Digest standing in for the .gch in ccache's hash: the include closure
|
||||
of the prefix header plus caller-supplied identity strings (versioned
|
||||
install paths, flags). Raises OSError when a header's identity cannot
|
||||
be established at all; callers must then compile without a pch."""
|
||||
digest = hashlib.sha256()
|
||||
closure = _include_closure(src_dir, include_headers)
|
||||
for name in sorted(closure):
|
||||
digest.update(name.encode(errors="surrogateescape"))
|
||||
digest.update(closure[name])
|
||||
digest.update(b"\0")
|
||||
for item in extra:
|
||||
digest.update(item.encode(errors="surrogateescape"))
|
||||
digest.update(b"\0")
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
# Tokens dropped when retargeting a TU's flags at the prefix header
|
||||
# (the pch compile must not touch depfiles)
|
||||
_PCH_STRIP_FLAGS_WITH_ARG = frozenset({"-o", "-c", "-MT", "-MF", "-MQ"})
|
||||
_PCH_STRIP_FLAGS = frozenset({"-MD", "-MMD", "-MP", "-MM", "-M"})
|
||||
|
||||
|
||||
def pch_compile_command(
|
||||
build_dir: Path, header: Path, gch: Path
|
||||
) -> tuple[list[str], Path] | None:
|
||||
"""The exact src C++ flags from compile_commands.json retargeted at the
|
||||
header, with the directory they resolve against (relative -I paths must
|
||||
be expanded and executed from the same root); None (logged) when no
|
||||
configured C++ TU is available yet."""
|
||||
from esphome.core import CORE
|
||||
|
||||
try:
|
||||
entries = json.loads(
|
||||
(build_dir / "compile_commands.json").read_text(encoding="utf-8")
|
||||
)
|
||||
except (OSError, json.JSONDecodeError) as err:
|
||||
# Configure already succeeded, so an unusable DB is a real anomaly
|
||||
_LOGGER.warning("No usable compile database, skipping pch: %s", err)
|
||||
return None
|
||||
if not isinstance(entries, list):
|
||||
_LOGGER.warning("Malformed compile database, skipping pch")
|
||||
return None
|
||||
# CMake may spell paths through a symlink differently than CORE does
|
||||
# (macOS /tmp vs /private/tmp), so compare resolved paths
|
||||
src_root = Path(CORE.relative_src_path()).resolve()
|
||||
entry = next(
|
||||
(
|
||||
e
|
||||
for e in entries
|
||||
if isinstance(e, dict)
|
||||
and isinstance(e.get("file"), str)
|
||||
and e["file"].endswith(CXX_SOURCE_SUFFIXES)
|
||||
and Path(e["file"]).resolve().is_relative_to(src_root)
|
||||
),
|
||||
None,
|
||||
)
|
||||
if entry is None:
|
||||
_LOGGER.warning("No src C++ entry in the compile database, skipping pch")
|
||||
return None
|
||||
directory = entry.get("directory")
|
||||
cmd_dir = Path(directory) if isinstance(directory, str) and directory else build_dir
|
||||
command = entry.get("command")
|
||||
tokens = expand_response_files(
|
||||
split_command(command if isinstance(command, str) else ""), cmd_dir
|
||||
)
|
||||
# A DB recorded with ccache enabled prefixes the compiler with the
|
||||
# launcher; the .gch must be compiled directly
|
||||
if tokens and is_launcher(tokens[0]):
|
||||
tokens = tokens[1:]
|
||||
if not tokens:
|
||||
# "arguments"-style or empty entries must skip, not spawn "-x ..."
|
||||
_LOGGER.warning("Compile database entry has no usable command, skipping pch")
|
||||
return None
|
||||
args: list[str] = []
|
||||
arg_it = iter(tokens)
|
||||
for tok in arg_it:
|
||||
if tok in _PCH_STRIP_FLAGS_WITH_ARG:
|
||||
next(arg_it, None)
|
||||
continue
|
||||
if tok in _PCH_STRIP_FLAGS:
|
||||
continue
|
||||
if tok == "-include":
|
||||
# Drop only the injected prefix; user force-includes must reach
|
||||
# the .gch compile or GCC rejects it over the macro mismatch
|
||||
inc = next(arg_it, "")
|
||||
if not inc.endswith(PCH_HEADER_NAME):
|
||||
args.extend(("-include", inc))
|
||||
continue
|
||||
args.append(tok)
|
||||
return [*args, "-x", "c++-header", "-c", str(header), "-o", str(gch)], cmd_dir
|
||||
|
||||
|
||||
def _log_pch_in_use() -> None:
|
||||
# The only place a user can discover the knob; emitted only once a
|
||||
# .gch is actually fresh or being built
|
||||
_LOGGER.info(
|
||||
"Compiling with a precompiled header (set ESPHOME_PCH_ENABLE=0 to disable)"
|
||||
)
|
||||
|
||||
|
||||
def _read_stamp(path: Path) -> str:
|
||||
"""A corrupt sidecar must read as stale, not kill the pch forever."""
|
||||
try:
|
||||
return path.read_text(encoding="utf-8").strip()
|
||||
except (OSError, UnicodeDecodeError):
|
||||
return ""
|
||||
|
||||
|
||||
def discard_pch(build_dir: Path) -> None:
|
||||
"""Remove the pch sidecars so a stale .gch is never consumed.
|
||||
|
||||
Bumps the header only when a .gch was actually removed: TUs compiled
|
||||
against it have incomplete depfiles, while a repeat failure with no
|
||||
.gch must not force a full rebuild every build. A .gch that survives
|
||||
an unlink failure would be consumed silently (wrong output, not a
|
||||
slow build), so that raises.
|
||||
"""
|
||||
header = build_dir / PCH_HEADER_NAME
|
||||
gch = Path(f"{header}.gch")
|
||||
had_gch = gch.is_file()
|
||||
errors = []
|
||||
for sidecar in (gch, Path(f"{gch}.sum")):
|
||||
try:
|
||||
sidecar.unlink(missing_ok=True)
|
||||
except OSError as err:
|
||||
if sidecar.is_file():
|
||||
from esphome.core import EsphomeError
|
||||
|
||||
raise EsphomeError(
|
||||
f"Could not discard the stale precompiled header: {err}"
|
||||
) from err
|
||||
errors.append(err)
|
||||
for err in errors:
|
||||
_LOGGER.warning("Could not discard the pch sidecars: %s", err)
|
||||
if had_gch and header.is_file():
|
||||
with suppress(OSError):
|
||||
os.utime(header)
|
||||
|
||||
|
||||
def prepare_pch(
|
||||
build_dir: Path, include_headers: tuple[str, ...], extra: Iterable[str]
|
||||
) -> None:
|
||||
"""Compile ``build_dir``'s .gch from compile_commands.json flags and
|
||||
write its ccache .sum.
|
||||
|
||||
The .sum doubles as the freshness stamp and folds in the compile
|
||||
command, so a flag-only change rebuilds the .gch; ``extra`` carries
|
||||
backend identity (framework version, sdkconfig, ...). A failed
|
||||
compile falls back to the plain header include.
|
||||
"""
|
||||
from esphome.core import CORE
|
||||
|
||||
header = build_dir / PCH_HEADER_NAME
|
||||
gch = Path(f"{header}.gch")
|
||||
sum_path = Path(f"{gch}.sum")
|
||||
cmd_and_dir = pch_compile_command(build_dir, header, gch)
|
||||
if cmd_and_dir is None:
|
||||
# Freshness cannot be validated; a leftover .gch must not be consumed
|
||||
discard_pch(build_dir)
|
||||
pch_degraded("no usable compile command")
|
||||
return
|
||||
cmd, cmd_dir = cmd_and_dir
|
||||
# Strip like ccache's rewriting (user CCACHE_BASEDIR wins); the raw
|
||||
# build path covers unresolved (symlinked) spellings
|
||||
cmd_id = (
|
||||
" ".join(cmd)
|
||||
.replace(effective_ccache_basedir(), "")
|
||||
.replace(str(CORE.build_path), "")
|
||||
)
|
||||
try:
|
||||
checksum = pch_checksum(
|
||||
CORE.relative_src_path(),
|
||||
include_headers,
|
||||
(
|
||||
# The closure is sorted, so root order only enters via the text
|
||||
pch_header_text(include_headers),
|
||||
*extra,
|
||||
cmd_id,
|
||||
),
|
||||
)
|
||||
except (OSError, UnicodeError) as err:
|
||||
# Identity unknown: a stale cache entry must never be served
|
||||
_LOGGER.warning(
|
||||
"Could not establish the pch identity; compiling without it: %s", err
|
||||
)
|
||||
discard_pch(build_dir)
|
||||
pch_degraded(f"identity unknown: {err}")
|
||||
return
|
||||
failed_marker = Path(f"{gch}.failed")
|
||||
|
||||
def _run(
|
||||
run_cmd: list[str], what: str, stdin: str | None = None
|
||||
) -> subprocess.CompletedProcess | None:
|
||||
"""Spawn one pch tool step; environmental failures discard and
|
||||
degrade (None): spawn/IO/timeout errors and signal kills never
|
||||
latch the marker."""
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
run_cmd,
|
||||
cwd=cmd_dir,
|
||||
# C locale keeps diagnostics matchable by _TRANSIENT_ERRORS
|
||||
env={**os.environ, "LC_ALL": "C"},
|
||||
input=stdin,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
timeout=300,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError) as err:
|
||||
_LOGGER.warning("Precompiled header %s did not run: %s", what, err)
|
||||
discard_pch(build_dir)
|
||||
pch_degraded(f"{what} did not run: {err}")
|
||||
return None
|
||||
if proc.returncode < 0:
|
||||
# Killed by a signal (OOM, ^C): environmental, do not latch
|
||||
_LOGGER.warning(
|
||||
"Precompiled header %s was killed (signal %d); retrying next build",
|
||||
what,
|
||||
-proc.returncode,
|
||||
)
|
||||
discard_pch(build_dir)
|
||||
pch_degraded(f"{what} killed by signal {-proc.returncode}")
|
||||
return None
|
||||
return proc
|
||||
|
||||
def _fail(error: str, reason: str, latch: bool) -> None:
|
||||
"""Discard and degrade; deterministic failures latch when asked."""
|
||||
_LOGGER.warning(
|
||||
"Precompiled header failed; compiling without it: %s", error[:400]
|
||||
)
|
||||
# Latching paths keep the full compiler output recoverable
|
||||
_LOGGER.debug("Full pch output: %s", error)
|
||||
discard_pch(build_dir)
|
||||
if latch and not any(m in error for m in _TRANSIENT_ERRORS):
|
||||
# Skip retries until a header/flag/backend-identity/command change
|
||||
failed_marker.write_text(checksum + "\n", encoding="utf-8")
|
||||
os.utime(header)
|
||||
pch_degraded(f"{reason}: {error[:200]}")
|
||||
|
||||
def _probe(latch: bool = True) -> None:
|
||||
"""Load-check the built .gch: some toolchains build one they then
|
||||
refuse to load (per-process ASLR). Dep flags are already stripped
|
||||
from cmd, so no -MF is needed; cmd ends with the fixed
|
||||
"-x c++-header -c -o" tail. A cached-header rejection may not
|
||||
reproduce (per-process), so that caller passes latch=False."""
|
||||
if cmd[-6:-4] != ["-x", "c++-header"]:
|
||||
# The slice below depends on pch_compile_command's fixed tail
|
||||
_LOGGER.warning("Unexpected pch command shape: %s", cmd[-6:])
|
||||
discard_pch(build_dir)
|
||||
pch_degraded("unexpected pch command shape")
|
||||
return
|
||||
base = cmd[:-6]
|
||||
probe = _run([*base, *pch_probe_args(str(header))], "probe", stdin="")
|
||||
if probe is None:
|
||||
return
|
||||
if probe.returncode != 0:
|
||||
# Disambiguate: only blame the pch when the same compile passes
|
||||
# without it; a failing baseline is its own (latchable) problem
|
||||
baseline = _run([*base, *pch_probe_tail()], "probe baseline", stdin="")
|
||||
if baseline is None:
|
||||
return
|
||||
if baseline.returncode == 0:
|
||||
error = probe.stderr.strip() or f"exit code {probe.returncode}"
|
||||
_fail(error, "toolchain cannot load the pch", latch=latch)
|
||||
else:
|
||||
error = baseline.stderr.strip() or f"exit code {baseline.returncode}"
|
||||
_fail(error, "probe cannot run at all", latch=latch)
|
||||
|
||||
if gch.is_file() and _read_stamp(sum_path) == checksum:
|
||||
_log_pch_in_use()
|
||||
if pch_strict():
|
||||
# Rejection is per-process, so a cached .gch must re-prove
|
||||
# loadability for the strict gate (CI-only cost); no latch,
|
||||
# since the rejection may not reproduce either
|
||||
_probe(latch=False)
|
||||
return
|
||||
if _read_stamp(failed_marker) == checksum:
|
||||
_LOGGER.info(
|
||||
"Precompiled header disabled after an earlier failure; delete %s to retry",
|
||||
failed_marker,
|
||||
)
|
||||
pch_degraded("earlier failure latched")
|
||||
return
|
||||
_log_pch_in_use()
|
||||
result = _run(cmd, "compile")
|
||||
if result is None:
|
||||
return
|
||||
error = None
|
||||
if result.returncode != 0:
|
||||
error = result.stderr.strip() or f"exit code {result.returncode}"
|
||||
elif not gch.is_file():
|
||||
error = "compiler produced no .gch"
|
||||
if error is not None:
|
||||
_fail(error, "compile failed", latch=True)
|
||||
return
|
||||
_probe()
|
||||
if not gch.is_file():
|
||||
# The probe discarded a rejected or unrunnable .gch
|
||||
return
|
||||
failed_marker.unlink(missing_ok=True)
|
||||
sum_path.write_text(checksum + "\n", encoding="utf-8")
|
||||
# Consumers depend on the header (depfiles cannot see through a .gch);
|
||||
# bump it so users of the previous .gch recompile
|
||||
os.utime(header)
|
||||
@@ -893,6 +893,20 @@ message NoiseEncryptionSetKeyResponse {
|
||||
bool success = 1;
|
||||
}
|
||||
|
||||
// Single-use session resume ticket, sent unsolicited by the device after a
|
||||
// Noise connection authenticates. A client presents it in the ClientHello of
|
||||
// its next connection to skip the curve25519 handshake; the device then
|
||||
// issues a fresh ticket on that connection. Never sent on plaintext
|
||||
// connections. Clients that do not understand it drop it silently.
|
||||
// Contents are secret; the device generator redacts this message from dump_to
|
||||
message NoiseResumeTicket {
|
||||
option (id) = 152;
|
||||
option (source) = SOURCE_SERVER;
|
||||
option (ifdef) = "USE_API_NOISE";
|
||||
|
||||
bytes ticket = 1; // session_id(8) || secret(32)
|
||||
}
|
||||
|
||||
// ==================== HOMEASSISTANT.SERVICE ====================
|
||||
message SubscribeHomeassistantServicesRequest {
|
||||
option (id) = 34;
|
||||
|
||||
@@ -1779,6 +1779,9 @@ void APIConnection::complete_authentication_() {
|
||||
this->send_time_request();
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_API_NOISE
|
||||
this->send_resume_ticket_();
|
||||
#endif
|
||||
#ifdef USE_ZWAVE_PROXY
|
||||
if (zwave_proxy::global_zwave_proxy != nullptr) {
|
||||
zwave_proxy::global_zwave_proxy->api_connection_authenticated(this);
|
||||
@@ -1786,6 +1789,27 @@ void APIConnection::complete_authentication_() {
|
||||
#endif
|
||||
}
|
||||
|
||||
#ifdef USE_API_NOISE
|
||||
void APIConnection::send_resume_ticket_() {
|
||||
#ifdef USE_API_PLAINTEXT
|
||||
// Only encrypted transports get a ticket: on dual-mode builds a plaintext
|
||||
// connection has no frame footer
|
||||
if (this->helper_->frame_footer_size() == 0) {
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
noise::ResumeTicket ticket;
|
||||
if (!this->parent_->get_noise_ctx().resume_cache().issue(ticket)) {
|
||||
return;
|
||||
}
|
||||
NoiseResumeTicket msg;
|
||||
msg.set_ticket(reinterpret_cast<const uint8_t *>(&ticket), sizeof(ticket));
|
||||
// A dropped ticket is harmless: the client does a full handshake next time
|
||||
static_cast<void>(this->send_message(msg));
|
||||
noise_clean(&ticket, sizeof(ticket));
|
||||
}
|
||||
#endif
|
||||
|
||||
bool APIConnection::send_hello_response_(const HelloRequest &msg) {
|
||||
// Copy client name with truncation if needed (set_client_name handles truncation)
|
||||
this->helper_->set_client_name(msg.client_info.c_str(), msg.client_info.size());
|
||||
|
||||
@@ -381,6 +381,11 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
// Helper function to handle authentication completion
|
||||
void complete_authentication_();
|
||||
|
||||
#ifdef USE_API_NOISE
|
||||
// Issue a fresh single-use session resume ticket over the encrypted channel
|
||||
void send_resume_ticket_();
|
||||
#endif
|
||||
|
||||
// Pattern B helpers: send response and return success/failure
|
||||
bool send_hello_response_(const HelloRequest &msg);
|
||||
bool send_disconnect_response_();
|
||||
|
||||
@@ -271,8 +271,8 @@ APIError APINoiseFrameHelper::state_action_client_hello_() {
|
||||
if (aerr != APIError::OK) {
|
||||
return handle_handshake_frame_error_(aerr);
|
||||
}
|
||||
// ignore contents, may be used in future for flags
|
||||
// Resize for: existing prologue + 2 size bytes + frame data
|
||||
// Contents are extension flags (today: the resume offer); mixed into the
|
||||
// prologue either way. Resize for: existing prologue + 2 size bytes + frame data
|
||||
size_t old_size = this->prologue_.size();
|
||||
size_t rx_size = this->rx_buf_.size();
|
||||
if (!this->prologue_.resize(old_size + 2 + rx_size)) [[unlikely]] {
|
||||
@@ -289,6 +289,8 @@ APIError APINoiseFrameHelper::state_action_client_hello_() {
|
||||
return APIError::OK;
|
||||
}
|
||||
APIError APINoiseFrameHelper::state_action_server_hello_() {
|
||||
// A verified resume offer (still in rx_buf_ from the client hello step)
|
||||
// replaces the whole handshake; any failure falls back to the full one.
|
||||
// send server hello
|
||||
const auto &name = App.get_name();
|
||||
char mac[MAC_ADDRESS_BUFFER_SIZE];
|
||||
@@ -302,7 +304,9 @@ APIError APINoiseFrameHelper::state_action_server_hello_() {
|
||||
|
||||
// 1 (proto) + name (max ESPHOME_DEVICE_NAME_MAX_LEN) + 1 (name null)
|
||||
// + mac (MAC_ADDRESS_BUFFER_SIZE - 1) + 1 (mac null)
|
||||
constexpr size_t max_msg_size = 1 + ESPHOME_DEVICE_NAME_MAX_LEN + 1 + MAC_ADDRESS_BUFFER_SIZE;
|
||||
// + optional resume accept extension
|
||||
constexpr size_t max_msg_size =
|
||||
1 + ESPHOME_DEVICE_NAME_MAX_LEN + 1 + MAC_ADDRESS_BUFFER_SIZE + noise::RESUME_ACCEPT_SIZE;
|
||||
uint8_t msg[max_msg_size];
|
||||
|
||||
// chosen proto
|
||||
@@ -313,16 +317,32 @@ APIError APINoiseFrameHelper::state_action_server_hello_() {
|
||||
// node mac, terminated by null byte
|
||||
std::memcpy(msg + mac_offset, mac, MAC_ADDRESS_BUFFER_SIZE);
|
||||
|
||||
// The accept extension, if any, is written straight after the mac
|
||||
size_t ext_len = this->ctx_.resume_cache().try_accept(
|
||||
this->rx_buf_.data(), this->rx_buf_.size(), this->prologue_.data(), this->prologue_.size(), msg + total_size,
|
||||
sizeof(msg) - total_size, send_cipher_, recv_cipher_);
|
||||
bool resume = ext_len != 0;
|
||||
total_size += ext_len;
|
||||
|
||||
APIError aerr = write_frame_(msg, total_size);
|
||||
if (aerr != APIError::OK)
|
||||
return aerr;
|
||||
|
||||
// start handshake
|
||||
aerr = init_handshake_();
|
||||
if (aerr != APIError::OK)
|
||||
return aerr;
|
||||
|
||||
state_ = State::HANDSHAKE;
|
||||
if (resume) {
|
||||
// A resuming client waits for this hello instead of pipelining
|
||||
// handshake message 1, so the transport is ready now
|
||||
this->frame_footer_size_ = noise_cipherstate_get_mac_length(this->send_cipher_);
|
||||
HELPER_LOG("Session resumed!");
|
||||
state_ = State::DATA;
|
||||
} else {
|
||||
aerr = init_handshake_();
|
||||
if (aerr != APIError::OK)
|
||||
return aerr;
|
||||
state_ = State::HANDSHAKE;
|
||||
}
|
||||
// init_handshake_ copied the prologue into the handshake state; the resume
|
||||
// path is done with it too
|
||||
this->prologue_.release();
|
||||
return APIError::OK;
|
||||
}
|
||||
APIError APINoiseFrameHelper::state_action_handshake_() {
|
||||
@@ -552,8 +572,6 @@ APIError APINoiseFrameHelper::init_handshake_() {
|
||||
APIError aerr = handle_noise_error_(err, LOG_STR("noise_handshake_init"), APIError::HANDSHAKESTATE_SETUP_FAILED);
|
||||
if (aerr != APIError::OK)
|
||||
return aerr;
|
||||
// init copies the prologue into the handshakestate, so we can get rid of it now
|
||||
prologue_.release();
|
||||
return APIError::OK;
|
||||
}
|
||||
|
||||
|
||||
@@ -1061,6 +1061,16 @@ uint32_t NoiseEncryptionSetKeyResponse::calculate_size() const {
|
||||
size += ProtoSize::calc_bool(1, this->success);
|
||||
return size;
|
||||
}
|
||||
uint8_t *NoiseResumeTicket::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
|
||||
uint8_t *__restrict__ pos = buffer.get_pos();
|
||||
ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 1, this->ticket_ptr_, this->ticket_len_);
|
||||
return pos;
|
||||
}
|
||||
uint32_t NoiseResumeTicket::calculate_size() const {
|
||||
uint32_t size = 0;
|
||||
size += ProtoSize::calc_length(1, this->ticket_len_);
|
||||
return size;
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_API_HOMEASSISTANT_SERVICES
|
||||
uint8_t *HomeassistantServiceMap::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
|
||||
|
||||
@@ -1147,6 +1147,27 @@ class NoiseEncryptionSetKeyResponse final : public ProtoMessage {
|
||||
|
||||
protected:
|
||||
};
|
||||
class NoiseResumeTicket final : public ProtoMessage {
|
||||
public:
|
||||
static constexpr uint16_t MESSAGE_TYPE = 152;
|
||||
static constexpr uint8_t ESTIMATED_SIZE = 19;
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
const LogString *message_name() const override { return LOG_STR("noise_resume_ticket"); }
|
||||
#endif
|
||||
const uint8_t *ticket_ptr_{nullptr};
|
||||
size_t ticket_len_{0};
|
||||
void set_ticket(const uint8_t *data, size_t len) {
|
||||
this->ticket_ptr_ = data;
|
||||
this->ticket_len_ = len;
|
||||
}
|
||||
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
|
||||
uint32_t calculate_size() const;
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
const char *dump_to(DumpBuffer &out) const override;
|
||||
#endif
|
||||
|
||||
protected:
|
||||
};
|
||||
#endif
|
||||
#ifdef USE_API_HOMEASSISTANT_SERVICES
|
||||
class HomeassistantServiceMap final : public ProtoMessage {
|
||||
|
||||
@@ -1393,6 +1393,10 @@ const char *NoiseEncryptionSetKeyResponse::dump_to(DumpBuffer &out) const {
|
||||
dump_field(out, ESPHOME_PSTR("success"), this->success);
|
||||
return out.c_str();
|
||||
}
|
||||
const char *NoiseResumeTicket::dump_to(DumpBuffer &out) const {
|
||||
out.append_p(ESPHOME_PSTR("NoiseResumeTicket {}"));
|
||||
return out.c_str();
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_API_HOMEASSISTANT_SERVICES
|
||||
const char *HomeassistantServiceMap::dump_to(DumpBuffer &out) const {
|
||||
|
||||
@@ -10,7 +10,6 @@ import subprocess
|
||||
from typing import Any
|
||||
|
||||
from esphome import yaml_util
|
||||
from esphome.build_helpers.pch import PCH_PREFIX_HEADER, pch_enabled, pch_extra_scripts
|
||||
import esphome.codegen as cg
|
||||
from esphome.components.const import CONF_ENABLE_OTA_DOWNGRADE_PROTECTION
|
||||
from esphome.config_helpers import filter_source_files_from_defines
|
||||
@@ -58,7 +57,6 @@ from esphome.coroutine import CoroPriority, coroutine_with_priority
|
||||
from esphome.espidf.component import generate_idf_components
|
||||
import esphome.final_validate as fv
|
||||
from esphome.helpers import copy_file_if_changed, rmtree, write_file_if_changed
|
||||
from esphome.platformio.toolchain import copy_pch_script
|
||||
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
|
||||
from esphome.types import ConfigType
|
||||
from esphome.writer import clean_build, clean_cmake_cache
|
||||
@@ -2457,11 +2455,6 @@ async def to_code(config):
|
||||
|
||||
cg.add_platformio_option("lib_ldf_mode", "off")
|
||||
cg.add_platformio_option("lib_compat_mode", "strict")
|
||||
# CI-speed only: this toolchain is being dropped, so the pch gets
|
||||
# the same curated prefix as host with no further investment
|
||||
cg.add_platformio_option("extra_scripts", pch_extra_scripts())
|
||||
if pch_enabled():
|
||||
cg.add_platformio_option("build_src_flags", f"-include {PCH_PREFIX_HEADER}")
|
||||
cg.add_platformio_option("platform", conf[CONF_PLATFORM_VERSION])
|
||||
cg.add_platformio_option("board", config[CONF_BOARD])
|
||||
cg.add_platformio_option("board_upload.flash_size", config[CONF_FLASH_SIZE])
|
||||
@@ -3379,8 +3372,6 @@ def _write_idf_component_yml():
|
||||
def copy_files():
|
||||
_write_sdkconfig()
|
||||
_write_idf_component_yml()
|
||||
if not CORE.using_toolchain_esp_idf:
|
||||
copy_pch_script()
|
||||
|
||||
if "partitions.csv" not in CORE.data[KEY_ESP32][KEY_EXTRA_BUILD_FILES]:
|
||||
flash_size = CORE.data[KEY_ESP32][KEY_FLASH_SIZE]
|
||||
|
||||
@@ -3,10 +3,8 @@ from pathlib import Path
|
||||
import platform
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from esphome.build_helpers.pch import pch_extra_scripts
|
||||
import esphome.codegen as cg
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
@@ -16,7 +14,6 @@ from esphome.const import (
|
||||
CONF_FRAMEWORK,
|
||||
CONF_PLATFORM_VERSION,
|
||||
CONF_SOURCE,
|
||||
CONF_TOOLCHAIN,
|
||||
CONF_VERSION,
|
||||
KEY_CORE,
|
||||
KEY_FRAMEWORK_VERSION,
|
||||
@@ -24,7 +21,6 @@ from esphome.const import (
|
||||
KEY_TARGET_PLATFORM,
|
||||
PLATFORM_ESP8266,
|
||||
ThreadModel,
|
||||
Toolchain,
|
||||
)
|
||||
from esphome.core import (
|
||||
CORE,
|
||||
@@ -35,13 +31,12 @@ from esphome.core import (
|
||||
)
|
||||
from esphome.core.config import BOARD_MAX_LENGTH
|
||||
from esphome.helpers import IS_MACOS, copy_file_if_changed
|
||||
from esphome.platformio.toolchain import copy_ccache_script, copy_pch_script
|
||||
from esphome.platformio.toolchain import copy_ccache_script
|
||||
from esphome.storage_json import StorageJSON
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .boards import BOARDS, ESP8266_BOARD_BUILD, board_ld_script
|
||||
from .boards import BOARDS, board_ld_script
|
||||
from .const import (
|
||||
BUILD_FLASH_MODES,
|
||||
CONF_EARLY_PIN_INIT,
|
||||
CONF_ENABLE_SERIAL,
|
||||
CONF_ENABLE_SERIAL1,
|
||||
@@ -49,7 +44,6 @@ from .const import (
|
||||
KEY_BOARD,
|
||||
KEY_ESP8266,
|
||||
KEY_PIN_INITIAL_STATES,
|
||||
KEY_SCANF_FLOAT,
|
||||
KEY_SERIAL1_REQUIRED,
|
||||
KEY_SERIAL_REQUIRED,
|
||||
KEY_WAVEFORM_REQUIRED,
|
||||
@@ -109,53 +103,6 @@ def set_core_data(config: ConfigType) -> ConfigType:
|
||||
return config
|
||||
|
||||
|
||||
_TOOLCHAINS = (Toolchain.PLATFORMIO, Toolchain.ARDUINO)
|
||||
_validate_toolchain = cv.toolchain_enum(_TOOLCHAINS)
|
||||
_resolve_toolchain = cv.resolve_toolchain("ESP8266", _TOOLCHAINS, Toolchain.PLATFORMIO)
|
||||
|
||||
|
||||
def _validate_native_toolchain(config: ConfigType) -> ConfigType:
|
||||
"""Constraints of the native (non-PlatformIO) Arduino toolchain."""
|
||||
if not CORE.using_toolchain_arduino:
|
||||
return config
|
||||
from esphome.arduino8266.framework import MIN_FRAMEWORK_VERSION
|
||||
|
||||
conf = config[CONF_FRAMEWORK]
|
||||
version = cv.Version.parse(conf[CONF_VERSION])
|
||||
if version < MIN_FRAMEWORK_VERSION:
|
||||
raise cv.Invalid(
|
||||
"'toolchain: arduino' requires framework version "
|
||||
f"{MIN_FRAMEWORK_VERSION} or newer"
|
||||
)
|
||||
# platform_version is a PlatformIO concept; drop it (as esp32's native
|
||||
# toolchain does), warning when a custom pin is discarded. The floor
|
||||
# above guarantees the schema-derived default is the ARDUINO_4 spec.
|
||||
if (
|
||||
conf.pop(CONF_PLATFORM_VERSION, _ARDUINO_4_PLATFORM_SPEC)
|
||||
!= _ARDUINO_4_PLATFORM_SPEC
|
||||
):
|
||||
_LOGGER.warning(
|
||||
"'platform_version' is ignored by 'toolchain: arduino'; the native "
|
||||
"toolchain downloads the framework and compiler directly"
|
||||
)
|
||||
if conf[CONF_SOURCE] != _format_framework_arduino_version(version):
|
||||
raise cv.Invalid(
|
||||
"'toolchain: arduino' does not support a custom framework source; "
|
||||
"use 'toolchain: platformio'"
|
||||
)
|
||||
# BOARDS is a subset of ESP8266_BOARD_BUILD today; the second clause is
|
||||
# a drift guard for the independently regenerated tables
|
||||
if (
|
||||
config[CONF_BOARD] not in BOARDS
|
||||
or config[CONF_BOARD] not in ESP8266_BOARD_BUILD
|
||||
):
|
||||
raise cv.Invalid(
|
||||
f"Board '{config[CONF_BOARD]}' is not supported by "
|
||||
"'toolchain: arduino'; use 'toolchain: platformio'"
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
def get_download_types(storage_json: StorageJSON) -> list[dict[str, str]]:
|
||||
"""Binary-download entries for a built ESP8266 firmware.
|
||||
|
||||
@@ -185,7 +132,7 @@ def _format_framework_arduino_version(ver: cv.Version) -> str:
|
||||
# a PIO platformio/framework-arduinoespressif8266 value
|
||||
# List of package versions: https://api.registry.platformio.org/v3/packages/platformio/tool/framework-arduinoespressif8266
|
||||
# Same encoding the native toolchain uses for its package download, so a
|
||||
# custom-source check against this value cannot drift from what it fetches.
|
||||
# version bump cannot drift between the two paths.
|
||||
from esphome.arduino8266.framework import framework_package_version
|
||||
|
||||
try:
|
||||
@@ -247,7 +194,7 @@ def _arduino_check_versions(value: ConfigType) -> ConfigType:
|
||||
platform_version = value.get(CONF_PLATFORM_VERSION)
|
||||
if platform_version is None:
|
||||
if version >= cv.Version(3, 1, 0):
|
||||
platform_version = _ARDUINO_4_PLATFORM_SPEC
|
||||
platform_version = _parse_platform_version(str(ARDUINO_4_PLATFORM_VERSION))
|
||||
else:
|
||||
platform_version = _parse_platform_version(str(ARDUINO_3_PLATFORM_VERSION))
|
||||
value[CONF_PLATFORM_VERSION] = platform_version
|
||||
@@ -270,10 +217,6 @@ def _parse_platform_version(value: Any) -> str:
|
||||
return value
|
||||
|
||||
|
||||
# The platform_version derived for every core >= 3.1.0 config
|
||||
_ARDUINO_4_PLATFORM_SPEC = _parse_platform_version(str(ARDUINO_4_PLATFORM_VERSION))
|
||||
|
||||
|
||||
ARDUINO_FRAMEWORK_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
@@ -290,6 +233,7 @@ ARDUINO_FRAMEWORK_SCHEMA = cv.All(
|
||||
)
|
||||
|
||||
|
||||
BUILD_FLASH_MODES = ["qio", "qout", "dio", "dout"]
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
@@ -306,30 +250,15 @@ CONFIG_SCHEMA = cv.All(
|
||||
cv.Optional(CONF_ENABLE_SERIAL1): cv.boolean,
|
||||
cv.Optional(CONF_ENABLE_FULL_PRINTF, default=False): cv.boolean,
|
||||
cv.Optional(CONF_ENABLE_SCANF_FLOAT): cv.boolean,
|
||||
cv.Optional(
|
||||
CONF_TOOLCHAIN, visibility=cv.Visibility.ADVANCED
|
||||
): _validate_toolchain,
|
||||
}
|
||||
),
|
||||
_resolve_toolchain,
|
||||
_validate_native_toolchain,
|
||||
# Until the native toolchain lands, PlatformIO is the only backend;
|
||||
# reject a --toolchain this platform cannot serve yet.
|
||||
cv.require_platformio_toolchain("ESP8266"),
|
||||
set_core_data,
|
||||
)
|
||||
|
||||
|
||||
def native_toolchain_module():
|
||||
"""The native build backend for the resolved toolchain, if any.
|
||||
|
||||
``__main__`` dispatches from its own toolchain-keyed table; this helper
|
||||
serves the component's internal callers.
|
||||
"""
|
||||
if not CORE.using_toolchain_arduino:
|
||||
return None
|
||||
from esphome.arduino8266 import toolchain
|
||||
|
||||
return toolchain
|
||||
|
||||
|
||||
def check_rosetta() -> None:
|
||||
"""Fail fast when the x86_64 ESP8266 toolchain cannot run on this Mac.
|
||||
|
||||
@@ -365,13 +294,12 @@ def _choose_ld_script(board: str) -> str:
|
||||
|
||||
@coroutine_with_priority(CoroPriority.PLATFORM)
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
use_platformio = CORE.using_toolchain_platformio
|
||||
cg.add(esp8266_ns.setup_preferences())
|
||||
|
||||
if use_platformio:
|
||||
cg.add_platformio_option("lib_ldf_mode", "off")
|
||||
cg.add_platformio_option("lib_compat_mode", "strict")
|
||||
cg.add_platformio_option("board", config[CONF_BOARD])
|
||||
cg.add_platformio_option("lib_ldf_mode", "off")
|
||||
cg.add_platformio_option("lib_compat_mode", "strict")
|
||||
|
||||
cg.add_platformio_option("board", config[CONF_BOARD])
|
||||
cg.add_build_flag("-DUSE_ESP8266")
|
||||
cg.set_cpp_standard("gnu++20")
|
||||
cg.add_define("ESPHOME_BOARD", config[CONF_BOARD])
|
||||
@@ -387,33 +315,28 @@ async def to_code(config: ConfigType) -> None:
|
||||
"enabling scanf float support (~8KB flash)"
|
||||
)
|
||||
|
||||
# The native generator reads the same decision (KEY_SCANF_FLOAT)
|
||||
CORE.data[KEY_ESP8266][KEY_SCANF_FLOAT] = bool(enable_scanf_float)
|
||||
if use_platformio:
|
||||
extra_scripts = [
|
||||
"pre:ccache.py",
|
||||
"pre:testing_mode.py",
|
||||
"pre:exclude_updater.py",
|
||||
"pre:exclude_waveform.py",
|
||||
"pre:relocate_ratetable.py",
|
||||
]
|
||||
if not enable_scanf_float:
|
||||
extra_scripts.append("pre:remove_float_scanf.py")
|
||||
extra_scripts.extend(pch_extra_scripts())
|
||||
extra_scripts.append("post:post_build.py")
|
||||
cg.add_platformio_option("extra_scripts", extra_scripts)
|
||||
extra_scripts = [
|
||||
"pre:ccache.py",
|
||||
"pre:testing_mode.py",
|
||||
"pre:exclude_updater.py",
|
||||
"pre:exclude_waveform.py",
|
||||
"pre:relocate_ratetable.py",
|
||||
]
|
||||
if not enable_scanf_float:
|
||||
extra_scripts.append("pre:remove_float_scanf.py")
|
||||
extra_scripts.append("post:post_build.py")
|
||||
cg.add_platformio_option("extra_scripts", extra_scripts)
|
||||
|
||||
conf = config[CONF_FRAMEWORK]
|
||||
cg.add_platformio_option("framework", "arduino")
|
||||
cg.add_build_flag("-DUSE_ARDUINO")
|
||||
cg.add_build_flag("-DUSE_ESP8266_FRAMEWORK_ARDUINO")
|
||||
cg.add_build_flag("-Wno-nonnull-compare")
|
||||
if use_platformio:
|
||||
cg.add_platformio_option("framework", "arduino")
|
||||
cg.add_platformio_option("platform", conf[CONF_PLATFORM_VERSION])
|
||||
cg.add_platformio_option(
|
||||
"platform_packages",
|
||||
[f"platformio/framework-arduinoespressif8266@{conf[CONF_SOURCE]}"],
|
||||
)
|
||||
cg.add_platformio_option("platform", conf[CONF_PLATFORM_VERSION])
|
||||
cg.add_platformio_option(
|
||||
"platform_packages",
|
||||
[f"platformio/framework-arduinoespressif8266@{conf[CONF_SOURCE]}"],
|
||||
)
|
||||
|
||||
# Default for platformio is LWIP2_LOW_MEMORY with:
|
||||
# - MSS=536
|
||||
@@ -451,8 +374,7 @@ async def to_code(config: ConfigType) -> None:
|
||||
# Force-include inline std::__throw_* overrides so GCC dead-strips the unused
|
||||
# libstdc++ error message strings (e.g. "basic_string::_M_create") from DRAM.
|
||||
# See throw_stubs.h for details. Must be prepended before <string>, so this
|
||||
# uses build_src_flags with -include. Unconditional: the native build
|
||||
# generator reads the same option, keeping one source of truth.
|
||||
# uses build_src_flags with -include.
|
||||
cg.add_platformio_option(
|
||||
"build_src_flags", "-include esphome/components/esp8266/throw_stubs.h"
|
||||
)
|
||||
@@ -482,8 +404,6 @@ async def to_code(config: ConfigType) -> None:
|
||||
# implementation in the Arduino ESP8266 core.
|
||||
cg.add_build_flag("-Wl,--wrap=millis")
|
||||
|
||||
# Unconditional: the native build generator reads the same option,
|
||||
# keeping one source of truth
|
||||
cg.add_platformio_option("board_build.flash_mode", config[CONF_BOARD_FLASH_MODE])
|
||||
|
||||
ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]
|
||||
@@ -492,7 +412,7 @@ async def to_code(config: ConfigType) -> None:
|
||||
cg.RawExpression(f"VERSION_CODE({ver.major}, {ver.minor}, {ver.patch})"),
|
||||
)
|
||||
|
||||
if use_platformio and config[CONF_BOARD] in BOARDS:
|
||||
if config[CONF_BOARD] in BOARDS:
|
||||
cg.add_platformio_option(
|
||||
"board_build.ldscript", _choose_ld_script(config[CONF_BOARD])
|
||||
)
|
||||
@@ -533,24 +453,8 @@ async def finalize_serial_config() -> None:
|
||||
cg.add_build_flag("-DNO_GLOBAL_SERIAL1")
|
||||
|
||||
|
||||
# Called by __main__.compile_program; returning False falls through to the
|
||||
# PlatformIO toolchain.
|
||||
def run_compile(args, config: ConfigType) -> bool:
|
||||
# Positive check: the native backend only runs when explicitly resolved
|
||||
toolchain = native_toolchain_module()
|
||||
if toolchain is None:
|
||||
return False
|
||||
if toolchain.run_compile(config, CORE.verbose) != 0:
|
||||
raise EsphomeError("ESP8266 native build failed")
|
||||
return True
|
||||
|
||||
|
||||
# Called by writer.py
|
||||
def copy_files() -> None:
|
||||
# Native builds skip the PlatformIO extra scripts; the build generator
|
||||
# carries their logic
|
||||
if CORE.using_toolchain_arduino:
|
||||
return
|
||||
dir = Path(__file__).parent
|
||||
for script in (
|
||||
"post_build",
|
||||
@@ -565,7 +469,6 @@ def copy_files() -> None:
|
||||
CORE.relative_build_path(f"{script}.py"),
|
||||
)
|
||||
copy_ccache_script()
|
||||
copy_pch_script()
|
||||
|
||||
|
||||
# ESP logs stack trace decoder, based on https://github.com/me-no-dev/EspExceptionDecoder
|
||||
@@ -608,75 +511,22 @@ ESP8266_EXCEPTION_CODES = {
|
||||
}
|
||||
|
||||
|
||||
_DECODE_WARNED_AT: dict[str, float] = {}
|
||||
def _decode_pc(config: ConfigType, addr: str) -> None:
|
||||
from esphome.platformio import toolchain
|
||||
|
||||
|
||||
def _warn_decode_problem(key: str, message: str, *args) -> bool:
|
||||
"""Warn, deduplicated briefly so a burst of stack-dump addresses warns
|
||||
once but a later dump warns again; returns whether it warned so the
|
||||
caller can mark suppressed addresses individually."""
|
||||
now = time.monotonic()
|
||||
last = _DECODE_WARNED_AT.get(key)
|
||||
if last is not None and now - last < 30:
|
||||
return False
|
||||
_DECODE_WARNED_AT[key] = now
|
||||
_LOGGER.warning(message, *args)
|
||||
return True
|
||||
|
||||
|
||||
def _decode_pc(config: ConfigType, addr: str, *, bulk: bool = False) -> None:
|
||||
"""Decode one crash address. ``bulk``: the caller is scanning every
|
||||
8-hex stack word, most of which are not code addresses -- unmappable
|
||||
ones log at debug so real frames are not buried."""
|
||||
if (native_toolchain := native_toolchain_module()) is not None:
|
||||
addr2line = native_toolchain.get_addr2line_path()
|
||||
elf = native_toolchain.get_elf_path()
|
||||
for path in (addr2line, elf):
|
||||
if not path.is_file():
|
||||
_warn_decode_problem(
|
||||
str(path), "Cannot decode crash addresses: %s missing", path
|
||||
)
|
||||
# The detailed warning names no address; mark named
|
||||
# registers, but bulk stack words at debug (~150 per dump)
|
||||
log = _LOGGER.debug if bulk else _LOGGER.warning
|
||||
log("Not decoded %s (toolchain file missing)", addr)
|
||||
return
|
||||
addr2line, elf = str(addr2line), str(elf)
|
||||
else:
|
||||
from esphome.platformio import toolchain
|
||||
|
||||
idedata = toolchain.get_idedata(config)
|
||||
if not idedata.addr2line_path or not idedata.firmware_elf_path:
|
||||
_warn_decode_problem(
|
||||
"no-addr2line",
|
||||
"Cannot decode crash addresses: no addr2line or ELF in idedata",
|
||||
)
|
||||
log = _LOGGER.debug if bulk else _LOGGER.warning
|
||||
log("Not decoded %s (no addr2line or ELF)", addr)
|
||||
return
|
||||
addr2line, elf = idedata.addr2line_path, idedata.firmware_elf_path
|
||||
command = [addr2line, "-pfiaC", "-e", elf, addr]
|
||||
idedata = toolchain.get_idedata(config)
|
||||
if not idedata.addr2line_path or not idedata.firmware_elf_path:
|
||||
_LOGGER.debug("decode_pc no addr2line")
|
||||
return
|
||||
command = [idedata.addr2line_path, "-pfiaC", "-e", idedata.firmware_elf_path, addr]
|
||||
try:
|
||||
translation = subprocess.check_output(command, close_fds=False).decode().strip()
|
||||
except Exception as err: # noqa: BLE001 # pylint: disable=broad-except
|
||||
# Warn, not debug: a failing addr2line must be visible. The warning
|
||||
# is rate-limited across a dump, so mark every undecoded address
|
||||
# inline or the rest read as merely unmappable
|
||||
if not _warn_decode_problem(
|
||||
"addr2line-failed", "Could not decode crash address %s (%s)", addr, err
|
||||
):
|
||||
# The detailed warning already named this address; mark only
|
||||
# the rate-limited ones, and bulk stack words at debug
|
||||
log = _LOGGER.debug if bulk else _LOGGER.warning
|
||||
log("Not decoded %s (addr2line failed)", addr)
|
||||
except Exception: # noqa: BLE001 # pylint: disable=broad-except
|
||||
_LOGGER.debug("Caught exception for command %s", command, exc_info=1)
|
||||
return
|
||||
|
||||
if "?? ??:0" in translation:
|
||||
# A named register that fails to decode is confusing silence; a
|
||||
# bulk stack word failing is the expected common case
|
||||
log = _LOGGER.debug if bulk else _LOGGER.warning
|
||||
log("Not decoded %s (address not in %s)", addr, elf)
|
||||
# Nothing useful
|
||||
return
|
||||
translation = translation.replace(" at ??:?", "").replace(":?", "")
|
||||
_LOGGER.warning("Decoded %s", translation)
|
||||
@@ -746,6 +596,6 @@ def process_stacktrace(config: ConfigType, line: str, backtrace_state: bool) ->
|
||||
|
||||
if backtrace_state:
|
||||
for addr in re.finditer(STACKTRACE_ESP8266_BACKTRACE_PC_RE, line):
|
||||
_decode_pc(config, addr.group(), bulk=True)
|
||||
_decode_pc(config, addr.group())
|
||||
|
||||
return backtrace_state
|
||||
|
||||
@@ -16,6 +16,7 @@ KEY_WAVEFORM_REQUIRED = "waveform_required"
|
||||
KEY_SERIAL_REQUIRED = "serial_required"
|
||||
KEY_SERIAL1_REQUIRED = "serial1_required"
|
||||
# Set for the native (non-PlatformIO) toolchain's build generator
|
||||
KEY_FLASH_MODE = "flash_mode"
|
||||
KEY_SCANF_FLOAT = "scanf_float"
|
||||
# Per-board flash-layout override consumed by board_ld_script()
|
||||
KEY_LDSCRIPT = "ldscript"
|
||||
@@ -72,6 +73,3 @@ def enable_serial1() -> None:
|
||||
enable_serial1()
|
||||
"""
|
||||
CORE.data.setdefault(KEY_ESP8266, {})[KEY_SERIAL1_REQUIRED] = True
|
||||
|
||||
|
||||
BUILD_FLASH_MODES = ("qio", "qout", "dio", "dout")
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
from esphome.build_helpers.pch import PCH_PREFIX_HEADER, pch_enabled, pch_extra_scripts
|
||||
import esphome.codegen as cg
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
@@ -11,7 +10,7 @@ from esphome.const import (
|
||||
ThreadModel,
|
||||
)
|
||||
from esphome.core import CORE
|
||||
from esphome.platformio.toolchain import copy_ccache_script, copy_pch_script
|
||||
from esphome.platformio.toolchain import copy_ccache_script
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .const import KEY_HOST
|
||||
@@ -56,18 +55,9 @@ async def to_code(config: ConfigType) -> None:
|
||||
cg.add_platformio_option("platform", "platformio/native")
|
||||
cg.add_platformio_option("lib_ldf_mode", "off")
|
||||
cg.add_platformio_option("lib_compat_mode", "strict")
|
||||
cg.add_platformio_option("extra_scripts", ["pre:ccache.py", *pch_extra_scripts()])
|
||||
if pch_enabled():
|
||||
# Curated prefix for the pch (the script folds it plus defines.h):
|
||||
# host has no framework force-includes, and the per-TU cost is the
|
||||
# STL closure behind the core headers. Measured -43% compile CPU.
|
||||
# Gated so ESPHOME_PCH_ENABLE=0 restores the strict view. When the
|
||||
# .gch fails to build or load, the force-include stays and every TU
|
||||
# parses the closure as text: correct, but slower than no pch.
|
||||
cg.add_platformio_option("build_src_flags", f"-include {PCH_PREFIX_HEADER}")
|
||||
cg.add_platformio_option("extra_scripts", ["pre:ccache.py"])
|
||||
|
||||
|
||||
# Called by writer.py
|
||||
def copy_files() -> None:
|
||||
copy_ccache_script()
|
||||
copy_pch_script()
|
||||
|
||||
@@ -2,7 +2,6 @@ import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from esphome.build_helpers.pch import pch_extra_scripts
|
||||
import esphome.codegen as cg
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
@@ -27,7 +26,7 @@ from esphome.const import (
|
||||
from esphome.core import CORE
|
||||
from esphome.core.config import BOARD_MAX_LENGTH
|
||||
from esphome.helpers import copy_file_if_changed
|
||||
from esphome.platformio.toolchain import copy_ccache_script, copy_pch_script
|
||||
from esphome.platformio.toolchain import copy_ccache_script
|
||||
from esphome.storage_json import StorageJSON
|
||||
|
||||
from . import gpio # noqa: F401
|
||||
@@ -514,7 +513,7 @@ async def component_to_code(config):
|
||||
# it for project source files only. GCC uses the last -O flag.
|
||||
build_src_flags += " -Os"
|
||||
cg.add_platformio_option("build_src_flags", build_src_flags)
|
||||
cg.add_platformio_option("extra_scripts", ["pre:ccache.py", *pch_extra_scripts()])
|
||||
cg.add_platformio_option("extra_scripts", ["pre:ccache.py"])
|
||||
# IRAM_ATTR is a no-op on BK72xx (SDK masks FIQ+IRQ around flash ops).
|
||||
# On other families, patch_linker.py routes .sram.text into the right
|
||||
# RAM-executable output section and prints a post-link placement summary.
|
||||
@@ -620,4 +619,3 @@ def copy_files() -> None:
|
||||
CORE.relative_build_path("patch_linker.py"),
|
||||
)
|
||||
copy_ccache_script()
|
||||
copy_pch_script()
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
#include <cstdint>
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#include "noise_resume.h"
|
||||
|
||||
namespace esphome::noise {
|
||||
|
||||
using psk_t = std::array<uint8_t, 32>;
|
||||
@@ -26,13 +28,19 @@ class NoiseContext {
|
||||
/// psk points at 32 bytes that outlive the context (PROGMEM or caller owned
|
||||
/// RAM); nullptr means no key. Runtime callers map the all-zeros key to
|
||||
/// nullptr themselves; validation keeps it out of yaml.
|
||||
void set_psk(const uint8_t *psk) { this->psk_ = psk; }
|
||||
void set_psk(const uint8_t *psk) {
|
||||
this->psk_ = psk;
|
||||
// Resume tickets were minted under the old key; forget them
|
||||
this->resume_cache_.clear();
|
||||
}
|
||||
/// Copy the key out (flash-aware on ESP8266); all zeros when none is set.
|
||||
void load_psk(psk_t &out) const;
|
||||
bool has_psk() const { return this->psk_ != nullptr; }
|
||||
ResumeTicketCache &resume_cache() { return this->resume_cache_; }
|
||||
|
||||
protected:
|
||||
const uint8_t *psk_{nullptr};
|
||||
ResumeTicketCache resume_cache_;
|
||||
};
|
||||
|
||||
/// Convert a noise error code to a readable error
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
#include "noise_resume.h"
|
||||
#ifdef USE_NOISE
|
||||
#include <cstring>
|
||||
|
||||
#include <noise/protocol.h>
|
||||
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
namespace esphome::noise {
|
||||
|
||||
const char RESUME_LABEL_OFFER[6] PROGMEM = "offer";
|
||||
const char RESUME_LABEL_CONFIRM[8] PROGMEM = "confirm";
|
||||
const char RESUME_LABEL_KEYS[5] PROGMEM = "keys";
|
||||
bool resume_kdf(const uint8_t *secret, const char *label, size_t label_len, const uint8_t *a, size_t a_len,
|
||||
const uint8_t *b, size_t b_len, const uint8_t *hash_in, size_t hash_in_len, uint8_t *out1,
|
||||
size_t out1_len, uint8_t *out2) {
|
||||
uint8_t data[RESUME_KDF_MAX_DATA];
|
||||
uint8_t scratch[32];
|
||||
size_t len = label_len + a_len + b_len;
|
||||
progmem_memcpy(data, label, label_len);
|
||||
std::memcpy(data + label_len, a, a_len);
|
||||
std::memcpy(data + label_len + a_len, b, b_len);
|
||||
NoiseHashState *hash = nullptr;
|
||||
if (noise_hashstate_new_by_id(&hash, NOISE_HASH_SHA256) != NOISE_ERROR_NONE) {
|
||||
return false;
|
||||
}
|
||||
int err = NOISE_ERROR_NONE;
|
||||
if (hash_in != nullptr) {
|
||||
err = noise_hashstate_hash_one(hash, hash_in, hash_in_len, data + len, 32);
|
||||
len += 32;
|
||||
}
|
||||
if (err == NOISE_ERROR_NONE) {
|
||||
err = noise_hashstate_hkdf(hash, secret, RESUME_SECRET_SIZE, data, len, out1, out1_len,
|
||||
out2 != nullptr ? out2 : scratch, 32);
|
||||
}
|
||||
noise_hashstate_free(hash);
|
||||
noise_clean(data, sizeof(data));
|
||||
noise_clean(scratch, sizeof(scratch));
|
||||
return err == NOISE_ERROR_NONE;
|
||||
}
|
||||
|
||||
bool ResumeTicketCache::issue(ResumeTicket &out) {
|
||||
if (!random_bytes(reinterpret_cast<uint8_t *>(&out), sizeof(out))) {
|
||||
return false;
|
||||
}
|
||||
uint8_t slot = this->next_;
|
||||
this->next_ = static_cast<uint8_t>((slot + 1) % SLOTS);
|
||||
this->slots_[slot] = out;
|
||||
this->used_mask_ |= static_cast<uint8_t>(1u << slot);
|
||||
return true;
|
||||
}
|
||||
|
||||
size_t ResumeTicketCache::try_accept(const uint8_t *offer, size_t offer_len, const uint8_t *prologue,
|
||||
size_t prologue_len, uint8_t *out_ext, size_t out_capacity,
|
||||
NoiseCipherState *&send_cipher, NoiseCipherState *&recv_cipher) {
|
||||
if (offer_len != RESUME_OFFER_SIZE || offer[0] != RESUME_OFFER_VERSION || out_capacity < RESUME_ACCEPT_SIZE) {
|
||||
return 0;
|
||||
}
|
||||
const uint8_t *session_id = offer + RESUME_OFFER_SESSION_ID_OFFSET;
|
||||
const uint8_t *client_nonce = offer + RESUME_OFFER_NONCE_OFFSET;
|
||||
ResumeTicket *ticket = nullptr;
|
||||
for (uint8_t i = 0; i < SLOTS; i++) {
|
||||
if ((this->used_mask_ & (1u << i)) &&
|
||||
std::memcmp(this->slots_[i].session_id, session_id, RESUME_SESSION_ID_SIZE) == 0) {
|
||||
ticket = &this->slots_[i];
|
||||
this->used_mask_ &= static_cast<uint8_t>(~(1u << i));
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (ticket == nullptr) {
|
||||
return 0;
|
||||
}
|
||||
uint8_t expected[RESUME_MAC_SIZE];
|
||||
bool ok = resume_compute_offer_mac(ticket->secret, session_id, client_nonce, expected) &&
|
||||
noise_is_equal(expected, offer + RESUME_OFFER_MAC_OFFSET, RESUME_MAC_SIZE);
|
||||
noise_clean(expected, sizeof(expected));
|
||||
if (!ok) {
|
||||
// Bad MAC: keep the ticket so a forger cannot burn it
|
||||
this->used_mask_ |= static_cast<uint8_t>(1u << static_cast<uint8_t>(ticket - this->slots_));
|
||||
return 0;
|
||||
}
|
||||
// The ticket is spent from here; any later failure falls back to the full
|
||||
// handshake and the client gets a fresh one.
|
||||
uint8_t *server_nonce = out_ext + 1;
|
||||
uint8_t k_c2d[32];
|
||||
uint8_t k_d2c[32];
|
||||
out_ext[0] = RESUME_ACCEPT_VERSION;
|
||||
ok = random_bytes(server_nonce, RESUME_NONCE_SIZE) &&
|
||||
resume_compute_confirm_mac(ticket->secret, client_nonce, server_nonce, out_ext + 1 + RESUME_NONCE_SIZE) &&
|
||||
resume_derive_keys(ticket->secret, client_nonce, server_nonce, prologue, prologue_len, k_c2d, k_d2c);
|
||||
noise_clean(ticket, sizeof(*ticket));
|
||||
if (ok) {
|
||||
recv_cipher = resume_make_cipher(k_c2d);
|
||||
send_cipher = resume_make_cipher(k_d2c);
|
||||
ok = recv_cipher != nullptr && send_cipher != nullptr;
|
||||
if (!ok) {
|
||||
noise_cipherstate_free(recv_cipher);
|
||||
noise_cipherstate_free(send_cipher);
|
||||
recv_cipher = nullptr;
|
||||
send_cipher = nullptr;
|
||||
}
|
||||
}
|
||||
noise_clean(k_c2d, sizeof(k_c2d));
|
||||
noise_clean(k_d2c, sizeof(k_d2c));
|
||||
return ok ? RESUME_ACCEPT_SIZE : 0;
|
||||
}
|
||||
|
||||
void ResumeTicketCache::clear() {
|
||||
noise_clean(this->slots_, sizeof(this->slots_));
|
||||
this->used_mask_ = 0;
|
||||
}
|
||||
|
||||
NoiseCipherState *resume_make_cipher(const uint8_t *key) {
|
||||
NoiseCipherState *cipher = nullptr;
|
||||
if (noise_cipherstate_new_by_id(&cipher, NOISE_CIPHER_CHACHAPOLY) != NOISE_ERROR_NONE) {
|
||||
return nullptr;
|
||||
}
|
||||
if (noise_cipherstate_init_key(cipher, key, 32) != NOISE_ERROR_NONE) {
|
||||
noise_cipherstate_free(cipher);
|
||||
return nullptr;
|
||||
}
|
||||
return cipher;
|
||||
}
|
||||
|
||||
} // namespace esphome::noise
|
||||
#endif // USE_NOISE
|
||||
@@ -0,0 +1,132 @@
|
||||
#pragma once
|
||||
#include "esphome/core/defines.h"
|
||||
#ifdef USE_NOISE
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
// Forward declaration matching <noise/protocol/cipherstate.h>; keeps noise-c
|
||||
// headers out of everything that includes noise.h.
|
||||
extern "C" {
|
||||
typedef struct NoiseCipherState_s NoiseCipherState; // NOLINT(modernize-use-using)
|
||||
}
|
||||
|
||||
namespace esphome::noise {
|
||||
|
||||
/** Session resume for the noise transports.
|
||||
*
|
||||
* After a full handshake the responder issues a single-use ticket over the
|
||||
* encrypted channel. A client presents it in its next ClientHello and both
|
||||
* sides derive the transport keys with HKDF-SHA256 alone, skipping the two
|
||||
* curve25519 operations. Old peers ignore the extension bytes on both
|
||||
* sides, so every mismatch degrades to a normal full handshake.
|
||||
*
|
||||
* HKDF is the Noise construction (noise_hashstate_hkdf). Derivations:
|
||||
* offer_mac = HKDF(secret, "offer" || session_id || client_nonce).out1[:16]
|
||||
* confirm_mac = HKDF(secret, "confirm" || client_nonce || server_nonce).out1[:16]
|
||||
* k_c2d, k_d2c = HKDF(secret, "keys" || client_nonce || server_nonce || SHA256(prologue))
|
||||
*
|
||||
* An offering client sends handshake message 1 only after a decline.
|
||||
* Resumed sessions have no ephemeral DH; the ticket is wiped on use.
|
||||
*/
|
||||
|
||||
static constexpr uint8_t RESUME_OFFER_VERSION = 0x01;
|
||||
static constexpr uint8_t RESUME_ACCEPT_VERSION = 0x01;
|
||||
static constexpr size_t RESUME_SESSION_ID_SIZE = 8;
|
||||
static constexpr size_t RESUME_NONCE_SIZE = 16;
|
||||
static constexpr size_t RESUME_MAC_SIZE = 16;
|
||||
static constexpr size_t RESUME_SECRET_SIZE = 32;
|
||||
|
||||
// ClientHello body: version | session_id | client_nonce | offer_mac
|
||||
static constexpr size_t RESUME_OFFER_SIZE = 1 + RESUME_SESSION_ID_SIZE + RESUME_NONCE_SIZE + RESUME_MAC_SIZE; // 41
|
||||
static constexpr size_t RESUME_OFFER_SESSION_ID_OFFSET = 1;
|
||||
static constexpr size_t RESUME_OFFER_NONCE_OFFSET = RESUME_OFFER_SESSION_ID_OFFSET + RESUME_SESSION_ID_SIZE;
|
||||
static constexpr size_t RESUME_OFFER_MAC_OFFSET = RESUME_OFFER_NONCE_OFFSET + RESUME_NONCE_SIZE;
|
||||
|
||||
// ServerHello trailing extension: version | server_nonce | confirm_mac
|
||||
static constexpr size_t RESUME_ACCEPT_SIZE = 1 + RESUME_NONCE_SIZE + RESUME_MAC_SIZE; // 33
|
||||
|
||||
struct ResumeTicket {
|
||||
uint8_t session_id[RESUME_SESSION_ID_SIZE];
|
||||
uint8_t secret[RESUME_SECRET_SIZE];
|
||||
};
|
||||
// Sent on the wire as one blob: session_id || secret
|
||||
static_assert(sizeof(ResumeTicket) == RESUME_SESSION_ID_SIZE + RESUME_SECRET_SIZE, "ticket must be packed");
|
||||
|
||||
/// Fixed-slot RAM cache of single-use resume tickets. Lost on reboot by
|
||||
/// design: clients fall back to a full handshake.
|
||||
class ResumeTicketCache {
|
||||
public:
|
||||
/// Generate a fresh ticket into out and store it, evicting the oldest
|
||||
/// slot. Returns false (and stores nothing) if the RNG fails.
|
||||
bool issue(ResumeTicket &out);
|
||||
/// Accept a resume offer: verify and consume the ticket (single use; a
|
||||
/// forged MAC never burns one), build both transport ciphers, and write
|
||||
/// the ServerHello accept extension into out_ext. Returns the extension
|
||||
/// length, or 0 (nothing allocated) on any miss, failure, or when
|
||||
/// out_capacity is too small. Secrets are wiped internally.
|
||||
size_t try_accept(const uint8_t *offer, size_t offer_len, const uint8_t *prologue, size_t prologue_len,
|
||||
uint8_t *out_ext, size_t out_capacity, NoiseCipherState *&send_cipher,
|
||||
NoiseCipherState *&recv_cipher);
|
||||
/// Forget every ticket (PSK change).
|
||||
void clear();
|
||||
|
||||
// Round robin; more clients than slots thrash and fall back to full handshakes
|
||||
static constexpr uint8_t SLOTS = 2;
|
||||
static_assert(SLOTS <= 8, "used_mask_ is uint8_t");
|
||||
|
||||
protected:
|
||||
ResumeTicket slots_[SLOTS];
|
||||
uint8_t used_mask_{0};
|
||||
uint8_t next_{0};
|
||||
};
|
||||
|
||||
/// HKDF labels, PROGMEM on ESP8266.
|
||||
extern const char RESUME_LABEL_OFFER[6];
|
||||
extern const char RESUME_LABEL_CONFIRM[8];
|
||||
extern const char RESUME_LABEL_KEYS[5];
|
||||
|
||||
// Largest KDF input: "keys" || client_nonce || server_nonce || SHA256(prologue)
|
||||
static constexpr size_t RESUME_KDF_MAX_DATA =
|
||||
sizeof(RESUME_LABEL_KEYS) - 1 + RESUME_NONCE_SIZE + RESUME_NONCE_SIZE + 32;
|
||||
|
||||
/// Noise-construction HKDF-SHA256 keyed with the ticket secret over
|
||||
/// label || a || b [|| SHA256(hash_in)], at most RESUME_KDF_MAX_DATA. out2 == nullptr means MAC only.
|
||||
bool resume_kdf(const uint8_t *secret, const char *label, size_t label_len, const uint8_t *a, size_t a_len,
|
||||
const uint8_t *b, size_t b_len, const uint8_t *hash_in, size_t hash_in_len, uint8_t *out1,
|
||||
size_t out1_len, uint8_t *out2);
|
||||
|
||||
/// offer_mac for the ClientHello resume offer (what a client computes and
|
||||
/// try_accept checks).
|
||||
inline bool resume_compute_offer_mac(const uint8_t *secret, const uint8_t *session_id, const uint8_t *client_nonce,
|
||||
uint8_t *out_mac) {
|
||||
static_assert(sizeof(RESUME_LABEL_OFFER) - 1 + RESUME_SESSION_ID_SIZE + RESUME_NONCE_SIZE <= RESUME_KDF_MAX_DATA,
|
||||
"KDF buffer");
|
||||
return resume_kdf(secret, RESUME_LABEL_OFFER, sizeof(RESUME_LABEL_OFFER) - 1, session_id, RESUME_SESSION_ID_SIZE,
|
||||
client_nonce, RESUME_NONCE_SIZE, nullptr, 0, out_mac, RESUME_MAC_SIZE, nullptr);
|
||||
}
|
||||
|
||||
/// confirm_mac for the ServerHello extension.
|
||||
inline bool resume_compute_confirm_mac(const uint8_t *secret, const uint8_t *client_nonce, const uint8_t *server_nonce,
|
||||
uint8_t *out_mac) {
|
||||
static_assert(sizeof(RESUME_LABEL_CONFIRM) - 1 + RESUME_NONCE_SIZE + RESUME_NONCE_SIZE <= RESUME_KDF_MAX_DATA,
|
||||
"KDF buffer");
|
||||
return resume_kdf(secret, RESUME_LABEL_CONFIRM, sizeof(RESUME_LABEL_CONFIRM) - 1, client_nonce, RESUME_NONCE_SIZE,
|
||||
server_nonce, RESUME_NONCE_SIZE, nullptr, 0, out_mac, RESUME_MAC_SIZE, nullptr);
|
||||
}
|
||||
|
||||
/// Derive the transport keys. k_c2d encrypts client-to-device traffic,
|
||||
/// k_d2c device-to-client.
|
||||
inline bool resume_derive_keys(const uint8_t *secret, const uint8_t *client_nonce, const uint8_t *server_nonce,
|
||||
const uint8_t *prologue, size_t prologue_len, uint8_t *k_c2d, uint8_t *k_d2c) {
|
||||
static_assert(sizeof(RESUME_LABEL_KEYS) - 1 + RESUME_NONCE_SIZE + RESUME_NONCE_SIZE + 32 <= RESUME_KDF_MAX_DATA,
|
||||
"KDF buffer");
|
||||
return resume_kdf(secret, RESUME_LABEL_KEYS, sizeof(RESUME_LABEL_KEYS) - 1, client_nonce, RESUME_NONCE_SIZE,
|
||||
server_nonce, RESUME_NONCE_SIZE, prologue, prologue_len, k_c2d, 32, k_d2c);
|
||||
}
|
||||
|
||||
/// Build a ChaChaPoly cipher state keyed with key (32 bytes); nullptr on
|
||||
/// failure. Nonce counter starts at 0, exactly like a post-split cipher.
|
||||
NoiseCipherState *resume_make_cipher(const uint8_t *key);
|
||||
|
||||
} // namespace esphome::noise
|
||||
#endif // USE_NOISE
|
||||
@@ -1,24 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from functools import partial
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
|
||||
from esphome import pins
|
||||
from esphome.build_helpers import pch
|
||||
from esphome.build_helpers.pch import (
|
||||
PCH_DEFAULT_HEADERS,
|
||||
PCH_HEADER_NAME,
|
||||
mark_pch_emitted,
|
||||
pch_cmake_consumer,
|
||||
pch_enabled,
|
||||
pch_header_text,
|
||||
)
|
||||
import esphome.codegen as cg
|
||||
from esphome.components.zephyr import (
|
||||
add_extra_script,
|
||||
@@ -816,9 +805,6 @@ def _generate_cmake_lists() -> bool:
|
||||
")",
|
||||
]
|
||||
|
||||
if consumer := pch_cmake_consumer("app", "${APP_SOURCES}"):
|
||||
lines += consumer.splitlines()
|
||||
|
||||
if link_flags:
|
||||
lines += [
|
||||
"",
|
||||
@@ -833,66 +819,6 @@ def _generate_cmake_lists() -> bool:
|
||||
)
|
||||
|
||||
|
||||
def _app_build_dir(build_dir: Path) -> Path:
|
||||
"""The CMake binary dir of the app image: sysbuild nests it in a
|
||||
domain dir named after the app source dir. Probed on disk (the
|
||||
non-sysbuild zephyr/ output dir has no CMakeCache.txt) so it stays
|
||||
truthful mid-build, unlike an SDK-version check."""
|
||||
sysbuild_app = build_dir / "zephyr"
|
||||
try:
|
||||
cache = (sysbuild_app / "CMakeCache.txt").stat()
|
||||
except (FileNotFoundError, NotADirectoryError):
|
||||
return build_dir
|
||||
# Other stat errors propagate; is_file() would silently mislocate the pch
|
||||
return sysbuild_app if stat.S_ISREG(cache.st_mode) else build_dir
|
||||
|
||||
|
||||
def _prepare_pch(app_dir: Path) -> None:
|
||||
"""Build the .gch between the cmake and compile phases of west."""
|
||||
if not pch_enabled():
|
||||
pch.discard_pch(app_dir)
|
||||
pch.pch_disabled_degraded()
|
||||
return
|
||||
# First, so OBJECT_DEPENDS is satisfied even when the pch degrades
|
||||
app_dir.mkdir(parents=True, exist_ok=True)
|
||||
write_file_if_changed(
|
||||
app_dir / PCH_HEADER_NAME, pch_header_text(PCH_DEFAULT_HEADERS)
|
||||
)
|
||||
# New layout first (Zephyr >= 3.4 nests under zephyr/); fixed candidates
|
||||
# keep the .sum identity deterministic and skip walking generated/
|
||||
generated = app_dir / "zephyr" / "include" / "generated"
|
||||
autoconf = None
|
||||
for candidate in (generated / "zephyr" / "autoconf.h", generated / "autoconf.h"):
|
||||
if candidate.exists():
|
||||
autoconf = candidate
|
||||
break
|
||||
if autoconf is None:
|
||||
# Fail closed: autoconf.h is the .sum's Kconfig identity
|
||||
_LOGGER.warning("No autoconf.h found; compiling without the pch")
|
||||
pch.discard_pch(app_dir)
|
||||
pch.pch_degraded("autoconf.h missing")
|
||||
return
|
||||
try:
|
||||
autoconf_text = autoconf.read_text(encoding="utf-8")
|
||||
except OSError as err:
|
||||
_LOGGER.warning(
|
||||
"Could not read %s; compiling without the pch: %s", autoconf, err
|
||||
)
|
||||
pch.discard_pch(app_dir)
|
||||
pch.pch_degraded(f"autoconf unreadable: {err}")
|
||||
return
|
||||
pch.prepare_pch(
|
||||
app_dir,
|
||||
PCH_DEFAULT_HEADERS,
|
||||
(
|
||||
str(CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]),
|
||||
zephyr_data()[KEY_BOARD],
|
||||
autoconf_text,
|
||||
*get_project_compile_flags(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _copy_if_exists(src: Path, dst: Path) -> None:
|
||||
if src.is_file():
|
||||
shutil.copy2(src, dst)
|
||||
@@ -945,58 +871,6 @@ def run_compile(args, config: ConfigType) -> bool:
|
||||
str(source_dir),
|
||||
]
|
||||
|
||||
if pch_enabled():
|
||||
# Consumers carry the -include; gate the ccache relaxation on it
|
||||
# (Zephyr auto-enables ccache as the compiler launcher when found)
|
||||
mark_pch_emitted()
|
||||
env.update(pch.ccache_pch_env())
|
||||
|
||||
# Configure first so the .gch compiles from settled compile DB
|
||||
# flags. Only when the app DB is missing: input changes wipe the
|
||||
# build dir, so an existing DB is settled and --cmake-only would
|
||||
# reconfigure for nothing.
|
||||
prepare = True
|
||||
app_dir = _app_build_dir(build_dir)
|
||||
if not (app_dir / "compile_commands.json").is_file():
|
||||
if not run_command_ok(
|
||||
west_cmd + ["--cmake-only", "--", "-DCMAKE_EXPORT_COMPILE_COMMANDS=ON"],
|
||||
env=env,
|
||||
stream_output=True,
|
||||
cwd=str(paths["framework_path"]),
|
||||
):
|
||||
raise EsphomeError("nRF52 native build configure failed")
|
||||
# The configure phase creates the sysbuild domain dir: re-resolve
|
||||
app_dir = _app_build_dir(build_dir)
|
||||
# kernel.h needs the build-time syscall headers; under sysbuild
|
||||
# the target exists only in the app domain's ninja
|
||||
if not run_command_ok(
|
||||
[
|
||||
"cmake",
|
||||
"--build",
|
||||
str(app_dir),
|
||||
"--target",
|
||||
"zephyr_generated_headers",
|
||||
],
|
||||
env=env,
|
||||
stream_output=True,
|
||||
cwd=str(paths["framework_path"]),
|
||||
):
|
||||
# A pch-only prerequisite: degrade, let the real build report.
|
||||
# Also skip the .gch compile: it would fail on the missing
|
||||
# headers and latch .gch.failed until an identity change
|
||||
_LOGGER.warning(
|
||||
"Zephyr header generation failed; compiling without the pch"
|
||||
)
|
||||
pch.discard_pch(app_dir)
|
||||
pch.pch_degraded("zephyr_generated_headers failed")
|
||||
prepare = False
|
||||
else:
|
||||
prepare = True
|
||||
app_dir = _app_build_dir(build_dir)
|
||||
|
||||
if prepare:
|
||||
pch.guarded_prepare(app_dir, partial(_prepare_pch, app_dir))
|
||||
|
||||
if not run_command_ok(
|
||||
west_cmd,
|
||||
env=env,
|
||||
|
||||
@@ -6,7 +6,6 @@ from string import ascii_letters, digits
|
||||
import subprocess
|
||||
from typing import Any
|
||||
|
||||
from esphome.build_helpers.pch import pch_extra_scripts
|
||||
import esphome.codegen as cg
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
@@ -34,7 +33,7 @@ from esphome.core import (
|
||||
)
|
||||
from esphome.core.config import BOARD_MAX_LENGTH
|
||||
from esphome.helpers import copy_file_if_changed, read_file, write_file_if_changed
|
||||
from esphome.platformio.toolchain import copy_ccache_script, copy_pch_script
|
||||
from esphome.platformio.toolchain import copy_ccache_script
|
||||
from esphome.storage_json import StorageJSON
|
||||
from esphome.types import ConfigType
|
||||
|
||||
@@ -341,10 +340,7 @@ async def to_code(config: ConfigType) -> None:
|
||||
cg.add_define("ESPHOME_VARIANT", VARIANT_FRIENDLY[variant])
|
||||
cg.add_define(ThreadModel.SINGLE)
|
||||
|
||||
cg.add_platformio_option(
|
||||
"extra_scripts",
|
||||
["pre:ccache.py", *pch_extra_scripts(), "post:post_build.py"],
|
||||
)
|
||||
cg.add_platformio_option("extra_scripts", ["pre:ccache.py", "post:post_build.py"])
|
||||
|
||||
conf = config[CONF_FRAMEWORK]
|
||||
cg.add_platformio_option("framework", "arduino")
|
||||
@@ -648,7 +644,6 @@ def copy_files() -> None:
|
||||
CORE.relative_build_path("inject_lwip_include.py"),
|
||||
)
|
||||
copy_ccache_script()
|
||||
copy_pch_script()
|
||||
_generate_lwipopts_h()
|
||||
if generate_pio_files():
|
||||
path = CORE.relative_src_path("esphome.h")
|
||||
|
||||
@@ -1143,14 +1143,13 @@ class EsphomeCore:
|
||||
def add_platformio_option(
|
||||
self, key: str, value: str | list[str], *, replace: bool = False
|
||||
) -> None:
|
||||
"""Set a platformio.ini option; values append to an existing list
|
||||
(a string as one element) unless ``replace`` is True, which
|
||||
overwrites any existing value."""
|
||||
"""Set a platformio.ini option; list values append to an existing list
|
||||
unless ``replace`` is True, which overwrites any existing value."""
|
||||
new_val = value
|
||||
old_val = self.platformio_options.get(key)
|
||||
if not replace and isinstance(old_val, list):
|
||||
# A user platformio_options string must merge, not assert
|
||||
new_val = old_val + ([value] if isinstance(value, str) else value)
|
||||
assert isinstance(value, list)
|
||||
new_val = old_val + value
|
||||
self.platformio_options[key] = new_val
|
||||
|
||||
def _get_variable_generator(self, id):
|
||||
|
||||
@@ -555,14 +555,7 @@ NATIVE_ARDUINO_PIO_OPTIONS = frozenset({"board_build.f_cpu", "board_build.ldscri
|
||||
# that is stored rather than translated away. Consumed by the esp8266 native
|
||||
# backend (later in this chain) for its ignored-option warning; defined here
|
||||
# so it stays adjacent to the routing.
|
||||
# build_src_flags and board_build.flash_mode: set unconditionally by
|
||||
# esp8266/__init__ and read by the native generator; not user-routable, so
|
||||
# not in the set above
|
||||
NATIVE_ARDUINO_CONSUMED_PIO_OPTIONS = NATIVE_ARDUINO_PIO_OPTIONS | {
|
||||
"lib_ignore",
|
||||
"build_src_flags",
|
||||
"board_build.flash_mode",
|
||||
}
|
||||
NATIVE_ARDUINO_CONSUMED_PIO_OPTIONS = NATIVE_ARDUINO_PIO_OPTIONS | {"lib_ignore"}
|
||||
|
||||
|
||||
@coroutine_with_priority(CoroPriority.FINAL)
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
#pragma once
|
||||
// Curated precompiled-header prefix for backends that force-include it via
|
||||
// build_src_flags (the pch script folds it into the .gch). Guarded because
|
||||
// build_src_flags also reaches C and assembly src edges.
|
||||
#ifdef __cplusplus
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/automation.h"
|
||||
#endif
|
||||
+17
-27
@@ -16,7 +16,6 @@ from esphome.build_helpers.ccache import (
|
||||
parse_enable_env,
|
||||
resolve_ccache_path,
|
||||
)
|
||||
from esphome.build_helpers.pch import ccache_pch_env
|
||||
from esphome.build_helpers.tools_cache import IDF_TOOLS_CACHE, tools_cache_path
|
||||
from esphome.core import Version
|
||||
from esphome.framework_helpers import (
|
||||
@@ -1206,33 +1205,15 @@ def _ccache_env() -> dict[str, str]:
|
||||
|
||||
Only values the user has not already set in the environment are returned, so
|
||||
a custom ``CCACHE_DIR`` / ``CCACHE_MAXSIZE`` / etc. is respected.
|
||||
The pch settings add ``time_macros`` sloppiness process-wide; the visible
|
||||
effect is a cached TU can keep an older ``esp_app_desc`` build timestamp.
|
||||
"""
|
||||
if not _ccache_enabled():
|
||||
# The raw knob value (e.g. "disable") is still inherited by idf.py
|
||||
# via os.environ, where a non-false-constant string reads as
|
||||
# truthy; export the canonical off spelling instead
|
||||
return {"IDF_CCACHE_ENABLE": "0"}
|
||||
|
||||
env = ccache_defaults_env(get_idf_tools_path() / "ccache")
|
||||
env.update(ccache_pch_env())
|
||||
# Exactly one canonical spelling ever reaches idf.py, whatever the
|
||||
# accepted input spelling was ("enable", "yes", ...)
|
||||
env["IDF_CCACHE_ENABLE"] = "1"
|
||||
return env
|
||||
|
||||
|
||||
def _ccache_enabled() -> bool:
|
||||
"""Whether ESP-IDF compiles run under ccache.
|
||||
|
||||
IDF_CCACHE_ENABLE (the backend-native knob) wins over the shared
|
||||
ESPHOME_CCACHE_ENABLE; when unset, enabled iff a runnable binary is
|
||||
on PATH.
|
||||
"""
|
||||
# IDF_CCACHE_ENABLE (the backend-native knob) wins over the shared
|
||||
# ESPHOME_CCACHE_ENABLE.
|
||||
idf_knob = parse_enable_env("IDF_CCACHE_ENABLE")
|
||||
if idf_knob is False:
|
||||
return False
|
||||
# The raw value (e.g. "disable") is still inherited by idf.py via
|
||||
# os.environ, where a non-false-constant string reads as truthy;
|
||||
# export the canonical off spelling instead
|
||||
return {"IDF_CCACHE_ENABLE": "0"}
|
||||
if idf_knob is True:
|
||||
# Forced on ignores the runnability verdict, but the outcome is
|
||||
# worth saying out loud. Probed directly (not via the resolver,
|
||||
@@ -1252,8 +1233,17 @@ def _ccache_enabled() -> bool:
|
||||
"IDF_CCACHE_ENABLE=1 forces on the ccache at %s even though "
|
||||
"it failed to run; idf.py will use it anyway",
|
||||
)
|
||||
return True
|
||||
return resolve_ccache_path() is not None
|
||||
elif resolve_ccache_path() is None:
|
||||
# ESP-IDF silently skips ccache without the binary; export the
|
||||
# canonical off spelling so an unparsable inherited value (or a
|
||||
# probe-rejected ccache idf.py would still find) cannot enable it
|
||||
return {"IDF_CCACHE_ENABLE": "0"}
|
||||
|
||||
env = ccache_defaults_env(get_idf_tools_path() / "ccache")
|
||||
# Exactly one canonical spelling ever reaches idf.py, whatever the
|
||||
# accepted input spelling was ("enable", "yes", ...)
|
||||
env["IDF_CCACHE_ENABLE"] = "1"
|
||||
return env
|
||||
|
||||
|
||||
def get_framework_env(
|
||||
|
||||
@@ -528,12 +528,6 @@ def run_compile(config, verbose: bool) -> int:
|
||||
return result.returncode
|
||||
_patch_memory_segments()
|
||||
|
||||
# After every reconfigure so compile_commands and sdkconfig are settled
|
||||
from esphome.build_gen.espidf import prepare_pch
|
||||
from esphome.build_helpers.pch import guarded_prepare
|
||||
|
||||
guarded_prepare(CORE.relative_build_path("build"), prepare_pch)
|
||||
|
||||
# Build
|
||||
args = []
|
||||
|
||||
|
||||
@@ -1,416 +0,0 @@
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
import posixpath
|
||||
import re
|
||||
import shlex
|
||||
import stat
|
||||
import subprocess
|
||||
import traceback
|
||||
|
||||
# pylint: disable=E0602
|
||||
Import("env") # noqa: F821
|
||||
_projenv_error = None
|
||||
try:
|
||||
Import("projenv") # noqa: F821
|
||||
except Exception as err: # noqa: BLE001 -- not exported under -t nobuild
|
||||
projenv = None
|
||||
_projenv_error = err
|
||||
|
||||
# Precompile the src force-includes plus defines.h and force-include the
|
||||
# result into C++ src compiles only; their preprocessed output is unchanged.
|
||||
# Registration is gated host-side (pch_enabled()). Keep the closure, ccache
|
||||
# values, probe flow, stamp flow, and env-knob spellings in sync with
|
||||
# build_helpers/pch.py.
|
||||
|
||||
# Compiler failures that clear on their own must not latch the .failed marker
|
||||
_TRANSIENT_ERRORS = ("No space left", "Cannot allocate", "Resource temporarily")
|
||||
|
||||
# Keep in sync with helpers.TRUTHY_ENV_STRINGS / FALSY_ENV_STRINGS
|
||||
_TRUTHY = ("1", "true", "yes", "on", "enable")
|
||||
_FALSY = ("", "0", "false", "no", "off", "disable")
|
||||
_STRICT_RAW = os.environ.get("ESPHOME_PCH_STRICT")
|
||||
_STRICT_VALUE = (_STRICT_RAW or "").strip().lower()
|
||||
_STRICT = _STRICT_VALUE in _TRUTHY
|
||||
if _STRICT_RAW is not None and _STRICT_VALUE not in _TRUTHY + _FALSY:
|
||||
# A typo must not silently turn the gate into a no-op
|
||||
raise RuntimeError(f"Unrecognized ESPHOME_PCH_STRICT={_STRICT_RAW!r}; use 1 or 0")
|
||||
|
||||
_INCLUDE_RE = re.compile(rb'^\s*#\s*include\s+["<]([^">]+)[">]', re.MULTILINE)
|
||||
_CORE_HEADER = "esphome/core/defines.h"
|
||||
|
||||
|
||||
def _raise_walk_error(err: OSError) -> None:
|
||||
raise err
|
||||
|
||||
|
||||
def _resolves_dir(path: Path) -> bool:
|
||||
"""False when missing; other stat failures propagate."""
|
||||
try:
|
||||
return stat.S_ISDIR(path.stat().st_mode)
|
||||
except (FileNotFoundError, NotADirectoryError):
|
||||
return False
|
||||
|
||||
|
||||
def _resolves(path: Path) -> bool:
|
||||
"""False when missing; other stat failures propagate (identity unknown)."""
|
||||
try:
|
||||
return stat.S_ISREG(path.stat().st_mode)
|
||||
except (FileNotFoundError, NotADirectoryError):
|
||||
return False
|
||||
|
||||
|
||||
def _include_closure(src_dir: Path, roots: list) -> dict:
|
||||
"""Quoted-include closure: src-relative name -> contents (mirror of
|
||||
build_helpers/pch.py)."""
|
||||
seen = {}
|
||||
stack = [(name, "") for name in roots]
|
||||
while stack:
|
||||
name, from_dir = stack.pop()
|
||||
for candidate in (f"{from_dir}/{name}" if from_dir else name, name):
|
||||
rel = posixpath.normpath(candidate)
|
||||
if not rel.startswith("..") and _resolves(src_dir / rel):
|
||||
break
|
||||
else:
|
||||
continue
|
||||
if rel in seen:
|
||||
continue
|
||||
try:
|
||||
data = (src_dir / rel).read_bytes()
|
||||
except OSError as err:
|
||||
# A marker would truncate the transitive walk; fail closed
|
||||
print(f"ESPHome: could not read {rel} for the pch checksum: {err}")
|
||||
raise
|
||||
seen[rel] = data
|
||||
parent = posixpath.dirname(rel)
|
||||
stack.extend(
|
||||
(inc.decode(errors="surrogateescape"), parent)
|
||||
for inc in _INCLUDE_RE.findall(data)
|
||||
)
|
||||
return seen
|
||||
|
||||
|
||||
def _shell_arg(element) -> str | None:
|
||||
"""One compiler argv from one SCons element, matching the real spawn:
|
||||
spaced elements pass whole, the rest get one shell unquote (skipped on
|
||||
Windows, where shlex would eat path backslashes)."""
|
||||
arg = str(element)
|
||||
if " " in arg or os.name == "nt":
|
||||
return arg.replace('\\"', '"')
|
||||
if not arg.strip():
|
||||
return arg
|
||||
try:
|
||||
tokens = shlex.split(arg)
|
||||
except ValueError as err:
|
||||
print(f"ESPHome: could not lex flag {arg!r} for the pch: {err}")
|
||||
return None
|
||||
if len(tokens) != 1:
|
||||
# A flag the model cannot reproduce would diverge the .gch's flags
|
||||
print(f"ESPHome: cannot model flag {arg!r} for the pch")
|
||||
return None
|
||||
return tokens[0]
|
||||
|
||||
|
||||
def _compile_gch(cxx, flags, header: Path, gch: Path, proj_dir: Path):
|
||||
"""Compile the .gch, then probe that the toolchain can load it back
|
||||
(GCC 10 on macOS arm64 rejects its own per-process). Returns an error
|
||||
string or None; OSError propagates as transient."""
|
||||
result = subprocess.run( # noqa: PLW1510
|
||||
[cxx, "-x", "c++-header", *flags, "-c", str(header), "-o", str(gch)],
|
||||
cwd=proj_dir,
|
||||
# C locale keeps diagnostics matchable by _TRANSIENT_ERRORS
|
||||
env={**os.environ, "LC_ALL": "C"},
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode < 0:
|
||||
# Signal-killed (OOM, ^C): route to the transient no-marker path
|
||||
raise OSError(f"compiler killed by signal {-result.returncode}")
|
||||
if result.returncode != 0:
|
||||
return result.stderr
|
||||
return _probe_gch(cxx, flags, header, proj_dir)
|
||||
|
||||
|
||||
def _probe_run(cxx, flags, extra, proj_dir: Path):
|
||||
probe = subprocess.run( # noqa: PLW1510
|
||||
[cxx, *flags, *extra, "-fsyntax-only", "-x", "c++", "-"],
|
||||
cwd=proj_dir,
|
||||
env={**os.environ, "LC_ALL": "C"},
|
||||
input="",
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if probe.returncode < 0:
|
||||
raise OSError(f"probe killed by signal {-probe.returncode}")
|
||||
return probe
|
||||
|
||||
|
||||
def _probe_gch(cxx, flags, header: Path, proj_dir: Path):
|
||||
"""Load-check an existing .gch; error string or None. Rejection must
|
||||
be a nonzero exit (keep in sync with pch_probe_args); a baseline run
|
||||
without the pch keeps environmental failures from being blamed on it."""
|
||||
# -MF is only legal alongside a dependency flag; pass it solely to
|
||||
# redirect a depfile that -MD/-MMD in the flags would otherwise write
|
||||
dep_redirect = (
|
||||
["-MF", os.devnull]
|
||||
if any(f in ("-MD", "-MMD", "-M", "-MM") for f in flags)
|
||||
else []
|
||||
)
|
||||
probe = _probe_run(
|
||||
cxx,
|
||||
flags,
|
||||
[*dep_redirect, "-Winvalid-pch", "-Werror=invalid-pch", "-include", str(header)],
|
||||
proj_dir,
|
||||
)
|
||||
if probe.returncode == 0:
|
||||
return None
|
||||
baseline = _probe_run(cxx, flags, dep_redirect, proj_dir)
|
||||
if baseline.returncode != 0:
|
||||
# Deterministic and latchable; the transient filter at the caller
|
||||
# keeps resource exhaustion from latching
|
||||
return f"probe cannot run at all: {baseline.stderr.strip()[:200]}"
|
||||
return f"toolchain cannot load the pch: {probe.stderr.strip()}"
|
||||
|
||||
|
||||
def _read_stamp(path: Path) -> str:
|
||||
"""A corrupt sidecar must read as stale, not kill the pch forever."""
|
||||
try:
|
||||
return path.read_text(encoding="utf-8").strip()
|
||||
except (OSError, UnicodeDecodeError):
|
||||
return ""
|
||||
|
||||
|
||||
def _setup_pch() -> bool | None:
|
||||
if projenv is None:
|
||||
print(f"ESPHome: projenv unavailable ({_projenv_error}); skipping pch")
|
||||
try:
|
||||
from SCons.Script import COMMAND_LINE_TARGETS
|
||||
except ImportError:
|
||||
# No SCons is an anomaly under PlatformIO: the unknown state
|
||||
# must not read as success (strict decides fatality at the gate)
|
||||
return False
|
||||
# Expected under -t nobuild (nothing compiles); a missing
|
||||
# projenv on a real compile must not pass strict
|
||||
return "nobuild" in [str(t) for t in COMMAND_LINE_TARGETS]
|
||||
# Project root: SCons compiles run here, so the relative -include
|
||||
# resolves; an absolute path would break cross-device ccache sharing.
|
||||
proj_dir = Path(env.subst("$PROJECT_DIR")) # noqa: F821
|
||||
src_dir = Path(env.subst("$PROJECT_SRC_DIR")) # noqa: F821
|
||||
header = proj_dir / "esphome_pch.h"
|
||||
gch = Path(f"{header}.gch")
|
||||
sum_path = Path(f"{gch}.sum")
|
||||
|
||||
cxx = projenv.subst("$CXX") # noqa: F821
|
||||
# The header holds the -include entries itself, so the .gch compile must
|
||||
# not see them; consumers keep theirs, which the .gch then satisfies.
|
||||
flags = []
|
||||
include_headers = []
|
||||
raw_args = [
|
||||
_shell_arg(element)
|
||||
for element in projenv.subst_list("$CXXFLAGS $CCFLAGS $_CCCOMCOM")[0] # noqa: F821
|
||||
]
|
||||
if any(arg is None for arg in raw_args):
|
||||
print("ESPHome: skipping precompiled header: unmodelable flag")
|
||||
return
|
||||
flag_it = iter(raw_args)
|
||||
for tok in flag_it:
|
||||
if tok == "-include":
|
||||
include_headers.append(next(flag_it, ""))
|
||||
elif tok.startswith("-include") and not tok.startswith("-include-"):
|
||||
include_headers.append(tok[len("-include") :])
|
||||
else:
|
||||
flags.append(tok)
|
||||
if any(not name for name in include_headers):
|
||||
print("ESPHome: build_src_flags has a trailing -include; skipping pch")
|
||||
return
|
||||
# Fold only relative names resolving under src/: consumers keep their
|
||||
# own -include entries, so folding an unguarded user header would
|
||||
# include it twice; unfolded ones stay consumer-only.
|
||||
try:
|
||||
folded = [
|
||||
name
|
||||
for name in include_headers
|
||||
if not Path(name).is_absolute() and _resolves(src_dir / name)
|
||||
]
|
||||
except OSError as err:
|
||||
print(f"ESPHome: skipping precompiled header: {err}")
|
||||
return
|
||||
if unfolded := [n for n in include_headers if n not in folded]:
|
||||
print(f"ESPHome: not precompiling non-src force-includes: {unfolded}")
|
||||
content = "".join(f'#include "{name}"\n' for name in (*folded, _CORE_HEADER))
|
||||
|
||||
digest = hashlib.sha256()
|
||||
digest.update(content.encode(errors="surrogateescape"))
|
||||
digest.update(cxx.encode(errors="surrogateescape"))
|
||||
# Mirror CCACHE_BASEDIR: strip the per-device build path so identical
|
||||
# configs produce identical .sum files and share cache entries
|
||||
flags_id = " ".join(flags)
|
||||
if basedir := os.environ.get("CCACHE_BASEDIR"):
|
||||
flags_id = flags_id.replace(basedir, "")
|
||||
digest.update(flags_id.encode(errors="surrogateescape"))
|
||||
# GCC never validates a .gch against its source headers, and PlatformIO
|
||||
# package paths carry no version, so a package bump must invalidate here
|
||||
platform = env.PioPlatform() # noqa: F821
|
||||
for package in sorted(platform.packages):
|
||||
try:
|
||||
version = platform.get_package_version(package)
|
||||
except KeyError:
|
||||
# An unresolved manifest must not hash as a constant
|
||||
if platform.get_package(package) is not None:
|
||||
print(f"ESPHome: skipping precompiled header: no version for {package}")
|
||||
return
|
||||
version = None # absent optional package
|
||||
except Exception as err: # noqa: BLE001
|
||||
# No trustworthy package identity: a stale .gch could survive
|
||||
print(f"ESPHome: skipping precompiled header: {err}")
|
||||
return
|
||||
digest.update(f"{package}={version}".encode())
|
||||
digest.update(b"\0")
|
||||
try:
|
||||
closure = _include_closure(src_dir, [*folded, _CORE_HEADER])
|
||||
except OSError as err:
|
||||
print(f"ESPHome: skipping precompiled header: {err}")
|
||||
return
|
||||
for rel in sorted(closure):
|
||||
digest.update(rel.encode(errors="surrogateescape"))
|
||||
digest.update(closure[rel])
|
||||
digest.update(b"\0")
|
||||
# Project-local -I dirs (e.g. rp2's lwip_override) hold generated
|
||||
# headers the src closure cannot see; hash them too
|
||||
prev = ""
|
||||
try:
|
||||
for tok in flags:
|
||||
inc = tok[2:] if tok.startswith("-I") and len(tok) > 2 else ""
|
||||
if prev == "-I":
|
||||
inc = tok
|
||||
prev = tok
|
||||
if not inc:
|
||||
continue
|
||||
inc_dir = Path(inc)
|
||||
if not (
|
||||
_resolves_dir(inc_dir)
|
||||
and inc_dir.is_relative_to(proj_dir)
|
||||
and not inc_dir.is_relative_to(src_dir)
|
||||
# Library trees never enter the prefix closure; walking them
|
||||
# would read every library file each build
|
||||
and not inc_dir.is_relative_to(proj_dir / ".piolibdeps")
|
||||
and not inc_dir.is_relative_to(proj_dir / ".pioenvs")
|
||||
):
|
||||
continue
|
||||
local_headers = []
|
||||
# os.walk with onerror: rglob would swallow unlistable subtrees
|
||||
for root, _dirs, files in os.walk(inc_dir, onerror=_raise_walk_error):
|
||||
local_headers.extend(
|
||||
Path(root) / f
|
||||
for f in files
|
||||
if f.endswith((".h", ".hpp", ".hh", ".inc"))
|
||||
)
|
||||
for local in sorted(local_headers):
|
||||
digest.update(str(local.relative_to(proj_dir)).encode())
|
||||
digest.update(local.read_bytes())
|
||||
digest.update(b"\0")
|
||||
except OSError as err:
|
||||
print(f"ESPHome: skipping precompiled header: {err}")
|
||||
return
|
||||
checksum = digest.hexdigest()
|
||||
|
||||
# The ccache .sum sidecar doubles as the freshness stamp
|
||||
fresh = (
|
||||
header.is_file()
|
||||
and gch.is_file()
|
||||
and sum_path.is_file()
|
||||
and (_read_stamp(sum_path) == checksum)
|
||||
)
|
||||
if fresh and _STRICT:
|
||||
# Rejection is per-process: strict re-proves a cached .gch loads
|
||||
# (mirrors the pch_strict() re-probe in build_helpers/pch.py)
|
||||
error = _probe_gch(cxx, flags, header, proj_dir)
|
||||
if error is not None:
|
||||
print(f"ESPHome: {error}")
|
||||
gch.unlink(missing_ok=True)
|
||||
sum_path.unlink(missing_ok=True)
|
||||
return
|
||||
if not fresh:
|
||||
failed_marker = Path(f"{gch}.failed")
|
||||
if _read_stamp(failed_marker) == checksum:
|
||||
print(
|
||||
"ESPHome: skipping precompiled header (previous attempt "
|
||||
f"failed); delete {failed_marker.name} to retry"
|
||||
)
|
||||
return
|
||||
header.write_text(content, encoding="utf-8")
|
||||
try:
|
||||
error = _compile_gch(cxx, flags, header, gch, proj_dir)
|
||||
except OSError as err:
|
||||
# Transient spawn/IO failure: no marker, retry next build
|
||||
print(f"ESPHome: precompiled header compile did not run: {err}")
|
||||
gch.unlink(missing_ok=True)
|
||||
sum_path.unlink(missing_ok=True)
|
||||
return
|
||||
if error is not None:
|
||||
print("ESPHome: precompiled header failed; compiling without it")
|
||||
print(error)
|
||||
gch.unlink(missing_ok=True)
|
||||
sum_path.unlink(missing_ok=True)
|
||||
if any(m in error for m in _TRANSIENT_ERRORS):
|
||||
# Resource exhaustion clears on its own; retry next build
|
||||
return
|
||||
# Skip retries until a flag/header/platform change alters the checksum
|
||||
failed_marker.write_text(checksum + "\n", encoding="utf-8")
|
||||
return
|
||||
failed_marker.unlink(missing_ok=True)
|
||||
sum_path.write_text(checksum + "\n", encoding="utf-8")
|
||||
|
||||
# Computed first so the flags and env land together: a raise between
|
||||
# them would leave a pch-consuming build without its ccache settings.
|
||||
# projenv["ENV"] aliases os.environ, so these reach all TUs; only
|
||||
# time_macros affects non-pch TUs. User values win.
|
||||
ccache_updates = {
|
||||
key: value
|
||||
for key, value in (
|
||||
("CCACHE_SLOPPINESS", "pch_defines,time_macros"),
|
||||
("CCACHE_PCH_EXTSUM", "true"),
|
||||
)
|
||||
if key not in os.environ
|
||||
}
|
||||
sloppiness = os.environ.get("CCACHE_SLOPPINESS")
|
||||
if sloppiness is not None:
|
||||
tokens = {tok.strip() for tok in sloppiness.split(",")}
|
||||
missing = [t for t in ("pch_defines", "time_macros") if t not in tokens]
|
||||
if missing:
|
||||
# Without these ccache declines every pch-consuming compile
|
||||
ccache_updates["CCACHE_SLOPPINESS"] = ",".join((sloppiness, *missing))
|
||||
print(f"ESPHome: adding {','.join(missing)} to CCACHE_SLOPPINESS for the pch")
|
||||
extsum = os.environ.get("CCACHE_PCH_EXTSUM")
|
||||
if extsum is not None and extsum.strip().lower() not in ("1", "true", "yes", "on"):
|
||||
# ccache then hashes the non-reproducible .gch bytes: permanent misses
|
||||
print(f"ESPHome: CCACHE_PCH_EXTSUM={extsum} disables pch caching")
|
||||
# Prepended: GCC only uses a .gch while no other tokens precede it.
|
||||
# The relative name also reaches "pio run -t idedata" output.
|
||||
# -Wno-error: the per-process probe can pass while a later cc1plus
|
||||
# rejects the .gch; that must stay a warning under user -Werror.
|
||||
projenv.Prepend( # noqa: F821
|
||||
CXXFLAGS=[
|
||||
"-Winvalid-pch",
|
||||
# Strict inverts: a per-process consumer rejection reds the build
|
||||
"-Werror=invalid-pch" if _STRICT else "-Wno-error=invalid-pch",
|
||||
"-include",
|
||||
header.name,
|
||||
]
|
||||
)
|
||||
projenv["ENV"].update(ccache_updates) # noqa: F821
|
||||
print("ESPHome: Compiling with precompiled header")
|
||||
return True
|
||||
|
||||
|
||||
|
||||
try:
|
||||
_used = _setup_pch()
|
||||
except Exception: # noqa: BLE001 -- a speedup must never break the build
|
||||
if _STRICT:
|
||||
raise
|
||||
print("ESPHome: pch internal error; compiling without it")
|
||||
traceback.print_exc()
|
||||
else:
|
||||
if _STRICT and not _used:
|
||||
raise RuntimeError("ESPHOME_PCH_STRICT: precompiled header was not used")
|
||||
@@ -289,15 +289,6 @@ def copy_ccache_script() -> None:
|
||||
)
|
||||
|
||||
|
||||
def copy_pch_script() -> None:
|
||||
"""Copy the shared precompiled-header SCons post-script into the build
|
||||
dir; platform components pair it with ``post:pch.py`` in extra_scripts."""
|
||||
copy_file_if_changed(
|
||||
Path(__file__).parent / "pch.py.script",
|
||||
CORE.relative_build_path("pch.py"),
|
||||
)
|
||||
|
||||
|
||||
def default_libdeps_dir() -> str:
|
||||
"""The PLATFORMIO_LIBDEPS_DIR value a pio run defaults to; the package
|
||||
prefetch must resolve installed libraries against the same dir."""
|
||||
|
||||
+4
-15
@@ -7,7 +7,6 @@ import re
|
||||
import time
|
||||
|
||||
from esphome import loader
|
||||
from esphome.build_helpers.pch import PCH_ARTIFACT_NAMES, PCH_PREFIX_HEADER
|
||||
from esphome.compiled_config import save_compiled_config
|
||||
from esphome.config import iter_component_configs, iter_components
|
||||
from esphome.const import (
|
||||
@@ -247,7 +246,6 @@ def copy_src_tree():
|
||||
Path(
|
||||
"esphome/core/ring_buffer.h"
|
||||
), # moved to components/ring_buffer/, removed in 2026.11.0
|
||||
Path(PCH_PREFIX_HEADER), # build machinery, not user API
|
||||
}
|
||||
include_l = []
|
||||
for target, _ in source_files_l:
|
||||
@@ -611,20 +609,11 @@ def clean_build(clear_pio_cache: bool = True, *, full: bool = False):
|
||||
if idf_path.is_dir():
|
||||
_LOGGER.info("Deleting %s", idf_path)
|
||||
rmtree(idf_path)
|
||||
# The PlatformIO pch artifacts live at the project root so the
|
||||
# relative -include resolves; a partial clean must drop them too
|
||||
for name in PCH_ARTIFACT_NAMES:
|
||||
CORE.relative_build_path(name).unlink(missing_ok=True)
|
||||
|
||||
# The idedata caches are derived from the build but live under the data
|
||||
# dir, not the build path, so they must be removed separately in both
|
||||
# modes. Globbed (name.json plus name.<backend>.json) so a future
|
||||
# backend suffix cannot silently drift out of clean-all.
|
||||
idedata_dir = CORE.relative_internal_path("idedata")
|
||||
for idedata_cache in (
|
||||
*idedata_dir.glob(f"{CORE.name}.json"),
|
||||
*idedata_dir.glob(f"{CORE.name}.*.json"),
|
||||
):
|
||||
# The idedata cache is derived from the build but lives under the data dir,
|
||||
# not the build path, so it must be removed separately in both modes.
|
||||
idedata_cache = CORE.relative_internal_path("idedata", f"{CORE.name}.json")
|
||||
if idedata_cache.is_file():
|
||||
_LOGGER.info("Deleting %s", idedata_cache)
|
||||
idedata_cache.unlink()
|
||||
|
||||
|
||||
@@ -2531,6 +2531,11 @@ def calculate_message_max_size(desc: descriptor.DescriptorProto) -> int | None:
|
||||
return total_size
|
||||
|
||||
|
||||
# Contents must never reach the log: dump_to prints only the name
|
||||
SENSITIVE_MESSAGES = {"NoiseResumeTicket"}
|
||||
SENSITIVE_MESSAGES_SEEN: set[str] = set()
|
||||
|
||||
|
||||
def build_message_type(
|
||||
desc: descriptor.DescriptorProto,
|
||||
base_class_fields: dict[str, list[descriptor.FieldDescriptorProto]],
|
||||
@@ -2808,6 +2813,10 @@ def build_message_type(
|
||||
public_content.append(prot)
|
||||
# If no fields to calculate size for or message doesn't need encoding, the default implementation in ProtoMessage will be used
|
||||
|
||||
if desc.name in SENSITIVE_MESSAGES:
|
||||
dump = []
|
||||
SENSITIVE_MESSAGES_SEEN.add(desc.name)
|
||||
|
||||
# dump_to method declaration in header
|
||||
prot = "#ifdef HAS_PROTO_MESSAGE_DUMP\n"
|
||||
prot += "const char *dump_to(DumpBuffer &out) const override;\n"
|
||||
@@ -3715,6 +3724,11 @@ static const char *const TAG = "api.service";
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# A renamed message must fail the build, not silently start dumping secrets
|
||||
missing = SENSITIVE_MESSAGES - SENSITIVE_MESSAGES_SEEN
|
||||
if missing:
|
||||
raise RuntimeError(f"SENSITIVE_MESSAGES not found in api.proto: {missing}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
||||
+24
-137
@@ -50,7 +50,6 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from collections import Counter
|
||||
from collections.abc import Callable
|
||||
from enum import StrEnum
|
||||
from functools import cache
|
||||
import json
|
||||
@@ -532,58 +531,37 @@ ESP32_PLATFORMIO_TRIGGER_PATH_PREFIXES = ("esphome/platformio/",)
|
||||
# - esphome/build_gen/platformio.py -- the PlatformIO build generator
|
||||
# - script/test_build_components.py -- the harness the job invokes
|
||||
# - .github/workflows/ci.yml -- the job's own definition
|
||||
# Shared by every toolchain smoke-test job: the harness it invokes and the
|
||||
# workflow that defines it
|
||||
_SMOKE_HARNESS_TRIGGER_FILES = frozenset(
|
||||
ESP32_PLATFORMIO_TRIGGER_FILES = frozenset(
|
||||
{
|
||||
"esphome/build_gen/platformio.py",
|
||||
"script/test_build_components.py",
|
||||
".github/workflows/ci.yml",
|
||||
}
|
||||
)
|
||||
|
||||
ESP32_PLATFORMIO_TRIGGER_FILES = _SMOKE_HARNESS_TRIGGER_FILES | {
|
||||
"esphome/build_gen/platformio.py",
|
||||
# The pch machinery the strict smoke job polices, and the modules it
|
||||
# imports; the rest of build_helpers/ does not affect PlatformIO builds
|
||||
"esphome/build_helpers/pch.py",
|
||||
"esphome/build_helpers/ccache.py",
|
||||
"esphome/build_helpers/idedata.py",
|
||||
}
|
||||
|
||||
|
||||
def _path_or_file_trigger(
|
||||
files: list[str],
|
||||
trigger_files: frozenset[str],
|
||||
trigger_prefixes: tuple[str, ...],
|
||||
) -> bool:
|
||||
"""Whether any changed file matches the given infrastructure triggers."""
|
||||
return any(
|
||||
file in trigger_files or file.startswith(trigger_prefixes) for file in files
|
||||
)
|
||||
|
||||
|
||||
@cache
|
||||
def _cached_components_closure(files: tuple[str, ...]) -> frozenset[str]:
|
||||
"""Dependency closure of the changed components, from the changed files.
|
||||
|
||||
The walk is expensive and every toolchain smoke-test job asks for the
|
||||
same file list, so compute it once per run."""
|
||||
component_files = [f for f in files if filter_component_and_test_files(f)]
|
||||
return frozenset(get_components_with_dependencies(component_files, True))
|
||||
|
||||
|
||||
def _esp32_platformio_path_or_file_trigger(files: list[str]) -> bool:
|
||||
"""Whether any changed file is a PlatformIO infrastructure / harness trigger."""
|
||||
return _path_or_file_trigger(
|
||||
files, ESP32_PLATFORMIO_TRIGGER_FILES, ESP32_PLATFORMIO_TRIGGER_PATH_PREFIXES
|
||||
)
|
||||
for file in files:
|
||||
if file in ESP32_PLATFORMIO_TRIGGER_FILES:
|
||||
return True
|
||||
if any(
|
||||
file.startswith(prefix) for prefix in ESP32_PLATFORMIO_TRIGGER_PATH_PREFIXES
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _esp_idf_infra_changed(files: list[str]) -> bool:
|
||||
"""Whether any changed file is ESP-IDF build/runner infrastructure."""
|
||||
return _path_or_file_trigger(
|
||||
files, ESP_IDF_INFRA_TRIGGER_FILES, ESP_IDF_INFRA_TRIGGER_PATH_PREFIXES
|
||||
)
|
||||
for file in files:
|
||||
if file in ESP_IDF_INFRA_TRIGGER_FILES:
|
||||
return True
|
||||
if any(
|
||||
file.startswith(prefix) for prefix in ESP_IDF_INFRA_TRIGGER_PATH_PREFIXES
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def esp32_platformio_components_to_test(branch: str | None = None) -> list[str]:
|
||||
@@ -621,23 +599,15 @@ def esp32_platformio_components_to_test(branch: str | None = None) -> list[str]:
|
||||
Returns:
|
||||
Sorted list of component names to compile.
|
||||
"""
|
||||
return _toolchain_components_to_test(
|
||||
branch, ESP32_PLATFORMIO_TEST_COMPONENTS, _esp32_platformio_path_or_file_trigger
|
||||
)
|
||||
|
||||
|
||||
def _toolchain_components_to_test(
|
||||
branch: str | None,
|
||||
test_set: frozenset[str],
|
||||
infra_trigger: Callable[[list[str]], bool],
|
||||
) -> list[str]:
|
||||
"""The shared narrowing rule for the per-toolchain smoke-test jobs."""
|
||||
files = changed_files(branch)
|
||||
|
||||
if core_changed(files) or infra_trigger(files):
|
||||
return sorted(test_set)
|
||||
if core_changed(files) or _esp32_platformio_path_or_file_trigger(files):
|
||||
return sorted(ESP32_PLATFORMIO_TEST_COMPONENTS)
|
||||
|
||||
return sorted(test_set & _cached_components_closure(tuple(files)))
|
||||
component_files = [f for f in files if filter_component_and_test_files(f)]
|
||||
changed = get_components_with_dependencies(component_files, True)
|
||||
|
||||
return sorted(ESP32_PLATFORMIO_TEST_COMPONENTS & set(changed))
|
||||
|
||||
|
||||
def should_run_esp32_platformio(branch: str | None = None) -> bool:
|
||||
@@ -658,83 +628,6 @@ def should_run_esp32_platformio(branch: str | None = None) -> bool:
|
||||
return bool(esp32_platformio_components_to_test(branch))
|
||||
|
||||
|
||||
# The `--toolchain arduino` smoke-test set: covers the core, the bundled and
|
||||
# converted registry libraries, and the waveform path.
|
||||
ESP8266_NATIVE_TEST_COMPONENTS = frozenset(
|
||||
{
|
||||
"esp8266",
|
||||
"api",
|
||||
"web_server",
|
||||
"captive_portal",
|
||||
"mqtt",
|
||||
"esp8266_pwm",
|
||||
"neopixelbus",
|
||||
"bme280_i2c",
|
||||
"uart",
|
||||
}
|
||||
)
|
||||
|
||||
# Infrastructure whose changes always trigger the native ESP8266 compile
|
||||
# test. esphome/build_helpers/ holds the idedata and size-summary helpers
|
||||
# the backend shares with the native ESP-IDF build.
|
||||
ESP8266_NATIVE_TRIGGER_PATH_PREFIXES = (
|
||||
"esphome/arduino8266/",
|
||||
"esphome/arduino/",
|
||||
"esphome/build_helpers/",
|
||||
)
|
||||
# Shared library-conversion modules every native build imports; espidf-only
|
||||
# infra (build_gen/espidf.py) deliberately stays out of the esp8266 set.
|
||||
_NATIVE_SHARED_TRIGGER_FILES = frozenset(
|
||||
{
|
||||
"esphome/framework_helpers.py",
|
||||
"esphome/platformio/library.py",
|
||||
"esphome/platformio/extra_script.py",
|
||||
}
|
||||
)
|
||||
# Tripwire: the shared modules must stay in the ESP-IDF trigger set too
|
||||
# (now defined in clang_tidy_hash), or its smoke test silently skips them
|
||||
assert _NATIVE_SHARED_TRIGGER_FILES <= ESP_IDF_INFRA_TRIGGER_FILES
|
||||
ESP8266_NATIVE_TRIGGER_FILES = (
|
||||
_NATIVE_SHARED_TRIGGER_FILES
|
||||
| _SMOKE_HARNESS_TRIGGER_FILES
|
||||
| {
|
||||
"esphome/build_gen/arduino8266.py",
|
||||
"esphome/build_gen/build_tool.py",
|
||||
"esphome/components/esp8266/build_surgery.py",
|
||||
"esphome/components/esp8266/boards.py",
|
||||
"esphome/platformio/registry.py",
|
||||
# esp8266/__init__.py imports copy_ccache_script from it
|
||||
"esphome/platformio/toolchain.py",
|
||||
".github/actions/cache-arduino8266/action.yml",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _esp8266_native_path_or_file_trigger(files: list[str]) -> bool:
|
||||
"""Whether any changed file is native-ESP8266 infrastructure / harness."""
|
||||
# base_python_changed covers the top-level esphome/*.py modules the
|
||||
# native backend imports directly (framework_helpers, helpers, writer,
|
||||
# __main__); without it a change there would silently skip this job.
|
||||
# base_python_changed is deliberately broad (any top-level esphome/*.py)
|
||||
# as belt-and-braces while the backend is new; narrow it to the modules
|
||||
# the backend imports once the toolchain has soaked a few releases
|
||||
return base_python_changed(files) or _path_or_file_trigger(
|
||||
files, ESP8266_NATIVE_TRIGGER_FILES, ESP8266_NATIVE_TRIGGER_PATH_PREFIXES
|
||||
)
|
||||
|
||||
|
||||
def esp8266_native_components_to_test(branch: str | None = None) -> list[str]:
|
||||
"""Subset of ``ESP8266_NATIVE_TEST_COMPONENTS`` the job needs to compile.
|
||||
|
||||
Same narrowing logic as ``esp32_platformio_components_to_test``: the full
|
||||
list on core or infrastructure changes, otherwise the intersection with
|
||||
the changed-component dependency closure (empty list skips the job).
|
||||
"""
|
||||
return _toolchain_components_to_test(
|
||||
branch, ESP8266_NATIVE_TEST_COMPONENTS, _esp8266_native_path_or_file_trigger
|
||||
)
|
||||
|
||||
|
||||
def determine_cpp_unit_tests(
|
||||
branch: str | None = None,
|
||||
) -> tuple[bool, list[str]]:
|
||||
@@ -1333,8 +1226,6 @@ def main() -> None:
|
||||
run_device_builder = True
|
||||
esp32_platformio_components = sorted(ESP32_PLATFORMIO_TEST_COMPONENTS)
|
||||
run_esp32_platformio = True
|
||||
esp8266_native_components = sorted(ESP8266_NATIVE_TEST_COMPONENTS)
|
||||
run_esp8266_native = True
|
||||
else:
|
||||
integration_run_all, integration_test_files = determine_integration_tests(
|
||||
args.branch
|
||||
@@ -1346,8 +1237,6 @@ def main() -> None:
|
||||
run_device_builder = should_run_device_builder(args.branch)
|
||||
esp32_platformio_components = esp32_platformio_components_to_test(args.branch)
|
||||
run_esp32_platformio = bool(esp32_platformio_components)
|
||||
esp8266_native_components = esp8266_native_components_to_test(args.branch)
|
||||
run_esp8266_native = bool(esp8266_native_components)
|
||||
run_integration, integration_test_buckets = _compute_integration_test_buckets(
|
||||
integration_run_all, integration_test_files
|
||||
)
|
||||
@@ -1543,8 +1432,6 @@ def main() -> None:
|
||||
"device_builder": run_device_builder,
|
||||
"esp32_platformio": run_esp32_platformio,
|
||||
"esp32_platformio_components": ",".join(esp32_platformio_components),
|
||||
"esp8266_native": run_esp8266_native,
|
||||
"esp8266_native_components": ",".join(esp8266_native_components),
|
||||
"changed_components": changed_components,
|
||||
"changed_components_with_tests": changed_components_with_tests,
|
||||
"directly_changed_components_with_tests": list(directly_changed_with_tests),
|
||||
|
||||
@@ -1104,6 +1104,10 @@ def get_components_per_integration_fixture() -> dict[str, set[str]]:
|
||||
|
||||
|
||||
_TEST_FUNC_RE = re.compile(r"async def (test_\w+)")
|
||||
# Any usage form (decorator, pytestmark assignment or list element); only
|
||||
# test_*.py files are scanned, so the marker docs elsewhere cannot false-hit
|
||||
_SHARED_YAML_USE_RE = re.compile(r"\bmark\.shared_yaml")
|
||||
_SHARED_YAML_ARG_RE = re.compile(r"\(\s*[\"'](\w+)[\"']\s*\)")
|
||||
|
||||
|
||||
@cache
|
||||
@@ -1123,6 +1127,19 @@ def get_fixture_to_test_files() -> dict[str, frozenset[str]]:
|
||||
for func in _TEST_FUNC_RE.findall(content):
|
||||
base_name = func.replace("test_", "").partition("[")[0]
|
||||
result.setdefault(base_name, set()).add(rel_path)
|
||||
# Shared fixtures are named by marker, not by a test function; each
|
||||
# decorator must carry a string literal or its fixture would silently
|
||||
# map to no tests
|
||||
for use in _SHARED_YAML_USE_RE.finditer(content):
|
||||
arg = _SHARED_YAML_ARG_RE.match(content, use.end())
|
||||
if arg is None:
|
||||
line = content.count("\n", 0, use.start()) + 1
|
||||
raise ValueError(
|
||||
f"{rel_path}:{line}: shared_yaml marker must take a "
|
||||
"single-line string literal so CI test selection can map "
|
||||
"its fixture"
|
||||
)
|
||||
result.setdefault(arg.group(1), set()).add(rel_path)
|
||||
|
||||
return {k: frozenset(v) for k, v in result.items()}
|
||||
|
||||
|
||||
@@ -1027,7 +1027,6 @@ def test_components(
|
||||
isolated_components: set[str] | None = None,
|
||||
base_only: bool = False,
|
||||
toolchain: str | None = None,
|
||||
fail_on_no_tests: bool = False,
|
||||
) -> int:
|
||||
"""Test components with optional intelligent grouping.
|
||||
|
||||
@@ -1062,34 +1061,20 @@ def test_components(
|
||||
# toolchain build.
|
||||
include_validate = esphome_command != "compile"
|
||||
|
||||
# Find all component tests; remember which components each pattern
|
||||
# (wildcards included) matched, for the deferred no-tests accounting
|
||||
# Find all component tests
|
||||
all_tests = {}
|
||||
pattern_components: dict[str, set[str]] = {}
|
||||
for pattern in component_patterns:
|
||||
# Skip empty patterns (happens when components list is empty string)
|
||||
if not pattern:
|
||||
continue
|
||||
found = find_component_tests(
|
||||
tests_dir, pattern, base_only, include_validate=include_validate
|
||||
all_tests.update(
|
||||
find_component_tests(
|
||||
tests_dir, pattern, base_only, include_validate=include_validate
|
||||
)
|
||||
)
|
||||
pattern_components[pattern] = set(found)
|
||||
all_tests.update(found)
|
||||
|
||||
# The flag's contract is "no test matched fails": a fully blank pattern
|
||||
# list would otherwise slide into the reference-baseline fallback and
|
||||
# exit green while building nothing a caller asked for
|
||||
if fail_on_no_tests and not any(component_patterns):
|
||||
print("No components requested (blank component list)")
|
||||
return 1
|
||||
|
||||
if fail_on_no_tests and not all_tests:
|
||||
# Nothing matched at all: fail before the synthetic baseline build,
|
||||
# which would spend a compile reporting success on nothing. Partial
|
||||
# matches defer to the per-pattern accounting after the summary.
|
||||
print(f"No components found matching: {component_patterns}")
|
||||
return 1
|
||||
|
||||
# If no components found, build a reference configuration for baseline comparison
|
||||
# Create a synthetic "empty" component test that will build just the base config
|
||||
if not all_tests:
|
||||
print(f"No components found matching: {component_patterns}")
|
||||
print(
|
||||
@@ -1193,26 +1178,6 @@ def test_components(
|
||||
toolchain=toolchain,
|
||||
)
|
||||
|
||||
silent: list[str] = []
|
||||
if fail_on_no_tests:
|
||||
# A green run that built nothing for a requested pattern (renamed
|
||||
# fixture, missing base file, version-suffix mismatch, a wildcard
|
||||
# matching no component) must not pass CI. Per pattern: an
|
||||
# all-or-nothing check would let one silent pattern hide behind the
|
||||
# others. Opt-in: some legs (the esp32-ard smoke subset)
|
||||
# legitimately match nothing. Failing is deferred past the summary
|
||||
# so a real failure's reproduce commands still print.
|
||||
built = {c for r in test_results for c in r.components}
|
||||
# A pattern is silent when it matched no fixture, or when none of
|
||||
# its matched components produced a build (wildcards included)
|
||||
silent = [
|
||||
p
|
||||
for p in component_patterns
|
||||
if p and not (pattern_components.get(p, set()) & built)
|
||||
]
|
||||
if silent:
|
||||
print(f"No tests ran for requested pattern(s): {', '.join(silent)}")
|
||||
|
||||
# Separate results into passed and failed
|
||||
passed_results = [r for r in test_results if r.success]
|
||||
failed_results = [r for r in test_results if not r.success]
|
||||
@@ -1244,7 +1209,7 @@ def test_components(
|
||||
if os.environ.get("GITHUB_STEP_SUMMARY"):
|
||||
write_github_summary(test_results, toolchain=toolchain)
|
||||
|
||||
if failed_results or silent:
|
||||
if failed_results:
|
||||
return 1
|
||||
|
||||
return 0
|
||||
@@ -1299,12 +1264,6 @@ def main() -> int:
|
||||
"--toolchain",
|
||||
help="Select toolchain for compiling.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--fail-on-no-tests",
|
||||
action="store_true",
|
||||
help="Exit non-zero when no test matched (for CI legs whose "
|
||||
"components must all have fixtures)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
@@ -1323,7 +1282,6 @@ def main() -> int:
|
||||
continue_on_fail=args.continue_on_fail,
|
||||
enable_grouping=not args.no_grouping,
|
||||
isolated_components=isolated_components,
|
||||
fail_on_no_tests=args.fail_on_no_tests,
|
||||
base_only=args.base_only,
|
||||
toolchain=args.toolchain,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <cstring>
|
||||
|
||||
#include <noise/protocol.h>
|
||||
|
||||
#include "esphome/components/noise/noise.h"
|
||||
#include "esphome/components/noise/noise_resume.h"
|
||||
|
||||
namespace esphome::noise::testing {
|
||||
|
||||
// Known-answer vectors shared with the client implementation
|
||||
// (aioesphomeapi tests/test_noise_resume.py); the two must stay identical
|
||||
// byte for byte or resumed sessions cannot interoperate.
|
||||
static const uint8_t KAT_SECRET[RESUME_SECRET_SIZE] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16,
|
||||
17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32};
|
||||
static const uint8_t KAT_SESSION_ID[RESUME_SESSION_ID_SIZE] = {0xa0, 0xa1, 0xa2, 0xa3, 0xa4, 0xa5, 0xa6, 0xa7};
|
||||
static const uint8_t KAT_CLIENT_NONCE[RESUME_NONCE_SIZE] = {0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17,
|
||||
0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f};
|
||||
static const uint8_t KAT_SERVER_NONCE[RESUME_NONCE_SIZE] = {0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37,
|
||||
0x38, 0x39, 0x3a, 0x3b, 0x3c, 0x3d, 0x3e, 0x3f};
|
||||
static const uint8_t KAT_OFFER_MAC[RESUME_MAC_SIZE] = {0xa8, 0x08, 0xea, 0xdb, 0xec, 0x81, 0xa7, 0xcb,
|
||||
0xf4, 0xca, 0xaa, 0xb8, 0x0d, 0x7f, 0x9d, 0x01};
|
||||
static const uint8_t KAT_CONFIRM_MAC[RESUME_MAC_SIZE] = {0x09, 0xa3, 0x70, 0x3e, 0xc8, 0x34, 0x77, 0xe9,
|
||||
0x45, 0xe7, 0xf1, 0x61, 0x9d, 0x4f, 0x6a, 0x76};
|
||||
static const uint8_t KAT_K_C2D[32] = {0xd6, 0x01, 0xe3, 0xc1, 0x16, 0xa1, 0x64, 0x66, 0xdb, 0xc5, 0x9e,
|
||||
0xdd, 0x60, 0x2a, 0x64, 0x1e, 0xbe, 0xf5, 0x11, 0x95, 0x98, 0xd2,
|
||||
0xf2, 0x47, 0x1b, 0xc6, 0x8c, 0x51, 0x8f, 0xbe, 0xb7, 0x23};
|
||||
static const uint8_t KAT_K_D2C[32] = {0x7f, 0x8d, 0x57, 0x7e, 0x9f, 0xb4, 0xbb, 0xde, 0x86, 0xcd, 0xa9,
|
||||
0xf4, 0x9b, 0x42, 0xe7, 0x24, 0xc8, 0x49, 0xce, 0x89, 0xd8, 0x96,
|
||||
0x3f, 0x3c, 0x4b, 0x3f, 0x8f, 0x80, 0xc2, 0x56, 0xab, 0x65};
|
||||
|
||||
/// The one place in this file that spells the offer wire layout
|
||||
static void build_offer(uint8_t *offer, const uint8_t *session_id, const uint8_t *client_nonce, const uint8_t *mac) {
|
||||
offer[0] = RESUME_OFFER_VERSION;
|
||||
std::memcpy(offer + RESUME_OFFER_SESSION_ID_OFFSET, session_id, RESUME_SESSION_ID_SIZE);
|
||||
std::memcpy(offer + RESUME_OFFER_NONCE_OFFSET, client_nonce, RESUME_NONCE_SIZE);
|
||||
std::memcpy(offer + RESUME_OFFER_MAC_OFFSET, mac, RESUME_MAC_SIZE);
|
||||
}
|
||||
|
||||
/// "NoiseAPIInit" || be16(len) || offer, exactly as the api frame helper mixes it
|
||||
static constexpr size_t KAT_PROLOGUE_SIZE = 12 + 2 + RESUME_OFFER_SIZE;
|
||||
static void build_prologue(uint8_t *out, const uint8_t *offer) {
|
||||
std::memcpy(out, "NoiseAPIInit", 12); // NOLINT(bugprone-not-null-terminated-result)
|
||||
out[12] = 0x00;
|
||||
out[13] = RESUME_OFFER_SIZE;
|
||||
std::memcpy(out + 14, offer, RESUME_OFFER_SIZE);
|
||||
}
|
||||
|
||||
static void build_offer_for_ticket(uint8_t *offer, const ResumeTicket &ticket, const uint8_t *client_nonce) {
|
||||
uint8_t mac[RESUME_MAC_SIZE];
|
||||
ASSERT_TRUE(resume_compute_offer_mac(ticket.secret, ticket.session_id, client_nonce, mac));
|
||||
build_offer(offer, ticket.session_id, client_nonce, mac);
|
||||
}
|
||||
|
||||
/// Test access to the protected slots so a test can plant the KAT ticket
|
||||
struct TestCache : ResumeTicketCache {
|
||||
void plant(const uint8_t *session_id, const uint8_t *secret) {
|
||||
std::memcpy(this->slots_[0].session_id, session_id, RESUME_SESSION_ID_SIZE);
|
||||
std::memcpy(this->slots_[0].secret, secret, RESUME_SECRET_SIZE);
|
||||
this->used_mask_ |= 1u;
|
||||
}
|
||||
};
|
||||
|
||||
TEST(NoiseResumeKat, ConfirmMacMatchesClientImplementation) {
|
||||
uint8_t mac[RESUME_MAC_SIZE];
|
||||
ASSERT_TRUE(resume_compute_confirm_mac(KAT_SECRET, KAT_CLIENT_NONCE, KAT_SERVER_NONCE, mac));
|
||||
EXPECT_EQ(std::memcmp(mac, KAT_CONFIRM_MAC, RESUME_MAC_SIZE), 0);
|
||||
}
|
||||
|
||||
TEST(NoiseResumeKat, OfferMacMatchesClientImplementation) {
|
||||
uint8_t mac[RESUME_MAC_SIZE];
|
||||
ASSERT_TRUE(resume_compute_offer_mac(KAT_SECRET, KAT_SESSION_ID, KAT_CLIENT_NONCE, mac));
|
||||
EXPECT_EQ(std::memcmp(mac, KAT_OFFER_MAC, RESUME_MAC_SIZE), 0);
|
||||
}
|
||||
|
||||
TEST(NoiseResumeKat, KeyDerivationMatchesClientImplementation) {
|
||||
// Prologue used by the shared vectors: "NoiseAPIInit" + be16(41) + a
|
||||
// 41-byte offer whose MAC field is 16 bytes of 0xEE
|
||||
uint8_t mac_filler[RESUME_MAC_SIZE];
|
||||
std::memset(mac_filler, 0xEE, sizeof(mac_filler));
|
||||
uint8_t offer[RESUME_OFFER_SIZE];
|
||||
build_offer(offer, KAT_SESSION_ID, KAT_CLIENT_NONCE, mac_filler);
|
||||
uint8_t prologue[KAT_PROLOGUE_SIZE];
|
||||
build_prologue(prologue, offer);
|
||||
|
||||
uint8_t k_c2d[32], k_d2c[32];
|
||||
ASSERT_TRUE(
|
||||
resume_derive_keys(KAT_SECRET, KAT_CLIENT_NONCE, KAT_SERVER_NONCE, prologue, sizeof(prologue), k_c2d, k_d2c));
|
||||
EXPECT_EQ(std::memcmp(k_c2d, KAT_K_C2D, 32), 0);
|
||||
EXPECT_EQ(std::memcmp(k_d2c, KAT_K_D2C, 32), 0);
|
||||
}
|
||||
|
||||
TEST(NoiseResumeCache, TryAcceptConsumesTicketOnceAndProvesPossession) {
|
||||
TestCache cache;
|
||||
cache.plant(KAT_SESSION_ID, KAT_SECRET);
|
||||
|
||||
uint8_t offer[RESUME_OFFER_SIZE];
|
||||
build_offer(offer, KAT_SESSION_ID, KAT_CLIENT_NONCE, KAT_OFFER_MAC);
|
||||
uint8_t prologue[KAT_PROLOGUE_SIZE];
|
||||
build_prologue(prologue, offer);
|
||||
|
||||
uint8_t ext[RESUME_ACCEPT_SIZE];
|
||||
NoiseCipherState *send = nullptr, *recv = nullptr;
|
||||
ASSERT_EQ(cache.try_accept(offer, sizeof(offer), prologue, sizeof(prologue), ext, sizeof(ext), send, recv),
|
||||
RESUME_ACCEPT_SIZE);
|
||||
ASSERT_NE(send, nullptr);
|
||||
ASSERT_NE(recv, nullptr);
|
||||
|
||||
// The extension proves possession: verify like the client does
|
||||
EXPECT_EQ(ext[0], RESUME_ACCEPT_VERSION);
|
||||
const uint8_t *server_nonce = ext + 1;
|
||||
uint8_t expected_confirm[RESUME_MAC_SIZE];
|
||||
ASSERT_TRUE(resume_compute_confirm_mac(KAT_SECRET, KAT_CLIENT_NONCE, server_nonce, expected_confirm));
|
||||
EXPECT_EQ(std::memcmp(ext + 1 + RESUME_NONCE_SIZE, expected_confirm, RESUME_MAC_SIZE), 0);
|
||||
|
||||
// The ciphers must interoperate with the documented key derivation
|
||||
uint8_t k_c2d[32], k_d2c[32];
|
||||
ASSERT_TRUE(resume_derive_keys(KAT_SECRET, KAT_CLIENT_NONCE, server_nonce, prologue, sizeof(prologue), k_c2d, k_d2c));
|
||||
NoiseCipherState *client_send = resume_make_cipher(k_c2d);
|
||||
ASSERT_NE(client_send, nullptr);
|
||||
uint8_t buf[64] = "resumed";
|
||||
NoiseBuffer nb;
|
||||
noise_buffer_init(nb);
|
||||
noise_buffer_set_inout(nb, buf, 7, sizeof(buf));
|
||||
ASSERT_EQ(noise_cipherstate_encrypt(client_send, &nb), NOISE_ERROR_NONE);
|
||||
ASSERT_EQ(noise_cipherstate_decrypt(recv, &nb), NOISE_ERROR_NONE);
|
||||
EXPECT_EQ(std::memcmp(buf, "resumed", 7), 0);
|
||||
noise_cipherstate_free(client_send);
|
||||
noise_cipherstate_free(send);
|
||||
noise_cipherstate_free(recv);
|
||||
|
||||
// Single use: the same offer must miss the second time
|
||||
NoiseCipherState *send2 = nullptr, *recv2 = nullptr;
|
||||
EXPECT_EQ(cache.try_accept(offer, sizeof(offer), prologue, sizeof(prologue), ext, sizeof(ext), send2, recv2), 0u);
|
||||
EXPECT_EQ(send2, nullptr);
|
||||
EXPECT_EQ(recv2, nullptr);
|
||||
}
|
||||
|
||||
TEST(NoiseResumeCache, BadMacOrMalformedOfferLeavesTicketIntact) {
|
||||
TestCache cache;
|
||||
cache.plant(KAT_SESSION_ID, KAT_SECRET);
|
||||
|
||||
uint8_t offer[RESUME_OFFER_SIZE];
|
||||
uint8_t bad_mac[RESUME_MAC_SIZE];
|
||||
std::memcpy(bad_mac, KAT_OFFER_MAC, RESUME_MAC_SIZE);
|
||||
bad_mac[0] ^= 0x01;
|
||||
build_offer(offer, KAT_SESSION_ID, KAT_CLIENT_NONCE, bad_mac);
|
||||
|
||||
uint8_t prologue[1] = {0};
|
||||
uint8_t ext[RESUME_ACCEPT_SIZE];
|
||||
NoiseCipherState *send = nullptr, *recv = nullptr;
|
||||
// A forged offer must not burn the ticket
|
||||
EXPECT_EQ(cache.try_accept(offer, sizeof(offer), prologue, sizeof(prologue), ext, sizeof(ext), send, recv), 0u);
|
||||
// Wrong size or version must be recognized as "no offer"
|
||||
build_offer(offer, KAT_SESSION_ID, KAT_CLIENT_NONCE, KAT_OFFER_MAC);
|
||||
EXPECT_EQ(cache.try_accept(offer, sizeof(offer) - 1, prologue, sizeof(prologue), ext, sizeof(ext), send, recv), 0u);
|
||||
offer[0] = 0x7f;
|
||||
EXPECT_EQ(cache.try_accept(offer, sizeof(offer), prologue, sizeof(prologue), ext, sizeof(ext), send, recv), 0u);
|
||||
offer[0] = RESUME_OFFER_VERSION;
|
||||
// No room for the extension must also decline without burning it
|
||||
EXPECT_EQ(cache.try_accept(offer, sizeof(offer), prologue, sizeof(prologue), ext, sizeof(ext) - 1, send, recv), 0u);
|
||||
// The genuine offer still redeems
|
||||
EXPECT_EQ(cache.try_accept(offer, sizeof(offer), prologue, sizeof(prologue), ext, sizeof(ext), send, recv),
|
||||
RESUME_ACCEPT_SIZE);
|
||||
noise_cipherstate_free(send);
|
||||
noise_cipherstate_free(recv);
|
||||
}
|
||||
|
||||
TEST(NoiseResumeCache, SetPskForgetsTickets) {
|
||||
NoiseContext ctx;
|
||||
ResumeTicket ticket;
|
||||
ASSERT_TRUE(ctx.resume_cache().issue(ticket));
|
||||
|
||||
psk_t psk{};
|
||||
psk[0] = 1;
|
||||
ctx.set_psk(psk.data());
|
||||
|
||||
uint8_t offer[RESUME_OFFER_SIZE];
|
||||
build_offer_for_ticket(offer, ticket, KAT_CLIENT_NONCE);
|
||||
uint8_t prologue[KAT_PROLOGUE_SIZE];
|
||||
build_prologue(prologue, offer);
|
||||
uint8_t ext[RESUME_ACCEPT_SIZE];
|
||||
NoiseCipherState *send = nullptr, *recv = nullptr;
|
||||
EXPECT_EQ(
|
||||
ctx.resume_cache().try_accept(offer, sizeof(offer), prologue, sizeof(prologue), ext, sizeof(ext), send, recv),
|
||||
0u);
|
||||
EXPECT_EQ(send, nullptr);
|
||||
EXPECT_EQ(recv, nullptr);
|
||||
}
|
||||
|
||||
TEST(NoiseResumeCache, IssueRotatesSlotsAndClearForgetsAll) {
|
||||
ResumeTicketCache cache;
|
||||
ResumeTicket tickets[ResumeTicketCache::SLOTS + 1];
|
||||
for (auto &ticket : tickets) {
|
||||
ASSERT_TRUE(cache.issue(ticket));
|
||||
}
|
||||
uint8_t offer[RESUME_OFFER_SIZE];
|
||||
uint8_t prologue[1] = {0};
|
||||
uint8_t ext[RESUME_ACCEPT_SIZE];
|
||||
|
||||
// The oldest ticket was evicted by the one-past-capacity issue
|
||||
build_offer_for_ticket(offer, tickets[0], KAT_CLIENT_NONCE);
|
||||
NoiseCipherState *send = nullptr, *recv = nullptr;
|
||||
EXPECT_EQ(cache.try_accept(offer, sizeof(offer), prologue, sizeof(prologue), ext, sizeof(ext), send, recv), 0u);
|
||||
// The rest remain redeemable
|
||||
for (int i = 1; i <= ResumeTicketCache::SLOTS; i++) {
|
||||
build_offer_for_ticket(offer, tickets[i], KAT_CLIENT_NONCE);
|
||||
EXPECT_EQ(cache.try_accept(offer, sizeof(offer), prologue, sizeof(prologue), ext, sizeof(ext), send, recv),
|
||||
RESUME_ACCEPT_SIZE);
|
||||
noise_cipherstate_free(send);
|
||||
noise_cipherstate_free(recv);
|
||||
send = recv = nullptr;
|
||||
}
|
||||
|
||||
// clear() forgets everything
|
||||
ResumeTicket ticket;
|
||||
ASSERT_TRUE(cache.issue(ticket));
|
||||
cache.clear();
|
||||
build_offer_for_ticket(offer, ticket, KAT_CLIENT_NONCE);
|
||||
EXPECT_EQ(cache.try_accept(offer, sizeof(offer), prologue, sizeof(prologue), ext, sizeof(ext), send, recv), 0u);
|
||||
}
|
||||
|
||||
} // namespace esphome::noise::testing
|
||||
@@ -21,6 +21,13 @@ The `yaml_config` fixture automatically loads YAML configurations based on the t
|
||||
- The fixture file must exist or the test will fail with a clear error message
|
||||
- The fixture automatically injects a dynamic port number into the API configuration
|
||||
|
||||
Tests marked `@pytest.mark.shared_yaml("name")` load `fixtures/name.yaml` instead
|
||||
of the test-named file and compile it in a shared, hash-keyed build directory, so
|
||||
the whole group pays one full compile and each test only a relink. The marker
|
||||
argument must be a single-line string literal (CI test selection maps fixtures to
|
||||
test files by scanning for it), and marked tests must hand the `yaml_config`
|
||||
content to `run_compiled` unmodified.
|
||||
|
||||
### Key Fixtures
|
||||
|
||||
- `run_compiled` - Combines write, compile, and run operations into a single context manager
|
||||
|
||||
+335
-81
@@ -4,17 +4,22 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncGenerator, Callable, Generator
|
||||
from contextlib import AbstractAsyncContextManager, asynccontextmanager
|
||||
from contextlib import AbstractAsyncContextManager, asynccontextmanager, suppress
|
||||
import fcntl
|
||||
from functools import cache
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from typing import TextIO
|
||||
|
||||
from aioesphomeapi import APIClient, APIConnectionError, LogParser, ReconnectLogic
|
||||
@@ -23,7 +28,13 @@ import pytest_asyncio
|
||||
|
||||
import esphome.config
|
||||
from esphome.core import CORE
|
||||
from esphome.helpers import get_usable_cpu_count
|
||||
from esphome.helpers import (
|
||||
get_usable_cpu_count,
|
||||
read_file,
|
||||
rmtree,
|
||||
write_file,
|
||||
write_file_if_changed,
|
||||
)
|
||||
from esphome.platformio.toolchain import get_idedata
|
||||
|
||||
from .const import (
|
||||
@@ -56,6 +67,21 @@ import pty # not available on Windows
|
||||
pytest.register_assert_rewrite("tests.integration.entity_utils")
|
||||
|
||||
|
||||
def pytest_configure(config: pytest.Config) -> None:
|
||||
config.addinivalue_line(
|
||||
"markers",
|
||||
"shared_yaml(name): load fixtures/<name>.yaml and compile it in a shared, "
|
||||
"hash-keyed incremental build directory",
|
||||
)
|
||||
|
||||
|
||||
FIXTURES_DIR = Path(__file__).parent / "fixtures"
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
|
||||
# CI caches parts of this path; keep in sync with ci.yml integration-tests.
|
||||
INTEGRATION_TESTS_ROOT = Path.home() / ".esphome-integration-tests"
|
||||
|
||||
|
||||
def _get_platformio_env(cache_dir: Path) -> dict[str, str]:
|
||||
"""Get environment variables for PlatformIO with shared cache."""
|
||||
env = os.environ.copy()
|
||||
@@ -78,7 +104,7 @@ def _get_platformio_env(cache_dir: Path) -> dict[str, str]:
|
||||
)
|
||||
# Compile with THIS tree's esphome sources, not wherever the venv's editable
|
||||
# install points (which may be a different git worktree or checkout).
|
||||
repo_root = str(Path(__file__).resolve().parent.parent.parent)
|
||||
repo_root = str(REPO_ROOT)
|
||||
existing = env.get("PYTHONPATH")
|
||||
env["PYTHONPATH"] = f"{repo_root}{os.pathsep}{existing}" if existing else repo_root
|
||||
return env
|
||||
@@ -88,8 +114,7 @@ def _get_platformio_env(cache_dir: Path) -> dict[str, str]:
|
||||
def shared_platformio_cache() -> Generator[Path]:
|
||||
"""Initialize a shared PlatformIO cache for all integration tests."""
|
||||
# Use a dedicated directory for integration tests to avoid conflicts.
|
||||
# CI caches parts of this path; keep in sync with ci.yml integration-tests.
|
||||
test_cache_dir = Path.home() / ".esphome-integration-tests"
|
||||
test_cache_dir = INTEGRATION_TESTS_ROOT
|
||||
cache_dir = test_cache_dir / "platformio"
|
||||
|
||||
# Use a lock file in the home directory to ensure only one process initializes the cache
|
||||
@@ -112,7 +137,9 @@ def shared_platformio_cache() -> Generator[Path]:
|
||||
init_dir = Path(tmpdir)
|
||||
fixture_path = Path(__file__).parent / "fixtures" / "cache_init.yaml"
|
||||
config_path = init_dir / "cache_init.yaml"
|
||||
config_path.write_text(fixture_path.read_text())
|
||||
config_path.write_text(
|
||||
fixture_path.read_text(encoding="utf-8"), encoding="utf-8"
|
||||
)
|
||||
|
||||
# Run compilation to populate the cache
|
||||
# We must succeed here to avoid race conditions where multiple
|
||||
@@ -162,13 +189,6 @@ def integration_test_dir() -> Generator[Path]:
|
||||
yield Path(tmpdir)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def isolated_preferences(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
"""Host preferences persist per device name; give the test its own so a
|
||||
provisioned key never leaks into another run."""
|
||||
monkeypatch.setenv("ESPHOME_PREFDIR", str(tmp_path / "prefs"))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def reserved_tcp_port() -> Generator[tuple[int, socket.socket]]:
|
||||
"""Reserve an unused TCP port by holding the socket open."""
|
||||
@@ -188,21 +208,29 @@ def unused_tcp_port(reserved_tcp_port: tuple[int, socket.socket]) -> int:
|
||||
return reserved_tcp_port[0]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolated_preferences(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path:
|
||||
"""Give every test its own host prefs dir; prefs are keyed only by device
|
||||
name, which tests sharing a fixture also share."""
|
||||
prefdir = tmp_path / "prefs"
|
||||
monkeypatch.setenv("ESPHOME_PREFDIR", str(prefdir))
|
||||
return prefdir
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def yaml_config(request: pytest.FixtureRequest, unused_tcp_port: int) -> str:
|
||||
"""Load YAML configuration based on test name."""
|
||||
# Get the test function name
|
||||
test_name: str = request.node.name
|
||||
# Extract the base test name (remove test_ prefix and any parametrization)
|
||||
base_name = test_name.replace("test_", "").partition("[")[0]
|
||||
shared_name = _shared_yaml_name(request)
|
||||
# Base test name: test_ prefix and any parametrization stripped
|
||||
base_name = shared_name or request.node.name.replace("test_", "").partition("[")[0]
|
||||
|
||||
# Load the fixture file
|
||||
fixture_path = Path(__file__).parent / "fixtures" / f"{base_name}.yaml"
|
||||
fixture_path = FIXTURES_DIR / f"{base_name}.yaml"
|
||||
if not fixture_path.exists():
|
||||
raise FileNotFoundError(f"Fixture file not found: {fixture_path}")
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
content = await loop.run_in_executor(None, fixture_path.read_text)
|
||||
content = await loop.run_in_executor(None, read_file, fixture_path)
|
||||
|
||||
# Replace the port in the config if it contains api section
|
||||
if "api:" in content:
|
||||
@@ -226,11 +254,13 @@ async def yaml_config(request: pytest.FixtureRequest, unused_tcp_port: int) -> s
|
||||
|
||||
# Replace external component path placeholder if present
|
||||
if "EXTERNAL_COMPONENT_PATH" in content:
|
||||
external_components_path = str(
|
||||
Path(__file__).parent / "fixtures" / "external_components"
|
||||
)
|
||||
external_components_path = str(FIXTURES_DIR / "external_components")
|
||||
content = content.replace("EXTERNAL_COMPONENT_PATH", external_components_path)
|
||||
|
||||
if shared_name is not None:
|
||||
# _compile verifies the marked test compiles this content unmodified
|
||||
request.node._shared_yaml_content = content
|
||||
|
||||
return content
|
||||
|
||||
|
||||
@@ -240,24 +270,218 @@ async def write_yaml_config(
|
||||
) -> AsyncGenerator[ConfigWriter]:
|
||||
"""Write YAML configuration to a file."""
|
||||
# Get the test name for default filename
|
||||
test_name = request.node.name
|
||||
base_name = test_name.replace("test_", "").split("[")[0]
|
||||
base_name = request.node.name.replace("test_", "").partition("[")[0]
|
||||
|
||||
async def _write_config(content: str, filename: str | None = None) -> Path:
|
||||
if filename is None:
|
||||
filename = f"{base_name}.yaml"
|
||||
config_path = integration_test_dir / filename
|
||||
loop = asyncio.get_running_loop()
|
||||
await loop.run_in_executor(None, config_path.write_text, content)
|
||||
await loop.run_in_executor(None, write_file, config_path, content)
|
||||
return config_path
|
||||
|
||||
yield _write_config
|
||||
|
||||
|
||||
# Deliberately not CI-cached (ci.yml caches only platformio/ subpaths); stale
|
||||
# dirs for a fixture are pruned when its content hash changes.
|
||||
SHARED_BUILDS_ROOT = INTEGRATION_TESTS_ROOT / "builds"
|
||||
|
||||
# In the dir name (not just the hash) so pruning stays inside this checkout
|
||||
_REPO_KEY = hashlib.sha256(str(REPO_ROOT).encode()).hexdigest()[:8]
|
||||
|
||||
# Give a contended shared build lock time for a full cold compile ahead of us
|
||||
_SHARED_LOCK_TIMEOUT_S = 900
|
||||
_SHARED_LOCK_POLL_S = 0.1
|
||||
_SHARED_LOCK_REPORT_S = 30
|
||||
|
||||
# Reclaims dirs orphaned by fixture renames or deleted checkouts
|
||||
_STALE_BUILD_MAX_AGE_S = 30 * 24 * 3600
|
||||
|
||||
# ELF path per shared build dir; constant once compiled, so resolve it only once
|
||||
_shared_elf_paths: dict[Path, Path] = {}
|
||||
|
||||
# Dirs this process already swept; pruning is session-scoped work
|
||||
_pruned_dirs: set[Path] = set()
|
||||
|
||||
|
||||
def _shared_yaml_name(request: pytest.FixtureRequest) -> str | None:
|
||||
"""Name passed to the shared_yaml marker, or None when unmarked."""
|
||||
marker = request.node.get_closest_marker("shared_yaml")
|
||||
if marker is None:
|
||||
return None
|
||||
# Exactly one \w+ positional arg: the name doubles as a build dir
|
||||
# component, and CI test selection (script/helpers.py) parses the same shape
|
||||
if (
|
||||
len(marker.args) != 1
|
||||
or marker.kwargs
|
||||
or not re.fullmatch(r"\w+", str(marker.args[0]))
|
||||
):
|
||||
raise ValueError(
|
||||
"shared_yaml marker requires exactly one \\w+ fixture name literal"
|
||||
)
|
||||
return marker.args[0]
|
||||
|
||||
|
||||
def _shared_build_prefix(name: str) -> str:
|
||||
return f"{name}-{_REPO_KEY}-"
|
||||
|
||||
|
||||
@cache
|
||||
def _shared_build_dir(name: str) -> Path:
|
||||
"""Dir keyed by checkout and fixture source, before per-test injections."""
|
||||
key = hashlib.sha256((FIXTURES_DIR / f"{name}.yaml").read_bytes()).hexdigest()[:16]
|
||||
return SHARED_BUILDS_ROOT / (_shared_build_prefix(name) + key)
|
||||
|
||||
|
||||
def _read_stamp(stamp: Path, shared_dir: Path) -> Path | None:
|
||||
"""ELF path recorded by the last completed compile, or None."""
|
||||
try:
|
||||
text = stamp.read_text(encoding="utf-8").strip()
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except OSError as err:
|
||||
print(f"Cannot read {stamp}: {err}")
|
||||
return None
|
||||
if not text:
|
||||
print(f"Ignoring empty stamp {stamp}")
|
||||
return None
|
||||
built = Path(text)
|
||||
# Never trust a stamp pointing outside its own build dir as an unlink target
|
||||
if shared_dir.resolve() in built.resolve().parents:
|
||||
return built
|
||||
print(f"Ignoring stamp {stamp} pointing outside {shared_dir}")
|
||||
return None
|
||||
|
||||
|
||||
def _unused_since(stale: Path, cutoff: float) -> bool:
|
||||
"""Whether a build dir looks untouched since cutoff; unknown counts as used."""
|
||||
# Newest of the .built stamp (rewritten by every completed compile) and the
|
||||
# dir itself (freshened by a worker claiming the dir before locking)
|
||||
newest: float | None = None
|
||||
for probe in (stale / ".built", stale):
|
||||
try:
|
||||
mtime = probe.stat().st_mtime
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
except NotADirectoryError:
|
||||
return True # a stray file where a dir should be; reclaimable
|
||||
except OSError as err:
|
||||
print(f"Cannot age-probe {stale}: {err}")
|
||||
return False # unknown never authorizes deletion
|
||||
newest = mtime if newest is None else max(newest, mtime)
|
||||
return newest is not None and newest < cutoff
|
||||
|
||||
|
||||
def _prune_stale_builds(name: str, keep: Path) -> None:
|
||||
"""Remove outdated build dirs (blocking, run in executor): this checkout's
|
||||
other dirs for the fixture, plus anything untouched for 30 days. Tolerates
|
||||
other workers pruning the same dirs concurrently."""
|
||||
cutoff = time.time() - _STALE_BUILD_MAX_AGE_S
|
||||
prefix = _shared_build_prefix(name)
|
||||
for stale in SHARED_BUILDS_ROOT.iterdir():
|
||||
if stale == keep:
|
||||
continue
|
||||
same_fixture = stale.name.startswith(prefix)
|
||||
if not same_fixture and not _unused_since(stale, cutoff):
|
||||
continue
|
||||
# Creating .lock bumps the dir mtime, so remember whether the re-probe
|
||||
# under the lock can trust it
|
||||
lock_preexisting = (stale / ".lock").exists()
|
||||
try:
|
||||
lock_file = (stale / ".lock").open("w")
|
||||
except FileNotFoundError:
|
||||
continue # pruned by another worker meanwhile
|
||||
except NotADirectoryError:
|
||||
print(f"Removing stray file {stale}")
|
||||
stale.unlink(missing_ok=True)
|
||||
continue
|
||||
except OSError as err:
|
||||
print(f"Cannot prune {stale}: {err}")
|
||||
continue
|
||||
with lock_file:
|
||||
try:
|
||||
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError:
|
||||
continue # still in use by another run
|
||||
# Re-probe under the lock: a worker freshens its dir before
|
||||
# locking, so a just-claimed dir no longer looks unused. A dir
|
||||
# whose .lock we just created cannot be held by anyone, and our
|
||||
# own open bumped its mtime, so its pre-open probe stands
|
||||
if (
|
||||
lock_preexisting
|
||||
and not same_fixture
|
||||
and not _unused_since(stale, cutoff)
|
||||
):
|
||||
continue
|
||||
# rmtree tolerates races; a leftover partial tree only costs a
|
||||
# rebuild, since the ELF is deleted before every compile
|
||||
try:
|
||||
rmtree(stale)
|
||||
except OSError as err:
|
||||
print(f"Failed to prune {stale}: {err}")
|
||||
|
||||
|
||||
async def _run_esphome_compile(
|
||||
config_path: Path, cwd: Path, env: dict[str, str]
|
||||
) -> None:
|
||||
"""Run `esphome compile`, retrying up to 3 times on a segfault."""
|
||||
max_retries = 3
|
||||
for attempt in range(max_retries):
|
||||
# Compile using subprocess, inheriting stdout/stderr to show progress
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
sys.executable,
|
||||
"-m",
|
||||
"esphome",
|
||||
"compile",
|
||||
str(config_path),
|
||||
cwd=cwd,
|
||||
stdout=None, # Inherit stdout
|
||||
stderr=None, # Inherit stderr
|
||||
stdin=asyncio.subprocess.DEVNULL,
|
||||
# Start in a new process group to isolate signal handling
|
||||
start_new_session=True,
|
||||
env=env,
|
||||
close_fds=False,
|
||||
)
|
||||
await proc.wait()
|
||||
|
||||
if proc.returncode == 0:
|
||||
break
|
||||
if proc.returncode == -11 and attempt < max_retries - 1:
|
||||
# Segfault (-11 = SIGSEGV), retry
|
||||
print(
|
||||
f"Compilation segfaulted (attempt {attempt + 1}/{max_retries}), retrying..."
|
||||
)
|
||||
await asyncio.sleep(1) # Brief pause before retry
|
||||
continue
|
||||
raise RuntimeError(
|
||||
f"Failed to compile {config_path}, return code: {proc.returncode}. "
|
||||
f"Run with 'pytest -s' to see compilation output."
|
||||
)
|
||||
|
||||
|
||||
def _resolve_compiled_binary(config_path: Path) -> Path:
|
||||
"""Load the config to learn the compiled ELF path (blocking, run in executor)."""
|
||||
CORE.reset() # Reset CORE state between test runs
|
||||
CORE.config_path = config_path
|
||||
config = esphome.config.read_config(
|
||||
{"command": "compile", "config": str(config_path)}
|
||||
)
|
||||
if config is None:
|
||||
raise RuntimeError(f"Failed to read config from {config_path}")
|
||||
idedata = get_idedata(config)
|
||||
binary_path = Path(idedata.firmware_elf_path)
|
||||
if not binary_path.exists():
|
||||
raise RuntimeError(f"Compiled binary not found at {binary_path}")
|
||||
return binary_path
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def compile_esphome(
|
||||
integration_test_dir: Path,
|
||||
shared_platformio_cache: Path,
|
||||
request: pytest.FixtureRequest,
|
||||
) -> AsyncGenerator[CompileFunction]:
|
||||
"""Compile an ESPHome configuration and return the binary path."""
|
||||
|
||||
@@ -265,66 +489,96 @@ async def compile_esphome(
|
||||
# Use the shared PlatformIO cache for faster compilation
|
||||
# This avoids re-downloading dependencies for each test
|
||||
env = _get_platformio_env(shared_platformio_cache)
|
||||
|
||||
# Retry compilation up to 3 times if we get a segfault
|
||||
max_retries = 3
|
||||
for attempt in range(max_retries):
|
||||
# Compile using subprocess, inheriting stdout/stderr to show progress
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
sys.executable,
|
||||
"-m",
|
||||
"esphome",
|
||||
"compile",
|
||||
str(config_path),
|
||||
cwd=integration_test_dir,
|
||||
stdout=None, # Inherit stdout
|
||||
stderr=None, # Inherit stderr
|
||||
stdin=asyncio.subprocess.DEVNULL,
|
||||
# Start in a new process group to isolate signal handling
|
||||
start_new_session=True,
|
||||
env=env,
|
||||
close_fds=False,
|
||||
)
|
||||
await proc.wait()
|
||||
|
||||
if proc.returncode == 0:
|
||||
# Success!
|
||||
break
|
||||
if proc.returncode == -11 and attempt < max_retries - 1:
|
||||
# Segfault (-11 = SIGSEGV), retry
|
||||
print(
|
||||
f"Compilation segfaulted (attempt {attempt + 1}/{max_retries}), retrying..."
|
||||
)
|
||||
await asyncio.sleep(1) # Brief pause before retry
|
||||
continue
|
||||
# Other error or final retry
|
||||
raise RuntimeError(
|
||||
f"Failed to compile {config_path}, return code: {proc.returncode}. "
|
||||
f"Run with 'pytest -s' to see compilation output."
|
||||
)
|
||||
|
||||
# Load the config to get idedata (blocking call, must use executor)
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
def _read_config_and_get_binary():
|
||||
CORE.reset() # Reset CORE state between test runs
|
||||
CORE.config_path = config_path
|
||||
config = esphome.config.read_config(
|
||||
{"command": "compile", "config": str(config_path)}
|
||||
name = _shared_yaml_name(request)
|
||||
if name is None:
|
||||
await _run_esphome_compile(config_path, integration_test_dir, env)
|
||||
return await loop.run_in_executor(
|
||||
None, _resolve_compiled_binary, config_path
|
||||
)
|
||||
if config is None:
|
||||
raise RuntimeError(f"Failed to read config from {config_path}")
|
||||
|
||||
# Get the compiled binary path
|
||||
idedata = get_idedata(config)
|
||||
return Path(idedata.firmware_elf_path)
|
||||
|
||||
binary_path = await loop.run_in_executor(None, _read_config_and_get_binary)
|
||||
|
||||
if not binary_path.exists():
|
||||
raise RuntimeError(f"Compiled binary not found at {binary_path}")
|
||||
|
||||
return binary_path
|
||||
# Shared fixture: build in a hash-keyed dir so tests sharing a config
|
||||
# pay one full compile and later only a main.cpp (port) rebuild + relink
|
||||
shared_dir = _shared_build_dir(name)
|
||||
shared_dir.mkdir(parents=True, exist_ok=True)
|
||||
# Freshen the dir before locking so a concurrent age sweep, which
|
||||
# re-probes under the lock, never reaps a dir a worker just claimed;
|
||||
# if a peer reaped it already, the guarded lock open recreates it
|
||||
with suppress(FileNotFoundError):
|
||||
os.utime(shared_dir)
|
||||
if shared_dir not in _pruned_dirs:
|
||||
_pruned_dirs.add(shared_dir)
|
||||
await loop.run_in_executor(None, _prune_stale_builds, name, shared_dir)
|
||||
shared_config = shared_dir / f"{name}.yaml"
|
||||
private_binary = integration_test_dir / f"{name}.elf"
|
||||
content = await loop.run_in_executor(None, read_file, config_path)
|
||||
if content != getattr(request.node, "_shared_yaml_content", None):
|
||||
# The dir is keyed by the fixture source; a mutated config would be
|
||||
# cached under a hash that does not describe it
|
||||
raise RuntimeError(
|
||||
"shared_yaml tests must compile the yaml_config content unmodified"
|
||||
)
|
||||
# flock serializes concurrent xdist workers; closing the fd releases it.
|
||||
# Hand-rolled rather than filelock.FileLock: non-blocking retries keep
|
||||
# the wait cancellable, while a blocking acquire in an executor thread
|
||||
# would survive test cancellation holding the fd
|
||||
try:
|
||||
lock_file = (shared_dir / ".lock").open("w")
|
||||
except FileNotFoundError:
|
||||
# A peer run pruning divergent hashes reaped the dir between our
|
||||
# mkdir and this open; recreate it and pay a full rebuild
|
||||
shared_dir.mkdir(parents=True, exist_ok=True)
|
||||
lock_file = (shared_dir / ".lock").open("w")
|
||||
with lock_file:
|
||||
start = time.monotonic()
|
||||
last_report = start
|
||||
while True:
|
||||
try:
|
||||
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
break
|
||||
except BlockingIOError:
|
||||
now = time.monotonic()
|
||||
if now - start > _SHARED_LOCK_TIMEOUT_S:
|
||||
raise RuntimeError(
|
||||
f"Timed out waiting for the {shared_dir} lock"
|
||||
) from None
|
||||
if now - last_report >= _SHARED_LOCK_REPORT_S:
|
||||
last_report = now
|
||||
print(
|
||||
f"Waited {now - start:.0f}s for another worker's "
|
||||
f"build of {shared_dir.name}"
|
||||
)
|
||||
await asyncio.sleep(_SHARED_LOCK_POLL_S)
|
||||
# .built carries the ELF path of the last completed compile, so
|
||||
# later workers skip the config re-read in _resolve_compiled_binary
|
||||
stamp = shared_dir / ".built"
|
||||
if (built := _shared_elf_paths.get(shared_dir)) is None:
|
||||
built = await loop.run_in_executor(None, _read_stamp, stamp, shared_dir)
|
||||
# Delete the ELF before compiling: whatever exists afterwards is
|
||||
# this compile's output, so no staleness check is ever needed.
|
||||
# With no usable stamp, sweep any leftover at the known layout
|
||||
if built is not None:
|
||||
built.unlink(missing_ok=True)
|
||||
else:
|
||||
# Layout-agnostic: ESPHOME_BUILD_PATH can move the build tree
|
||||
for leftover in shared_dir.rglob("program"):
|
||||
if leftover.is_file():
|
||||
leftover.unlink()
|
||||
await loop.run_in_executor(
|
||||
None, write_file_if_changed, shared_config, content
|
||||
)
|
||||
await _run_esphome_compile(shared_config, shared_dir, env)
|
||||
if built is None or not built.exists():
|
||||
built = await loop.run_in_executor(
|
||||
None, _resolve_compiled_binary, shared_config
|
||||
)
|
||||
_shared_elf_paths[shared_dir] = built
|
||||
await loop.run_in_executor(None, write_file, stamp, str(built))
|
||||
# Copy out before unlocking: another worker may relink firmware.elf
|
||||
# while this test is still running its private copy
|
||||
await loop.run_in_executor(None, shutil.copy2, built, private_binary)
|
||||
return private_binary
|
||||
|
||||
yield _compile
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
esphome:
|
||||
name: host-noise-resume
|
||||
host:
|
||||
api:
|
||||
encryption:
|
||||
key: N4Yle5YirwZhPiHHsdZLdOA73ndj/84veVaLhTvxCuU=
|
||||
# VERY_VERBOSE so the frame helper logs "Session resumed!"
|
||||
logger:
|
||||
level: VERY_VERBOSE
|
||||
@@ -1,58 +0,0 @@
|
||||
esphome:
|
||||
name: test-batch-window-filters
|
||||
|
||||
host:
|
||||
api:
|
||||
batch_delay: 0ms # Disable batching to receive all state updates
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
# Template sensor that we'll use to publish values
|
||||
sensor:
|
||||
- platform: template
|
||||
name: "Source Sensor"
|
||||
id: source_sensor
|
||||
accuracy_decimals: 2
|
||||
|
||||
# Batch window filters (window_size == send_every) - use streaming filters
|
||||
- platform: copy
|
||||
source_id: source_sensor
|
||||
name: "Min Sensor"
|
||||
id: min_sensor
|
||||
filters:
|
||||
- min:
|
||||
window_size: 5
|
||||
send_every: 5
|
||||
send_first_at: 1
|
||||
|
||||
- platform: copy
|
||||
source_id: source_sensor
|
||||
name: "Max Sensor"
|
||||
id: max_sensor
|
||||
filters:
|
||||
- max:
|
||||
window_size: 5
|
||||
send_every: 5
|
||||
send_first_at: 1
|
||||
|
||||
- platform: copy
|
||||
source_id: source_sensor
|
||||
name: "Moving Avg Sensor"
|
||||
id: moving_avg_sensor
|
||||
filters:
|
||||
- sliding_window_moving_average:
|
||||
window_size: 5
|
||||
send_every: 5
|
||||
send_first_at: 1
|
||||
|
||||
# Button to trigger publishing test values
|
||||
button:
|
||||
- platform: template
|
||||
name: "Publish Values Button"
|
||||
id: publish_button
|
||||
on_press:
|
||||
- lambda: |-
|
||||
// Publish 10 values: 1.0, 2.0, ..., 10.0
|
||||
for (int i = 1; i <= 10; i++) {
|
||||
id(source_sensor).publish_state(float(i));
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
esphome:
|
||||
name: uart-mock-modbus-cli-rw
|
||||
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
level: VERBOSE
|
||||
|
||||
external_components:
|
||||
- source:
|
||||
type: local
|
||||
path: EXTERNAL_COMPONENT_PATH
|
||||
|
||||
# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"]
|
||||
# The actual UART bus used is the uart_mock component below
|
||||
uart:
|
||||
baud_rate: 115200
|
||||
port: /dev/null
|
||||
|
||||
# Two virtual buses looped back to each other: the client's transmissions reach the server and the
|
||||
# server's replies reach the client. auto_start so forwarding is active before the button fires.
|
||||
uart_mock:
|
||||
- id: virtual_uart_server
|
||||
baud_rate: 9600
|
||||
auto_start: true
|
||||
debug:
|
||||
on_tx:
|
||||
- then:
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_client
|
||||
data: !lambda return data;
|
||||
- id: virtual_uart_client
|
||||
baud_rate: 9600
|
||||
auto_start: true
|
||||
debug:
|
||||
on_tx:
|
||||
- then:
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_server
|
||||
data: !lambda return data;
|
||||
|
||||
globals:
|
||||
- id: stored_1
|
||||
type: uint16_t
|
||||
initial_value: "0"
|
||||
|
||||
modbus:
|
||||
- uart_id: virtual_uart_server
|
||||
id: virtual_modbus_server
|
||||
role: server
|
||||
- uart_id: virtual_uart_client
|
||||
id: virtual_modbus_client
|
||||
role: client
|
||||
turnaround_time: 10ms
|
||||
|
||||
modbus_server:
|
||||
- address: 1
|
||||
modbus_id: virtual_modbus_server
|
||||
registers:
|
||||
# Writable + readable register: the read publishes what it returns, so the test can confirm the
|
||||
# write half of the 0x17 ran before the read half (Modbus 6.17).
|
||||
- address: 0x01
|
||||
value_type: U_WORD
|
||||
read_lambda: |-
|
||||
id(srv_read_1).publish_state(id(stored_1));
|
||||
return id(stored_1);
|
||||
write_lambda: |-
|
||||
id(stored_1) = x;
|
||||
id(srv_write_1).publish_state(x);
|
||||
return true;
|
||||
# Read-only register, returned together with 0x01 by the 2-register read half.
|
||||
- address: 0x02
|
||||
value_type: U_WORD
|
||||
read_lambda: return 0x00AA;
|
||||
|
||||
sensor:
|
||||
# Server-side observations.
|
||||
- platform: template
|
||||
name: "srv_write_1"
|
||||
id: srv_write_1
|
||||
- platform: template
|
||||
name: "srv_read_1"
|
||||
id: srv_read_1
|
||||
# Client-side read-back: the values the client's on_response received.
|
||||
- platform: template
|
||||
name: "client_read_0"
|
||||
id: client_read_0
|
||||
- platform: template
|
||||
name: "client_read_1"
|
||||
id: client_read_1
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: "Start Scenario"
|
||||
id: start_scenario_btn
|
||||
on_press:
|
||||
# FC 0x17: write reg 0x0001 = 0x1234, then read regs 0x0001..0x0002 back in the same transaction.
|
||||
- modbus_client.read_write_multiple_registers:
|
||||
address: 0x01
|
||||
read_address: 0x0001
|
||||
read_count: 2
|
||||
write_address: 0x0001
|
||||
values: [0x1234]
|
||||
on_response:
|
||||
then:
|
||||
- lambda: |-
|
||||
// values is the read-back block: reg 0x0001 (must be the just-written 0x1234) and reg 0x0002.
|
||||
if (values.size() >= 2) {
|
||||
id(client_read_0).publish_state(values[0]);
|
||||
id(client_read_1).publish_state(values[1]);
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
esphome:
|
||||
name: uart-mock-modbus-custom-pdu
|
||||
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
level: VERBOSE
|
||||
|
||||
external_components:
|
||||
- source:
|
||||
type: local
|
||||
path: EXTERNAL_COMPONENT_PATH
|
||||
|
||||
# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"]
|
||||
# The actual UART bus used is the uart_mock component below
|
||||
uart:
|
||||
baud_rate: 115200
|
||||
port: /dev/null
|
||||
|
||||
uart_mock:
|
||||
- id: virtual_uart_server
|
||||
baud_rate: 9600
|
||||
auto_start: true
|
||||
debug:
|
||||
on_tx:
|
||||
- then:
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_controller
|
||||
data: !lambda return data;
|
||||
- id: virtual_uart_controller
|
||||
baud_rate: 9600
|
||||
auto_start: true
|
||||
debug:
|
||||
on_tx:
|
||||
- then:
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_server
|
||||
data: !lambda return data;
|
||||
|
||||
modbus:
|
||||
- uart_id: virtual_uart_server
|
||||
id: virtual_modbus_server
|
||||
role: server
|
||||
- uart_id: virtual_uart_controller
|
||||
id: virtual_modbus_controller
|
||||
role: client
|
||||
turnaround_time: 10ms
|
||||
|
||||
modbus_controller:
|
||||
- address: 1
|
||||
modbus_id: virtual_modbus_controller
|
||||
id: modbus_controller_1
|
||||
update_interval: 1s
|
||||
|
||||
modbus_server:
|
||||
- address: 1
|
||||
modbus_id: virtual_modbus_server
|
||||
id: modbus_server_1
|
||||
registers:
|
||||
- address: 0x01
|
||||
value_type: U_WORD
|
||||
read_lambda: return 259;
|
||||
|
||||
sensor:
|
||||
# Plain read to confirm the controller <-> server link is up.
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "plain_read"
|
||||
address: 0x01
|
||||
register_type: holding
|
||||
value_type: U_WORD
|
||||
# Custom PDU: read holding register 0x0001, count 1. The PDU is
|
||||
# {function code, address hi, address lo, count hi, count lo}; the device
|
||||
# address and CRC are added by the hub. The lambda parses the response payload
|
||||
# (the register value, big-endian).
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "custom_read"
|
||||
custom_pdu: [0x03, 0x00, 0x01, 0x00, 0x01]
|
||||
lambda: |-
|
||||
if (data.size() < 2) return {};
|
||||
return (float) ((data[0] << 8) | data[1]);
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: "Start Scenario"
|
||||
id: start_scenario_btn
|
||||
# This test does not have anything to start (mock is autostart)
|
||||
@@ -1,106 +0,0 @@
|
||||
esphome:
|
||||
name: uart-mock-modbus-dep-buffer
|
||||
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
level: VERBOSE
|
||||
|
||||
external_components:
|
||||
- source:
|
||||
type: local
|
||||
path: EXTERNAL_COMPONENT_PATH
|
||||
|
||||
# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"]
|
||||
# The actual UART bus used is the uart_mock component below
|
||||
uart:
|
||||
baud_rate: 115200
|
||||
port: /dev/null
|
||||
|
||||
uart_mock:
|
||||
- id: virtual_uart_server
|
||||
baud_rate: 9600
|
||||
auto_start: true
|
||||
debug:
|
||||
on_tx:
|
||||
- then:
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_controller
|
||||
data: !lambda return data;
|
||||
- id: virtual_uart_controller
|
||||
baud_rate: 9600
|
||||
auto_start: true
|
||||
debug:
|
||||
on_tx:
|
||||
- then:
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_server
|
||||
data: !lambda return data;
|
||||
|
||||
globals:
|
||||
- id: reg10
|
||||
type: uint16_t
|
||||
initial_value: "0"
|
||||
|
||||
modbus:
|
||||
- uart_id: virtual_uart_server
|
||||
id: virtual_modbus_server
|
||||
role: server
|
||||
- uart_id: virtual_uart_controller
|
||||
id: virtual_modbus_controller
|
||||
role: client
|
||||
turnaround_time: 10ms
|
||||
|
||||
modbus_controller:
|
||||
- address: 1
|
||||
modbus_id: virtual_modbus_controller
|
||||
id: modbus_controller_1
|
||||
update_interval: 1s
|
||||
|
||||
modbus_server:
|
||||
- address: 1
|
||||
modbus_id: virtual_modbus_server
|
||||
id: modbus_server_1
|
||||
registers:
|
||||
- address: 0x10
|
||||
value_type: U_WORD
|
||||
read_lambda: return id(reg10);
|
||||
write_lambda: |-
|
||||
id(reg10) = x;
|
||||
return true;
|
||||
|
||||
# A number whose write_lambda uses the DEPRECATED buffer parameter (fills `payload` with a legacy raw
|
||||
# frame as words: device address + function code + data) instead of the new item->write_* API. The write
|
||||
# must still land with its legacy semantics, and the one-time deprecation warning must fire only once per
|
||||
# entity no matter how many writes happen.
|
||||
number:
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "buf_number"
|
||||
id: buf_number
|
||||
address: 0x10
|
||||
register_type: holding
|
||||
value_type: U_WORD
|
||||
min_value: 0
|
||||
max_value: 1000
|
||||
step: 1
|
||||
write_lambda: |-
|
||||
// Legacy raw frame as words: [addr 0x01 | fc 0x06], register 0x0010, value.
|
||||
payload.push_back(0x0106);
|
||||
payload.push_back(0x0010);
|
||||
payload.push_back((uint16_t) x);
|
||||
return {};
|
||||
|
||||
# Reports the server-side register so the test can observe that the deprecated buffer write landed.
|
||||
sensor:
|
||||
- platform: template
|
||||
name: "written_value"
|
||||
id: written_value
|
||||
update_interval: 0.5s
|
||||
lambda: "return id(reg10);"
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: "Start Scenario"
|
||||
id: start_scenario_btn
|
||||
# The test drives the writes via number_command; the mock is autostart.
|
||||
@@ -1,95 +0,0 @@
|
||||
esphome:
|
||||
name: uart-mock-modbus-lambda-invert
|
||||
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
level: VERBOSE
|
||||
|
||||
external_components:
|
||||
- source:
|
||||
type: local
|
||||
path: EXTERNAL_COMPONENT_PATH
|
||||
|
||||
# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"]
|
||||
# The actual UART bus used is the uart_mock component below
|
||||
uart:
|
||||
baud_rate: 115200
|
||||
port: /dev/null
|
||||
|
||||
uart_mock:
|
||||
- id: virtual_uart_server
|
||||
baud_rate: 9600
|
||||
auto_start: true
|
||||
debug:
|
||||
on_tx:
|
||||
- then:
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_controller
|
||||
data: !lambda return data;
|
||||
- id: virtual_uart_controller
|
||||
baud_rate: 9600
|
||||
auto_start: true
|
||||
debug:
|
||||
on_tx:
|
||||
- then:
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_server
|
||||
data: !lambda return data;
|
||||
|
||||
globals:
|
||||
- id: reg40
|
||||
type: uint16_t
|
||||
initial_value: "5"
|
||||
|
||||
modbus:
|
||||
- uart_id: virtual_uart_server
|
||||
id: virtual_modbus_server
|
||||
role: server
|
||||
- uart_id: virtual_uart_controller
|
||||
id: virtual_modbus_controller
|
||||
role: client
|
||||
turnaround_time: 10ms
|
||||
|
||||
modbus_controller:
|
||||
- address: 1
|
||||
modbus_id: virtual_modbus_controller
|
||||
id: modbus_controller_1
|
||||
update_interval: 1s
|
||||
|
||||
modbus_server:
|
||||
- address: 1
|
||||
modbus_id: virtual_modbus_server
|
||||
id: modbus_server_1
|
||||
registers:
|
||||
- address: 0x40
|
||||
value_type: U_WORD
|
||||
read_lambda: return id(reg40);
|
||||
write_lambda: id(reg40) = x; return true;
|
||||
|
||||
# An active-low holding switch: the write_lambda inverts the wire value, but the entity must still
|
||||
# report the REQUESTED state. assumed_state keeps the register unpolled, so the published state comes
|
||||
# only from write_state() - turning ON writes 0x0000 yet the switch shows ON.
|
||||
switch:
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "invert_switch"
|
||||
register_type: holding
|
||||
address: 0x40
|
||||
assumed_state: true
|
||||
write_lambda: |-
|
||||
return !x;
|
||||
|
||||
sensor:
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "reg_40"
|
||||
address: 0x40
|
||||
register_type: holding
|
||||
value_type: U_WORD
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: "Start Scenario"
|
||||
id: start_scenario_btn
|
||||
# This test does not have anything to start (mock is autostart)
|
||||
@@ -1,97 +0,0 @@
|
||||
esphome:
|
||||
name: uart-mock-modbus-lambda-write
|
||||
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
level: VERBOSE
|
||||
|
||||
external_components:
|
||||
- source:
|
||||
type: local
|
||||
path: EXTERNAL_COMPONENT_PATH
|
||||
|
||||
# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"]
|
||||
# The actual UART bus used is the uart_mock component below
|
||||
uart:
|
||||
baud_rate: 115200
|
||||
port: /dev/null
|
||||
|
||||
uart_mock:
|
||||
- id: virtual_uart_server
|
||||
baud_rate: 9600
|
||||
auto_start: true
|
||||
debug:
|
||||
on_tx:
|
||||
- then:
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_controller
|
||||
data: !lambda return data;
|
||||
- id: virtual_uart_controller
|
||||
baud_rate: 9600
|
||||
auto_start: true
|
||||
debug:
|
||||
on_tx:
|
||||
- then:
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_server
|
||||
data: !lambda return data;
|
||||
|
||||
globals:
|
||||
- id: reg30
|
||||
type: uint16_t
|
||||
initial_value: "0"
|
||||
|
||||
modbus:
|
||||
- uart_id: virtual_uart_server
|
||||
id: virtual_modbus_server
|
||||
role: server
|
||||
- uart_id: virtual_uart_controller
|
||||
id: virtual_modbus_controller
|
||||
role: client
|
||||
turnaround_time: 10ms
|
||||
|
||||
modbus_controller:
|
||||
- address: 1
|
||||
modbus_id: virtual_modbus_controller
|
||||
id: modbus_controller_1
|
||||
update_interval: 1s
|
||||
|
||||
modbus_server:
|
||||
- address: 1
|
||||
modbus_id: virtual_modbus_server
|
||||
id: modbus_server_1
|
||||
registers:
|
||||
- address: 0x30
|
||||
value_type: U_WORD
|
||||
read_lambda: return id(reg30);
|
||||
write_lambda: id(reg30) = x; return true;
|
||||
|
||||
# A COIL-type switch (assumed_state, write-only) whose write_lambda ignores its own coil type and instead
|
||||
# drives a HOLDING-REGISTER write on the mock server through the entity itself: `item` IS the command, so
|
||||
# item->write_single_register() sends a register write from a coil entity (cross-type). Returning nothing
|
||||
# (an empty optional) tells the write path the lambda already dispatched the frame - no default coil write.
|
||||
switch:
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "cross_switch"
|
||||
register_type: coil
|
||||
address: 0x00
|
||||
assumed_state: true
|
||||
write_lambda: |-
|
||||
item->write_single_register(0x30, x ? 1234 : 0);
|
||||
return {};
|
||||
|
||||
sensor:
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "reg_30"
|
||||
address: 0x30
|
||||
register_type: holding
|
||||
value_type: U_WORD
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: "Start Scenario"
|
||||
id: start_scenario_btn
|
||||
# This test does not have anything to start (mock is autostart)
|
||||
@@ -0,0 +1,233 @@
|
||||
esphome:
|
||||
name: uart-mock-modbus-loopback
|
||||
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
level: VERBOSE
|
||||
|
||||
external_components:
|
||||
- source:
|
||||
type: local
|
||||
path: EXTERNAL_COMPONENT_PATH
|
||||
|
||||
# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"]
|
||||
# The actual UART bus used is the uart_mock component below
|
||||
uart:
|
||||
baud_rate: 115200
|
||||
port: /dev/null
|
||||
|
||||
# Shared loopback fixture (see the shared_yaml markers in the test file);
|
||||
# register spaces are disjoint so each test only observes its own entities.
|
||||
uart_mock:
|
||||
- id: virtual_uart_server
|
||||
baud_rate: 9600
|
||||
auto_start: true
|
||||
debug:
|
||||
on_tx:
|
||||
- then:
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_controller
|
||||
data: !lambda return data;
|
||||
- id: virtual_uart_controller
|
||||
baud_rate: 9600
|
||||
auto_start: true
|
||||
debug:
|
||||
on_tx:
|
||||
- then:
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_server
|
||||
data: !lambda return data;
|
||||
|
||||
globals:
|
||||
- id: reg10
|
||||
type: uint16_t
|
||||
initial_value: "100"
|
||||
- id: reg11
|
||||
type: uint16_t
|
||||
initial_value: "200"
|
||||
- id: reg12
|
||||
type: uint16_t
|
||||
initial_value: "300"
|
||||
- id: reg13
|
||||
type: uint16_t
|
||||
initial_value: "0xABCD"
|
||||
- id: reg30
|
||||
type: uint16_t
|
||||
initial_value: "0"
|
||||
- id: reg40
|
||||
type: uint16_t
|
||||
initial_value: "5"
|
||||
- id: reg50
|
||||
type: uint16_t
|
||||
initial_value: "0"
|
||||
|
||||
modbus:
|
||||
- uart_id: virtual_uart_server
|
||||
id: virtual_modbus_server
|
||||
role: server
|
||||
- uart_id: virtual_uart_controller
|
||||
id: virtual_modbus_controller
|
||||
role: client
|
||||
turnaround_time: 10ms
|
||||
|
||||
modbus_controller:
|
||||
- address: 1
|
||||
modbus_id: virtual_modbus_controller
|
||||
id: modbus_controller_1
|
||||
update_interval: 1s
|
||||
|
||||
modbus_server:
|
||||
- address: 1
|
||||
modbus_id: virtual_modbus_server
|
||||
registers:
|
||||
- address: 0x01
|
||||
value_type: U_WORD
|
||||
read_lambda: return 259;
|
||||
- address: 0x10
|
||||
value_type: U_WORD
|
||||
read_lambda: return id(reg10);
|
||||
write_lambda: id(reg10) = x; return true;
|
||||
- address: 0x11
|
||||
value_type: U_WORD
|
||||
read_lambda: return id(reg11);
|
||||
write_lambda: id(reg11) = x; return true;
|
||||
- address: 0x12
|
||||
value_type: U_WORD
|
||||
read_lambda: return id(reg12);
|
||||
write_lambda: id(reg12) = x; return true;
|
||||
- address: 0x13
|
||||
value_type: U_WORD
|
||||
read_lambda: return id(reg13);
|
||||
- address: 0x30
|
||||
value_type: U_WORD
|
||||
read_lambda: return id(reg30);
|
||||
write_lambda: id(reg30) = x; return true;
|
||||
- address: 0x40
|
||||
value_type: U_WORD
|
||||
read_lambda: return id(reg40);
|
||||
write_lambda: id(reg40) = x; return true;
|
||||
- address: 0x50
|
||||
value_type: U_WORD
|
||||
read_lambda: return id(reg50);
|
||||
write_lambda: id(reg50) = x; return true;
|
||||
|
||||
# Byte-based offset: 2 bytes -> register 0x11 (the old code folded it in as a
|
||||
# register count, hitting 0x12). assumed_state keeps the switch write-only.
|
||||
switch:
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "offset_switch"
|
||||
register_type: holding
|
||||
address: 0x10
|
||||
offset: 2
|
||||
assumed_state: true
|
||||
# Reading switch, byte offset 6 -> register 0x13; the pre-fix resolution (0x16)
|
||||
# would draw ILLEGAL_DATA_ADDRESS and never publish.
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "read_offset_switch"
|
||||
register_type: holding
|
||||
address: 0x10
|
||||
offset: 6
|
||||
bitmask: 0x1
|
||||
# Coil switch whose write_lambda dispatches a holding-register write via `item`;
|
||||
# returning an empty optional suppresses the default coil write.
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "cross_switch"
|
||||
register_type: coil
|
||||
address: 0x00
|
||||
assumed_state: true
|
||||
write_lambda: |-
|
||||
item->write_single_register(0x30, x ? 1234 : 0);
|
||||
return {};
|
||||
# Active-low: the write_lambda inverts the wire value but the entity must still
|
||||
# report the requested state (assumed_state keeps the register unpolled).
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "invert_switch"
|
||||
register_type: holding
|
||||
address: 0x40
|
||||
assumed_state: true
|
||||
write_lambda: |-
|
||||
return !x;
|
||||
|
||||
# Uses the deprecated buffer parameter (legacy raw frame as words); the write
|
||||
# must land and the deprecation warning must fire only once per entity.
|
||||
number:
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "buf_number"
|
||||
id: buf_number
|
||||
address: 0x50
|
||||
register_type: holding
|
||||
value_type: U_WORD
|
||||
min_value: 0
|
||||
max_value: 1000
|
||||
step: 1
|
||||
write_lambda: |-
|
||||
// Legacy raw frame as words: [addr 0x01 | fc 0x06], register 0x0050, value.
|
||||
payload.push_back(0x0106);
|
||||
payload.push_back(0x0050);
|
||||
payload.push_back((uint16_t) x);
|
||||
return {};
|
||||
|
||||
sensor:
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "plain_read"
|
||||
address: 0x01
|
||||
register_type: holding
|
||||
value_type: U_WORD
|
||||
# Custom PDU: read holding register 0x0001; device address and CRC are added
|
||||
# by the hub. The lambda parses the big-endian register value.
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "custom_read"
|
||||
custom_pdu: [0x03, 0x00, 0x01, 0x00, 0x01]
|
||||
lambda: |-
|
||||
if (data.size() < 2) return {};
|
||||
return (float) ((data[0] << 8) | data[1]);
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "reg_10"
|
||||
address: 0x10
|
||||
register_type: holding
|
||||
value_type: U_WORD
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "reg_11"
|
||||
address: 0x11
|
||||
register_type: holding
|
||||
value_type: U_WORD
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "reg_12"
|
||||
address: 0x12
|
||||
register_type: holding
|
||||
value_type: U_WORD
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "reg_30"
|
||||
address: 0x30
|
||||
register_type: holding
|
||||
value_type: U_WORD
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "reg_40"
|
||||
address: 0x40
|
||||
register_type: holding
|
||||
value_type: U_WORD
|
||||
# Reports the server-side register so the test can observe that the deprecated buffer write landed.
|
||||
- platform: template
|
||||
name: "written_value"
|
||||
id: written_value
|
||||
update_interval: 0.5s
|
||||
lambda: "return id(reg50);"
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: "Start Scenario"
|
||||
id: start_scenario_btn
|
||||
# Nothing to start (mock is autostart); tests drive entities directly
|
||||
+110
-11
@@ -1,5 +1,5 @@
|
||||
esphome:
|
||||
name: uart-mock-modbus-server-contro
|
||||
name: uart-mock-modbus-mesh
|
||||
|
||||
host:
|
||||
api:
|
||||
@@ -17,13 +17,14 @@ uart:
|
||||
baud_rate: 115200
|
||||
port: /dev/null
|
||||
|
||||
# Shared 3-bus mesh (see the shared_yaml markers): addr 1 = typed read-only
|
||||
# registers, addr 5 = the read/write 0x17 target, addr 2/3 on the second
|
||||
# server hub. auto_start everywhere: the controller polls at boot, so the
|
||||
# forwarding must already be live or early requests generate warnings.
|
||||
# Every test presses Start Scenario, so all merged actions fire in every test.
|
||||
uart_mock:
|
||||
- id: virtual_uart_server
|
||||
baud_rate: 9600
|
||||
# auto_start must be true for loopback fixtures: the modbus controller
|
||||
# polls on its update_interval immediately at boot, so the uart_mock
|
||||
# forwarding must already be active or early requests are lost and
|
||||
# generate modbus warnings.
|
||||
auto_start: true
|
||||
debug:
|
||||
on_tx:
|
||||
@@ -31,35 +32,68 @@ uart_mock:
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_controller
|
||||
data: !lambda return data;
|
||||
- id: virtual_uart_controller
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_server_2
|
||||
data: !lambda return data;
|
||||
- id: virtual_uart_server_2
|
||||
baud_rate: 9600
|
||||
auto_start: true # See comment on virtual_uart_server above
|
||||
auto_start: true
|
||||
debug:
|
||||
on_tx:
|
||||
- then:
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_server
|
||||
data: !lambda return data;
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_controller
|
||||
data: !lambda return data;
|
||||
- id: virtual_uart_controller
|
||||
baud_rate: 9600
|
||||
auto_start: true
|
||||
debug:
|
||||
on_tx:
|
||||
- then:
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_server
|
||||
data: !lambda return data;
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_server_2
|
||||
data: !lambda return data;
|
||||
|
||||
globals:
|
||||
- id: stored_1
|
||||
type: uint16_t
|
||||
initial_value: "0"
|
||||
|
||||
modbus:
|
||||
- uart_id: virtual_uart_server
|
||||
id: virtual_modbus_server
|
||||
role: server
|
||||
- uart_id: virtual_uart_server_2
|
||||
id: virtual_modbus_server_2
|
||||
role: server
|
||||
- uart_id: virtual_uart_controller
|
||||
id: virtual_modbus_controller
|
||||
id: virtual_modbus_client
|
||||
role: client
|
||||
turnaround_time: 10ms
|
||||
|
||||
modbus_controller:
|
||||
- address: 1
|
||||
modbus_id: virtual_modbus_controller
|
||||
modbus_id: virtual_modbus_client
|
||||
id: modbus_controller_1
|
||||
update_interval: 1s
|
||||
- address: 2
|
||||
modbus_id: virtual_modbus_client
|
||||
id: modbus_controller_2
|
||||
update_interval: 1s
|
||||
- address: 3
|
||||
modbus_id: virtual_modbus_client
|
||||
id: modbus_controller_3
|
||||
update_interval: 1s
|
||||
|
||||
modbus_server:
|
||||
- address: 1
|
||||
modbus_id: virtual_modbus_server
|
||||
id: modbus_server_1
|
||||
registers:
|
||||
- address: 0x01
|
||||
value_type: U_WORD
|
||||
@@ -103,6 +137,34 @@ modbus_server:
|
||||
- address: 0x28
|
||||
value_type: FP32_R
|
||||
read_lambda: return 3.14;
|
||||
- address: 5
|
||||
modbus_id: virtual_modbus_server
|
||||
registers:
|
||||
# Writable + readable register: srv_write_1 plus the client's read-back
|
||||
# confirm the write half of the 0x17 ran before the read half (Modbus 6.17).
|
||||
- address: 0x01
|
||||
value_type: U_WORD
|
||||
read_lambda: return id(stored_1);
|
||||
write_lambda: |-
|
||||
id(stored_1) = x;
|
||||
id(srv_write_1).publish_state(x);
|
||||
return true;
|
||||
# Read-only register, returned together with 0x01 by the 2-register read half.
|
||||
- address: 0x02
|
||||
value_type: U_WORD
|
||||
read_lambda: return 0x00AA;
|
||||
- address: 2
|
||||
modbus_id: virtual_modbus_server_2
|
||||
registers:
|
||||
- address: 0x01
|
||||
value_type: U_WORD
|
||||
read_lambda: return 919;
|
||||
- address: 3
|
||||
modbus_id: virtual_modbus_server_2
|
||||
registers:
|
||||
- address: 0x01
|
||||
value_type: U_WORD
|
||||
read_lambda: return 929;
|
||||
|
||||
sensor:
|
||||
- platform: modbus_controller
|
||||
@@ -195,9 +257,46 @@ sensor:
|
||||
address: 0x28
|
||||
register_type: holding
|
||||
value_type: FP32_R
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_2
|
||||
name: "multi_reg_a"
|
||||
address: 0x01
|
||||
register_type: holding
|
||||
value_type: U_WORD
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_3
|
||||
name: "multi_reg_b"
|
||||
address: 0x01
|
||||
register_type: holding
|
||||
value_type: U_WORD
|
||||
# client_read_write observations, server- and client-side.
|
||||
- platform: template
|
||||
name: "srv_write_1"
|
||||
id: srv_write_1
|
||||
- platform: template
|
||||
name: "client_read_0"
|
||||
id: client_read_0
|
||||
- platform: template
|
||||
name: "client_read_1"
|
||||
id: client_read_1
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: "Start Scenario"
|
||||
id: start_scenario_btn
|
||||
# This test does not have anything to start (mock is autostart)
|
||||
on_press:
|
||||
# FC 0x17: write reg 0x0001 = 0x1234, then read regs 0x0001..0x0002 back in the same transaction.
|
||||
- modbus_client.read_write_multiple_registers:
|
||||
address: 5
|
||||
read_address: 0x0001
|
||||
read_count: 2
|
||||
write_address: 0x0001
|
||||
values: [0x1234]
|
||||
on_response:
|
||||
then:
|
||||
- lambda: |-
|
||||
// values is the read-back block: reg 0x0001 (must be the just-written 0x1234) and reg 0x0002.
|
||||
if (values.size() >= 2) {
|
||||
id(client_read_0).publish_state(values[0]);
|
||||
id(client_read_1).publish_state(values[1]);
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
esphome:
|
||||
name: uart-mock-modbus-reg-offset
|
||||
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
level: VERBOSE
|
||||
|
||||
external_components:
|
||||
- source:
|
||||
type: local
|
||||
path: EXTERNAL_COMPONENT_PATH
|
||||
|
||||
# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"]
|
||||
# The actual UART bus used is the uart_mock component below
|
||||
uart:
|
||||
baud_rate: 115200
|
||||
port: /dev/null
|
||||
|
||||
uart_mock:
|
||||
- id: virtual_uart_server
|
||||
baud_rate: 9600
|
||||
auto_start: true
|
||||
debug:
|
||||
on_tx:
|
||||
- then:
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_controller
|
||||
data: !lambda return data;
|
||||
- id: virtual_uart_controller
|
||||
baud_rate: 9600
|
||||
auto_start: true
|
||||
debug:
|
||||
on_tx:
|
||||
- then:
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_server
|
||||
data: !lambda return data;
|
||||
|
||||
globals:
|
||||
- id: reg10
|
||||
type: uint16_t
|
||||
initial_value: "100"
|
||||
- id: reg11
|
||||
type: uint16_t
|
||||
initial_value: "200"
|
||||
- id: reg12
|
||||
type: uint16_t
|
||||
initial_value: "300"
|
||||
- id: reg13
|
||||
type: uint16_t
|
||||
initial_value: "0xABCD"
|
||||
|
||||
modbus:
|
||||
- uart_id: virtual_uart_server
|
||||
id: virtual_modbus_server
|
||||
role: server
|
||||
- uart_id: virtual_uart_controller
|
||||
id: virtual_modbus_controller
|
||||
role: client
|
||||
turnaround_time: 10ms
|
||||
|
||||
modbus_controller:
|
||||
- address: 1
|
||||
modbus_id: virtual_modbus_controller
|
||||
id: modbus_controller_1
|
||||
update_interval: 1s
|
||||
|
||||
modbus_server:
|
||||
- address: 1
|
||||
modbus_id: virtual_modbus_server
|
||||
id: modbus_server_1
|
||||
registers:
|
||||
- address: 0x10
|
||||
value_type: U_WORD
|
||||
read_lambda: return id(reg10);
|
||||
write_lambda: id(reg10) = x; return true;
|
||||
- address: 0x11
|
||||
value_type: U_WORD
|
||||
read_lambda: return id(reg11);
|
||||
write_lambda: id(reg11) = x; return true;
|
||||
- address: 0x12
|
||||
value_type: U_WORD
|
||||
read_lambda: return id(reg12);
|
||||
write_lambda: id(reg12) = x; return true;
|
||||
- address: 0x13
|
||||
value_type: U_WORD
|
||||
read_lambda: return id(reg13);
|
||||
write_lambda: id(reg13) = x; return true;
|
||||
|
||||
# A holding-register switch at 0x10 with a 2-BYTE offset. offset is byte-based, so the write must target
|
||||
# register 0x10 + 2/2 = 0x11. The old (pre-fix) behavior folded offset into the address as a register
|
||||
# count, hitting 0x12 instead. assumed_state keeps the switch write-only so it does not read any register.
|
||||
switch:
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "offset_switch"
|
||||
register_type: holding
|
||||
address: 0x10
|
||||
offset: 2
|
||||
assumed_state: true
|
||||
# A holding-register switch that READS its state. Byte offset 6 -> register 0x10 + 6/2 = 0x13. Post-fix
|
||||
# the switch itself resolves to 0x13 (the even byte offset folds into the address as whole registers) and
|
||||
# joins the 0x10..0x13 range, so no separate 0x13 sensor is needed. Pre-fix the whole byte offset folds
|
||||
# into the address (0x16), where the server answers ILLEGAL_DATA_ADDRESS and the switch never publishes.
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "read_offset_switch"
|
||||
register_type: holding
|
||||
address: 0x10
|
||||
offset: 6
|
||||
bitmask: 0x1
|
||||
|
||||
sensor:
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "reg_10"
|
||||
address: 0x10
|
||||
register_type: holding
|
||||
value_type: U_WORD
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "reg_11"
|
||||
address: 0x11
|
||||
register_type: holding
|
||||
value_type: U_WORD
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "reg_12"
|
||||
address: 0x12
|
||||
register_type: holding
|
||||
value_type: U_WORD
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: "Start Scenario"
|
||||
id: start_scenario_btn
|
||||
# This test does not have anything to start (mock is autostart)
|
||||
@@ -1,124 +0,0 @@
|
||||
esphome:
|
||||
name: uart-mock-modbus-server-test
|
||||
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
level: VERBOSE
|
||||
|
||||
external_components:
|
||||
- source:
|
||||
type: local
|
||||
path: EXTERNAL_COMPONENT_PATH
|
||||
|
||||
# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"]
|
||||
# The actual UART bus used is the uart_mock component below
|
||||
uart:
|
||||
baud_rate: 115200
|
||||
port: /dev/null
|
||||
|
||||
uart_mock:
|
||||
- id: virtual_uart_dev
|
||||
baud_rate: 9600
|
||||
rx_full_threshold: 120
|
||||
rx_timeout: 2
|
||||
auto_start: false
|
||||
debug:
|
||||
injections:
|
||||
- delay: 100ms
|
||||
inject_rx: [0x01, 0x03, 0x00, 0x03, 0x00, 0x01, 0x74, 0x0A] # Read holding register 3 on device 1 (basic_read)
|
||||
- delay: 100ms
|
||||
# Read holding register 7 on device 2
|
||||
# Reply from device 2
|
||||
# Read holding register 5 on device 1 (read_after_peer_response)
|
||||
inject_rx:
|
||||
[
|
||||
0x02,
|
||||
0x03,
|
||||
0x00,
|
||||
0x07,
|
||||
0x00,
|
||||
0x01,
|
||||
0x35,
|
||||
0xF8,
|
||||
0x02,
|
||||
0x03,
|
||||
0x02,
|
||||
0x00,
|
||||
0xF0,
|
||||
0xFC,
|
||||
0x00,
|
||||
0x01,
|
||||
0x03,
|
||||
0x00,
|
||||
0x05,
|
||||
0x00,
|
||||
0x01,
|
||||
0x94,
|
||||
0x0B,
|
||||
]
|
||||
- delay: 100ms
|
||||
inject_rx: [0x02, 0x03, 0x00, 0x07, 0x00, 0x01, 0x35, 0xF8] # Read holding register 7 on device 2, with no response
|
||||
- delay: 100ms
|
||||
# Read holding register 7 on device 2, with no response
|
||||
# Read holding register A on device 1 (read_after_peer_timeout)
|
||||
inject_rx:
|
||||
[
|
||||
0x02,
|
||||
0x03,
|
||||
0x00,
|
||||
0x07,
|
||||
0x00,
|
||||
0x01,
|
||||
0x35,
|
||||
0xF8,
|
||||
0x01,
|
||||
0x03,
|
||||
0x00,
|
||||
0x0A,
|
||||
0x00,
|
||||
0x01,
|
||||
0xA4,
|
||||
0x08,
|
||||
]
|
||||
|
||||
modbus:
|
||||
uart_id: virtual_uart_dev
|
||||
role: server
|
||||
|
||||
modbus_server:
|
||||
- address: 1
|
||||
registers:
|
||||
- address: 0x03
|
||||
value_type: U_WORD
|
||||
read_lambda: |-
|
||||
id(basic_read).publish_state(1);
|
||||
return 1;
|
||||
- address: 0x05
|
||||
value_type: U_WORD
|
||||
read_lambda: |-
|
||||
id(read_after_peer_response).publish_state(1);
|
||||
return 1;
|
||||
- address: 0x0A
|
||||
value_type: U_WORD
|
||||
read_lambda: |-
|
||||
id(read_after_peer_timeout).publish_state(1);
|
||||
return 1;
|
||||
|
||||
sensor:
|
||||
- platform: template
|
||||
name: "basic_read"
|
||||
id: basic_read
|
||||
- platform: template
|
||||
name: "read_after_peer_response"
|
||||
id: read_after_peer_response
|
||||
- platform: template
|
||||
name: "read_after_peer_timeout"
|
||||
id: read_after_peer_timeout
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: "Start Scenario"
|
||||
id: start_scenario_btn
|
||||
on_press:
|
||||
- lambda: "id(virtual_uart_dev).start_scenario();"
|
||||
@@ -1,116 +0,0 @@
|
||||
esphome:
|
||||
name: uart-mock-modbus-server-mult
|
||||
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
level: VERBOSE
|
||||
|
||||
external_components:
|
||||
- source:
|
||||
type: local
|
||||
path: EXTERNAL_COMPONENT_PATH
|
||||
|
||||
# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"]
|
||||
# The actual UART bus used is the uart_mock component below
|
||||
uart:
|
||||
baud_rate: 115200
|
||||
port: /dev/null
|
||||
|
||||
uart_mock:
|
||||
- id: virtual_uart_server
|
||||
baud_rate: 9600
|
||||
# auto_start must be true for loopback fixtures: the modbus controller
|
||||
# polls on its update_interval immediately at boot, so the uart_mock
|
||||
# forwarding must already be active or early requests are lost and
|
||||
# generate modbus warnings.
|
||||
auto_start: true
|
||||
debug:
|
||||
on_tx:
|
||||
- then:
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_controller
|
||||
data: !lambda return data;
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_server_2
|
||||
data: !lambda return data;
|
||||
- id: virtual_uart_server_2
|
||||
baud_rate: 9600
|
||||
auto_start: true # See comment on virtual_uart_server above
|
||||
debug:
|
||||
on_tx:
|
||||
- then:
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_server
|
||||
data: !lambda return data;
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_controller
|
||||
data: !lambda return data;
|
||||
- id: virtual_uart_controller
|
||||
baud_rate: 9600
|
||||
auto_start: true # See comment on virtual_uart_server above
|
||||
debug:
|
||||
on_tx:
|
||||
- then:
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_server
|
||||
data: !lambda return data;
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_server_2
|
||||
data: !lambda return data;
|
||||
|
||||
modbus:
|
||||
- uart_id: virtual_uart_server
|
||||
id: virtual_modbus_server
|
||||
role: server
|
||||
- uart_id: virtual_uart_server_2
|
||||
id: virtual_modbus_server_2
|
||||
role: server
|
||||
- uart_id: virtual_uart_controller
|
||||
id: virtual_modbus_client
|
||||
role: client
|
||||
turnaround_time: 10ms
|
||||
|
||||
modbus_controller:
|
||||
- address: 1
|
||||
modbus_id: virtual_modbus_client
|
||||
update_interval: 1s
|
||||
id: modbus_controller_1
|
||||
- address: 2
|
||||
modbus_id: virtual_modbus_client
|
||||
update_interval: 1s
|
||||
id: modbus_controller_2
|
||||
|
||||
modbus_server:
|
||||
- address: 1
|
||||
modbus_id: virtual_modbus_server
|
||||
registers:
|
||||
- address: 0x01
|
||||
value_type: U_WORD
|
||||
read_lambda: return 919;
|
||||
- address: 2
|
||||
modbus_id: virtual_modbus_server_2
|
||||
registers:
|
||||
- address: 0x01
|
||||
value_type: U_WORD
|
||||
read_lambda: return 929;
|
||||
|
||||
sensor:
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "reg_u_word"
|
||||
address: 0x01
|
||||
register_type: holding
|
||||
value_type: U_WORD
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_2
|
||||
name: "reg_u_word_2"
|
||||
address: 0x01
|
||||
register_type: holding
|
||||
value_type: U_WORD
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: "Start Scenario"
|
||||
id: start_scenario_btn
|
||||
# This test does not have anything to start (mock is autostart)
|
||||
+47
-8
@@ -1,5 +1,5 @@
|
||||
esphome:
|
||||
name: uart-mock-modbus-srv-rw
|
||||
name: uart-mock-modbus-srv-injected
|
||||
|
||||
host:
|
||||
api:
|
||||
@@ -17,6 +17,8 @@ uart:
|
||||
baud_rate: 115200
|
||||
port: /dev/null
|
||||
|
||||
# Shared server-role fixture (see the shared_yaml markers in the test file);
|
||||
# the injections concatenate and each test waits only on its own sensors.
|
||||
uart_mock:
|
||||
- id: virtual_uart_dev
|
||||
baud_rate: 9600
|
||||
@@ -25,18 +27,31 @@ uart_mock:
|
||||
auto_start: false
|
||||
debug:
|
||||
injections:
|
||||
# FC 0x17 Read/Write Multiple Registers on device 1:
|
||||
# write reg 0x0001 = 0x1234 (qty 1), then read regs 0x0001..0x0002 (qty 2).
|
||||
# Per Modbus 6.17 the write is performed before the read, so reg 0x0001 must
|
||||
# read back the just-written 0x1234 in the same request.
|
||||
- delay: 100ms
|
||||
inject_rx: [0x01, 0x03, 0x00, 0x03, 0x00, 0x01, 0x74, 0x0A] # Read holding register 3 on device 1 (basic_read)
|
||||
- delay: 100ms
|
||||
# Read holding register 7 on device 2, its reply, then read holding
|
||||
# register 5 on device 1 (read_after_peer_response)
|
||||
inject_rx: [0x02, 0x03, 0x00, 0x07, 0x00, 0x01, 0x35, 0xF8,
|
||||
0x02, 0x03, 0x02, 0x00, 0xF0, 0xFC,
|
||||
0x00, 0x01, 0x03, 0x00, 0x05, 0x00, 0x01, 0x94, 0x0B]
|
||||
- delay: 100ms
|
||||
inject_rx: [0x02, 0x03, 0x00, 0x07, 0x00, 0x01, 0x35, 0xF8] # Read holding register 7 on device 2, with no response
|
||||
- delay: 100ms
|
||||
# Read holding register 7 on device 2 with no response, then read
|
||||
# holding register A on device 1 (read_after_peer_timeout)
|
||||
inject_rx: [0x02, 0x03, 0x00, 0x07, 0x00, 0x01, 0x35, 0xF8,
|
||||
0x01, 0x03, 0x00, 0x0A, 0x00, 0x01, 0xA4, 0x08]
|
||||
# FC 0x17 on device 1: write reg 0x0001 = 0x1234 then read 0x0001..0x0002;
|
||||
# per Modbus 6.17 the write runs first, so 0x0001 must read back 0x1234.
|
||||
- delay: 100ms
|
||||
inject_rx:
|
||||
[0x01, 0x17, 0x00, 0x01, 0x00, 0x02, 0x00, 0x01, 0x00, 0x01, 0x02, 0x12, 0x34, 0x49, 0xD8]
|
||||
# FC 0x17: write reg 0x0003 = 0x5678 (qty 1), then read reg 0x0003 (qty 1) -
|
||||
# FC 0x17: write reg 0x0006 = 0x5678 (qty 1), then read reg 0x0006 (qty 1) -
|
||||
# a write and read targeting a different register block.
|
||||
- delay: 100ms
|
||||
inject_rx:
|
||||
[0x01, 0x17, 0x00, 0x03, 0x00, 0x01, 0x00, 0x03, 0x00, 0x01, 0x02, 0x56, 0x78, 0x9B, 0x10]
|
||||
[0x01, 0x17, 0x00, 0x06, 0x00, 0x01, 0x00, 0x06, 0x00, 0x01, 0x02, 0x56, 0x78, 0x8B, 0x55]
|
||||
|
||||
globals:
|
||||
- id: stored_1
|
||||
@@ -70,8 +85,18 @@ modbus_server:
|
||||
read_lambda: |-
|
||||
id(rw_read_2).publish_state(0x00AA);
|
||||
return 0x00AA;
|
||||
# Second writable + readable register, targeted by the second request.
|
||||
- address: 0x03
|
||||
value_type: U_WORD
|
||||
read_lambda: |-
|
||||
id(basic_read).publish_state(1);
|
||||
return 1;
|
||||
- address: 0x05
|
||||
value_type: U_WORD
|
||||
read_lambda: |-
|
||||
id(read_after_peer_response).publish_state(1);
|
||||
return 1;
|
||||
# Second writable + readable register, targeted by the second FC 0x17 request.
|
||||
- address: 0x06
|
||||
value_type: U_WORD
|
||||
read_lambda: |-
|
||||
id(rw_read_3).publish_state(id(stored_3));
|
||||
@@ -80,8 +105,22 @@ modbus_server:
|
||||
id(stored_3) = x;
|
||||
id(rw_write_3).publish_state(x);
|
||||
return true;
|
||||
- address: 0x0A
|
||||
value_type: U_WORD
|
||||
read_lambda: |-
|
||||
id(read_after_peer_timeout).publish_state(1);
|
||||
return 1;
|
||||
|
||||
sensor:
|
||||
- platform: template
|
||||
name: "basic_read"
|
||||
id: basic_read
|
||||
- platform: template
|
||||
name: "read_after_peer_response"
|
||||
id: read_after_peer_response
|
||||
- platform: template
|
||||
name: "read_after_peer_timeout"
|
||||
id: read_after_peer_timeout
|
||||
- platform: template
|
||||
name: "rw_write_1"
|
||||
id: rw_write_1
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Helpers for manipulating the host platform's preferences file.
|
||||
|
||||
ESPHome's host platform stores preferences in
|
||||
``~/.esphome/prefs/<app_name>.prefs`` using a simple binary layout that
|
||||
``$ESPHOME_PREFDIR/<app_name>.prefs`` using a simple binary layout that
|
||||
mirrors ``HostPreferences::sync()``:
|
||||
``[uint32_t key][uint8_t len][uint8_t data[len]]`` per entry.
|
||||
|
||||
@@ -11,13 +11,21 @@ boot (e.g. forcing safe mode) or to clear stale state between runs.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import struct
|
||||
|
||||
|
||||
def host_prefs_path(device_name: str) -> Path:
|
||||
"""Return the on-disk prefs file path for a host-platform device."""
|
||||
return Path.home() / ".esphome" / "prefs" / f"{device_name}.prefs"
|
||||
"""Return the on-disk prefs file path for a host-platform device.
|
||||
|
||||
Requires ESPHOME_PREFDIR, which the autouse isolated_preferences fixture
|
||||
sets; refusing the ~/.esphome/prefs fallback keeps tests off real user
|
||||
data if the fixture is ever bypassed."""
|
||||
prefdir = os.environ.get("ESPHOME_PREFDIR")
|
||||
if not prefdir:
|
||||
raise RuntimeError("ESPHOME_PREFDIR is not set; refusing the real prefs dir")
|
||||
return Path(prefdir) / f"{device_name}.prefs"
|
||||
|
||||
|
||||
def clear_host_prefs(device_name: str) -> None:
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Integration test for noise session resume."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import aioesphomeapi.core
|
||||
import pytest
|
||||
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
NOISE_KEY = "N4Yle5YirwZhPiHHsdZLdOA73ndj/84veVaLhTvxCuU="
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_noise_resume(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""A reconnect with the ticket from the first connection resumes the session."""
|
||||
if not hasattr(aioesphomeapi.core, "ResumeAPIError"):
|
||||
pytest.skip("aioesphomeapi without noise session resume")
|
||||
|
||||
resumed = asyncio.Event()
|
||||
resumed_count = 0
|
||||
|
||||
def on_line(line: str) -> None:
|
||||
nonlocal resumed_count
|
||||
if "Session resumed" in line:
|
||||
resumed_count += 1
|
||||
resumed.set()
|
||||
|
||||
async with (
|
||||
run_compiled(yaml_config, line_callback=on_line),
|
||||
api_client_connected(noise_psk=NOISE_KEY) as client,
|
||||
):
|
||||
# First connection: full handshake, the device issues a ticket
|
||||
info = await client.device_info()
|
||||
assert info.name == "host-noise-resume"
|
||||
assert resumed_count == 0
|
||||
|
||||
# Same client reconnects and offers the ticket
|
||||
await client.disconnect()
|
||||
await client.connect(login=True)
|
||||
info = await client.device_info()
|
||||
assert info.name == "host-noise-resume"
|
||||
await asyncio.wait_for(resumed.wait(), timeout=10.0)
|
||||
assert resumed_count == 1
|
||||
resumed.clear()
|
||||
|
||||
# The resumed session issued a fresh ticket, so it resumes again
|
||||
await client.disconnect()
|
||||
await client.connect(login=True)
|
||||
info = await client.device_info()
|
||||
assert info.name == "host-noise-resume"
|
||||
await asyncio.wait_for(resumed.wait(), timeout=10.0)
|
||||
assert resumed_count == 2
|
||||
@@ -24,7 +24,6 @@ from .types import (
|
||||
RunCompiledFunction,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.usefixtures("isolated_preferences")
|
||||
NEW_KEY = PROVISIONING_PSK
|
||||
|
||||
|
||||
|
||||
@@ -41,15 +41,6 @@ async def _poll_until_exists(path: Path) -> None:
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolated_preferences(monkeypatch: pytest.MonkeyPatch, tmp_path) -> Path:
|
||||
"""Keep host preferences per-test so this test never touches the real
|
||||
~/.esphome/prefs and never races other tests over ESPHOME_PREFDIR."""
|
||||
prefdir = tmp_path / "prefs"
|
||||
monkeypatch.setenv("ESPHOME_PREFDIR", str(prefdir))
|
||||
return prefdir / f"{DEVICE_NAME}.prefs"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_host_preferences_suspend_resume(
|
||||
yaml_config: str,
|
||||
@@ -58,7 +49,7 @@ async def test_host_preferences_suspend_resume(
|
||||
isolated_preferences: Path,
|
||||
) -> None:
|
||||
"""Test that a running syncer flushes, a suspended one doesn't, and resume restores flushing."""
|
||||
pref_file = isolated_preferences
|
||||
pref_file = isolated_preferences / f"{DEVICE_NAME}.prefs"
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
saved_in_memory = loop.create_future()
|
||||
|
||||
@@ -11,14 +11,6 @@ from .state_utils import InitialStateHelper, require_entity
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolated_preferences(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
|
||||
"""Keep host preferences per-test so RESTORE_AND_ON never loads a stale value left
|
||||
behind by a previous run (host preferences otherwise persist to ~/.esphome/prefs,
|
||||
keyed only by device name)."""
|
||||
monkeypatch.setenv("ESPHOME_PREFDIR", str(tmp_path / "prefs"))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_light_initial_state(
|
||||
yaml_config: str,
|
||||
|
||||
@@ -173,6 +173,7 @@ async def test_uart_mock_modbus_no_threshold(
|
||||
_assert_no_modbus_errors(error_log_lines, warning_log_lines)
|
||||
|
||||
|
||||
@pytest.mark.shared_yaml("uart_mock_modbus_server_injected")
|
||||
@pytest.mark.asyncio
|
||||
async def test_uart_mock_modbus_server(
|
||||
yaml_config: str,
|
||||
@@ -203,6 +204,7 @@ async def test_uart_mock_modbus_server(
|
||||
_assert_no_modbus_errors(error_log_lines, warning_log_lines)
|
||||
|
||||
|
||||
@pytest.mark.shared_yaml("uart_mock_modbus_server_injected")
|
||||
@pytest.mark.asyncio
|
||||
async def test_uart_mock_modbus_server_read_write(
|
||||
yaml_config: str,
|
||||
@@ -231,8 +233,8 @@ async def test_uart_mock_modbus_server_read_write(
|
||||
"rw_write_1": 4660, # 0x1234 written to reg 0x0001
|
||||
"rw_read_1": 4660, # reg 0x0001 reads back the just-written value
|
||||
"rw_read_2": 170, # 0x00AA read from reg 0x0002 in the same request
|
||||
"rw_write_3": 22136, # 0x5678 written to reg 0x0003
|
||||
"rw_read_3": 22136, # reg 0x0003 reads back the just-written value
|
||||
"rw_write_3": 22136, # 0x5678 written to reg 0x0006
|
||||
"rw_read_3": 22136, # reg 0x0006 reads back the just-written value
|
||||
}
|
||||
)
|
||||
|
||||
@@ -241,7 +243,8 @@ async def test_uart_mock_modbus_server_read_write(
|
||||
api_client_connected() as client,
|
||||
):
|
||||
await tracker.setup_and_start_scenario(client)
|
||||
await tracker.await_all(futures)
|
||||
# The FC 0x17 injections fire last, behind four earlier 100ms delays
|
||||
await tracker.await_all(futures, timeout=4.0)
|
||||
_assert_no_modbus_errors(error_log_lines, warning_log_lines)
|
||||
|
||||
|
||||
@@ -296,6 +299,7 @@ async def test_uart_mock_modbus_server_read_write_invalid(
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.shared_yaml("uart_mock_modbus_mesh")
|
||||
@pytest.mark.asyncio
|
||||
async def test_uart_mock_modbus_server_controller(
|
||||
yaml_config: str,
|
||||
@@ -485,6 +489,7 @@ async def test_uart_mock_modbus_server_controller_bits(
|
||||
_assert_no_modbus_errors(error_log_lines, warning_log_lines)
|
||||
|
||||
|
||||
@pytest.mark.shared_yaml("uart_mock_modbus_mesh")
|
||||
@pytest.mark.asyncio
|
||||
async def test_uart_mock_modbus_server_controller_multiple(
|
||||
yaml_config: str,
|
||||
@@ -495,7 +500,7 @@ async def test_uart_mock_modbus_server_controller_multiple(
|
||||
|
||||
line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback()
|
||||
|
||||
expected_values = {"reg_u_word": 919, "reg_u_word_2": 929}
|
||||
expected_values = {"multi_reg_a": 919, "multi_reg_b": 929}
|
||||
tracker = SensorTracker(list(expected_values.keys()))
|
||||
futures = tracker.expect_all(expected_values)
|
||||
|
||||
@@ -706,6 +711,7 @@ async def test_uart_mock_modbus_shared_address(
|
||||
_assert_no_modbus_errors(error_log_lines, warning_log_lines)
|
||||
|
||||
|
||||
@pytest.mark.shared_yaml("uart_mock_modbus_loopback")
|
||||
@pytest.mark.asyncio
|
||||
async def test_uart_mock_modbus_custom_pdu(
|
||||
yaml_config: str,
|
||||
@@ -932,6 +938,7 @@ async def test_uart_mock_modbus_broadcast_write(
|
||||
_assert_no_modbus_errors(error_log_lines, warning_log_lines)
|
||||
|
||||
|
||||
@pytest.mark.shared_yaml("uart_mock_modbus_mesh")
|
||||
@pytest.mark.asyncio
|
||||
async def test_uart_mock_modbus_client_read_write(
|
||||
yaml_config: str,
|
||||
@@ -947,9 +954,7 @@ async def test_uart_mock_modbus_client_read_write(
|
||||
"""
|
||||
line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback()
|
||||
|
||||
tracker = SensorTracker(
|
||||
["srv_write_1", "srv_read_1", "client_read_0", "client_read_1"]
|
||||
)
|
||||
tracker = SensorTracker(["srv_write_1", "client_read_0", "client_read_1"])
|
||||
futures = tracker.expect_all(
|
||||
{
|
||||
"srv_write_1": 4660, # server wrote 0x1234 to reg 0x0001
|
||||
@@ -967,6 +972,7 @@ async def test_uart_mock_modbus_client_read_write(
|
||||
_assert_no_modbus_errors(error_log_lines, warning_log_lines)
|
||||
|
||||
|
||||
@pytest.mark.shared_yaml("uart_mock_modbus_loopback")
|
||||
@pytest.mark.asyncio
|
||||
async def test_uart_mock_modbus_register_offset(
|
||||
yaml_config: str,
|
||||
@@ -1022,6 +1028,7 @@ async def test_uart_mock_modbus_register_offset(
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.shared_yaml("uart_mock_modbus_loopback")
|
||||
@pytest.mark.asyncio
|
||||
async def test_uart_mock_modbus_lambda_write(
|
||||
yaml_config: str,
|
||||
@@ -1058,6 +1065,7 @@ async def test_uart_mock_modbus_lambda_write(
|
||||
await tracker.await_change(wrote_30, "reg_30", timeout=4.0)
|
||||
|
||||
|
||||
@pytest.mark.shared_yaml("uart_mock_modbus_loopback")
|
||||
@pytest.mark.asyncio
|
||||
async def test_uart_mock_modbus_lambda_invert(
|
||||
yaml_config: str,
|
||||
@@ -1113,6 +1121,7 @@ async def test_uart_mock_modbus_lambda_invert(
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.shared_yaml("uart_mock_modbus_loopback")
|
||||
@pytest.mark.asyncio
|
||||
async def test_uart_mock_modbus_deprecated_write_buffer(
|
||||
yaml_config: str,
|
||||
|
||||
@@ -78,17 +78,6 @@ def mock_esp32_platformio_components_to_test() -> Generator[Mock, None, None]:
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_esp8266_native_components_to_test() -> Generator[Mock, None, None]:
|
||||
"""Mock esp8266_native_components_to_test from determine_jobs.
|
||||
|
||||
main() drives both the ``esp8266_native`` boolean output and the
|
||||
``esp8266_native_components`` CSV from this one function.
|
||||
"""
|
||||
with patch.object(determine_jobs, "esp8266_native_components_to_test") as mock:
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_determine_cpp_unit_tests() -> Generator[Mock, None, None]:
|
||||
"""Mock determine_cpp_unit_tests from helpers."""
|
||||
@@ -117,7 +106,6 @@ def clear_determine_jobs_caches() -> None:
|
||||
"""Clear all cached functions before each test."""
|
||||
determine_jobs._is_clang_tidy_full_scan.cache_clear()
|
||||
determine_jobs._component_has_tests.cache_clear()
|
||||
determine_jobs._cached_components_closure.cache_clear()
|
||||
|
||||
|
||||
def test_main_all_tests_should_run(
|
||||
@@ -128,7 +116,6 @@ def test_main_all_tests_should_run(
|
||||
mock_should_run_import_time: Mock,
|
||||
mock_should_run_device_builder: Mock,
|
||||
mock_esp32_platformio_components_to_test: Mock,
|
||||
mock_esp8266_native_components_to_test: Mock,
|
||||
mock_changed_files: Mock,
|
||||
mock_determine_cpp_unit_tests: Mock,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
@@ -145,7 +132,6 @@ def test_main_all_tests_should_run(
|
||||
mock_should_run_import_time.return_value = True
|
||||
mock_should_run_device_builder.return_value = True
|
||||
mock_esp32_platformio_components_to_test.return_value = ["api", "esp32"]
|
||||
mock_esp8266_native_components_to_test.return_value = ["api", "logger"]
|
||||
mock_determine_cpp_unit_tests.return_value = (False, ["wifi", "api", "sensor"])
|
||||
|
||||
# Mock changed_files to return non-component files (to avoid memory impact)
|
||||
@@ -222,8 +208,6 @@ def test_main_all_tests_should_run(
|
||||
assert output["device_builder"] is True
|
||||
assert output["esp32_platformio"] is True
|
||||
assert output["esp32_platformio_components"] == "api,esp32"
|
||||
assert output["esp8266_native"] is True
|
||||
assert output["esp8266_native_components"] == "api,logger"
|
||||
assert output["changed_components"] == ["wifi", "api", "sensor"]
|
||||
# changed_components_with_tests will only include components that actually have test files
|
||||
assert "changed_components_with_tests" in output
|
||||
@@ -260,7 +244,6 @@ def test_main_no_tests_should_run(
|
||||
mock_should_run_import_time: Mock,
|
||||
mock_should_run_device_builder: Mock,
|
||||
mock_esp32_platformio_components_to_test: Mock,
|
||||
mock_esp8266_native_components_to_test: Mock,
|
||||
mock_changed_files: Mock,
|
||||
mock_determine_cpp_unit_tests: Mock,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
@@ -277,7 +260,6 @@ def test_main_no_tests_should_run(
|
||||
mock_should_run_import_time.return_value = False
|
||||
mock_should_run_device_builder.return_value = False
|
||||
mock_esp32_platformio_components_to_test.return_value = []
|
||||
mock_esp8266_native_components_to_test.return_value = []
|
||||
mock_determine_cpp_unit_tests.return_value = (False, [])
|
||||
|
||||
# Mock changed_files to return no component files
|
||||
@@ -320,8 +302,6 @@ def test_main_no_tests_should_run(
|
||||
assert output["device_builder"] is False
|
||||
assert output["esp32_platformio"] is False
|
||||
assert output["esp32_platformio_components"] == ""
|
||||
assert output["esp8266_native"] is False
|
||||
assert output["esp8266_native_components"] == ""
|
||||
assert output["changed_components"] == []
|
||||
assert output["changed_components_with_tests"] == []
|
||||
assert output["component_test_count"] == 0
|
||||
@@ -1013,10 +993,6 @@ _ESP32_PLATFORMIO_FULL_LIST_FILES = [
|
||||
# PlatformIO subsystem (path-prefix trigger) + build generator
|
||||
["esphome/platformio/runner.py"],
|
||||
["esphome/platformio/toolchain.py"],
|
||||
# The pch modules are standalone triggers, not the whole build_helpers/
|
||||
["esphome/build_helpers/pch.py"],
|
||||
["esphome/build_helpers/ccache.py"],
|
||||
["esphome/build_helpers/idedata.py"],
|
||||
["esphome/build_gen/platformio.py"],
|
||||
# Workflow / harness files
|
||||
["script/test_build_components.py"],
|
||||
@@ -1075,9 +1051,6 @@ def test_esp32_platformio_components_to_test_returns_full_list_on_infrastructure
|
||||
# Non-PlatformIO files in esphome/build_gen/ do NOT trigger the
|
||||
# full list -- only esphome/build_gen/platformio.py is a trigger.
|
||||
(["esphome/build_gen/espidf.py"], [], []),
|
||||
# build_helpers modules the pch does not import are not triggers.
|
||||
(["esphome/build_helpers/size_summary.py"], [], []),
|
||||
(["esphome/build_helpers/ninja.py"], [], []),
|
||||
# Docs / unrelated files -> empty.
|
||||
(["README.md"], [], []),
|
||||
([], [], []),
|
||||
@@ -3178,78 +3151,6 @@ def test_memory_impact_elf_layouts_are_found(tmp_path: Path) -> None:
|
||||
assert find_elf_path(build_path) == elf, f"{platform} ELF not found"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"changed",
|
||||
[
|
||||
"esphome/arduino8266/framework.py",
|
||||
"esphome/build_gen/arduino8266.py",
|
||||
"esphome/components/esp8266/build_surgery.py",
|
||||
# Shared modules the native build depends on
|
||||
"esphome/build_helpers/idedata.py",
|
||||
"esphome/platformio/library.py",
|
||||
# Top-level esphome/*.py modules the backend imports directly
|
||||
"esphome/framework_helpers.py",
|
||||
"esphome/writer.py",
|
||||
# esp8266/__init__.py imports copy_ccache_script from it
|
||||
"esphome/platformio/toolchain.py",
|
||||
# The composite cache action must not ship unexercised
|
||||
".github/actions/cache-arduino8266/action.yml",
|
||||
],
|
||||
)
|
||||
def test_esp8266_native_components_full_list_on_infra_change(changed: str) -> None:
|
||||
"""Native-ESP8266 infrastructure changes run the full test list."""
|
||||
with (
|
||||
patch.object(determine_jobs, "changed_files", return_value=[changed]),
|
||||
patch.object(
|
||||
determine_jobs,
|
||||
"get_components_with_dependencies",
|
||||
return_value=["wifi"],
|
||||
),
|
||||
):
|
||||
result = determine_jobs.esp8266_native_components_to_test()
|
||||
assert result == sorted(determine_jobs.ESP8266_NATIVE_TEST_COMPONENTS)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("changed_files", "dependency_closure", "expected"),
|
||||
[
|
||||
# Tested component changed -- narrow to the intersection.
|
||||
(
|
||||
["esphome/components/mqtt/mqtt_client.cpp"],
|
||||
["mqtt", "json"],
|
||||
["mqtt"],
|
||||
),
|
||||
# Components outside the test set return an empty list (job skipped).
|
||||
(
|
||||
["esphome/components/wifi/wifi_component.cpp"],
|
||||
["wifi", "network"],
|
||||
[],
|
||||
),
|
||||
# espidf infrastructure is not an esp8266-native trigger; the
|
||||
# native backend depends on esphome/build_helpers/ instead.
|
||||
(["esphome/build_gen/espidf.py"], [], []),
|
||||
(["esphome/espidf/toolchain.py"], [], []),
|
||||
(["README.md"], [], []),
|
||||
],
|
||||
)
|
||||
def test_esp8266_native_components_to_test_narrowing(
|
||||
changed_files: list[str],
|
||||
dependency_closure: list[str],
|
||||
expected: list[str],
|
||||
) -> None:
|
||||
"""Component changes narrow the native-ESP8266 test list."""
|
||||
with (
|
||||
patch.object(determine_jobs, "changed_files", return_value=changed_files),
|
||||
patch.object(
|
||||
determine_jobs,
|
||||
"get_components_with_dependencies",
|
||||
return_value=dependency_closure,
|
||||
),
|
||||
):
|
||||
result = determine_jobs.esp8266_native_components_to_test()
|
||||
assert result == expected
|
||||
|
||||
|
||||
def test_compute_integration_test_buckets_no_durations_full_fanout() -> None:
|
||||
"""Without recorded durations the fan-out stays at the maximum."""
|
||||
files = [f"tests/integration/test_{i:03d}.py" for i in range(15)]
|
||||
|
||||
@@ -2122,6 +2122,34 @@ def test_get_cpp_changed_components_independent_of_cwd(
|
||||
) == ["time"]
|
||||
|
||||
|
||||
def test_fixture_map_includes_shared_yaml_markers() -> None:
|
||||
"""Fixtures named only by shared_yaml markers must map to their test file."""
|
||||
helpers.get_fixture_to_test_files.cache_clear()
|
||||
mapping = helpers.get_fixture_to_test_files()
|
||||
for fixture in (
|
||||
"uart_mock_modbus_loopback",
|
||||
"uart_mock_modbus_mesh",
|
||||
"uart_mock_modbus_server_injected",
|
||||
):
|
||||
assert mapping[fixture] == frozenset(
|
||||
{"tests/integration/test_uart_mock_modbus.py"}
|
||||
)
|
||||
|
||||
|
||||
def test_no_orphan_integration_fixtures() -> None:
|
||||
"""Every fixture must reach CI test selection; an orphan selects nothing."""
|
||||
helpers.get_fixture_to_test_files.cache_clear()
|
||||
mapping = helpers.get_fixture_to_test_files()
|
||||
fixtures_dir = (Path(__file__).parent.parent / "integration" / "fixtures").resolve()
|
||||
fixtures = list(fixtures_dir.glob("*.yaml"))
|
||||
assert fixtures, f"no fixtures found under {fixtures_dir}"
|
||||
# cache_init is covered via INTEGRATION_TESTS_TRIGGER_FILES instead
|
||||
orphans = [
|
||||
f.stem for f in fixtures if f.stem != "cache_init" and f.stem not in mapping
|
||||
]
|
||||
assert not orphans, f"fixtures invisible to CI test selection: {orphans}"
|
||||
|
||||
|
||||
def test_lpt_partition_balances_skewed_weights() -> None:
|
||||
"""Heavy items spread across groups instead of clustering."""
|
||||
items = [f"i{n}" for n in range(6)]
|
||||
|
||||
@@ -236,98 +236,3 @@ def test_run_grouped_test_closes_group_when_subprocess_raises(
|
||||
)
|
||||
|
||||
assert "::endgroup::" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_components_empty_match_fails_with_flag(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""Under --fail-on-no-tests, a real component filtered to a platform
|
||||
with no matching test file must not pass CI as a green zero-component
|
||||
compile."""
|
||||
rc = tbc.test_components(
|
||||
["logger"],
|
||||
"zz-none",
|
||||
"compile",
|
||||
False,
|
||||
enable_grouping=False,
|
||||
fail_on_no_tests=True,
|
||||
)
|
||||
assert rc == 1
|
||||
assert "No tests ran for requested pattern(s): logger" in (capsys.readouterr().out)
|
||||
|
||||
|
||||
def test_components_component_with_no_base_file_fails_with_flag(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A component whose fixture matches the platform but whose platform has
|
||||
no base file builds nothing; under the flag that silent zero fails by
|
||||
component name instead of hiding behind other components."""
|
||||
monkeypatch.setattr(tbc, "get_platform_base_files", lambda base_dir: {})
|
||||
rc = tbc.test_components(
|
||||
["logger"],
|
||||
"esp8266-ard",
|
||||
"compile",
|
||||
False,
|
||||
enable_grouping=False,
|
||||
fail_on_no_tests=True,
|
||||
)
|
||||
assert rc == 1
|
||||
assert "No tests ran for requested pattern(s): logger" in (capsys.readouterr().out)
|
||||
|
||||
|
||||
def test_components_blank_list_fails_with_flag(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""A fully blank component list must not slide into the baseline
|
||||
fallback and exit green under the flag."""
|
||||
rc = tbc.test_components(
|
||||
[""], "esp8266-ard", "compile", False, fail_on_no_tests=True
|
||||
)
|
||||
assert rc == 1
|
||||
assert "blank component list" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_components_wildcard_no_match_fails_with_flag(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""A wildcard matching nothing must not degrade to the synthetic
|
||||
baseline build and exit green under the flag."""
|
||||
rc = tbc.test_components(
|
||||
["zz_no_such*"],
|
||||
"esp8266-ard",
|
||||
"compile",
|
||||
False,
|
||||
enable_grouping=False,
|
||||
fail_on_no_tests=True,
|
||||
)
|
||||
assert rc == 1
|
||||
assert "No components found matching" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_components_empty_match_tolerated_without_flag() -> None:
|
||||
"""The esp32-ard smoke leg deliberately builds only the subset with a
|
||||
matching fixture; without the flag an empty match stays green."""
|
||||
assert (
|
||||
tbc.test_components(
|
||||
["logger"], "zz-none", "compile", False, enable_grouping=False
|
||||
)
|
||||
== 0
|
||||
)
|
||||
|
||||
|
||||
def test_components_unknown_component_fails_with_flag(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""A renamed smoke-test component must shrink coverage loudly, not fall
|
||||
into the reference-baseline build."""
|
||||
rc = tbc.test_components(
|
||||
["no_such_component_xyz"],
|
||||
"esp8266-ard",
|
||||
"compile",
|
||||
False,
|
||||
enable_grouping=False,
|
||||
fail_on_no_tests=True,
|
||||
)
|
||||
assert rc == 1
|
||||
assert "No components found matching" in capsys.readouterr().out
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -245,20 +244,3 @@ def test_copy_failure_leaves_no_partial_output(tmp_path: Path) -> None:
|
||||
):
|
||||
assert build_tool.main() == 1
|
||||
assert not dst.exists()
|
||||
|
||||
|
||||
def test_touch_creates_and_updates_stamp(tmp_path: Path) -> None:
|
||||
stamp = tmp_path / "esphome_pch.probe"
|
||||
assert build_tool._run_touch(str(stamp)) == 0
|
||||
assert stamp.is_file()
|
||||
os.utime(stamp, (1, 1))
|
||||
assert build_tool._run_touch(str(stamp)) == 0
|
||||
assert stamp.stat().st_mtime > 1
|
||||
|
||||
|
||||
def test_touch_reports_failure(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
missing_dir = tmp_path / "gone" / "stamp"
|
||||
assert build_tool._run_touch(str(missing_dir)) == 1
|
||||
assert "touch:" in capsys.readouterr().err
|
||||
|
||||
@@ -4,9 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
@@ -490,922 +488,3 @@ def test_get_component_cmakelists_no_compile_features() -> None:
|
||||
content = get_component_cmakelists()
|
||||
|
||||
assert "target_compile_features" not in content
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _pch_default_on(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Pin the knob so a developer's ESPHOME_PCH_ENABLE=0 cannot fail these."""
|
||||
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "1")
|
||||
|
||||
|
||||
def _make_pch_device(tmp_path: Path, name: str) -> Path:
|
||||
"""A device dir with the pch source headers and a stub compile_commands."""
|
||||
from esphome.build_helpers.pch import PCH_DEFAULT_HEADERS
|
||||
|
||||
dev = tmp_path / name
|
||||
for header in PCH_DEFAULT_HEADERS:
|
||||
path = dev / "src" / header
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text("")
|
||||
# A real quoted include chain and a per-device-named sdkconfig with
|
||||
# identical content: the closure and sdkconfig inputs must be exercised
|
||||
(dev / "src" / "esphome" / "core" / "defines.h").write_text(
|
||||
'#include "esphome/core/macros.h"\n'
|
||||
)
|
||||
(dev / "src" / "esphome" / "core" / "macros.h").write_text("#define M 1\n")
|
||||
# Both spellings: tests patch CORE.name to "test" or to the device name
|
||||
(dev / f"sdkconfig.{name}").write_text("CONFIG_X=y\n")
|
||||
(dev / "sdkconfig.test").write_text("CONFIG_X=y\n")
|
||||
build = dev / "build"
|
||||
build.mkdir(exist_ok=True)
|
||||
from esphome.build_helpers.pch import pch_header_text
|
||||
|
||||
(build / "esphome_pch.h").write_text(pch_header_text(PCH_DEFAULT_HEADERS))
|
||||
# Native separators: mixed f-string paths break the src-prefix match
|
||||
# on Windows
|
||||
src_file = str(dev / "src" / "a.cpp")
|
||||
(build / "compile_commands.json").write_text(
|
||||
json.dumps(
|
||||
[
|
||||
{
|
||||
"directory": str(build),
|
||||
"command": (
|
||||
"g++ -DX=1 -include esphome_pch.h "
|
||||
"-o esp-idf/src/CMakeFiles/__idf_src.dir/a.cpp.obj "
|
||||
f'-c "{src_file}"'
|
||||
),
|
||||
"file": src_file,
|
||||
}
|
||||
]
|
||||
)
|
||||
)
|
||||
return dev
|
||||
|
||||
|
||||
def test_prepare_pch_writes_header_and_sum(tmp_path: Path) -> None:
|
||||
from esphome.build_gen.espidf import prepare_pch
|
||||
|
||||
dev = _make_pch_device(tmp_path, "dev_a")
|
||||
CORE.build_path = dev
|
||||
gch = dev / "build" / "esphome_pch.h.gch"
|
||||
|
||||
def fake_compile(cmd, **kwargs):
|
||||
if "-fsyntax-only" in cmd:
|
||||
# The load probe follows a successful .gch build
|
||||
return subprocess.CompletedProcess(cmd, 0, "", "")
|
||||
# The compile must target the header, not the stub TU
|
||||
assert cmd[-5:-3] == ["c++-header", "-c"]
|
||||
gch.write_bytes(b"gch")
|
||||
return subprocess.CompletedProcess(cmd, 0, "", "")
|
||||
|
||||
with (
|
||||
patch.object(CORE, "name", "test"),
|
||||
patch("esphome.build_helpers.pch.subprocess.run", side_effect=fake_compile),
|
||||
):
|
||||
prepare_pch()
|
||||
checksum = (dev / "build" / "esphome_pch.h.gch.sum").read_text().strip()
|
||||
assert len(checksum) == 64
|
||||
# Unchanged inputs: the second call must not recompile
|
||||
with (
|
||||
patch.object(CORE, "name", "test"),
|
||||
patch("esphome.build_helpers.pch.subprocess.run", side_effect=AssertionError),
|
||||
):
|
||||
prepare_pch()
|
||||
|
||||
|
||||
def test_pch_no_device_path_poison(tmp_path: Path) -> None:
|
||||
"""Regression: neither the injected -include nor the .sum may carry the
|
||||
per-device build path, or cross-device ccache sharing breaks."""
|
||||
from esphome.build_gen.espidf import get_component_cmakelists, prepare_pch
|
||||
|
||||
sums = []
|
||||
for name in ("dev_a", "dev_b"):
|
||||
dev = _make_pch_device(tmp_path, name)
|
||||
CORE.build_path = dev
|
||||
gch = dev / "build" / "esphome_pch.h.gch"
|
||||
|
||||
def fake_compile(cmd, _gch=gch, **kwargs):
|
||||
_gch.write_bytes(b"gch")
|
||||
return subprocess.CompletedProcess(cmd, 0, "", "")
|
||||
|
||||
with (
|
||||
patch.object(CORE, "name", name),
|
||||
patch("esphome.build_helpers.pch.subprocess.run", side_effect=fake_compile),
|
||||
):
|
||||
prepare_pch()
|
||||
content = get_component_cmakelists()
|
||||
assert str(dev) not in content
|
||||
sums.append((dev / "build" / "esphome_pch.h.gch.sum").read_text())
|
||||
assert sums[0] == sums[1]
|
||||
|
||||
|
||||
def test_component_cmakelists_pch_block(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from esphome.build_gen.espidf import get_component_cmakelists
|
||||
|
||||
content = get_component_cmakelists()
|
||||
assert '"$<$<COMPILE_LANGUAGE:CXX>:-include>"' in content
|
||||
assert "-Wno-error=invalid-pch" in content
|
||||
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
|
||||
strict_content = get_component_cmakelists()
|
||||
assert "-Werror=invalid-pch" in strict_content
|
||||
monkeypatch.delenv("ESPHOME_PCH_STRICT")
|
||||
assert '"$<$<COMPILE_LANGUAGE:CXX>:esphome_pch.h>"' in content
|
||||
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0")
|
||||
assert "-include" not in get_component_cmakelists()
|
||||
|
||||
|
||||
def test_pch_compile_command_variants(tmp_path: Path) -> None:
|
||||
"""Missing DB, no matching entry, and launcher-prefixed commands."""
|
||||
from esphome.build_helpers.pch import pch_compile_command
|
||||
|
||||
build = tmp_path / "build"
|
||||
build.mkdir()
|
||||
header = build / "esphome_pch.h"
|
||||
gch = build / "esphome_pch.h.gch"
|
||||
assert pch_compile_command(build, header, gch) is None
|
||||
|
||||
(build / "compile_commands.json").write_text(
|
||||
json.dumps(
|
||||
[
|
||||
{"command": "gcc -c other.c", "file": "other.c"},
|
||||
]
|
||||
)
|
||||
)
|
||||
assert pch_compile_command(build, header, gch) is None
|
||||
|
||||
src_file = str(tmp_path / "src" / "esphome" / "a.cpp")
|
||||
(build / "compile_commands.json").write_text(
|
||||
json.dumps(
|
||||
[
|
||||
{
|
||||
"command": (
|
||||
"/usr/bin/ccache g++ -DX=1 -include esphome_pch.h -MMD "
|
||||
"-MT a.cpp.obj -MF a.cpp.obj.d "
|
||||
"-o esp-idf/src/CMakeFiles/__idf_src.dir/a.cpp.obj "
|
||||
f"-c {src_file}"
|
||||
),
|
||||
"file": src_file,
|
||||
},
|
||||
]
|
||||
)
|
||||
)
|
||||
# Launcher stripped; -include/-o/-c and depfile flags removed
|
||||
cmd, cmd_dir = pch_compile_command(build, header, gch)
|
||||
assert cmd == [
|
||||
"g++",
|
||||
"-DX=1",
|
||||
"-x",
|
||||
"c++-header",
|
||||
"-c",
|
||||
str(header),
|
||||
"-o",
|
||||
str(gch),
|
||||
]
|
||||
# The compile must run where the flags were resolved
|
||||
assert cmd_dir == build
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="symlinks need privileges on Windows")
|
||||
def test_pch_compile_command_matches_src_through_symlink(tmp_path: Path) -> None:
|
||||
"""Find the src TU when CMake spells paths through a different symlink (macOS /tmp)."""
|
||||
from esphome.build_helpers.pch import pch_compile_command
|
||||
|
||||
real = tmp_path / "real"
|
||||
(real / "src" / "esphome").mkdir(parents=True)
|
||||
link = tmp_path / "link"
|
||||
link.symlink_to(real, target_is_directory=True)
|
||||
CORE.build_path = str(real)
|
||||
build = real / "build"
|
||||
build.mkdir()
|
||||
header = build / "esphome_pch.h"
|
||||
gch = build / "esphome_pch.h.gch"
|
||||
src_file = str(link / "src" / "esphome" / "a.cpp")
|
||||
(build / "compile_commands.json").write_text(
|
||||
json.dumps([{"command": f"g++ -DX=1 -o a.obj -c {src_file}", "file": src_file}])
|
||||
)
|
||||
|
||||
cmd, cmd_dir = pch_compile_command(build, header, gch)
|
||||
|
||||
assert cmd[:2] == ["g++", "-DX=1"]
|
||||
assert cmd_dir == build
|
||||
|
||||
|
||||
def test_pch_compile_command_rejects_unusable_entries(tmp_path: Path) -> None:
|
||||
"""Malformed DB shapes and command-less entries skip cleanly instead of
|
||||
producing a compiler-less argv retried every build."""
|
||||
from esphome.build_helpers.pch import pch_compile_command
|
||||
|
||||
build = tmp_path / "build"
|
||||
build.mkdir()
|
||||
header = build / "esphome_pch.h"
|
||||
gch = build / "esphome_pch.h.gch"
|
||||
db = build / "compile_commands.json"
|
||||
src_file = str(tmp_path / "src" / "esphome" / "a.cpp")
|
||||
|
||||
db.write_text(json.dumps({"not": "a list"}))
|
||||
assert pch_compile_command(build, header, gch) is None
|
||||
|
||||
db.write_text(json.dumps(["just a string"]))
|
||||
assert pch_compile_command(build, header, gch) is None
|
||||
|
||||
# An empty-string directory must fall back to the build dir, not cwd
|
||||
db.write_text(
|
||||
json.dumps(
|
||||
[
|
||||
{
|
||||
"directory": "",
|
||||
"command": f"g++ -DX=1 -o a.obj -c {src_file}",
|
||||
"file": src_file,
|
||||
}
|
||||
]
|
||||
)
|
||||
)
|
||||
_, cmd_dir = pch_compile_command(build, header, gch)
|
||||
assert cmd_dir == build
|
||||
|
||||
# Corrupted entries with null fields must skip, not raise
|
||||
db.write_text(
|
||||
json.dumps(
|
||||
[
|
||||
{"file": None, "command": "g++ -c x.cpp", "directory": None},
|
||||
{"file": src_file, "command": None, "directory": None},
|
||||
]
|
||||
)
|
||||
)
|
||||
assert pch_compile_command(build, header, gch) is None
|
||||
|
||||
# arguments-style entry (allowed by the spec, unused by CMake)
|
||||
db.write_text(
|
||||
json.dumps([{"arguments": ["g++", "-c", src_file], "file": src_file}])
|
||||
)
|
||||
assert pch_compile_command(build, header, gch) is None
|
||||
|
||||
|
||||
def test_pch_header_list_order_is_in_checksum(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Reordering PCH_DEFAULT_HEADERS keeps the include closure identical, but the
|
||||
generated header text differs, so the .gch must rebuild."""
|
||||
import esphome.build_gen.espidf as espidf_mod
|
||||
|
||||
dev = _make_pch_device(tmp_path, "dev_r")
|
||||
CORE.build_path = dev
|
||||
gch = dev / "build" / "esphome_pch.h.gch"
|
||||
|
||||
def fake_compile(cmd, **kwargs):
|
||||
gch.write_bytes(b"gch")
|
||||
return subprocess.CompletedProcess(cmd, 0, "", "")
|
||||
|
||||
with (
|
||||
patch.object(CORE, "name", "test"),
|
||||
patch("esphome.build_helpers.pch.subprocess.run", side_effect=fake_compile),
|
||||
):
|
||||
espidf_mod.prepare_pch()
|
||||
first = (dev / "build" / "esphome_pch.h.gch.sum").read_text()
|
||||
monkeypatch.setattr(
|
||||
espidf_mod,
|
||||
"PCH_DEFAULT_HEADERS",
|
||||
tuple(reversed(espidf_mod.PCH_DEFAULT_HEADERS)),
|
||||
)
|
||||
espidf_mod.prepare_pch()
|
||||
assert (dev / "build" / "esphome_pch.h.gch.sum").read_text() != first
|
||||
|
||||
|
||||
def test_prepare_pch_failure_writes_marker_and_skips_retry(tmp_path: Path) -> None:
|
||||
from esphome.build_gen.espidf import prepare_pch
|
||||
|
||||
dev = _make_pch_device(tmp_path, "dev_f")
|
||||
CORE.build_path = dev
|
||||
calls = []
|
||||
|
||||
def failing_compile(cmd, **kwargs):
|
||||
calls.append(cmd)
|
||||
return subprocess.CompletedProcess(cmd, 1, "", "boom")
|
||||
|
||||
with (
|
||||
patch.object(CORE, "name", "test"),
|
||||
patch("esphome.build_helpers.pch.subprocess.run", side_effect=failing_compile),
|
||||
):
|
||||
prepare_pch()
|
||||
prepare_pch()
|
||||
assert len(calls) == 1
|
||||
assert not (dev / "build" / "esphome_pch.h.gch.sum").exists()
|
||||
assert (dev / "build" / "esphome_pch.h.gch.failed").exists()
|
||||
|
||||
|
||||
def test_prepare_pch_spawn_oserror_is_transient(tmp_path: Path) -> None:
|
||||
"""Spawn/IO failures retry on the next build instead of latching."""
|
||||
from esphome.build_gen.espidf import prepare_pch
|
||||
|
||||
dev = _make_pch_device(tmp_path, "dev_o")
|
||||
CORE.build_path = dev
|
||||
calls = []
|
||||
|
||||
def raising(cmd, **kwargs):
|
||||
calls.append(cmd)
|
||||
raise OSError("no such compiler")
|
||||
|
||||
header = dev / "build" / "esphome_pch.h"
|
||||
before = header.stat().st_mtime_ns
|
||||
with (
|
||||
patch.object(CORE, "name", "test"),
|
||||
patch("esphome.build_helpers.pch.subprocess.run", side_effect=raising),
|
||||
):
|
||||
prepare_pch()
|
||||
prepare_pch()
|
||||
assert not (dev / "build" / "esphome_pch.h.gch.failed").exists()
|
||||
assert not (dev / "build" / "esphome_pch.h.gch.sum").exists()
|
||||
assert len(calls) == 2
|
||||
# No .gch was ever in play, so the header must not be re-touched into
|
||||
# forcing a full rebuild on every failing build
|
||||
assert header.stat().st_mtime_ns == before
|
||||
|
||||
|
||||
def test_prepare_pch_transient_with_stale_gch_bumps_header(tmp_path: Path) -> None:
|
||||
"""A stale .gch removed on a transient failure must dirty its consumers."""
|
||||
from esphome.build_gen.espidf import prepare_pch
|
||||
|
||||
dev = _make_pch_device(tmp_path, "dev_s")
|
||||
CORE.build_path = dev
|
||||
gch = dev / "build" / "esphome_pch.h.gch"
|
||||
gch.write_bytes(b"stale")
|
||||
header = dev / "build" / "esphome_pch.h"
|
||||
os.utime(header, (1, 1))
|
||||
with (
|
||||
patch.object(CORE, "name", "test"),
|
||||
patch(
|
||||
"esphome.build_helpers.pch.subprocess.run",
|
||||
side_effect=OSError("no such compiler"),
|
||||
),
|
||||
):
|
||||
prepare_pch()
|
||||
assert not gch.exists()
|
||||
assert header.stat().st_mtime_ns > 1_000_000_000
|
||||
|
||||
|
||||
def test_prepare_pch_disabled_discards_and_skips_compile(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""The escape hatch is self-cleaning: a leftover .gch is removed."""
|
||||
from esphome.build_gen.espidf import prepare_pch
|
||||
|
||||
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0")
|
||||
dev = _make_pch_device(tmp_path, "dev_d")
|
||||
CORE.build_path = dev
|
||||
stale = dev / "build" / "esphome_pch.h.gch"
|
||||
stale.write_bytes(b"stale")
|
||||
with patch("esphome.build_helpers.pch.subprocess.run", side_effect=AssertionError):
|
||||
prepare_pch()
|
||||
assert not stale.exists()
|
||||
|
||||
|
||||
def test_prepare_pch_missing_sdkconfig_fails_closed(tmp_path: Path) -> None:
|
||||
"""No sdkconfig means no config identity for the .sum: no pch at all."""
|
||||
from esphome.build_gen.espidf import prepare_pch
|
||||
|
||||
dev = _make_pch_device(tmp_path, "dev_m")
|
||||
(dev / "sdkconfig.test").unlink()
|
||||
CORE.build_path = dev
|
||||
stale = dev / "build" / "esphome_pch.h.gch"
|
||||
stale.write_bytes(b"stale")
|
||||
with (
|
||||
patch.object(CORE, "name", "test"),
|
||||
patch("esphome.build_helpers.pch.subprocess.run", side_effect=AssertionError),
|
||||
):
|
||||
prepare_pch()
|
||||
assert not stale.exists()
|
||||
assert not (dev / "build" / "esphome_pch.h.gch.sum").exists()
|
||||
|
||||
|
||||
def test_prepare_pch_signal_kill_is_transient(tmp_path: Path) -> None:
|
||||
"""A signal-killed compile (OOM) must not latch the .failed marker."""
|
||||
from esphome.build_gen.espidf import prepare_pch
|
||||
|
||||
dev = _make_pch_device(tmp_path, "dev_k")
|
||||
CORE.build_path = dev
|
||||
calls = []
|
||||
|
||||
def killed(cmd, **kwargs):
|
||||
calls.append(cmd)
|
||||
return subprocess.CompletedProcess(cmd, -9, "", "")
|
||||
|
||||
with (
|
||||
patch.object(CORE, "name", "test"),
|
||||
patch("esphome.build_helpers.pch.subprocess.run", side_effect=killed),
|
||||
):
|
||||
prepare_pch()
|
||||
prepare_pch()
|
||||
assert not (dev / "build" / "esphome_pch.h.gch.failed").exists()
|
||||
assert len(calls) == 2
|
||||
|
||||
|
||||
def test_prepare_pch_probe_spawn_failure_degrades(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A probe that cannot run discards the pch; strict raises."""
|
||||
from esphome.build_gen.espidf import prepare_pch
|
||||
from esphome.core import EsphomeError
|
||||
|
||||
dev = _make_pch_device(tmp_path, "dev_pf")
|
||||
CORE.build_path = dev
|
||||
gch = dev / "build" / "esphome_pch.h.gch"
|
||||
|
||||
def probe_dies(cmd, **kwargs):
|
||||
if "-fsyntax-only" in cmd:
|
||||
raise OSError("probe spawn failed")
|
||||
gch.write_bytes(b"gch")
|
||||
return subprocess.CompletedProcess(cmd, 0, "", "")
|
||||
|
||||
with (
|
||||
patch.object(CORE, "name", "test"),
|
||||
patch("esphome.build_helpers.pch.subprocess.run", side_effect=probe_dies),
|
||||
):
|
||||
prepare_pch()
|
||||
assert not gch.exists()
|
||||
assert not (dev / "build" / "esphome_pch.h.gch.sum").exists()
|
||||
|
||||
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
|
||||
with (
|
||||
patch.object(CORE, "name", "test"),
|
||||
patch("esphome.build_helpers.pch.subprocess.run", side_effect=probe_dies),
|
||||
pytest.raises(EsphomeError, match="probe did not run"),
|
||||
):
|
||||
prepare_pch()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("stderr", "code"),
|
||||
[("", -9), ("fatal: No space left on device", 1)],
|
||||
ids=("signal-kill", "enospc"),
|
||||
)
|
||||
def test_prepare_pch_probe_environmental_failures_do_not_latch(
|
||||
tmp_path: Path, stderr: str, code: int
|
||||
) -> None:
|
||||
"""A signal-killed or ENOSPC probe retries next build, no marker."""
|
||||
from esphome.build_gen.espidf import prepare_pch
|
||||
|
||||
dev = _make_pch_device(tmp_path, "dev_pe")
|
||||
CORE.build_path = dev
|
||||
gch = dev / "build" / "esphome_pch.h.gch"
|
||||
|
||||
def env_probe(cmd, **kwargs):
|
||||
if "-fsyntax-only" in cmd:
|
||||
return subprocess.CompletedProcess(cmd, code, "", stderr)
|
||||
gch.write_bytes(b"gch")
|
||||
return subprocess.CompletedProcess(cmd, 0, "", "")
|
||||
|
||||
with (
|
||||
patch.object(CORE, "name", "test"),
|
||||
patch("esphome.build_helpers.pch.subprocess.run", side_effect=env_probe),
|
||||
):
|
||||
prepare_pch()
|
||||
assert not gch.exists()
|
||||
assert not (dev / "build" / "esphome_pch.h.gch.failed").exists()
|
||||
|
||||
|
||||
def test_prepare_pch_signal_kill_strict_raises(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
from esphome.build_gen.espidf import prepare_pch
|
||||
from esphome.core import EsphomeError
|
||||
|
||||
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
|
||||
dev = _make_pch_device(tmp_path, "dev_ks")
|
||||
CORE.build_path = dev
|
||||
with (
|
||||
patch.object(CORE, "name", "test"),
|
||||
patch(
|
||||
"esphome.build_helpers.pch.subprocess.run",
|
||||
side_effect=lambda cmd, **kw: subprocess.CompletedProcess(cmd, -9, "", ""),
|
||||
),
|
||||
pytest.raises(EsphomeError, match="killed by signal"),
|
||||
):
|
||||
prepare_pch()
|
||||
|
||||
|
||||
def test_prepare_pch_strict_raises_when_disabled(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Strict must not read a disabled pch as success."""
|
||||
from esphome.build_gen.espidf import prepare_pch
|
||||
from esphome.core import EsphomeError
|
||||
|
||||
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0")
|
||||
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
|
||||
dev = _make_pch_device(tmp_path, "dev_ds")
|
||||
CORE.build_path = dev
|
||||
with (
|
||||
patch.object(CORE, "name", "test"),
|
||||
pytest.raises(EsphomeError, match="disabled"),
|
||||
):
|
||||
prepare_pch()
|
||||
|
||||
|
||||
def test_prepare_pch_missing_sdkconfig_strict_raises(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
from esphome.build_gen.espidf import prepare_pch
|
||||
from esphome.core import EsphomeError
|
||||
|
||||
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
|
||||
dev = _make_pch_device(tmp_path, "dev_ss")
|
||||
(dev / "sdkconfig.test").unlink()
|
||||
CORE.build_path = dev
|
||||
with (
|
||||
patch.object(CORE, "name", "test"),
|
||||
pytest.raises(EsphomeError, match="sdkconfig unreadable"),
|
||||
):
|
||||
prepare_pch()
|
||||
|
||||
|
||||
def test_prepare_pch_without_compile_commands(tmp_path: Path) -> None:
|
||||
"""Stale checksum but no configured TU yet: no compile, no sidecars."""
|
||||
from esphome.build_gen.espidf import prepare_pch
|
||||
|
||||
dev = _make_pch_device(tmp_path, "dev_n")
|
||||
(dev / "build" / "compile_commands.json").unlink()
|
||||
CORE.build_path = dev
|
||||
with (
|
||||
patch.object(CORE, "name", "test"),
|
||||
patch("esphome.build_helpers.pch.subprocess.run", side_effect=AssertionError),
|
||||
):
|
||||
prepare_pch()
|
||||
assert not (dev / "build" / "esphome_pch.h.gch.sum").exists()
|
||||
assert not (dev / "build" / "esphome_pch.h.gch.failed").exists()
|
||||
|
||||
|
||||
def test_write_project_pch_disabled_writes_no_header(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
from esphome.build_gen.espidf import write_project
|
||||
|
||||
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0")
|
||||
_write_project_description(tmp_path, {})
|
||||
CORE.build_path = tmp_path
|
||||
with (
|
||||
patch("esphome.build_gen.espidf.get_esp32_variant", return_value="ESP32"),
|
||||
patch.object(CORE, "name", "test"),
|
||||
):
|
||||
write_project()
|
||||
assert not (tmp_path / "build" / "esphome_pch.h").exists()
|
||||
|
||||
|
||||
def test_write_project_writes_pch_header(tmp_path: Path) -> None:
|
||||
"""The header write_project emits is what _pch_cmake() force-includes;
|
||||
this pairing is the one non-fail-safe path in the design."""
|
||||
from esphome.build_gen.espidf import write_project
|
||||
from esphome.build_helpers.pch import PCH_DEFAULT_HEADERS, pch_header_text
|
||||
|
||||
_write_project_description(tmp_path, {})
|
||||
CORE.build_path = tmp_path
|
||||
with (
|
||||
patch("esphome.build_gen.espidf.get_esp32_variant", return_value="ESP32"),
|
||||
patch.object(CORE, "name", "test"),
|
||||
):
|
||||
write_project()
|
||||
assert (tmp_path / "build" / "esphome_pch.h").read_text() == pch_header_text(
|
||||
PCH_DEFAULT_HEADERS
|
||||
)
|
||||
|
||||
|
||||
def test_prepare_pch_stale_bailout_removes_gch(tmp_path: Path) -> None:
|
||||
"""A stale .gch must not survive when no compile command is available."""
|
||||
from esphome.build_gen.espidf import prepare_pch
|
||||
|
||||
dev = _make_pch_device(tmp_path, "dev_s")
|
||||
CORE.build_path = dev
|
||||
gch = dev / "build" / "esphome_pch.h.gch"
|
||||
gch.write_bytes(b"stale")
|
||||
(dev / "build" / "esphome_pch.h.gch.sum").write_text("stale-sum\n")
|
||||
(dev / "build" / "compile_commands.json").unlink()
|
||||
with patch.object(CORE, "name", "test"):
|
||||
prepare_pch()
|
||||
assert not gch.exists()
|
||||
assert not (dev / "build" / "esphome_pch.h.gch.sum").exists()
|
||||
|
||||
|
||||
def test_prepare_pch_zero_exit_without_gch_is_failure(tmp_path: Path) -> None:
|
||||
from esphome.build_gen.espidf import prepare_pch
|
||||
|
||||
dev = _make_pch_device(tmp_path, "dev_z")
|
||||
CORE.build_path = dev
|
||||
|
||||
def no_output(cmd, **kwargs):
|
||||
return subprocess.CompletedProcess(cmd, 0, "", "")
|
||||
|
||||
with (
|
||||
patch.object(CORE, "name", "test"),
|
||||
patch("esphome.build_helpers.pch.subprocess.run", side_effect=no_output),
|
||||
):
|
||||
prepare_pch()
|
||||
assert not (dev / "build" / "esphome_pch.h.gch.sum").exists()
|
||||
assert (dev / "build" / "esphome_pch.h.gch.failed").exists()
|
||||
|
||||
|
||||
def test_prepare_pch_bumps_header_for_object_depends(tmp_path: Path) -> None:
|
||||
"""The OBJECT_DEPENDS edge watches the header; a rebuilt .gch must bump
|
||||
it so pch-consuming TUs recompile."""
|
||||
import os as _os
|
||||
|
||||
from esphome.build_gen.espidf import prepare_pch
|
||||
|
||||
dev = _make_pch_device(tmp_path, "dev_t")
|
||||
CORE.build_path = dev
|
||||
header = dev / "build" / "esphome_pch.h"
|
||||
gch = dev / "build" / "esphome_pch.h.gch"
|
||||
_os.utime(header, (0, 0))
|
||||
before = header.stat().st_mtime
|
||||
|
||||
def fake_compile(cmd, **kwargs):
|
||||
gch.write_bytes(b"gch")
|
||||
return subprocess.CompletedProcess(cmd, 0, "", "")
|
||||
|
||||
with (
|
||||
patch.object(CORE, "name", "test"),
|
||||
patch("esphome.build_helpers.pch.subprocess.run", side_effect=fake_compile),
|
||||
):
|
||||
prepare_pch()
|
||||
assert header.stat().st_mtime > before
|
||||
|
||||
|
||||
def test_component_cmakelists_pch_object_depends() -> None:
|
||||
from esphome.build_gen.espidf import get_component_cmakelists
|
||||
|
||||
content = get_component_cmakelists()
|
||||
assert 'OBJECT_DEPENDS "${CMAKE_BINARY_DIR}/esphome_pch.h"' in content
|
||||
|
||||
|
||||
def test_prepare_pch_command_change_invalidates_sum(tmp_path: Path) -> None:
|
||||
"""A flag-only change in the compile DB must rebuild the .gch."""
|
||||
from esphome.build_gen.espidf import prepare_pch
|
||||
|
||||
dev = _make_pch_device(tmp_path, "dev_c")
|
||||
CORE.build_path = dev
|
||||
gch = dev / "build" / "esphome_pch.h.gch"
|
||||
|
||||
def fake_compile(cmd, **kwargs):
|
||||
gch.write_bytes(b"gch")
|
||||
return subprocess.CompletedProcess(cmd, 0, "", "")
|
||||
|
||||
with (
|
||||
patch.object(CORE, "name", "test"),
|
||||
patch("esphome.build_helpers.pch.subprocess.run", side_effect=fake_compile),
|
||||
):
|
||||
prepare_pch()
|
||||
first = (dev / "build" / "esphome_pch.h.gch.sum").read_text()
|
||||
db = dev / "build" / "compile_commands.json"
|
||||
db.write_text(db.read_text().replace("-DX=1", "-DX=2"))
|
||||
prepare_pch()
|
||||
assert (dev / "build" / "esphome_pch.h.gch.sum").read_text() != first
|
||||
|
||||
|
||||
def test_prepare_pch_keeps_user_force_includes(tmp_path: Path) -> None:
|
||||
from esphome.build_helpers.pch import pch_compile_command
|
||||
|
||||
dev = _make_pch_device(tmp_path, "dev_u")
|
||||
CORE.build_path = dev
|
||||
build = dev / "build"
|
||||
src_file = str(dev / "src" / "esphome" / "a.cpp")
|
||||
build.joinpath("compile_commands.json").write_text(
|
||||
json.dumps(
|
||||
[
|
||||
{
|
||||
"directory": str(build),
|
||||
"command": (
|
||||
"g++ -include user.h -include esphome_pch.h "
|
||||
f"-o a.obj -c {src_file}"
|
||||
),
|
||||
"file": src_file,
|
||||
}
|
||||
]
|
||||
)
|
||||
)
|
||||
cmd, _ = pch_compile_command(build, build / "esphome_pch.h", build / "x.gch")
|
||||
assert "user.h" in cmd
|
||||
assert "esphome_pch.h" not in " ".join(cmd[:-3])
|
||||
|
||||
|
||||
def test_prepare_pch_identity_unknown_discards(tmp_path: Path) -> None:
|
||||
"""An OSError from the checksum discards artifacts and skips the pch."""
|
||||
from esphome.build_gen.espidf import prepare_pch
|
||||
|
||||
dev = _make_pch_device(tmp_path, "dev_i")
|
||||
CORE.build_path = dev
|
||||
stale = dev / "build" / "esphome_pch.h.gch"
|
||||
stale.write_bytes(b"stale")
|
||||
with (
|
||||
patch.object(CORE, "name", "test"),
|
||||
patch(
|
||||
"esphome.build_helpers.pch.pch_checksum",
|
||||
side_effect=OSError("stat failed"),
|
||||
),
|
||||
patch("esphome.build_helpers.pch.subprocess.run", side_effect=AssertionError),
|
||||
):
|
||||
prepare_pch()
|
||||
assert not stale.exists()
|
||||
assert not (dev / "build" / "esphome_pch.h.gch.sum").exists()
|
||||
|
||||
|
||||
def test_prepare_pch_transient_compiler_failure_does_not_latch(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""ENOSPC-style failures clear on their own; no .failed marker."""
|
||||
from esphome.build_gen.espidf import prepare_pch
|
||||
|
||||
dev = _make_pch_device(tmp_path, "dev_e")
|
||||
CORE.build_path = dev
|
||||
calls = []
|
||||
|
||||
def enospc(cmd, **kwargs):
|
||||
calls.append(cmd)
|
||||
return subprocess.CompletedProcess(
|
||||
cmd, 1, "", "fatal error: No space left on device"
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(CORE, "name", "test"),
|
||||
patch("esphome.build_helpers.pch.subprocess.run", side_effect=enospc),
|
||||
):
|
||||
prepare_pch()
|
||||
prepare_pch()
|
||||
assert not (dev / "build" / "esphome_pch.h.gch.failed").exists()
|
||||
assert len(calls) == 2
|
||||
|
||||
|
||||
def test_prepare_pch_strict_raises_on_missing_db(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""ESPHOME_PCH_STRICT turns the silent skip into a failure."""
|
||||
from esphome.build_gen.espidf import prepare_pch
|
||||
from esphome.core import EsphomeError
|
||||
|
||||
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
|
||||
dev = _make_pch_device(tmp_path, "dev_st")
|
||||
(dev / "build" / "compile_commands.json").unlink()
|
||||
CORE.build_path = dev
|
||||
with (
|
||||
patch.object(CORE, "name", "test"),
|
||||
patch("esphome.build_helpers.pch.subprocess.run", side_effect=AssertionError),
|
||||
pytest.raises(EsphomeError, match="no usable compile command"),
|
||||
):
|
||||
prepare_pch()
|
||||
|
||||
|
||||
def test_prepare_pch_probe_rejection_latches_and_degrades(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A toolchain that cannot load its own .gch discards it, latches the
|
||||
marker, and fails strict mode."""
|
||||
from esphome.build_gen.espidf import prepare_pch
|
||||
from esphome.core import EsphomeError
|
||||
|
||||
dev = _make_pch_device(tmp_path, "dev_p")
|
||||
CORE.build_path = dev
|
||||
gch = dev / "build" / "esphome_pch.h.gch"
|
||||
|
||||
def rejecting(cmd, **kwargs):
|
||||
if "-fsyntax-only" in cmd:
|
||||
if "-include" not in cmd:
|
||||
# Baseline without the pch passes: the pch is to blame
|
||||
return subprocess.CompletedProcess(cmd, 0, "", "")
|
||||
return subprocess.CompletedProcess(
|
||||
cmd, 1, "", "error: esphome_pch.h.gch: had text segment "
|
||||
)
|
||||
gch.write_bytes(b"gch")
|
||||
return subprocess.CompletedProcess(cmd, 0, "", "")
|
||||
|
||||
with (
|
||||
patch.object(CORE, "name", "test"),
|
||||
patch("esphome.build_helpers.pch.subprocess.run", side_effect=rejecting),
|
||||
):
|
||||
prepare_pch()
|
||||
assert not gch.exists()
|
||||
assert (dev / "build" / "esphome_pch.h.gch.failed").exists()
|
||||
|
||||
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
|
||||
(dev / "build" / "esphome_pch.h.gch.failed").unlink()
|
||||
with (
|
||||
patch.object(CORE, "name", "test"),
|
||||
patch("esphome.build_helpers.pch.subprocess.run", side_effect=rejecting),
|
||||
pytest.raises(EsphomeError, match="cannot load the pch"),
|
||||
):
|
||||
prepare_pch()
|
||||
|
||||
|
||||
def test_prepare_pch_strict_reprobes_cached_gch(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Rejection is per-process: strict must re-prove a cached .gch loads."""
|
||||
from esphome.build_gen.espidf import prepare_pch
|
||||
from esphome.core import EsphomeError
|
||||
|
||||
dev = _make_pch_device(tmp_path, "dev_rc")
|
||||
CORE.build_path = dev
|
||||
gch = dev / "build" / "esphome_pch.h.gch"
|
||||
|
||||
def ok(cmd, **kwargs):
|
||||
if "-fsyntax-only" not in cmd:
|
||||
gch.write_bytes(b"gch")
|
||||
return subprocess.CompletedProcess(cmd, 0, "", "")
|
||||
|
||||
with (
|
||||
patch.object(CORE, "name", "test"),
|
||||
patch("esphome.build_helpers.pch.subprocess.run", side_effect=ok),
|
||||
):
|
||||
prepare_pch()
|
||||
assert gch.exists()
|
||||
|
||||
def reject(cmd, **kwargs):
|
||||
assert "-fsyntax-only" in cmd, "cached path must not recompile"
|
||||
if "-include" not in cmd:
|
||||
return subprocess.CompletedProcess(cmd, 0, "", "")
|
||||
return subprocess.CompletedProcess(
|
||||
cmd, 1, "", "error: esphome_pch.h.gch: had text segment "
|
||||
)
|
||||
|
||||
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
|
||||
with (
|
||||
patch.object(CORE, "name", "test"),
|
||||
patch("esphome.build_helpers.pch.subprocess.run", side_effect=reject),
|
||||
pytest.raises(EsphomeError, match="cannot load the pch"),
|
||||
):
|
||||
prepare_pch()
|
||||
assert not gch.exists()
|
||||
# Per-process rejection may not reproduce: the cached path must not
|
||||
# latch the pch off for later non-strict builds
|
||||
assert not (dev / "build" / "esphome_pch.h.gch.failed").exists()
|
||||
|
||||
|
||||
def test_prepare_pch_unexpected_command_shape_degrades(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A tail the probe slice cannot trust discards and degrades."""
|
||||
from esphome.build_gen.espidf import prepare_pch
|
||||
from esphome.core import EsphomeError
|
||||
|
||||
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
|
||||
dev = _make_pch_device(tmp_path, "dev_sh")
|
||||
CORE.build_path = dev
|
||||
gch = dev / "build" / "esphome_pch.h.gch"
|
||||
|
||||
def ok(cmd, **kwargs):
|
||||
gch.write_bytes(b"gch")
|
||||
return subprocess.CompletedProcess(cmd, 0, "", "")
|
||||
|
||||
with (
|
||||
patch.object(CORE, "name", "test"),
|
||||
patch("esphome.build_helpers.pch.subprocess.run", side_effect=ok),
|
||||
patch(
|
||||
"esphome.build_helpers.pch.pch_compile_command",
|
||||
return_value=(
|
||||
["g++", "-DX=1", "-c", "x", "-o", "y", "extra"],
|
||||
dev / "build",
|
||||
),
|
||||
),
|
||||
pytest.raises(EsphomeError, match="command shape"),
|
||||
):
|
||||
prepare_pch()
|
||||
assert not gch.exists()
|
||||
|
||||
# Non-strict: same shape problem degrades without raising
|
||||
monkeypatch.delenv("ESPHOME_PCH_STRICT")
|
||||
with (
|
||||
patch.object(CORE, "name", "test"),
|
||||
patch("esphome.build_helpers.pch.subprocess.run", side_effect=ok),
|
||||
patch(
|
||||
"esphome.build_helpers.pch.pch_compile_command",
|
||||
return_value=(
|
||||
["g++", "-DX=1", "-c", "x", "-o", "y", "extra"],
|
||||
dev / "build",
|
||||
),
|
||||
),
|
||||
):
|
||||
prepare_pch()
|
||||
assert not gch.exists()
|
||||
|
||||
|
||||
def test_prepare_pch_probe_baseline_spawn_failure_is_transient(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""A baseline that cannot spawn is environmental: no marker."""
|
||||
from esphome.build_gen.espidf import prepare_pch
|
||||
|
||||
dev = _make_pch_device(tmp_path, "dev_bs")
|
||||
CORE.build_path = dev
|
||||
gch = dev / "build" / "esphome_pch.h.gch"
|
||||
|
||||
def flaky(cmd, **kwargs):
|
||||
if "-fsyntax-only" in cmd:
|
||||
if "-include" in cmd:
|
||||
return subprocess.CompletedProcess(cmd, 1, "", "boom")
|
||||
raise OSError("baseline spawn failed")
|
||||
gch.write_bytes(b"gch")
|
||||
return subprocess.CompletedProcess(cmd, 0, "", "")
|
||||
|
||||
with (
|
||||
patch.object(CORE, "name", "test"),
|
||||
patch("esphome.build_helpers.pch.subprocess.run", side_effect=flaky),
|
||||
):
|
||||
prepare_pch()
|
||||
assert not gch.exists()
|
||||
assert not (dev / "build" / "esphome_pch.h.gch.failed").exists()
|
||||
|
||||
@@ -10,7 +10,6 @@ from unittest.mock import patch
|
||||
import pytest
|
||||
|
||||
from esphome.build_helpers import ccache
|
||||
from esphome.core import CORE
|
||||
|
||||
|
||||
def test_resolve_opt_out() -> None:
|
||||
@@ -121,17 +120,3 @@ def test_parse_enable_env_spelling_tables(
|
||||
"""cv.boolean's spelling tables plus the 1/0 env convention."""
|
||||
monkeypatch.setenv("ESPHOME_CCACHE_ENABLE", raw)
|
||||
assert ccache.parse_enable_env("ESPHOME_CCACHE_ENABLE") is expected
|
||||
|
||||
|
||||
def test_effective_ccache_basedir_prefers_user_value(tmp_path: Path) -> None:
|
||||
CORE.build_path = tmp_path
|
||||
# Drive-qualified on Windows: "/custom/base" is not absolute there
|
||||
base = "C:\\custom\\base" if os.name == "nt" else "/custom/base"
|
||||
with patch.dict(os.environ, {"CCACHE_BASEDIR": base}, clear=True):
|
||||
assert ccache.effective_ccache_basedir() == base
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
assert ccache.effective_ccache_basedir() == str(tmp_path.resolve())
|
||||
# Degenerate values would strip substrings ccache never rewrites
|
||||
for bad in ("", "/", "a/b"):
|
||||
with patch.dict(os.environ, {"CCACHE_BASEDIR": bad}, clear=True):
|
||||
assert ccache.effective_ccache_basedir() == str(tmp_path.resolve())
|
||||
|
||||
@@ -85,87 +85,6 @@ def test_parse_entry_resolves_relative_includes() -> None:
|
||||
assert all(Path(inc).is_absolute() for inc in includes)
|
||||
|
||||
|
||||
def test_parse_entry_resolves_force_include_path(tmp_path: Path) -> None:
|
||||
"""The pch -include is emitted relative to the build dir; idedata must
|
||||
resolve it so cached flags work from any cwd."""
|
||||
(tmp_path / "esphome_pch.h").write_text("")
|
||||
entry = _entry(
|
||||
str(tmp_path),
|
||||
f"{tmp_path}/src/esphome/x.cpp",
|
||||
"g++ -include esphome_pch.h -c x.cpp",
|
||||
)
|
||||
|
||||
_, _, _, cxx_flags = idedata.parse_entry(entry)
|
||||
|
||||
idx = cxx_flags.index("-include")
|
||||
resolved = cxx_flags[idx + 1]
|
||||
assert Path(resolved).is_absolute()
|
||||
assert resolved == str(tmp_path / "esphome_pch.h").replace("\\", "/")
|
||||
|
||||
|
||||
def test_parse_entry_resolves_joined_force_include(tmp_path: Path) -> None:
|
||||
"""The joined -includefoo.h spelling takes the same resolve path."""
|
||||
(tmp_path / "esphome_pch.h").write_text("")
|
||||
entry = _entry(
|
||||
str(tmp_path),
|
||||
f"{tmp_path}/src/esphome/x.cpp",
|
||||
"g++ -includeesphome_pch.h -c x.cpp",
|
||||
)
|
||||
|
||||
_, _, _, cxx_flags = idedata.parse_entry(entry)
|
||||
|
||||
resolved = cxx_flags[cxx_flags.index("-include") + 1]
|
||||
assert resolved == str(tmp_path / "esphome_pch.h").replace("\\", "/")
|
||||
|
||||
|
||||
def test_parse_entry_keeps_search_chain_force_include(tmp_path: Path) -> None:
|
||||
"""-include names resolved via the -I chain (libretiny's Arduino.h) must
|
||||
not be re-anchored to a nonexistent build-dir path."""
|
||||
entry = _entry(
|
||||
str(tmp_path),
|
||||
f"{tmp_path}/src/esphome/x.cpp",
|
||||
"g++ -include Arduino.h -c x.cpp",
|
||||
)
|
||||
|
||||
_, _, _, cxx_flags = idedata.parse_entry(entry)
|
||||
|
||||
assert cxx_flags[cxx_flags.index("-include") + 1] == "Arduino.h"
|
||||
|
||||
|
||||
def test_parse_entry_warns_on_vanished_force_include(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A build-dir force-include deleted by clean_build must leave a trail;
|
||||
a name resolvable via the -I chain must not warn."""
|
||||
inc = tmp_path / "inc"
|
||||
inc.mkdir()
|
||||
(inc / "Arduino.h").write_text("")
|
||||
entry = _entry(
|
||||
str(tmp_path),
|
||||
f"{tmp_path}/src/esphome/x.cpp",
|
||||
f"g++ -I{inc} -include Arduino.h -include esphome_pch.h -c x.cpp",
|
||||
)
|
||||
|
||||
_, _, _, cxx_flags = idedata.parse_entry(entry)
|
||||
|
||||
assert "Arduino.h" in cxx_flags
|
||||
assert "esphome_pch.h" in caplog.text
|
||||
assert "Arduino.h" not in caplog.text
|
||||
|
||||
|
||||
def test_parse_entry_drops_trailing_force_include(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
entry = _entry(
|
||||
str(tmp_path), f"{tmp_path}/src/esphome/x.cpp", "g++ -c x.cpp -include"
|
||||
)
|
||||
|
||||
_, _, _, cxx_flags = idedata.parse_entry(entry)
|
||||
|
||||
assert "-include" not in cxx_flags
|
||||
assert "no argument" in caplog.text
|
||||
|
||||
|
||||
def test_parse_entry_skips_dependency_flags() -> None:
|
||||
"""Dependency-generation flags (and their args) are dropped."""
|
||||
entry = _entry(
|
||||
@@ -185,7 +104,7 @@ def test_expand_response_files(tmp_path: Path) -> None:
|
||||
rsp = tmp_path / "flags.rsp"
|
||||
rsp.write_text("-DFROM_RSP -I/rsp/inc")
|
||||
|
||||
tokens = idedata.expand_response_files(
|
||||
tokens = idedata._expand_response_files(
|
||||
["g++", f"@{rsp.name}", "-c", "x.cpp"], tmp_path
|
||||
)
|
||||
|
||||
@@ -196,7 +115,7 @@ def test_expand_response_files(tmp_path: Path) -> None:
|
||||
|
||||
def test_expand_response_files_keeps_literal_when_missing(tmp_path: Path) -> None:
|
||||
"""An unreadable ``@file`` token is kept verbatim rather than dropped."""
|
||||
tokens = idedata.expand_response_files(["g++", "@nope.rsp"], tmp_path)
|
||||
tokens = idedata._expand_response_files(["g++", "@nope.rsp"], tmp_path)
|
||||
assert "@nope.rsp" in tokens
|
||||
|
||||
|
||||
@@ -408,7 +327,7 @@ def test_split_command_preserves_paths_and_unescapes_quotes() -> None:
|
||||
r"""Backslash paths survive while ``\"`` define-quoting is unescaped."""
|
||||
command = r"C:\esp\bin\riscv32-esp-elf-g++.exe -DVER=\"1.2.3\" -IC:/inc/a -c x.cpp"
|
||||
|
||||
tokens = idedata.split_command(command)
|
||||
tokens = idedata._split_command(command)
|
||||
|
||||
assert tokens[0] == r"C:\esp\bin\riscv32-esp-elf-g++.exe"
|
||||
assert '-DVER="1.2.3"' in tokens
|
||||
@@ -422,8 +341,8 @@ def test_split_command_empty_returns_empty() -> None:
|
||||
Guards against ``CommandLineToArgvW("")`` returning the current process name
|
||||
instead of an empty list.
|
||||
"""
|
||||
assert idedata.split_command("") == []
|
||||
assert idedata.split_command(" ") == []
|
||||
assert idedata._split_command("") == []
|
||||
assert idedata._split_command(" ") == []
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name != "nt", reason="Windows argv tokenization")
|
||||
@@ -584,9 +503,9 @@ def test_load_or_build_idedata_rebuilds_non_dict_cache(tmp_path: Path) -> None:
|
||||
def test_is_launcher_matches_only_known_launchers() -> None:
|
||||
"""Compilers of any shape pass; only the closed launcher set matches."""
|
||||
for token in ("/t/g++-13", "gcc-8.4.0", "clang++-17", "armcc", "icx", "cc"):
|
||||
assert not idedata.is_launcher(token)
|
||||
assert not idedata._is_launcher(token)
|
||||
for token in ("/opt/homebrew/bin/ccache", "CCACHE.EXE", "distcc", "sccache"):
|
||||
assert idedata.is_launcher(token)
|
||||
assert idedata._is_launcher(token)
|
||||
|
||||
|
||||
def test_load_or_build_idedata_corrupted_cache_is_logged(
|
||||
|
||||
@@ -1,381 +0,0 @@
|
||||
"""Tests for esphome.build_helpers.pch."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.build_helpers import pch
|
||||
|
||||
|
||||
def _write(src_dir: Path, name: str, content: str) -> None:
|
||||
path = src_dir / name
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[
|
||||
(None, True),
|
||||
("1", True),
|
||||
("0", False),
|
||||
("false", False),
|
||||
("", False),
|
||||
],
|
||||
)
|
||||
def test_pch_enabled(value: str | None, expected: bool) -> None:
|
||||
env = {} if value is None else {"ESPHOME_PCH_ENABLE": value}
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
assert pch.pch_enabled() is expected
|
||||
|
||||
|
||||
def test_ccache_pch_env_empty_until_emitted() -> None:
|
||||
"""No sloppiness relaxation for a build that skipped the pch."""
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
assert pch.ccache_pch_env() == {}
|
||||
|
||||
|
||||
def test_ccache_pch_env_enabled() -> None:
|
||||
pch.mark_pch_emitted()
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
env = pch.ccache_pch_env()
|
||||
assert env["CCACHE_SLOPPINESS"] == "pch_defines,time_macros"
|
||||
assert env["CCACHE_PCH_EXTSUM"] == "true"
|
||||
|
||||
|
||||
def test_ccache_pch_env_disabled() -> None:
|
||||
with patch.dict(os.environ, {"ESPHOME_PCH_ENABLE": "0"}, clear=True):
|
||||
assert pch.ccache_pch_env() == {}
|
||||
|
||||
|
||||
def test_ccache_pch_env_token_check_is_membership_not_substring(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A token merely containing ours must not suppress the union."""
|
||||
pch.mark_pch_emitted()
|
||||
with patch.dict(os.environ, {"CCACHE_SLOPPINESS": "pch_defines_extra"}, clear=True):
|
||||
env = pch.ccache_pch_env()
|
||||
assert env["CCACHE_SLOPPINESS"] == "pch_defines_extra,pch_defines,time_macros"
|
||||
|
||||
|
||||
def test_ccache_pch_env_unions_user_sloppiness(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Without pch_defines/time_macros ccache declines every pch-consuming
|
||||
compile, so missing tokens are unioned onto the user's value."""
|
||||
pch.mark_pch_emitted()
|
||||
with patch.dict(os.environ, {"CCACHE_SLOPPINESS": "locale"}, clear=True):
|
||||
env = pch.ccache_pch_env()
|
||||
assert env["CCACHE_SLOPPINESS"] == "locale,pch_defines,time_macros"
|
||||
assert env["CCACHE_PCH_EXTSUM"] == "true"
|
||||
assert "Adding pch_defines,time_macros" in caplog.text
|
||||
caplog.clear()
|
||||
with patch.dict(
|
||||
os.environ, {"CCACHE_SLOPPINESS": "pch_defines,time_macros"}, clear=True
|
||||
):
|
||||
env = pch.ccache_pch_env()
|
||||
assert "CCACHE_SLOPPINESS" not in env
|
||||
assert not caplog.records
|
||||
|
||||
|
||||
def test_pch_header_text_preserves_order() -> None:
|
||||
text = pch.pch_header_text(["b.h", "a.h"])
|
||||
assert text == '#include "b.h"\n#include "a.h"\n'
|
||||
|
||||
|
||||
def test_include_closure_resolves_relative_and_root(tmp_path: Path) -> None:
|
||||
"""Sibling includes resolve against the includer's directory first,
|
||||
full paths against the src root; unresolvable names end the walk."""
|
||||
_write(tmp_path, "esphome/components/x/a.h", '#include "b.h"\n')
|
||||
_write(
|
||||
tmp_path,
|
||||
"esphome/components/x/b.h",
|
||||
'#include "esphome/core/deep.h"\n#include <system.h>\n#include "missing.h"\n',
|
||||
)
|
||||
_write(tmp_path, "esphome/core/deep.h", "")
|
||||
closure = pch._include_closure(tmp_path, ["esphome/components/x/a.h"])
|
||||
assert sorted(closure) == [
|
||||
"esphome/components/x/a.h",
|
||||
"esphome/components/x/b.h",
|
||||
"esphome/core/deep.h",
|
||||
]
|
||||
|
||||
|
||||
def test_include_closure_handles_cycles(tmp_path: Path) -> None:
|
||||
_write(tmp_path, "a.h", '#include "b.h"\n')
|
||||
_write(tmp_path, "b.h", '#include "a.h"\n')
|
||||
assert sorted(pch._include_closure(tmp_path, ["a.h"])) == ["a.h", "b.h"]
|
||||
|
||||
|
||||
def test_include_closure_blocks_parent_escape(tmp_path: Path) -> None:
|
||||
_write(tmp_path / "src", "a.h", '#include "../outside.h"\n')
|
||||
(tmp_path / "outside.h").write_text("")
|
||||
assert sorted(pch._include_closure(tmp_path / "src", ["a.h"])) == ["a.h"]
|
||||
|
||||
|
||||
def test_pch_checksum_tracks_closure_content(tmp_path: Path) -> None:
|
||||
"""A transitive header edit or an extra-identity change must change the
|
||||
digest; unrelated files must not."""
|
||||
_write(tmp_path, "root.h", '#include "nested.h"\n')
|
||||
_write(tmp_path, "nested.h", "int a;\n")
|
||||
_write(tmp_path, "unrelated.h", "int u;\n")
|
||||
base = pch.pch_checksum(tmp_path, ["root.h"], ["id"])
|
||||
assert base == pch.pch_checksum(tmp_path, ["root.h"], ["id"])
|
||||
assert base != pch.pch_checksum(tmp_path, ["root.h"], ["other-id"])
|
||||
_write(tmp_path, "unrelated.h", "int changed;\n")
|
||||
assert base == pch.pch_checksum(tmp_path, ["root.h"], ["id"])
|
||||
_write(tmp_path, "nested.h", "int b;\n")
|
||||
assert base != pch.pch_checksum(tmp_path, ["root.h"], ["id"])
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
os.name == "nt" or os.geteuid() == 0, reason="chmod is ineffective here"
|
||||
)
|
||||
def test_include_closure_fails_closed_on_unreadable(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A marker would truncate the transitive walk; the OSError propagates
|
||||
so callers compile without a pch."""
|
||||
_write(tmp_path, "a.h", '#include "locked.h"\n')
|
||||
locked = tmp_path / "locked.h"
|
||||
locked.write_text("")
|
||||
locked.chmod(0)
|
||||
try:
|
||||
with pytest.raises(OSError):
|
||||
pch._include_closure(tmp_path, ["a.h"])
|
||||
finally:
|
||||
locked.chmod(0o644)
|
||||
assert "Could not read locked.h" in caplog.text
|
||||
|
||||
|
||||
def test_pch_extra_scripts_gated(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
assert pch.pch_extra_scripts() == ["post:pch.py"]
|
||||
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0")
|
||||
assert pch.pch_extra_scripts() == []
|
||||
|
||||
|
||||
def test_include_closure_raises_when_identity_unknown(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""An unreadable header propagates; callers compile without a pch."""
|
||||
|
||||
class _BadFile:
|
||||
def stat(self): # noqa: ANN202 -- regular-file mode only
|
||||
import os
|
||||
import stat as stat_mod
|
||||
|
||||
return os.stat_result((stat_mod.S_IFREG | 0o644,) + (0,) * 9)
|
||||
|
||||
def read_bytes(self) -> bytes:
|
||||
raise OSError("read failed")
|
||||
|
||||
class _FakeSrcDir:
|
||||
def __truediv__(self, rel: str) -> _BadFile:
|
||||
return _BadFile()
|
||||
|
||||
with pytest.raises(OSError, match="read failed"):
|
||||
pch._include_closure(_FakeSrcDir(), ["a.h"])
|
||||
assert "Could not read a.h" in caplog.text
|
||||
|
||||
|
||||
def test_include_closure_survives_non_utf8_include_name(tmp_path: Path) -> None:
|
||||
"""A non-UTF-8 quoted include must not abort the build; it simply does
|
||||
not resolve and ends the walk."""
|
||||
(tmp_path / "a.h").write_bytes(b'#include "bad\xff.h"\n#include "b.h"\n')
|
||||
(tmp_path / "b.h").write_text("")
|
||||
closure = pch._include_closure(tmp_path, ["a.h"])
|
||||
assert set(closure) == {"a.h", "b.h"}
|
||||
|
||||
|
||||
def test_pch_checksum_survives_surrogate_extra(tmp_path: Path) -> None:
|
||||
"""Install paths from non-UTF-8 filesystems carry surrogates; hashing
|
||||
them must not raise past the caller's identity-unknown guard."""
|
||||
assert pch.pch_checksum(tmp_path, [], ["/opt/bad\udcff/framework"])
|
||||
|
||||
|
||||
def test_include_closure_walks_angle_includes_under_src(tmp_path: Path) -> None:
|
||||
"""An angle include resolving under src/ must enter the digest; one
|
||||
that does not simply ends the walk."""
|
||||
_write(tmp_path, "a.h", "#include <local.h>\n#include <Arduino.h>\n")
|
||||
(tmp_path / "local.h").write_text("")
|
||||
closure = pch._include_closure(tmp_path, ["a.h"])
|
||||
assert set(closure) == {"a.h", "local.h"}
|
||||
|
||||
|
||||
def test_ccache_pch_env_warns_on_falsy_extsum(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A user CCACHE_PCH_EXTSUM=false makes ccache hash the .gch bytes."""
|
||||
pch.mark_pch_emitted()
|
||||
with patch.dict(os.environ, {"CCACHE_PCH_EXTSUM": "false"}, clear=True):
|
||||
env = pch.ccache_pch_env()
|
||||
assert "CCACHE_PCH_EXTSUM" not in env
|
||||
assert "disables pch caching" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[(None, False), ("0", False), ("1", True), ("true", True)],
|
||||
)
|
||||
def test_pch_strict(
|
||||
value: str | None, expected: bool, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
if value is None:
|
||||
monkeypatch.delenv("ESPHOME_PCH_STRICT", raising=False)
|
||||
else:
|
||||
monkeypatch.setenv("ESPHOME_PCH_STRICT", value)
|
||||
assert pch.pch_strict() is expected
|
||||
|
||||
|
||||
def test_pch_cmake_consumer_substitutes_target_and_sources(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.delenv("ESPHOME_PCH_ENABLE", raising=False)
|
||||
monkeypatch.delenv("ESPHOME_PCH_STRICT", raising=False)
|
||||
block = pch.pch_cmake_consumer("app", "${APP_SOURCES}")
|
||||
assert "target_compile_options(app PRIVATE" in block
|
||||
assert '"$<$<COMPILE_LANGUAGE:CXX>:-Winvalid-pch>"' in block
|
||||
assert "-Wno-error=invalid-pch" in block
|
||||
assert '"$<$<COMPILE_LANGUAGE:CXX>:esphome_pch.h>"' in block
|
||||
assert "set_source_files_properties(${APP_SOURCES} PROPERTIES" in block
|
||||
assert 'OBJECT_DEPENDS "${CMAKE_BINARY_DIR}/esphome_pch.h"' in block
|
||||
# Placeholder guard: survives a build-system-side pristine wipe
|
||||
assert 'file(TOUCH "${CMAKE_BINARY_DIR}/esphome_pch.h")' in block
|
||||
|
||||
|
||||
def test_pch_cmake_consumer_strict_escalates(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.delenv("ESPHOME_PCH_ENABLE", raising=False)
|
||||
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
|
||||
assert "-Werror=invalid-pch" in pch.pch_cmake_consumer("app", "${APP_SOURCES}")
|
||||
|
||||
|
||||
def test_pch_cmake_consumer_empty_when_disabled(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0")
|
||||
assert pch.pch_cmake_consumer("app", "${APP_SOURCES}") == ""
|
||||
|
||||
|
||||
def test_guarded_prepare_logs_placeholder_failure(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A failed placeholder touch must be traceable, not silent."""
|
||||
monkeypatch.delenv("ESPHOME_PCH_STRICT", raising=False)
|
||||
|
||||
def boom() -> None:
|
||||
raise RuntimeError("boom")
|
||||
|
||||
# Missing build dir: the touch raises and only warns
|
||||
pch.guarded_prepare(tmp_path / "missing", boom)
|
||||
assert "Could not create the pch placeholder" in caplog.text
|
||||
|
||||
|
||||
def test_pch_degraded_raises_only_in_strict(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from esphome.core import EsphomeError
|
||||
|
||||
monkeypatch.delenv("ESPHOME_PCH_STRICT", raising=False)
|
||||
pch.pch_degraded("reason")
|
||||
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
|
||||
with pytest.raises(EsphomeError, match="reason"):
|
||||
pch.pch_degraded("reason")
|
||||
|
||||
|
||||
def test_pch_extra_scripts_strict_raises_when_disabled(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from esphome.core import EsphomeError
|
||||
|
||||
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0")
|
||||
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
|
||||
with pytest.raises(EsphomeError, match="disabled"):
|
||||
pch.pch_extra_scripts()
|
||||
|
||||
|
||||
def test_pch_strict_rejects_unrecognized_values(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A typo must not silently disable the gate."""
|
||||
from esphome.core import EsphomeError
|
||||
|
||||
monkeypatch.setenv("ESPHOME_PCH_STRICT", "yolo")
|
||||
with pytest.raises(EsphomeError, match="Unrecognized ESPHOME_PCH_STRICT"):
|
||||
pch.pch_strict()
|
||||
|
||||
|
||||
def test_discard_pch_raises_when_gch_survives(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A .gch an unlink failure leaves behind would be consumed silently."""
|
||||
from pathlib import Path as _P
|
||||
|
||||
from esphome.core import EsphomeError
|
||||
|
||||
(tmp_path / "esphome_pch.h").write_text("")
|
||||
gch = tmp_path / "esphome_pch.h.gch"
|
||||
gch.write_bytes(b"gch")
|
||||
real_unlink = _P.unlink
|
||||
|
||||
def failing_unlink(self, missing_ok=False):
|
||||
if self.name.endswith(".gch"):
|
||||
raise OSError("readonly")
|
||||
return real_unlink(self, missing_ok=missing_ok)
|
||||
|
||||
monkeypatch.setattr(_P, "unlink", failing_unlink)
|
||||
with pytest.raises(EsphomeError, match="Could not discard"):
|
||||
pch.discard_pch(tmp_path)
|
||||
|
||||
|
||||
def test_discard_pch_raises_when_sum_survives(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""The .sum is ccache's pch identity; one that survives is as unsafe
|
||||
as a surviving .gch."""
|
||||
from pathlib import Path as _P
|
||||
|
||||
from esphome.core import EsphomeError
|
||||
|
||||
(tmp_path / "esphome_pch.h").write_text("")
|
||||
(tmp_path / "esphome_pch.h.gch").write_bytes(b"gch")
|
||||
(tmp_path / "esphome_pch.h.gch.sum").write_text("x")
|
||||
real_unlink = _P.unlink
|
||||
|
||||
def failing_unlink(self, missing_ok=False):
|
||||
if self.name.endswith(".sum"):
|
||||
raise OSError("readonly")
|
||||
return real_unlink(self, missing_ok=missing_ok)
|
||||
|
||||
monkeypatch.setattr(_P, "unlink", failing_unlink)
|
||||
with pytest.raises(EsphomeError, match="Could not discard"):
|
||||
pch.discard_pch(tmp_path)
|
||||
|
||||
|
||||
def test_discard_pch_warns_when_file_vanished_concurrently(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""An unlink error on a file that is nonetheless gone only warns."""
|
||||
from pathlib import Path as _P
|
||||
|
||||
(tmp_path / "esphome_pch.h").write_text("")
|
||||
(tmp_path / "esphome_pch.h.gch").write_bytes(b"gch")
|
||||
real_unlink = _P.unlink
|
||||
|
||||
def racing_unlink(self, missing_ok=False):
|
||||
if self.name.endswith(".sum"):
|
||||
# Racer removed it, then our unlink errored
|
||||
raise OSError("stale handle")
|
||||
return real_unlink(self, missing_ok=missing_ok)
|
||||
|
||||
monkeypatch.setattr(_P, "unlink", racing_unlink)
|
||||
pch.discard_pch(tmp_path)
|
||||
assert "Could not discard the pch sidecars" in caplog.text
|
||||
@@ -1,43 +0,0 @@
|
||||
"""The pch script must reach PlatformIO builds only; the native ESP-IDF
|
||||
toolchain has its own pch flow in build_gen/espidf.py."""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components import esp32
|
||||
from esphome.const import Toolchain
|
||||
from esphome.core import CORE
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("toolchain", "copied"),
|
||||
[(Toolchain.PLATFORMIO, True), (Toolchain.ESP_IDF, False)],
|
||||
)
|
||||
def test_copy_files_gates_pch_script_on_toolchain(
|
||||
toolchain: Toolchain, copied: bool, tmp_path: Path
|
||||
) -> None:
|
||||
CORE.toolchain = toolchain
|
||||
CORE.build_path = tmp_path
|
||||
from esphome.components.esp32 import (
|
||||
KEY_ESP32,
|
||||
KEY_EXTRA_BUILD_FILES,
|
||||
KEY_FLASH_SIZE,
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(esp32, "_write_sdkconfig") as write_sdkconfig,
|
||||
patch.object(esp32, "_write_idf_component_yml"),
|
||||
patch.object(esp32, "copy_pch_script") as copy_script,
|
||||
patch.object(esp32, "write_file_if_changed"),
|
||||
patch.object(esp32, "get_partition_csv"),
|
||||
patch.dict(
|
||||
CORE.data,
|
||||
{KEY_ESP32: {KEY_EXTRA_BUILD_FILES: {}, KEY_FLASH_SIZE: "4MB"}},
|
||||
),
|
||||
):
|
||||
esp32.copy_files()
|
||||
# Proves execution reached (and passed) the gate on both rows
|
||||
assert write_sdkconfig.called
|
||||
assert copy_script.called is copied
|
||||
@@ -1,193 +0,0 @@
|
||||
"""Tests for the native (non-PlatformIO) toolchain config validation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components import esp8266
|
||||
from esphome.components.esp8266 import (
|
||||
ARDUINO_FRAMEWORK_SCHEMA,
|
||||
_resolve_toolchain,
|
||||
_validate_native_toolchain,
|
||||
)
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_BOARD,
|
||||
CONF_FRAMEWORK,
|
||||
CONF_PLATFORM_VERSION,
|
||||
CONF_SOURCE,
|
||||
CONF_TOOLCHAIN,
|
||||
CONF_VERSION,
|
||||
Toolchain,
|
||||
)
|
||||
from esphome.core import CORE, EsphomeError
|
||||
from esphome.types import ConfigType
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _arduino_toolchain() -> Generator[None]:
|
||||
# The suite-wide reset_core fixture clears CORE.toolchain after each test
|
||||
CORE.toolchain = Toolchain.ARDUINO
|
||||
esp8266._DECODE_WARNED_AT.clear()
|
||||
yield
|
||||
esp8266._DECODE_WARNED_AT.clear()
|
||||
|
||||
|
||||
def _config(
|
||||
board: str = "nodemcuv2",
|
||||
version: str = "3.1.2",
|
||||
source: str | None = None,
|
||||
platform_version: str | None = None,
|
||||
) -> ConfigType:
|
||||
framework: dict[str, str] = {CONF_VERSION: version}
|
||||
if source is not None:
|
||||
framework[CONF_SOURCE] = source
|
||||
if platform_version is not None:
|
||||
framework[CONF_PLATFORM_VERSION] = platform_version
|
||||
# The real schema fills the source/platform_version defaults, so these
|
||||
# tests validate against what config validation actually emits
|
||||
return {
|
||||
CONF_FRAMEWORK: ARDUINO_FRAMEWORK_SCHEMA(framework),
|
||||
CONF_BOARD: board,
|
||||
}
|
||||
|
||||
|
||||
def test_valid_config_passes() -> None:
|
||||
config = _config()
|
||||
assert _validate_native_toolchain(config) is config
|
||||
|
||||
|
||||
def test_platformio_toolchain_skips_checks() -> None:
|
||||
# 3.0.2 is pio-legal (>= the global 3.0.0 floor) but below the native
|
||||
# toolchain's own 3.1.1 floor; the bogus board only the native path checks
|
||||
CORE.toolchain = Toolchain.PLATFORMIO
|
||||
config = _config(board="not_a_board", version="3.0.2")
|
||||
assert _validate_native_toolchain(config) is config
|
||||
|
||||
|
||||
def test_version_below_floor_rejected() -> None:
|
||||
# 3.1.0 has no registry package, so the native floor is 3.1.1
|
||||
with pytest.raises(cv.Invalid, match="3.1.1 or newer"):
|
||||
_validate_native_toolchain(_config(version="3.1.0"))
|
||||
|
||||
|
||||
def test_version_at_floor_accepted() -> None:
|
||||
_validate_native_toolchain(_config(version="3.1.1"))
|
||||
|
||||
|
||||
def test_custom_platform_version_warns_and_is_dropped(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
config = _config(platform_version="platformio/espressif8266@4.0.1")
|
||||
_validate_native_toolchain(config)
|
||||
assert "'platform_version' is ignored" in caplog.text
|
||||
assert CONF_PLATFORM_VERSION not in config[CONF_FRAMEWORK]
|
||||
|
||||
|
||||
def test_default_platform_version_does_not_warn(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
config = _config()
|
||||
_validate_native_toolchain(config)
|
||||
assert "'platform_version' is ignored" not in caplog.text
|
||||
assert CONF_PLATFORM_VERSION not in config[CONF_FRAMEWORK]
|
||||
|
||||
|
||||
def test_custom_source_rejected() -> None:
|
||||
with pytest.raises(cv.Invalid, match="custom framework source"):
|
||||
_validate_native_toolchain(
|
||||
_config(source="https://github.com/esp8266/Arduino.git")
|
||||
)
|
||||
|
||||
|
||||
def test_unsupported_board_rejected() -> None:
|
||||
with pytest.raises(cv.Invalid, match="not supported by"):
|
||||
_validate_native_toolchain(_config(board="not_a_board"))
|
||||
|
||||
|
||||
def test_yaml_toolchain_key_resolves() -> None:
|
||||
"""The documented `toolchain: arduino` YAML key selects the native path."""
|
||||
CORE.toolchain = None
|
||||
_resolve_toolchain({CONF_TOOLCHAIN: Toolchain.ARDUINO})
|
||||
assert CORE.toolchain == Toolchain.ARDUINO
|
||||
assert CORE.using_toolchain_arduino
|
||||
|
||||
|
||||
def test_yaml_toolchain_key_defaults_to_platformio() -> None:
|
||||
CORE.toolchain = None
|
||||
_resolve_toolchain({})
|
||||
assert CORE.toolchain == Toolchain.PLATFORMIO
|
||||
|
||||
|
||||
def test_decode_pc_native_missing_tools_warns_once(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A stack dump of many addresses produces one missing-tool warning."""
|
||||
|
||||
with (
|
||||
patch(
|
||||
"esphome.arduino8266.toolchain.get_addr2line_path",
|
||||
return_value=tmp_path / "missing-addr2line",
|
||||
),
|
||||
patch(
|
||||
"esphome.arduino8266.toolchain.get_elf_path",
|
||||
return_value=tmp_path / "missing.elf",
|
||||
),
|
||||
):
|
||||
esp8266._decode_pc({}, "40201234")
|
||||
esp8266._decode_pc({}, "40201238")
|
||||
assert caplog.text.count("Cannot decode crash addresses") == 1
|
||||
|
||||
|
||||
def test_decode_pc_platformio_missing_tools_warns_once(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""The PlatformIO branch reports a missing addr2line/ELF at the same
|
||||
warning level as the native one; raw undecoded addresses with no
|
||||
stated reason are undiagnosable at default log level."""
|
||||
|
||||
CORE.toolchain = Toolchain.PLATFORMIO
|
||||
idedata = SimpleNamespace(addr2line_path=None, firmware_elf_path=None)
|
||||
with patch("esphome.platformio.toolchain.get_idedata", return_value=idedata):
|
||||
esp8266._decode_pc({}, "40201234")
|
||||
esp8266._decode_pc({}, "40201238")
|
||||
assert caplog.text.count("Cannot decode crash addresses") == 1
|
||||
|
||||
|
||||
def test_resolve_toolchain_rejects_unsupported() -> None:
|
||||
"""ESP8266 rejects a CLI toolchain it cannot serve, like every platform."""
|
||||
|
||||
CORE.toolchain = Toolchain.SDK_NRF
|
||||
with pytest.raises(cv.Invalid, match="Unsupported toolchain 'sdk-nrf'"):
|
||||
_resolve_toolchain({})
|
||||
|
||||
|
||||
def test_run_compile_platformio_falls_through() -> None:
|
||||
"""Under toolchain: platformio the hook returns False without touching
|
||||
the native backend; this is what keeps existing users on PlatformIO."""
|
||||
CORE.toolchain = Toolchain.PLATFORMIO
|
||||
with patch("esphome.arduino8266.toolchain.run_compile") as mock_native:
|
||||
assert esp8266.run_compile(SimpleNamespace(), {}) is False
|
||||
mock_native.assert_not_called()
|
||||
|
||||
|
||||
def test_run_compile_arduino_failure_raises() -> None:
|
||||
"""A non-zero native build fails by name instead of returning success."""
|
||||
CORE.verbose = False
|
||||
with (
|
||||
patch("esphome.arduino8266.toolchain.run_compile", return_value=1),
|
||||
pytest.raises(EsphomeError, match="native build failed"),
|
||||
):
|
||||
esp8266.run_compile(SimpleNamespace(), {})
|
||||
|
||||
|
||||
def test_copy_files_native_skips_platformio_scripts(tmp_path: Path) -> None:
|
||||
"""The native build writes no PlatformIO extra scripts."""
|
||||
CORE.build_path = tmp_path
|
||||
esp8266.copy_files()
|
||||
assert list(tmp_path.iterdir()) == []
|
||||
@@ -1,322 +0,0 @@
|
||||
"""nrf52 sdk-nrf pch wiring: the CMake consumer block, the prepare wrapper,
|
||||
and the two-phase west split in run_compile."""
|
||||
|
||||
from collections.abc import Generator
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components import nrf52
|
||||
from esphome.components.zephyr.const import KEY_BOARD
|
||||
from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION, Toolchain
|
||||
from esphome.core import CORE, EsphomeError
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def pch_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("ESPHOME_PCH_ENABLE", raising=False)
|
||||
monkeypatch.delenv("ESPHOME_PCH_STRICT", raising=False)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def build_dir(tmp_path: Path) -> Path:
|
||||
d = tmp_path / "build" / ".pioenvs" / "livingroom"
|
||||
d.mkdir(parents=True)
|
||||
return d
|
||||
|
||||
|
||||
_AUTOCONF_TEXT = "#define CONFIG_GPIO 1\n"
|
||||
|
||||
|
||||
def _write_autoconf(build_dir: Path) -> Path:
|
||||
autoconf = build_dir / "zephyr" / "include" / "generated" / "zephyr" / "autoconf.h"
|
||||
autoconf.parent.mkdir(parents=True)
|
||||
autoconf.write_text(_AUTOCONF_TEXT)
|
||||
return autoconf
|
||||
|
||||
|
||||
def test_prepare_pch_disabled_discards_and_degrades(
|
||||
monkeypatch: pytest.MonkeyPatch, build_dir: Path
|
||||
) -> None:
|
||||
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0")
|
||||
gch = build_dir / "esphome_pch.h.gch"
|
||||
gch.write_bytes(b"x")
|
||||
nrf52._prepare_pch(build_dir)
|
||||
assert not gch.exists()
|
||||
|
||||
|
||||
def test_prepare_pch_disabled_strict_raises(
|
||||
monkeypatch: pytest.MonkeyPatch, build_dir: Path
|
||||
) -> None:
|
||||
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0")
|
||||
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
|
||||
with pytest.raises(EsphomeError, match="ESPHOME_PCH_STRICT"):
|
||||
nrf52._prepare_pch(build_dir)
|
||||
|
||||
|
||||
def test_prepare_pch_missing_autoconf_degrades(
|
||||
build_dir: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
with patch.object(nrf52.pch, "prepare_pch") as prepare:
|
||||
nrf52._prepare_pch(build_dir)
|
||||
assert not prepare.called
|
||||
assert "No autoconf.h found" in caplog.text
|
||||
# The header is written first so OBJECT_DEPENDS stays satisfied
|
||||
assert (build_dir / "esphome_pch.h").is_file()
|
||||
|
||||
|
||||
def test_prepare_pch_missing_autoconf_strict_raises(
|
||||
monkeypatch: pytest.MonkeyPatch, build_dir: Path
|
||||
) -> None:
|
||||
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
|
||||
with pytest.raises(EsphomeError, match="autoconf.h missing"):
|
||||
nrf52._prepare_pch(build_dir)
|
||||
|
||||
|
||||
def test_prepare_pch_unreadable_autoconf_fails_closed(
|
||||
build_dir: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
# A directory named autoconf.h: read_text raises OSError
|
||||
autoconf = build_dir / "zephyr" / "include" / "generated" / "autoconf.h"
|
||||
autoconf.mkdir(parents=True)
|
||||
with patch.object(nrf52.pch, "prepare_pch") as prepare:
|
||||
nrf52._prepare_pch(build_dir)
|
||||
assert not prepare.called
|
||||
assert "Could not read" in caplog.text
|
||||
|
||||
|
||||
def test_app_build_dir_sysbuild_layout(build_dir: Path) -> None:
|
||||
app = build_dir / "zephyr"
|
||||
app.mkdir()
|
||||
(app / "CMakeCache.txt").write_text("")
|
||||
assert nrf52._app_build_dir(build_dir) == app
|
||||
|
||||
|
||||
def test_app_build_dir_top_level_layout(build_dir: Path) -> None:
|
||||
# Non-sysbuild: build_dir/zephyr is the Zephyr output dir, no cache
|
||||
(build_dir / "zephyr").mkdir()
|
||||
assert nrf52._app_build_dir(build_dir) == build_dir
|
||||
|
||||
|
||||
def test_app_build_dir_ignores_cache_directory(build_dir: Path) -> None:
|
||||
(build_dir / "zephyr" / "CMakeCache.txt").mkdir(parents=True)
|
||||
assert nrf52._app_build_dir(build_dir) == build_dir
|
||||
|
||||
|
||||
def test_app_build_dir_propagates_stat_errors(build_dir: Path) -> None:
|
||||
# is_file() would swallow this and mislocate the pch
|
||||
with (
|
||||
patch.object(Path, "stat", side_effect=PermissionError("denied")),
|
||||
pytest.raises(PermissionError),
|
||||
):
|
||||
nrf52._app_build_dir(build_dir)
|
||||
|
||||
|
||||
def test_prepare_pch_extras_carry_build_identity(build_dir: Path) -> None:
|
||||
_write_autoconf(build_dir)
|
||||
with (
|
||||
patch.dict(CORE.data, {KEY_CORE: {KEY_FRAMEWORK_VERSION: "2.9.2"}}),
|
||||
patch.object(
|
||||
nrf52, "zephyr_data", return_value={KEY_BOARD: "adafruit_feather"}
|
||||
),
|
||||
patch.object(nrf52, "get_project_compile_flags", return_value=["-Os"]),
|
||||
patch.object(nrf52.pch, "prepare_pch") as prepare,
|
||||
):
|
||||
nrf52._prepare_pch(build_dir)
|
||||
assert (build_dir / "esphome_pch.h").is_file()
|
||||
(passed_dir, headers, extras) = prepare.call_args.args
|
||||
assert passed_dir == build_dir
|
||||
assert headers == nrf52.PCH_DEFAULT_HEADERS
|
||||
assert list(extras) == ["2.9.2", "adafruit_feather", _AUTOCONF_TEXT, "-Os"]
|
||||
|
||||
|
||||
def _generate_cmake(tmp_path: Path) -> str:
|
||||
CORE.config_path = tmp_path / "test.yaml"
|
||||
CORE.build_path = tmp_path / "build"
|
||||
CORE.name = "livingroom"
|
||||
with (
|
||||
patch(
|
||||
"esphome.components.zephyr.library.generate_zephyr_modules",
|
||||
return_value=[],
|
||||
),
|
||||
patch.object(nrf52, "get_project_compile_flags", return_value=["-Os"]),
|
||||
patch.object(nrf52, "get_project_link_flags", return_value=[]),
|
||||
):
|
||||
nrf52._generate_cmake_lists()
|
||||
return (tmp_path / "build" / "zephyr" / "CMakeLists.txt").read_text()
|
||||
|
||||
|
||||
def test_cmake_lists_include_pch_consumer_block(tmp_path: Path) -> None:
|
||||
# Content contract is pinned by the shared pch_cmake_consumer tests;
|
||||
# here only that the block reaches the generated CMakeLists
|
||||
text = _generate_cmake(tmp_path)
|
||||
assert "target_compile_options(app PRIVATE" in text
|
||||
assert 'OBJECT_DEPENDS "${CMAKE_BINARY_DIR}/esphome_pch.h"' in text
|
||||
|
||||
|
||||
def test_cmake_lists_pch_block_disabled(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0")
|
||||
assert "esphome_pch.h" not in _generate_cmake(tmp_path)
|
||||
|
||||
|
||||
CompileCtx = tuple[Mock, Mock, Path]
|
||||
|
||||
|
||||
class TestRunCompilePhases:
|
||||
"""The pch pre-build block in run_compile: header write, conditional
|
||||
cmake-only phase, and the never-abort-the-build exception contract."""
|
||||
|
||||
@pytest.fixture
|
||||
def compile_ctx(self, tmp_path: Path) -> Generator[CompileCtx, None, None]:
|
||||
CORE.config_path = tmp_path / "test.yaml"
|
||||
CORE.build_path = tmp_path / "build"
|
||||
CORE.name = "livingroom"
|
||||
CORE.toolchain = Toolchain.SDK_NRF
|
||||
with (
|
||||
patch.object(nrf52, "check_and_install"),
|
||||
patch.object(nrf52, "_generate_cmake_lists", return_value=False),
|
||||
patch.object(
|
||||
nrf52,
|
||||
"get_build_paths",
|
||||
return_value={
|
||||
"python_executable": "python3",
|
||||
"framework_path": tmp_path,
|
||||
},
|
||||
),
|
||||
patch.object(nrf52, "get_build_env", return_value={}),
|
||||
patch.object(nrf52, "zephyr_data", return_value={KEY_BOARD: "board"}),
|
||||
patch.object(nrf52, "run_command_ok") as run_cmd,
|
||||
patch.object(nrf52, "_prepare_pch") as prepare,
|
||||
):
|
||||
yield run_cmd, prepare, CORE.relative_pioenvs_path(CORE.name)
|
||||
|
||||
def _run(self) -> None:
|
||||
nrf52.run_compile(None, {})
|
||||
|
||||
def test_missing_db_runs_cmake_phase(self, compile_ctx: CompileCtx) -> None:
|
||||
run_cmd, prepare, build_dir = compile_ctx
|
||||
# cmake-only ok, generated headers ok, final build fails
|
||||
results = iter([True, True, False])
|
||||
|
||||
def west(cmd, **kwargs):
|
||||
# Phase 1 configures the sysbuild app domain
|
||||
app = build_dir / "zephyr"
|
||||
app.mkdir(parents=True, exist_ok=True)
|
||||
(app / "CMakeCache.txt").write_text("")
|
||||
return next(results)
|
||||
|
||||
run_cmd.side_effect = west
|
||||
with pytest.raises(EsphomeError, match="nRF52 native build failed"):
|
||||
self._run()
|
||||
assert "--cmake-only" in run_cmd.call_args_list[0].args[0]
|
||||
# Generated syscall headers are built in the app domain pre-pch
|
||||
headers_cmd = run_cmd.call_args_list[1].args[0]
|
||||
assert headers_cmd[:2] == ["cmake", "--build"]
|
||||
assert str(build_dir / "zephyr") in headers_cmd
|
||||
assert "zephyr_generated_headers" in headers_cmd
|
||||
assert "--cmake-only" not in run_cmd.call_args_list[2].args[0]
|
||||
# The pch is prepared in the app domain dir, not the sysbuild root
|
||||
assert prepare.call_args.args[0] == build_dir / "zephyr"
|
||||
|
||||
def test_generated_headers_failure_degrades(
|
||||
self, compile_ctx, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
run_cmd, prepare, _ = compile_ctx
|
||||
# headers target fails, the real build still runs (and fails here)
|
||||
run_cmd.side_effect = [True, False, False]
|
||||
with pytest.raises(EsphomeError, match="nRF52 native build failed"):
|
||||
self._run()
|
||||
assert "Zephyr header generation failed" in caplog.text
|
||||
# The doomed .gch compile is skipped: it would latch .gch.failed
|
||||
assert not prepare.called
|
||||
|
||||
def test_generated_headers_failure_strict_raises(
|
||||
self, monkeypatch: pytest.MonkeyPatch, compile_ctx: CompileCtx
|
||||
) -> None:
|
||||
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
|
||||
run_cmd, prepare, _ = compile_ctx
|
||||
run_cmd.side_effect = [True, False]
|
||||
with pytest.raises(EsphomeError, match="ESPHOME_PCH_STRICT"):
|
||||
self._run()
|
||||
assert not prepare.called
|
||||
|
||||
def test_cmake_phase_failure_raises(self, compile_ctx: CompileCtx) -> None:
|
||||
run_cmd, prepare, _ = compile_ctx
|
||||
run_cmd.side_effect = [False]
|
||||
with pytest.raises(EsphomeError, match="configure failed"):
|
||||
self._run()
|
||||
assert not prepare.called
|
||||
|
||||
def test_ccache_pch_env_reaches_west(self, compile_ctx: CompileCtx) -> None:
|
||||
run_cmd, _, _ = compile_ctx
|
||||
run_cmd.side_effect = [False]
|
||||
# clear=True also drops ambient CCACHE_*/ESPHOME_PCH_* overrides
|
||||
with (
|
||||
patch.dict("os.environ", {}, clear=True),
|
||||
pytest.raises(EsphomeError, match="configure failed"),
|
||||
):
|
||||
self._run()
|
||||
env = run_cmd.call_args.kwargs["env"]
|
||||
assert env["CCACHE_PCH_EXTSUM"] == "true"
|
||||
assert env["CCACHE_SLOPPINESS"] == "pch_defines,time_macros"
|
||||
|
||||
@pytest.mark.parametrize("sysbuild", [False, True])
|
||||
def test_settled_db_skips_cmake_phase(
|
||||
self, sysbuild: bool, compile_ctx: CompileCtx
|
||||
) -> None:
|
||||
run_cmd, prepare, build_dir = compile_ctx
|
||||
app = build_dir / "zephyr" if sysbuild else build_dir
|
||||
app.mkdir(parents=True)
|
||||
# A present top-level cache keeps the pristine wipe from dropping
|
||||
# the DB; the app-dir cache is the sysbuild layout marker
|
||||
(build_dir / "CMakeCache.txt").write_text("")
|
||||
(app / "CMakeCache.txt").write_text("")
|
||||
(app / "compile_commands.json").write_text("[]")
|
||||
run_cmd.side_effect = [False]
|
||||
with pytest.raises(EsphomeError, match="nRF52 native build failed"):
|
||||
self._run()
|
||||
assert run_cmd.call_count == 1
|
||||
assert "--cmake-only" not in run_cmd.call_args.args[0]
|
||||
assert prepare.call_args.args[0] == app
|
||||
|
||||
def test_disabled_skips_header_and_cmake_phase(
|
||||
self, monkeypatch: pytest.MonkeyPatch, compile_ctx: CompileCtx
|
||||
) -> None:
|
||||
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0")
|
||||
run_cmd, prepare, build_dir = compile_ctx
|
||||
run_cmd.side_effect = [False]
|
||||
with pytest.raises(EsphomeError, match="nRF52 native build failed"):
|
||||
self._run()
|
||||
assert run_cmd.call_count == 1
|
||||
assert not (build_dir / "esphome_pch.h").exists()
|
||||
# The wrapper still runs: it discards stale sidecars and feeds strict
|
||||
assert prepare.called
|
||||
|
||||
def test_prepare_failure_never_aborts_the_build(
|
||||
self, compile_ctx, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
run_cmd, prepare, build_dir = compile_ctx
|
||||
build_dir.mkdir(parents=True)
|
||||
# Keep the pristine wipe from dropping the dir the fallback touches
|
||||
(build_dir / "CMakeCache.txt").write_text("")
|
||||
prepare.side_effect = RuntimeError("boom")
|
||||
run_cmd.side_effect = [True, True, False]
|
||||
with pytest.raises(EsphomeError, match="nRF52 native build failed"):
|
||||
self._run()
|
||||
assert run_cmd.call_count == 3
|
||||
assert "Precompiled header setup failed" in caplog.text
|
||||
# The fallback still satisfies OBJECT_DEPENDS
|
||||
assert (build_dir / "esphome_pch.h").is_file()
|
||||
|
||||
def test_prepare_failure_strict_raises(
|
||||
self, monkeypatch: pytest.MonkeyPatch, compile_ctx: CompileCtx
|
||||
) -> None:
|
||||
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
|
||||
run_cmd, prepare, _ = compile_ctx
|
||||
prepare.side_effect = RuntimeError("boom")
|
||||
run_cmd.side_effect = [True, True]
|
||||
with pytest.raises(RuntimeError, match="boom"):
|
||||
self._run()
|
||||
@@ -1429,3 +1429,12 @@ async def test_add_platformio_options_native_arduino(
|
||||
assert "board_build.ldscript is ignored" in caplog.text
|
||||
assert "'arduino' toolchain" in caplog.text
|
||||
assert "upload_speed" not in caplog.text
|
||||
|
||||
|
||||
def test_esp8266_rejects_unsupported_cli_toolchain() -> None:
|
||||
"""Until the native backend lands, ESP8266 serves only PlatformIO."""
|
||||
from esphome.components.esp8266 import CONFIG_SCHEMA
|
||||
|
||||
CORE.toolchain = Toolchain.ARDUINO
|
||||
with pytest.raises(cv.Invalid, match="Unsupported toolchain 'arduino'"):
|
||||
CONFIG_SCHEMA({"board": "nodemcuv2"})
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
"""Run the native-toolchain serial-upload path and report heavy modules.
|
||||
|
||||
Executed as a subprocess by test_lazy_imports.py: heavy module names come
|
||||
in on argv, the ones found in sys.modules afterwards go out on stdout.
|
||||
``upload_using_esptool`` dispatches native toolchains through a
|
||||
toolchain-keyed table; if someone routes it back through the platform
|
||||
component packages (esp32 or esp8266), this reports the leak.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
from unittest.mock import patch
|
||||
|
||||
from _leak_report import print_leaked_modules
|
||||
|
||||
from esphome.__main__ import upload_using_esptool
|
||||
from esphome.const import (
|
||||
CONF_ESPHOME,
|
||||
KEY_CORE,
|
||||
KEY_ESP32,
|
||||
KEY_TARGET_PLATFORM,
|
||||
KEY_VARIANT,
|
||||
Toolchain,
|
||||
)
|
||||
from esphome.core import CORE
|
||||
|
||||
# An ambient ESPHOME_USE_SUBPROCESS would route past the patched
|
||||
# run_external_command into run_external_process and confuse the checks.
|
||||
os.environ.pop("ESPHOME_USE_SUBPROCESS", None)
|
||||
|
||||
config = {CONF_ESPHOME: {"platformio_options": {}}}
|
||||
|
||||
with tempfile.TemporaryDirectory() as build_dir:
|
||||
CORE.name = "leaktest"
|
||||
CORE.build_path = build_dir
|
||||
|
||||
for platform, toolchain, backend in (
|
||||
("esp8266", Toolchain.ARDUINO, "esphome.arduino8266.toolchain"),
|
||||
("esp32", Toolchain.ESP_IDF, "esphome.espidf.toolchain"),
|
||||
):
|
||||
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: platform}
|
||||
if platform == "esp32":
|
||||
CORE.data[KEY_ESP32] = {KEY_VARIANT: "ESP32S3"}
|
||||
CORE.toolchain = toolchain
|
||||
|
||||
import importlib
|
||||
|
||||
image = importlib.import_module(backend).get_factory_firmware_path()
|
||||
image.parent.mkdir(parents=True, exist_ok=True)
|
||||
image.write_bytes(b"\x00")
|
||||
|
||||
with patch("esphome.__main__.run_external_command", return_value=0) as mock_run:
|
||||
rc = upload_using_esptool(config, "/dev/ttyUSB0", None, None)
|
||||
|
||||
# Fail loudly if the upload path stopped doing its work; otherwise
|
||||
# an empty leak list could just mean nothing ran.
|
||||
if rc != 0:
|
||||
sys.exit(f"upload_using_esptool({platform}) returned {rc}")
|
||||
cmd = list(mock_run.call_args[0][1:])
|
||||
if str(image) not in [str(Path(c)) for c in cmd]:
|
||||
sys.exit(f"native factory image did not reach esptool: {cmd}")
|
||||
|
||||
print_leaked_modules()
|
||||
@@ -1,657 +0,0 @@
|
||||
"""Tests for esphome.arduino8266.toolchain (the ninja build driver)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.arduino8266 import framework, toolchain
|
||||
from esphome.build_helpers.pch import mark_pch_emitted
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_COMPILE_PROCESS_LIMIT,
|
||||
CONF_ESPHOME,
|
||||
KEY_CORE,
|
||||
KEY_FRAMEWORK_VERSION,
|
||||
)
|
||||
from esphome.core import CORE, EsphomeError
|
||||
|
||||
_SIZE_OUTPUT = """\
|
||||
firmware.elf :
|
||||
section size addr
|
||||
.data 1924 1073643520
|
||||
.noinit 56 1073645444
|
||||
.text 496 1074790400
|
||||
.irom0.text 342804 1075843088
|
||||
.text1 27489 1074790896
|
||||
.rodata 2588 1073645504
|
||||
.bss 26504 1073648096
|
||||
Total 401861
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup_core(tmp_path: Path) -> None:
|
||||
CORE.name = "test8266"
|
||||
CORE.config_path = tmp_path / "test8266.yaml"
|
||||
CORE.build_path = tmp_path
|
||||
CORE.data[KEY_CORE] = {KEY_FRAMEWORK_VERSION: cv.Version(3, 1, 2)}
|
||||
# run_compile verifies the produced artifacts; give every test a build
|
||||
# that "produced" them (tests for the guard delete them again). The
|
||||
# manifest comes first: artifacts must not be older than build.ninja.
|
||||
build_dir = CORE.relative_pioenvs_path("test8266")
|
||||
build_dir.mkdir(parents=True, exist_ok=True)
|
||||
(build_dir / "build.ninja").write_text("# manifest")
|
||||
for artifact in (
|
||||
"firmware.elf",
|
||||
"firmware.bin",
|
||||
"firmware.factory.bin",
|
||||
"firmware.ota.bin",
|
||||
):
|
||||
(build_dir / artifact).write_bytes(b"")
|
||||
|
||||
|
||||
def _paths(tmp_path: Path) -> framework.InstalledPaths:
|
||||
return framework.InstalledPaths(
|
||||
framework=tmp_path / "framework",
|
||||
toolchain=tmp_path / "toolchain",
|
||||
ninja=tmp_path / "ninja",
|
||||
)
|
||||
|
||||
|
||||
def test_path_getters(tmp_path: Path) -> None:
|
||||
assert toolchain.get_build_dir() == CORE.relative_pioenvs_path("test8266")
|
||||
assert toolchain.get_elf_path().name == "firmware.elf"
|
||||
# The framework accessor owns the layout and the Windows suffix
|
||||
suffix = ".exe" if os.name == "nt" else ""
|
||||
assert toolchain.get_addr2line_path().name == f"xtensa-lx106-elf-addr2line{suffix}"
|
||||
assert toolchain.get_objdump_path().name == f"xtensa-lx106-elf-objdump{suffix}"
|
||||
assert toolchain.get_readelf_path().name == f"xtensa-lx106-elf-readelf{suffix}"
|
||||
|
||||
|
||||
def test_run_compile_build_failure(tmp_path: Path) -> None:
|
||||
with (
|
||||
patch.object(framework, "check_and_install", return_value=_paths(tmp_path)),
|
||||
patch.object(framework, "get_build_env", return_value={}),
|
||||
patch("esphome.build_gen.arduino8266.write_project"),
|
||||
patch.object(
|
||||
toolchain.subprocess, "run", return_value=MagicMock(returncode=2)
|
||||
) as mock_run,
|
||||
patch.object(toolchain, "_write_compile_commands") as mock_compdb,
|
||||
):
|
||||
assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=True) == 2
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "-v" in cmd
|
||||
# The compile database is generated before the build runs, so a failed
|
||||
# build cannot leave a stale database behind.
|
||||
mock_compdb.assert_called_once()
|
||||
|
||||
|
||||
def test_run_compile_success(tmp_path: Path) -> None:
|
||||
with (
|
||||
patch.object(framework, "check_and_install", return_value=_paths(tmp_path)),
|
||||
patch.object(framework, "get_build_env", return_value={}),
|
||||
# An unchanged manifest is what makes the -n probe run
|
||||
patch("esphome.build_gen.arduino8266.write_project", return_value=False),
|
||||
patch.object(
|
||||
toolchain.subprocess,
|
||||
"run",
|
||||
return_value=MagicMock(returncode=0, stdout="", stderr=""),
|
||||
) as mock_run,
|
||||
patch.object(toolchain, "_write_compile_commands") as mock_compdb,
|
||||
patch.object(toolchain, "_print_size_summary") as mock_size,
|
||||
patch.object(toolchain, "get_idedata") as mock_idedata,
|
||||
):
|
||||
rc = toolchain.run_compile(
|
||||
{CONF_ESPHOME: {CONF_COMPILE_PROCESS_LIMIT: 4}}, verbose=False
|
||||
)
|
||||
assert rc == 0
|
||||
# The -n probe runs first, then the real build (cwd, no -C banner)
|
||||
ninja_calls = [c for c in mock_run.call_args_list if "ninja" in str(c[0][0][0])]
|
||||
assert "-n" in ninja_calls[0][0][0]
|
||||
# Explicit targets: a manifest missing them fails as "unknown target"
|
||||
assert ninja_calls[0][0][0][-1] == "firmware.ota.bin"
|
||||
cmd = ninja_calls[1][0][0]
|
||||
assert cmd[-4:] == ["-j", "4", "firmware.factory.bin", "firmware.ota.bin"]
|
||||
assert "-C" not in cmd
|
||||
assert ninja_calls[1][1]["cwd"] is not None
|
||||
mock_compdb.assert_called_once()
|
||||
mock_size.assert_called_once()
|
||||
mock_idedata.assert_called_once()
|
||||
|
||||
|
||||
def test_run_compile_noop_skips_the_build_spawn(tmp_path: Path) -> None:
|
||||
"""A no-op rebuild stays quiet: the -n probe answers "no work to do"
|
||||
and the real ninja spawn (and its banner) never happens."""
|
||||
with (
|
||||
patch.object(framework, "check_and_install", return_value=_paths(tmp_path)),
|
||||
patch.object(framework, "get_build_env", return_value={}),
|
||||
# An unchanged manifest is what makes the -n probe run
|
||||
patch("esphome.build_gen.arduino8266.write_project", return_value=False),
|
||||
patch.object(
|
||||
toolchain.subprocess,
|
||||
"run",
|
||||
return_value=MagicMock(
|
||||
returncode=0, stdout="ninja: no work to do.\n", stderr=""
|
||||
),
|
||||
) as mock_run,
|
||||
patch.object(toolchain, "_write_compile_commands"),
|
||||
patch.object(toolchain, "_print_size_summary"),
|
||||
patch.object(toolchain, "get_idedata"),
|
||||
):
|
||||
rc = toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False)
|
||||
assert rc == 0
|
||||
ninja_calls = [c for c in mock_run.call_args_list if "ninja" in str(c[0][0][0])]
|
||||
assert len(ninja_calls) == 1
|
||||
assert "-n" in ninja_calls[0][0][0]
|
||||
# Explicit targets: a manifest missing them fails as "unknown target"
|
||||
assert ninja_calls[0][0][0][-1] == "firmware.ota.bin"
|
||||
|
||||
|
||||
def test_run_compile_regenerates_stale_compdb(tmp_path: Path) -> None:
|
||||
"""An interrupted run can leave build.ninja newer than the compile DB;
|
||||
mere existence must not skip regeneration."""
|
||||
build_dir = toolchain.get_build_dir()
|
||||
build_dir.mkdir(parents=True, exist_ok=True)
|
||||
(build_dir / "build.ninja").write_text("")
|
||||
compdb = build_dir / "compile_commands.json"
|
||||
compdb.write_text("[]")
|
||||
os.utime(compdb, ((build_dir / "build.ninja").stat().st_mtime - 5,) * 2)
|
||||
with (
|
||||
patch.object(framework, "check_and_install", return_value=_paths(tmp_path)),
|
||||
patch.object(framework, "get_build_env", return_value={}),
|
||||
patch("esphome.build_gen.arduino8266.write_project", return_value=False),
|
||||
patch.object(
|
||||
toolchain.subprocess,
|
||||
"run",
|
||||
return_value=MagicMock(
|
||||
returncode=0, stdout="ninja: no work to do.\n", stderr=""
|
||||
),
|
||||
),
|
||||
patch.object(toolchain, "_write_compile_commands") as mock_compdb,
|
||||
patch.object(toolchain, "_print_size_summary"),
|
||||
patch.object(toolchain, "get_idedata"),
|
||||
):
|
||||
assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0
|
||||
mock_compdb.assert_called_once()
|
||||
|
||||
|
||||
def test_run_compile_surfaces_probe_diagnostics(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A load-time ninja diagnostic (a generator bug signal) reaches the
|
||||
user even when the no-work branch skips the real spawn."""
|
||||
with (
|
||||
patch.object(framework, "check_and_install", return_value=_paths(tmp_path)),
|
||||
patch.object(framework, "get_build_env", return_value={}),
|
||||
# An unchanged manifest is what makes the -n probe run
|
||||
patch("esphome.build_gen.arduino8266.write_project", return_value=False),
|
||||
patch.object(
|
||||
toolchain.subprocess,
|
||||
"run",
|
||||
return_value=MagicMock(
|
||||
returncode=0,
|
||||
stdout="ninja: no work to do.\n",
|
||||
stderr="ninja: warning: multiple rules generate x\n",
|
||||
),
|
||||
),
|
||||
patch.object(toolchain, "_write_compile_commands"),
|
||||
patch.object(toolchain, "_print_size_summary"),
|
||||
patch.object(toolchain, "get_idedata"),
|
||||
):
|
||||
rc = toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False)
|
||||
assert rc == 0
|
||||
assert "multiple rules generate x" in caplog.text
|
||||
|
||||
|
||||
def test_run_compile_missing_artifact_fails(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A zero ninja exit that produced no firmware must not be a green
|
||||
build (size summary and idedata only warn)."""
|
||||
(toolchain.get_build_dir() / "firmware.elf").unlink()
|
||||
with (
|
||||
patch.object(framework, "check_and_install", return_value=_paths(tmp_path)),
|
||||
patch.object(framework, "get_build_env", return_value={}),
|
||||
patch("esphome.build_gen.arduino8266.write_project"),
|
||||
patch.object(
|
||||
toolchain.subprocess,
|
||||
"run",
|
||||
return_value=MagicMock(returncode=0, stdout="", stderr=""),
|
||||
),
|
||||
patch.object(toolchain, "_write_compile_commands"),
|
||||
patch.object(toolchain, "_print_size_summary") as mock_size,
|
||||
patch.object(toolchain, "get_idedata"),
|
||||
):
|
||||
rc = toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False)
|
||||
assert rc == 1
|
||||
assert "Build produced no" in caplog.text
|
||||
mock_size.assert_not_called()
|
||||
|
||||
|
||||
def test_run_compile_warns_when_idedata_fails(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A failed idedata generation right after a successful build is visible,
|
||||
not deferred to a misleading error in a later command."""
|
||||
with (
|
||||
patch.object(framework, "check_and_install", return_value=_paths(tmp_path)),
|
||||
patch.object(framework, "get_build_env", return_value={}),
|
||||
patch("esphome.build_gen.arduino8266.write_project"),
|
||||
patch.object(
|
||||
toolchain.subprocess,
|
||||
"run",
|
||||
return_value=MagicMock(returncode=0, stdout="", stderr=""),
|
||||
),
|
||||
patch.object(toolchain, "_write_compile_commands"),
|
||||
patch.object(toolchain, "_print_size_summary"),
|
||||
patch.object(toolchain, "get_idedata", return_value=None),
|
||||
):
|
||||
assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0
|
||||
assert "No idedata was generated for this build" in caplog.text
|
||||
|
||||
|
||||
def test_write_compile_commands(tmp_path: Path) -> None:
|
||||
build_dir = tmp_path / "build"
|
||||
build_dir.mkdir()
|
||||
entries = '[{"file": "a.cpp", "command": "cc"}]\n'
|
||||
with patch.object(
|
||||
toolchain.subprocess,
|
||||
"run",
|
||||
return_value=MagicMock(returncode=0, stdout=entries),
|
||||
):
|
||||
toolchain._write_compile_commands(tmp_path / "ninja", build_dir, {})
|
||||
assert (build_dir / "compile_commands.json").read_text() == entries
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("stdout", "match"),
|
||||
[
|
||||
("[]\n", "empty compile database"),
|
||||
# A parse failure names its cause, not the rule-name story
|
||||
("not json", "unparsable compile database.*not json"),
|
||||
],
|
||||
)
|
||||
def test_write_compile_commands_bad_db_raises(
|
||||
tmp_path: Path, stdout: str, match: str
|
||||
) -> None:
|
||||
"""An empty or unparsable compile database fails the build with its
|
||||
actual cause and drops any stale database."""
|
||||
build_dir = tmp_path / "build"
|
||||
build_dir.mkdir()
|
||||
(build_dir / "compile_commands.json").write_text("[stale]")
|
||||
with (
|
||||
patch.object(
|
||||
toolchain.subprocess,
|
||||
"run",
|
||||
return_value=MagicMock(returncode=0, stdout=stdout),
|
||||
),
|
||||
pytest.raises(EsphomeError, match=match),
|
||||
):
|
||||
toolchain._write_compile_commands(tmp_path / "ninja", build_dir, {})
|
||||
assert not (build_dir / "compile_commands.json").exists()
|
||||
|
||||
|
||||
def test_write_compile_commands_failure_removes_stale_db(tmp_path: Path) -> None:
|
||||
"""A failed compdb run must not leave a stale database behind."""
|
||||
stale = tmp_path / "compile_commands.json"
|
||||
stale.write_text("[]")
|
||||
with (
|
||||
patch.object(
|
||||
toolchain.subprocess,
|
||||
"run",
|
||||
return_value=MagicMock(returncode=1, stderr="boom"),
|
||||
),
|
||||
pytest.raises(EsphomeError, match="compile_commands"),
|
||||
):
|
||||
toolchain._write_compile_commands(tmp_path / "ninja", tmp_path, {})
|
||||
assert not stale.exists()
|
||||
|
||||
|
||||
def test_parse_app_size(tmp_path: Path) -> None:
|
||||
ld = tmp_path / "eagle.flash.4m.ld"
|
||||
ld.write_text("MEMORY\n{\n irom0_0_seg : org = 0x40201010, len = 0xfeff0\n}\n")
|
||||
with patch("esphome.build_gen.arduino8266.get_flash_ld_path", return_value=ld):
|
||||
assert toolchain._parse_app_size(tmp_path, _paths(tmp_path)) == 0xFEFF0
|
||||
|
||||
ld.write_text("MEMORY { }\n")
|
||||
with patch("esphome.build_gen.arduino8266.get_flash_ld_path", return_value=ld):
|
||||
assert toolchain._parse_app_size(tmp_path, _paths(tmp_path)) is None
|
||||
|
||||
# A zero-length segment is bad data, not a budget; warn and drop it
|
||||
ld.write_text("MEMORY\n{\n irom0_0_seg : org = 0x40201010, len = 0x0\n}\n")
|
||||
with patch("esphome.build_gen.arduino8266.get_flash_ld_path", return_value=ld):
|
||||
assert toolchain._parse_app_size(tmp_path, _paths(tmp_path)) is None
|
||||
|
||||
with patch(
|
||||
"esphome.build_gen.arduino8266.get_flash_ld_path",
|
||||
return_value=tmp_path / "missing.ld",
|
||||
):
|
||||
assert toolchain._parse_app_size(tmp_path, _paths(tmp_path)) is None
|
||||
|
||||
|
||||
def test_print_size_summary(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
with (
|
||||
patch.object(
|
||||
toolchain.subprocess,
|
||||
"run",
|
||||
return_value=MagicMock(returncode=0, stdout=_SIZE_OUTPUT),
|
||||
),
|
||||
patch.object(toolchain, "_parse_app_size", return_value=1044464),
|
||||
):
|
||||
toolchain._print_size_summary(tmp_path, _paths(tmp_path))
|
||||
out = capsys.readouterr().out
|
||||
# Exact PlatformIO shape so script/ci_memory_impact_extract.py can parse it
|
||||
assert "RAM: [==== ] 37.9% (used 31016 bytes from 81920 bytes)" in out
|
||||
assert "Flash: [==== ] 35.9% (used 375301 bytes from 1044464 bytes)" in out
|
||||
|
||||
|
||||
def test_print_size_summary_missing_size_tool_warns(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A missing size binary degrades to a warning; the firmware already
|
||||
linked and must not be discarded."""
|
||||
with patch.object(
|
||||
toolchain.subprocess, "run", side_effect=FileNotFoundError("no size")
|
||||
):
|
||||
toolchain._print_size_summary(tmp_path, _paths(tmp_path))
|
||||
assert "Could not summarize firmware size" in caplog.text
|
||||
|
||||
|
||||
def test_print_size_summary_no_app_size(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
with (
|
||||
patch.object(
|
||||
toolchain.subprocess,
|
||||
"run",
|
||||
return_value=MagicMock(returncode=0, stdout=_SIZE_OUTPUT),
|
||||
),
|
||||
patch.object(toolchain, "_parse_app_size", return_value=None),
|
||||
):
|
||||
toolchain._print_size_summary(tmp_path, _paths(tmp_path))
|
||||
out = capsys.readouterr().out
|
||||
# Both lines are skipped together: a RAM line without Flash would skew
|
||||
# CI's memory-impact sums across builds
|
||||
assert out == ""
|
||||
|
||||
|
||||
def test_print_size_summary_size_tool_failure(
|
||||
tmp_path: Path,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
with patch.object(
|
||||
toolchain.subprocess,
|
||||
"run",
|
||||
return_value=MagicMock(returncode=1, stdout="", stderr="bad elf"),
|
||||
):
|
||||
toolchain._print_size_summary(tmp_path, _paths(tmp_path))
|
||||
assert capsys.readouterr().out == ""
|
||||
assert "Could not summarize firmware size" in caplog.text
|
||||
|
||||
|
||||
def test_get_idedata_delegates(tmp_path: Path) -> None:
|
||||
with (
|
||||
patch(
|
||||
"esphome.build_helpers.idedata.load_or_build_idedata",
|
||||
return_value={"cc_path": "x"},
|
||||
) as mock_load,
|
||||
patch.object(toolchain, "resolve_ccache_path", return_value="/cc/ccache"),
|
||||
):
|
||||
assert toolchain.get_idedata() == {"cc_path": "x"}
|
||||
compile_commands, elf, cache = mock_load.call_args[0]
|
||||
assert compile_commands.name == "compile_commands.json"
|
||||
assert elf.name == "firmware.elf"
|
||||
assert cache.name == "test8266.arduino.json"
|
||||
# The exact configured launcher string is passed for compile DB parsing
|
||||
# (resolve_ccache_path returns a str, untouched on every platform)
|
||||
assert mock_load.call_args.kwargs["launcher"] == "/cc/ccache"
|
||||
|
||||
|
||||
def test_get_idedata_no_ccache(tmp_path: Path) -> None:
|
||||
with (
|
||||
patch(
|
||||
"esphome.build_helpers.idedata.load_or_build_idedata", return_value={}
|
||||
) as mock_load,
|
||||
patch.object(toolchain, "resolve_ccache_path", return_value=None),
|
||||
):
|
||||
toolchain.get_idedata()
|
||||
assert mock_load.call_args.kwargs["launcher"] is None
|
||||
|
||||
|
||||
def test_run_compile_skips_compdb_when_ninja_unchanged(tmp_path: Path) -> None:
|
||||
"""An unchanged build.ninja means the compile DB is already current."""
|
||||
build_dir = toolchain.get_build_dir()
|
||||
build_dir.mkdir(parents=True, exist_ok=True)
|
||||
# write_project (stubbed below) always leaves a build.ninja behind
|
||||
(build_dir / "build.ninja").write_text("# manifest")
|
||||
|
||||
def run(regenerate_expected: bool) -> None:
|
||||
with (
|
||||
patch.object(framework, "check_and_install", return_value=_paths(tmp_path)),
|
||||
patch.object(framework, "get_build_env", return_value={}),
|
||||
patch("esphome.build_gen.arduino8266.write_project", return_value=False),
|
||||
patch.object(
|
||||
toolchain.subprocess,
|
||||
"run",
|
||||
return_value=MagicMock(returncode=0, stdout="", stderr=""),
|
||||
),
|
||||
patch.object(toolchain, "_write_compile_commands") as mock_compdb,
|
||||
patch.object(toolchain, "_print_size_summary"),
|
||||
patch.object(toolchain, "get_idedata"),
|
||||
):
|
||||
assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0
|
||||
assert mock_compdb.called == regenerate_expected
|
||||
|
||||
# Missing compile DB: regenerated even though build.ninja is unchanged
|
||||
run(regenerate_expected=True)
|
||||
# Present compile DB + unchanged build.ninja: skipped
|
||||
(build_dir / "compile_commands.json").write_text("[]")
|
||||
run(regenerate_expected=False)
|
||||
|
||||
|
||||
def test_print_size_summary_unparsable_section(
|
||||
tmp_path: Path,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A totals-relevant section that fails to parse must not produce a
|
||||
confident wrong number; an irrelevant one only warns."""
|
||||
bad = _SIZE_OUTPUT.replace(".bss 26504", ".bss abc")
|
||||
with patch.object(
|
||||
toolchain.subprocess,
|
||||
"run",
|
||||
return_value=MagicMock(returncode=0, stdout=bad),
|
||||
):
|
||||
toolchain._print_size_summary(tmp_path, _paths(tmp_path))
|
||||
assert capsys.readouterr().out == ""
|
||||
assert "Unparsable size output" in caplog.text
|
||||
|
||||
caplog.clear()
|
||||
harmless = _SIZE_OUTPUT + ".broken abc 0\n"
|
||||
with (
|
||||
patch.object(
|
||||
toolchain.subprocess,
|
||||
"run",
|
||||
return_value=MagicMock(returncode=0, stdout=harmless),
|
||||
),
|
||||
patch.object(toolchain, "_parse_app_size", return_value=1044464),
|
||||
):
|
||||
toolchain._print_size_summary(tmp_path, _paths(tmp_path))
|
||||
assert "RAM:" in capsys.readouterr().out
|
||||
assert "Unparsable size output" in caplog.text
|
||||
|
||||
|
||||
def test_print_size_summary_missing_section_skips_summary(
|
||||
tmp_path: Path,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A totals section absent from the output must not default to zero."""
|
||||
without_bss = "\n".join(
|
||||
line for line in _SIZE_OUTPUT.splitlines() if ".bss" not in line
|
||||
)
|
||||
with patch.object(
|
||||
toolchain.subprocess,
|
||||
"run",
|
||||
return_value=MagicMock(returncode=0, stdout=without_bss),
|
||||
):
|
||||
toolchain._print_size_summary(tmp_path, _paths(tmp_path))
|
||||
assert capsys.readouterr().out == ""
|
||||
assert "missing section(s) .bss" in caplog.text
|
||||
|
||||
|
||||
def test_warn_ignored_platformio_options(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""Component-added options the native build drops are warned by name;
|
||||
the honored ones (lib_ignore, f_cpu, ldscript, build_src_flags,
|
||||
flash_mode) stay quiet."""
|
||||
CORE.platformio_options = {
|
||||
"board_build.ldscript": "eagle.flash.4m2m.ld",
|
||||
"board_build.f_cpu": "160000000L",
|
||||
"board_build.filesystem": "littlefs",
|
||||
"board_build.flash_mode": "dio",
|
||||
"build_src_flags": "-include throw_stubs.h",
|
||||
"lib_ignore": ["Updater"],
|
||||
"upload_speed": "460800",
|
||||
}
|
||||
toolchain._warn_ignored_platformio_options()
|
||||
assert "platformio_options->board_build.filesystem is ignored" in caplog.text
|
||||
assert "native 'arduino' toolchain" in caplog.text
|
||||
assert "board_build.ldscript is ignored" not in caplog.text
|
||||
assert "board_build.f_cpu is ignored" not in caplog.text
|
||||
assert "lib_ignore" not in caplog.text
|
||||
assert "build_src_flags" not in caplog.text
|
||||
assert "flash_mode" not in caplog.text
|
||||
# Component-added upload_speed never gets read under the native
|
||||
# toolchain, so it must warn
|
||||
assert "upload_speed" in caplog.text
|
||||
|
||||
|
||||
def test_run_compile_idedata_error_does_not_fail_build(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""An unusable compile DB after a successful build warns, never fails."""
|
||||
with (
|
||||
patch.object(framework, "check_and_install", return_value=_paths(tmp_path)),
|
||||
patch.object(framework, "get_build_env", return_value={}),
|
||||
patch("esphome.build_gen.arduino8266.write_project"),
|
||||
patch.object(
|
||||
toolchain.subprocess,
|
||||
"run",
|
||||
return_value=MagicMock(returncode=0, stdout="", stderr=""),
|
||||
),
|
||||
patch.object(toolchain, "_write_compile_commands"),
|
||||
patch.object(toolchain, "_print_size_summary"),
|
||||
patch.object(
|
||||
toolchain,
|
||||
"get_idedata",
|
||||
side_effect=EsphomeError("compile database is unusable"),
|
||||
),
|
||||
):
|
||||
assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0
|
||||
assert "Could not generate idedata: compile database is unusable" in caplog.text
|
||||
|
||||
|
||||
def test_run_compile_skipped_size_summary_names_consequence(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A skipped RAM/Flash summary warns about the missing metric; the
|
||||
per-cause warnings alone are invisible to CI harnesses."""
|
||||
with (
|
||||
patch.object(framework, "check_and_install", return_value=_paths(tmp_path)),
|
||||
patch.object(framework, "get_build_env", return_value={}),
|
||||
patch("esphome.build_gen.arduino8266.write_project"),
|
||||
patch.object(
|
||||
toolchain.subprocess,
|
||||
"run",
|
||||
return_value=MagicMock(returncode=0, stdout="", stderr=""),
|
||||
),
|
||||
patch.object(toolchain, "_write_compile_commands"),
|
||||
patch.object(toolchain, "_print_size_summary", return_value=False),
|
||||
patch.object(toolchain, "get_idedata", return_value=None),
|
||||
):
|
||||
assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0
|
||||
assert "Firmware size summary unavailable for this build" in caplog.text
|
||||
|
||||
|
||||
def test_parse_app_size_non_utf8_ld_warns(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A corrupt (non-UTF-8) linker script degrades to the same warning as
|
||||
an unreadable one, never a traceback after a successful link."""
|
||||
paths = _paths(tmp_path)
|
||||
ld = tmp_path / "corrupt.ld"
|
||||
ld.write_bytes(b"\xff\xfe not utf8")
|
||||
with patch("esphome.build_gen.arduino8266.get_flash_ld_path", return_value=ld):
|
||||
assert toolchain._parse_app_size(tmp_path, paths) is None
|
||||
assert "Cannot read linker script" in caplog.text
|
||||
|
||||
|
||||
def test_run_compile_failed_probe_runs_full_build(tmp_path: Path) -> None:
|
||||
"""A failing -n probe (e.g. unknown target from a defective manifest)
|
||||
falls through to the real build so the error prints attributably."""
|
||||
probe = MagicMock(returncode=1, stdout="", stderr="")
|
||||
ok = MagicMock(returncode=0, stdout="", stderr="")
|
||||
|
||||
def fake_run(cmd, *args, **kwargs):
|
||||
return probe if "-n" in cmd else ok
|
||||
|
||||
with (
|
||||
patch.object(framework, "check_and_install", return_value=_paths(tmp_path)),
|
||||
patch.object(framework, "get_build_env", return_value={}),
|
||||
patch("esphome.build_gen.arduino8266.write_project", return_value=False),
|
||||
patch.object(toolchain.subprocess, "run", side_effect=fake_run) as mock_run,
|
||||
patch.object(toolchain, "_write_compile_commands"),
|
||||
patch.object(toolchain, "_print_size_summary", return_value=True),
|
||||
patch.object(toolchain, "get_idedata", return_value={}),
|
||||
):
|
||||
assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0
|
||||
# The real build ran after the failed probe
|
||||
assert any(
|
||||
"firmware.ota.bin" in c[0][0] and "-n" not in c[0][0]
|
||||
for c in mock_run.call_args_list
|
||||
)
|
||||
|
||||
|
||||
def test_get_idedata_accepts_preresolved_ccache() -> None:
|
||||
"""run_compile threads its resolved ccache through; the probe must not
|
||||
run again."""
|
||||
with (
|
||||
patch(
|
||||
"esphome.build_helpers.idedata.load_or_build_idedata",
|
||||
return_value={"ok": True},
|
||||
) as mock_build,
|
||||
patch.object(toolchain, "resolve_ccache_path") as mock_resolve,
|
||||
):
|
||||
assert toolchain.get_idedata("/usr/bin/ccache") == {"ok": True}
|
||||
mock_resolve.assert_not_called()
|
||||
assert mock_build.call_args.kwargs["launcher"] == "/usr/bin/ccache"
|
||||
|
||||
|
||||
def test_ccache_env_includes_pch_settings() -> None:
|
||||
"""The native build exports the ccache settings the pch needs."""
|
||||
mark_pch_emitted()
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
env = framework.ccache_env("/usr/bin/ccache")
|
||||
assert env["CCACHE_SLOPPINESS"] == "pch_defines,time_macros"
|
||||
assert env["CCACHE_PCH_EXTSUM"] == "true"
|
||||
|
||||
|
||||
def test_ccache_env_pch_disabled() -> None:
|
||||
with patch.dict(os.environ, {"ESPHOME_PCH_ENABLE": "0"}, clear=True):
|
||||
env = framework.ccache_env("/usr/bin/ccache")
|
||||
assert "CCACHE_SLOPPINESS" not in env
|
||||
assert "CCACHE_PCH_EXTSUM" not in env
|
||||
|
||||
|
||||
def test_ccache_env_respects_user_sloppiness() -> None:
|
||||
mark_pch_emitted()
|
||||
with patch.dict(os.environ, {"CCACHE_SLOPPINESS": "locale"}, clear=True):
|
||||
env = framework.ccache_env("/usr/bin/ccache")
|
||||
# The user's tokens survive; the ones the pch needs are unioned on
|
||||
assert env["CCACHE_SLOPPINESS"] == "locale,pch_defines,time_macros"
|
||||
assert env["CCACHE_PCH_EXTSUM"] == "true"
|
||||
@@ -568,16 +568,6 @@ class TestEsphomeCore:
|
||||
target.config_path = Path("foo/config")
|
||||
return target
|
||||
|
||||
def test_add_platformio_option_merges_string_into_list(self, target) -> None:
|
||||
"""A user platformio_options string lands after a component's list
|
||||
(FINAL priority) and must merge as one element, not assert."""
|
||||
target.add_platformio_option("extra_scripts", ["post:pch.py"])
|
||||
target.add_platformio_option("extra_scripts", "pre:mine.py")
|
||||
assert target.platformio_options["extra_scripts"] == [
|
||||
"post:pch.py",
|
||||
"pre:mine.py",
|
||||
]
|
||||
|
||||
def test_reset(self, target):
|
||||
"""Call reset on target and compare to new instance"""
|
||||
other = core.EsphomeCore().__dict__
|
||||
|
||||
@@ -19,7 +19,6 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.build_helpers.pch import _PCHData
|
||||
from esphome.espidf.framework import (
|
||||
ESPHOME_STAMP_FILE,
|
||||
STAMP_SCHEMA_VERSION,
|
||||
@@ -1593,14 +1592,10 @@ def _ccache_patches(tmp_path: Path, which: str | None, build_path: Path | None):
|
||||
"esphome.espidf.framework.get_idf_tools_path",
|
||||
return_value=tmp_path / "tools",
|
||||
),
|
||||
# ccache_defaults_env and the pch emission flag read CORE at call time
|
||||
# ccache_defaults_env (build_helpers.ccache) reads CORE at call time
|
||||
patch(
|
||||
"esphome.core.CORE",
|
||||
SimpleNamespace(
|
||||
build_path=build_path,
|
||||
# Pre-marked: these env tests model a pch-emitting build
|
||||
data={"pch": _PCHData(emitted=True)},
|
||||
),
|
||||
SimpleNamespace(build_path=build_path),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1614,9 +1609,6 @@ def test_ccache_env_default_enabled_when_available(tmp_path: Path) -> None:
|
||||
assert env["CCACHE_NOHASHDIR"] == "true"
|
||||
assert env["CCACHE_DEPEND"] == "1"
|
||||
assert env["CCACHE_BASEDIR"] == str((tmp_path / "build").resolve())
|
||||
# The pch cannot cache under ccache without these
|
||||
assert env["CCACHE_SLOPPINESS"] == "pch_defines,time_macros"
|
||||
assert env["CCACHE_PCH_EXTSUM"] == "true"
|
||||
|
||||
|
||||
def test_ccache_env_disabled_when_binary_missing(tmp_path: Path) -> None:
|
||||
|
||||
@@ -93,13 +93,6 @@ def test_get_configured_targets_ci_installs_all(monkeypatch: pytest.MonkeyPatch)
|
||||
assert toolchain._get_configured_targets() is None
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_ccache(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Deterministic run_compile: no host ccache probe, no pch work."""
|
||||
monkeypatch.setenv("IDF_CCACHE_ENABLE", "0")
|
||||
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0")
|
||||
|
||||
|
||||
def _setup_build(setup_core: Path) -> tuple[Path, Path]:
|
||||
"""Point CORE at a build dir; return (compile_commands, idedata cache) paths."""
|
||||
CORE.name = "test"
|
||||
@@ -667,75 +660,3 @@ def test_get_core_framework_version_from_core_data():
|
||||
|
||||
CORE.data = {KEY_ESP32: {KEY_IDF_VERSION: cv.Version(5, 5, 4)}}
|
||||
assert toolchain._get_core_framework_version() == "5.5.4"
|
||||
|
||||
|
||||
def test_run_compile_aborts_when_stale_pch_survives_discard(
|
||||
setup_core: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""An undiscardable stale .gch means silently wrong output: abort."""
|
||||
from esphome.core import EsphomeError
|
||||
|
||||
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "1")
|
||||
_setup_build(setup_core)
|
||||
|
||||
with (
|
||||
patch.object(toolchain, "need_reconfigure", return_value=False),
|
||||
patch.object(toolchain, "run_idf_py", return_value=0),
|
||||
patch.object(toolchain, "print_summary"),
|
||||
patch("esphome.build_gen.espidf.prepare_pch", side_effect=RuntimeError("boom")),
|
||||
patch(
|
||||
"esphome.build_helpers.pch.discard_pch",
|
||||
side_effect=EsphomeError("Could not discard the stale precompiled header"),
|
||||
),
|
||||
pytest.raises(EsphomeError, match="Could not discard"),
|
||||
):
|
||||
toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False)
|
||||
|
||||
|
||||
def test_run_compile_strict_reraises_pch_failure(
|
||||
setup_core: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""ESPHOME_PCH_STRICT must reach through the real compile flow."""
|
||||
from esphome.core import EsphomeError
|
||||
|
||||
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "1")
|
||||
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
|
||||
_setup_build(setup_core)
|
||||
|
||||
with (
|
||||
patch.object(toolchain, "need_reconfigure", return_value=False),
|
||||
patch.object(toolchain, "run_idf_py", return_value=0),
|
||||
patch.object(toolchain, "print_summary"),
|
||||
patch(
|
||||
"esphome.build_gen.espidf.prepare_pch",
|
||||
side_effect=EsphomeError("ESPHOME_PCH_STRICT: no usable compile command"),
|
||||
),
|
||||
pytest.raises(EsphomeError, match="no usable compile command"),
|
||||
):
|
||||
toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False)
|
||||
|
||||
|
||||
def test_run_compile_invokes_prepare_pch_and_survives_failure(
|
||||
setup_core: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""The pch hook runs before the build and a failure never aborts it."""
|
||||
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "1")
|
||||
_setup_build(setup_core)
|
||||
# A stale .gch must be discarded on the failure path, never consumed
|
||||
build = setup_core / "build" / "test" / "build"
|
||||
build.mkdir(parents=True, exist_ok=True)
|
||||
(build / "esphome_pch.h").write_text("")
|
||||
stale_gch = build / "esphome_pch.h.gch"
|
||||
stale_gch.write_bytes(b"stale")
|
||||
|
||||
with (
|
||||
patch.object(toolchain, "need_reconfigure", return_value=False),
|
||||
patch.object(toolchain, "run_idf_py", return_value=0),
|
||||
patch.object(toolchain, "print_summary"),
|
||||
patch(
|
||||
"esphome.build_gen.espidf.prepare_pch", side_effect=RuntimeError("boom")
|
||||
) as prepare,
|
||||
):
|
||||
assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0
|
||||
prepare.assert_called_once()
|
||||
assert not stale_gch.exists()
|
||||
|
||||
@@ -175,27 +175,6 @@ def test_esptool_upload_fast_path_does_not_import_heavy_modules(
|
||||
)
|
||||
|
||||
|
||||
def test_native_upload_fast_path_does_not_import_heavy_modules(
|
||||
fixture_path: Path,
|
||||
probe_env: dict[str, str],
|
||||
) -> None:
|
||||
"""The native-toolchain serial upload dispatches through a
|
||||
toolchain-keyed backend table; resolving the flash image must not drag
|
||||
in the esp32 or esp8266 component package or the validation stack.
|
||||
"""
|
||||
leaked = _leaked_from_fixture(
|
||||
fixture_path,
|
||||
probe_env,
|
||||
"native_upload_fast_path.py",
|
||||
extra=("esphome.components.esp8266",),
|
||||
)
|
||||
assert not leaked, (
|
||||
f"native upload_using_esptool pulls in heavy modules: {leaked}. "
|
||||
"The upload fast path skips validation; importing a platform "
|
||||
"component package executes its codegen module by design."
|
||||
)
|
||||
|
||||
|
||||
def test_api_client_does_not_import_heavy_modules() -> None:
|
||||
"""``esphome.api_client`` is on the logs fast path and must stay light.
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import sys
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, Self
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock, PropertyMock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
from pytest import CaptureFixture
|
||||
@@ -25,7 +25,6 @@ from esphome.__main__ import (
|
||||
Purpose,
|
||||
_get_configured_xtal_freq,
|
||||
_make_crystal_freq_callback,
|
||||
_native_toolchain_module,
|
||||
_redact_with_legacy_fallback,
|
||||
_resolve_network_devices,
|
||||
_should_subscribe_states,
|
||||
@@ -7133,7 +7132,7 @@ def test_command_run_rp2040_bootsel_redetects_serial_port() -> None:
|
||||
|
||||
def test_command_idedata_esp_idf_prints_json(capsys: CaptureFixture) -> None:
|
||||
"""Under the native ESP-IDF toolchain, idedata is emitted as JSON."""
|
||||
setup_core(platform=PLATFORM_ESP32)
|
||||
setup_core()
|
||||
CORE.toolchain = Toolchain.ESP_IDF
|
||||
data = {"cxx_path": "g++", "prog_path": "/build/firmware.elf"}
|
||||
|
||||
@@ -7147,7 +7146,7 @@ def test_command_idedata_esp_idf_prints_json(capsys: CaptureFixture) -> None:
|
||||
|
||||
def test_command_idedata_esp_idf_no_build_errors() -> None:
|
||||
"""Under ESP-IDF, a missing build (no idedata) returns an error, not a crash."""
|
||||
setup_core(platform=PLATFORM_ESP32)
|
||||
setup_core()
|
||||
CORE.toolchain = Toolchain.ESP_IDF
|
||||
|
||||
with patch("esphome.espidf.toolchain.get_idedata", return_value=None):
|
||||
@@ -7321,201 +7320,6 @@ def test_warn_source_tree_mismatch_falls_back_when_stat_fails(
|
||||
assert not caplog.text
|
||||
|
||||
|
||||
def test_upload_using_esptool_arduino_toolchain(
|
||||
tmp_path: Path,
|
||||
mock_run_external_command_main: Mock,
|
||||
) -> None:
|
||||
"""The native ESP8266 Arduino toolchain flashes its factory image at
|
||||
0x0, resolved from the toolchain-keyed backend table (deliberately not
|
||||
the platform hook: that import would break the upload fast path)."""
|
||||
setup_core(platform=PLATFORM_ESP8266, tmp_path=tmp_path, name="test")
|
||||
CORE.toolchain = Toolchain.ARDUINO
|
||||
from esphome.arduino8266 import toolchain as native
|
||||
|
||||
factory = native.get_factory_firmware_path()
|
||||
factory.parent.mkdir(parents=True, exist_ok=True)
|
||||
factory.touch()
|
||||
|
||||
config = {CONF_ESPHOME: {"platformio_options": {}}}
|
||||
result = upload_using_esptool(config, "/dev/ttyUSB0", None, None)
|
||||
|
||||
assert result == 0
|
||||
cmd_list = list(mock_run_external_command_main.call_args[0][1:])
|
||||
firmware_offset_idx = cmd_list.index("write-flash") + 4
|
||||
assert cmd_list[firmware_offset_idx] == "0x0"
|
||||
assert cmd_list[firmware_offset_idx + 1] == str(factory)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("toolchain", "pio_project_written"),
|
||||
[
|
||||
# The native toolchain generates its project at compile time, so
|
||||
# write_cpp_file must not write a platformio.ini; the default
|
||||
# toolchain writes the PlatformIO project files.
|
||||
(Toolchain.ARDUINO, False),
|
||||
(None, True),
|
||||
],
|
||||
)
|
||||
def test_write_cpp_file_project_generation_follows_toolchain(
|
||||
tmp_path: Path, toolchain: Toolchain | None, pio_project_written: bool
|
||||
) -> None:
|
||||
setup_core(platform=PLATFORM_ESP8266, tmp_path=tmp_path, name="test")
|
||||
CORE.toolchain = toolchain
|
||||
|
||||
with (
|
||||
patch("esphome.writer.write_cpp") as mock_write_cpp,
|
||||
patch("esphome.build_gen.platformio.write_project") as mock_pio_project,
|
||||
patch.object(
|
||||
type(CORE), "cpp_main_section", new_callable=PropertyMock
|
||||
) as mock_section,
|
||||
):
|
||||
mock_section.return_value = ""
|
||||
assert main.write_cpp_file() == 0
|
||||
|
||||
mock_write_cpp.assert_called_once()
|
||||
assert mock_pio_project.called is pio_project_written
|
||||
|
||||
|
||||
def test_command_idedata_arduino_prints_json(
|
||||
tmp_path: Path, capsys: CaptureFixture
|
||||
) -> None:
|
||||
"""Under the native ESP8266 Arduino toolchain, idedata is emitted as JSON."""
|
||||
setup_core(platform=PLATFORM_ESP8266, tmp_path=tmp_path)
|
||||
CORE.toolchain = Toolchain.ARDUINO
|
||||
data = {"cxx_path": "g++", "prog_path": "/build/firmware.elf"}
|
||||
|
||||
with patch(
|
||||
"esphome.arduino8266.toolchain.get_idedata", return_value=data
|
||||
) as mock_get:
|
||||
result = command_idedata(MagicMock(), CORE.config)
|
||||
|
||||
assert result == 0
|
||||
mock_get.assert_called_once_with()
|
||||
assert json.loads(capsys.readouterr().out) == data
|
||||
|
||||
|
||||
def test_command_idedata_arduino_no_build_errors(tmp_path: Path) -> None:
|
||||
"""A missing native build (no idedata) returns an error, not a crash."""
|
||||
setup_core(platform=PLATFORM_ESP8266, tmp_path=tmp_path)
|
||||
CORE.toolchain = Toolchain.ARDUINO
|
||||
|
||||
with patch("esphome.arduino8266.toolchain.get_idedata", return_value=None):
|
||||
result = command_idedata(MagicMock(), CORE.config)
|
||||
|
||||
assert result == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("platform", "toolchain", "module"),
|
||||
[
|
||||
(PLATFORM_ESP8266, Toolchain.ARDUINO, "esphome.arduino8266.toolchain"),
|
||||
(PLATFORM_ESP32, Toolchain.ESP_IDF, "esphome.espidf.toolchain"),
|
||||
],
|
||||
)
|
||||
def test_command_analyze_memory_native_toolchains(
|
||||
tmp_path: Path,
|
||||
mock_write_cpp: Mock,
|
||||
mock_compile_program: Mock,
|
||||
mock_get_esphome_components: Mock,
|
||||
mock_memory_analyzer_cli: Mock,
|
||||
mock_ram_strings_analyzer: Mock,
|
||||
platform: str,
|
||||
toolchain: Toolchain,
|
||||
module: str,
|
||||
) -> None:
|
||||
"""analyze-memory uses the native toolchain's binutils instead of
|
||||
falling into the PlatformIO branch."""
|
||||
setup_core(platform=platform, tmp_path=tmp_path, name="test_device")
|
||||
CORE.toolchain = toolchain
|
||||
|
||||
config = {CONF_ESPHOME: {CONF_NAME: "test_device"}}
|
||||
# The tools must exist: a missing binutils now fails by name instead of
|
||||
# silently falling back to host tools
|
||||
objdump = tmp_path / "objdump"
|
||||
readelf = tmp_path / "readelf"
|
||||
objdump.write_text("")
|
||||
readelf.write_text("")
|
||||
# The ELF must exist too: the analyzer swallows tool failures, so a
|
||||
# missing image would report zeroes with exit 0
|
||||
firmware_elf = tmp_path / "firmware.elf"
|
||||
firmware_elf.write_text("")
|
||||
with (
|
||||
patch(f"{module}.get_objdump_path", return_value=objdump),
|
||||
patch(f"{module}.get_readelf_path", return_value=readelf),
|
||||
patch(f"{module}.get_elf_path", return_value=firmware_elf),
|
||||
):
|
||||
result = command_analyze_memory(MockArgs(), config)
|
||||
|
||||
assert result == 0
|
||||
mock_memory_analyzer_cli.assert_called_once_with(
|
||||
str(firmware_elf),
|
||||
str(objdump),
|
||||
str(readelf),
|
||||
set(),
|
||||
idedata=None,
|
||||
)
|
||||
|
||||
|
||||
def test_command_analyze_memory_native_missing_elf_fails(
|
||||
tmp_path: Path,
|
||||
mock_write_cpp: Mock,
|
||||
mock_compile_program: Mock,
|
||||
mock_get_esphome_components: Mock,
|
||||
mock_memory_analyzer_cli: Mock,
|
||||
mock_ram_strings_analyzer: Mock,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A missing firmware.elf fails by name instead of an exit-0 zeroed
|
||||
report."""
|
||||
setup_core(platform=PLATFORM_ESP8266, tmp_path=tmp_path, name="test_device")
|
||||
CORE.toolchain = Toolchain.ARDUINO
|
||||
|
||||
config = {CONF_ESPHOME: {CONF_NAME: "test_device"}}
|
||||
objdump = tmp_path / "objdump"
|
||||
readelf = tmp_path / "readelf"
|
||||
objdump.write_text("")
|
||||
readelf.write_text("")
|
||||
module = "esphome.arduino8266.toolchain"
|
||||
with (
|
||||
patch(f"{module}.get_objdump_path", return_value=objdump),
|
||||
patch(f"{module}.get_readelf_path", return_value=readelf),
|
||||
patch(f"{module}.get_elf_path", return_value=tmp_path / "missing.elf"),
|
||||
):
|
||||
result = command_analyze_memory(MockArgs(), config)
|
||||
|
||||
assert result == 1
|
||||
assert "compile the configuration first" in caplog.text
|
||||
mock_memory_analyzer_cli.assert_not_called()
|
||||
|
||||
|
||||
def test_command_analyze_memory_missing_binutils_fails_by_name(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A truncated toolchain install fails naming the missing tool instead
|
||||
of silently analyzing with host binutils."""
|
||||
setup_core(platform="esp8266", tmp_path=tmp_path, name="test_device")
|
||||
CORE.toolchain = Toolchain.ARDUINO
|
||||
config = {CONF_ESPHOME: {CONF_NAME: "test_device"}}
|
||||
module = "esphome.arduino8266.toolchain"
|
||||
with (
|
||||
patch(f"{module}.get_objdump_path", return_value=tmp_path / "missing-objdump"),
|
||||
patch(f"{module}.get_readelf_path", return_value=tmp_path / "readelf"),
|
||||
patch("esphome.__main__.write_cpp", return_value=0),
|
||||
patch("esphome.__main__.compile_program", return_value=0),
|
||||
):
|
||||
assert command_analyze_memory(MockArgs(), config) == 1
|
||||
assert "missing-objdump" in caplog.text
|
||||
assert "toolchain install may be incomplete" in caplog.text
|
||||
|
||||
|
||||
def test_command_idedata_incompatible_toolchain(tmp_path: Path) -> None:
|
||||
"""A non-native, non-platformio toolchain errors out cleanly."""
|
||||
setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path)
|
||||
CORE.toolchain = Toolchain.SDK_NRF
|
||||
|
||||
assert command_idedata(MagicMock(), CORE.config) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"error",
|
||||
[
|
||||
@@ -7592,46 +7396,6 @@ def test_compile_program_espidf_idedata_none_warns(
|
||||
assert "No idedata was generated" in caplog.text
|
||||
|
||||
|
||||
def test_native_toolchain_table_serves_every_native_toolchain() -> None:
|
||||
"""Every member of NATIVE_TOOLCHAINS has a backend entry; a gap would
|
||||
surface as a targeted EsphomeError on the one affected config, and this
|
||||
pin keeps the table from drifting when a toolchain is added."""
|
||||
from esphome.const import NATIVE_TOOLCHAINS
|
||||
|
||||
assert {tc for _, tc in main._NATIVE_TOOLCHAIN_MODULES} == set(NATIVE_TOOLCHAINS)
|
||||
|
||||
|
||||
def test_native_toolchain_module_missing_backend_raises(tmp_path: Path) -> None:
|
||||
"""A native toolchain missing from the backend table is a bug and must
|
||||
fail, not silently degrade to the PlatformIO path."""
|
||||
setup_core(platform=PLATFORM_ESP8266, tmp_path=tmp_path, name="test_device")
|
||||
CORE.toolchain = Toolchain.ARDUINO
|
||||
with (
|
||||
patch.dict(main._NATIVE_TOOLCHAIN_MODULES, clear=True),
|
||||
pytest.raises(EsphomeError, match="no native build backend"),
|
||||
):
|
||||
_native_toolchain_module()
|
||||
|
||||
|
||||
def test_command_analyze_memory_unsupported_toolchain(
|
||||
tmp_path: Path,
|
||||
mock_write_cpp: Mock,
|
||||
mock_compile_program: Mock,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A hook-less non-PlatformIO toolchain is refused by name, never routed
|
||||
into the PlatformIO branch."""
|
||||
setup_core(platform=PLATFORM_NRF52, tmp_path=tmp_path, name="test_device")
|
||||
CORE.toolchain = Toolchain.SDK_NRF
|
||||
mock_write_cpp.return_value = 0
|
||||
mock_compile_program.return_value = 0
|
||||
|
||||
result = command_analyze_memory(MockArgs(), {CONF_ESPHOME: {CONF_NAME: "t"}})
|
||||
|
||||
assert result == 1
|
||||
assert "analyze-memory is not supported" in caplog.text
|
||||
|
||||
|
||||
def test_cli_toolchain_skips_the_validated_config_cache(tmp_path: Path) -> None:
|
||||
"""An explicit --toolchain must run the per-platform validators, so the
|
||||
upload/logs fast path becomes a cache miss."""
|
||||
@@ -7647,29 +7411,6 @@ def test_cli_toolchain_skips_the_validated_config_cache(tmp_path: Path) -> None:
|
||||
mock_read.assert_called_once()
|
||||
|
||||
|
||||
def test_upload_using_esptool_native_missing_firmware_raises(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""A stale or absent firmware.bin fails by name instead of flashing air."""
|
||||
setup_core(platform=PLATFORM_ESP8266, tmp_path=tmp_path, name="test")
|
||||
CORE.toolchain = Toolchain.ARDUINO
|
||||
with pytest.raises(EsphomeError, match="compile the configuration first"):
|
||||
upload_using_esptool(
|
||||
{CONF_ESPHOME: {"platformio_options": {}}}, "/dev/ttyUSB0", None, None
|
||||
)
|
||||
|
||||
|
||||
def test_compile_program_unclaimed_native_toolchain_raises(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""A resolved native toolchain no platform backend claims must fail,
|
||||
never fall through to the PlatformIO project path."""
|
||||
setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path, name="test_device")
|
||||
CORE.toolchain = Toolchain.ARDUINO # esp32 has no arduino-native backend
|
||||
with pytest.raises(EsphomeError, match="no platform backend claimed"):
|
||||
compile_program(MockArgs(), {})
|
||||
|
||||
|
||||
def test_cli_toolchain_still_refreshes_the_validated_config_cache(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
"""The pch prefix must keep resolving; a rename would silently
|
||||
collapse the precompiled set to defines.h with strict CI still green."""
|
||||
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
from esphome.build_helpers.pch import PCH_PREFIX_HEADER
|
||||
|
||||
REPO = Path(__file__).parents[2]
|
||||
|
||||
|
||||
def test_pch_prefix_resolves() -> None:
|
||||
prefix = REPO / PCH_PREFIX_HEADER
|
||||
assert prefix.is_file()
|
||||
body = prefix.read_text()
|
||||
includes = re.findall(r'#include "([^"]+)"', body)
|
||||
assert includes, "prefix wrapper folds nothing"
|
||||
for name in includes:
|
||||
assert (REPO / name).is_file(), f"{name} does not resolve"
|
||||
# The C guard is what keeps build_src_flags safe on C/assembly edges
|
||||
assert "#ifdef __cplusplus" in body
|
||||
@@ -1,581 +0,0 @@
|
||||
"""Tests for esphome/platformio/pch.py.script against a fake SCons env."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import stat
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.platformio import toolchain
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
os.name == "nt", reason="the fake compiler is a POSIX shell script"
|
||||
)
|
||||
|
||||
_SCRIPT = Path(toolchain.__file__).parent / "pch.py.script"
|
||||
|
||||
|
||||
class _FakePlatform:
|
||||
packages = {"framework-x": {}, "toolchain-y": {}}
|
||||
|
||||
def get_package_version(self, name: str) -> str:
|
||||
if name == "toolchain-y":
|
||||
raise KeyError(name)
|
||||
return "1.2.3"
|
||||
|
||||
def get_package(self, name: str) -> object | None:
|
||||
return None
|
||||
|
||||
|
||||
class _BrokenPlatform(_FakePlatform):
|
||||
def get_package_version(self, name: str) -> str:
|
||||
raise RuntimeError("manifest parse error")
|
||||
|
||||
|
||||
class _UnresolvedPlatform(_FakePlatform):
|
||||
"""KeyError from a package that IS installed: unresolved identity."""
|
||||
|
||||
def get_package_version(self, name: str) -> str:
|
||||
raise KeyError(name)
|
||||
|
||||
def get_package(self, name: str) -> object:
|
||||
return object()
|
||||
|
||||
|
||||
class _FakeSConsEnv(dict):
|
||||
"""Just enough of a SCons construction environment for pch.py."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
proj_dir: Path,
|
||||
src_dir: Path,
|
||||
cxx: str,
|
||||
flags: list[str],
|
||||
platform_cls: type[_FakePlatform] = _FakePlatform,
|
||||
):
|
||||
super().__init__(ENV={})
|
||||
self._subst = {
|
||||
"$PROJECT_DIR": str(proj_dir),
|
||||
"$PROJECT_SRC_DIR": str(src_dir),
|
||||
"$CXX": cxx,
|
||||
}
|
||||
self._flags = flags
|
||||
self._platform_cls = platform_cls
|
||||
self.prepended: list[str] = []
|
||||
|
||||
def subst(self, expr: str) -> str: # noqa: N802
|
||||
return self._subst[expr]
|
||||
|
||||
def subst_list(self, expr: str) -> list[list[str]]: # noqa: N802
|
||||
return [self._flags]
|
||||
|
||||
def PioPlatform(self) -> _FakePlatform: # noqa: N802
|
||||
return self._platform_cls()
|
||||
|
||||
def Prepend(self, CXXFLAGS: list[str]) -> None: # noqa: N802, N803
|
||||
self.prepended = CXXFLAGS
|
||||
|
||||
|
||||
def _fake_cxx(
|
||||
tmp_path: Path,
|
||||
fail: bool = False,
|
||||
fail_msg: str | None = None,
|
||||
reject_pch: bool = False,
|
||||
probe_exit: int = 0,
|
||||
) -> Path:
|
||||
"""A compiler stand-in that records its argv and writes the -o target.
|
||||
|
||||
With reject_pch it builds the .gch fine but, like GCC 10 on macOS arm64,
|
||||
warns on any consuming compile that the .gch cannot be loaded; probe_exit
|
||||
sets the exit code of non-header compiles (the load probe).
|
||||
"""
|
||||
cxx = tmp_path / "fake-gxx"
|
||||
body = (
|
||||
'printf -- ---call---\\\\n >> "$0.argv"; printf \'%s\\n\' "$@" >> "$0.argv"\n'
|
||||
)
|
||||
if fail:
|
||||
body += f"echo {fail_msg or 'boom'} >&2\nexit 1\n"
|
||||
else:
|
||||
# Only the c++-header compile has a -o; the load probe has none
|
||||
body += 'out=""; prev=""; mf=0; dep=0; inc=0\nfor a in "$@"; do [ "$prev" = "-o" ] && out="$a"; prev="$a"; [ "$a" = "-MF" ] && mf=1; [ "$a" = "-include" ] && inc=1; case "$a" in -M|-MM|-MD|-MMD) dep=1;; esac; done\n'
|
||||
# Real cc1plus rejects -MF without a dependency flag
|
||||
body += 'if [ "$mf" = 1 ] && [ "$dep" = 0 ]; then echo "cc1plus: error: to generate dependencies you must specify either \x27-M\x27 or \x27-MM\x27" >&2; exit 1; fi\n'
|
||||
body += '[ -n "$out" ] && echo gch > "$out"\n'
|
||||
if reject_pch:
|
||||
# -Werror=invalid-pch makes rejection a nonzero exit; the
|
||||
# baseline (no -include) still passes
|
||||
body += 'case " $* " in *c++-header*) ;; *) if [ "$inc" = 1 ]; then echo "error: esphome_pch.h.gch: had text segment at different address" >&2; exit 1; fi;; esac\n'
|
||||
body += f'case " $* " in *c++-header*) exit 0;; *) exit {probe_exit};; esac\n'
|
||||
cxx.write_text("#!/bin/sh\n" + body)
|
||||
cxx.chmod(cxx.stat().st_mode | stat.S_IEXEC)
|
||||
return cxx
|
||||
|
||||
|
||||
def _run_script(
|
||||
tmp_path: Path,
|
||||
flags: list[str] | None = None,
|
||||
fail: bool = False,
|
||||
fail_msg: str | None = None,
|
||||
reject_pch: bool = False,
|
||||
probe_exit: int = 0,
|
||||
missing_cxx: bool = False,
|
||||
env_vars: dict[str, str] | None = None,
|
||||
name: str = "dev",
|
||||
platform_cls: type[_FakePlatform] = _FakePlatform,
|
||||
) -> _FakeSConsEnv:
|
||||
proj = tmp_path / name
|
||||
src = proj / "src"
|
||||
(src / "esphome" / "core").mkdir(parents=True, exist_ok=True)
|
||||
(src / "esphome" / "core" / "defines.h").write_text("#define USE_X\n")
|
||||
cxx = _fake_cxx(
|
||||
tmp_path,
|
||||
fail=fail,
|
||||
fail_msg=fail_msg,
|
||||
reject_pch=reject_pch,
|
||||
probe_exit=probe_exit,
|
||||
)
|
||||
if missing_cxx:
|
||||
cxx = tmp_path / "no-such-gxx"
|
||||
args = (proj, src, str(cxx), flags or ["-DX=1"], platform_cls)
|
||||
# Distinct objects: the -include flags must land on projenv only
|
||||
global_env = _FakeSConsEnv(*args)
|
||||
projenv = _FakeSConsEnv(*args)
|
||||
projenv.global_env = global_env
|
||||
source = _SCRIPT.read_text()
|
||||
with patch.dict(os.environ, env_vars or {}, clear=True):
|
||||
exec( # noqa: S102
|
||||
compile(source, "pch.py", "exec"),
|
||||
{"Import": lambda *_names: None, "env": global_env, "projenv": projenv},
|
||||
)
|
||||
return projenv
|
||||
|
||||
|
||||
def test_pch_script_builds_and_prepends_relative_include(tmp_path: Path) -> None:
|
||||
scons_env = _run_script(tmp_path)
|
||||
proj = tmp_path / "dev"
|
||||
assert (proj / "esphome_pch.h").read_text().endswith('"esphome/core/defines.h"\n')
|
||||
assert (proj / "esphome_pch.h.gch").is_file()
|
||||
assert len((proj / "esphome_pch.h.gch.sum").read_text().strip()) == 64
|
||||
# Relative include: an absolute path would poison ccache keys
|
||||
assert scons_env.prepended == [
|
||||
"-Winvalid-pch",
|
||||
"-Wno-error=invalid-pch",
|
||||
"-include",
|
||||
"esphome_pch.h",
|
||||
]
|
||||
# In production projenv["ENV"] aliases os.environ; only the -include
|
||||
# flags are genuinely scoped to projenv (src compiles)
|
||||
assert scons_env["ENV"]["CCACHE_SLOPPINESS"] == "pch_defines,time_macros"
|
||||
assert scons_env["ENV"]["CCACHE_PCH_EXTSUM"] == "true"
|
||||
assert scons_env.global_env.prepended == []
|
||||
|
||||
|
||||
def test_pch_script_preserves_spaced_flag_elements(tmp_path: Path) -> None:
|
||||
"""One SCons element stays one compiler argv; -include pairs are
|
||||
stripped from the .gch compile."""
|
||||
spaced = tmp_path / "My Configs"
|
||||
spaced.mkdir()
|
||||
(tmp_path / "dev" / "src").mkdir(parents=True, exist_ok=True)
|
||||
(tmp_path / "dev" / "src" / "other.h").write_text("")
|
||||
flags = ['-DUSB_PRODUCT=\\"Pico 2W\\"', "-I", str(spaced), "-include", "other.h"]
|
||||
_run_script(tmp_path, flags=flags)
|
||||
calls = (tmp_path / "fake-gxx.argv").read_text().split("---call---\n")
|
||||
gch_call = next(c for c in calls if "c++-header" in c).splitlines()
|
||||
assert '-DUSB_PRODUCT="Pico 2W"' in gch_call
|
||||
assert str(spaced) in gch_call
|
||||
assert "-include" not in gch_call
|
||||
# The stripped src-resolvable -include is folded into the prefix header
|
||||
pch = (tmp_path / "dev" / "esphome_pch.h").read_text()
|
||||
assert pch.splitlines()[0] == '#include "other.h"'
|
||||
|
||||
|
||||
def test_pch_script_folds_joined_force_include_spelling(tmp_path: Path) -> None:
|
||||
"""-includefoo.h folds like the separated form, matching the native path."""
|
||||
(tmp_path / "dev" / "src").mkdir(parents=True, exist_ok=True)
|
||||
(tmp_path / "dev" / "src" / "other.h").write_text("")
|
||||
_run_script(tmp_path, flags=["-DX=1", "-includeother.h"])
|
||||
pch = (tmp_path / "dev" / "esphome_pch.h").read_text()
|
||||
assert pch.splitlines()[0] == '#include "other.h"'
|
||||
|
||||
|
||||
def test_pch_script_leaves_absolute_force_includes_unfolded(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""An absolute -include resolves through src_dir / name; it must still
|
||||
stay consumer-only or the host path enters the .sum."""
|
||||
outside = tmp_path / "outside.h"
|
||||
outside.write_text("")
|
||||
_run_script(tmp_path, flags=["-DX=1", "-include", str(outside)])
|
||||
pch = (tmp_path / "dev" / "esphome_pch.h").read_text()
|
||||
assert "outside.h" not in pch
|
||||
|
||||
|
||||
def test_pch_script_leaves_non_src_force_includes_unfolded(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""A user -include outside src/ must not enter the prefix header:
|
||||
consumers keep their own copy, so folding an unguarded header would
|
||||
include it twice."""
|
||||
_run_script(tmp_path, flags=["-DX=1", "-include", "user_extra.h"])
|
||||
pch = (tmp_path / "dev" / "esphome_pch.h").read_text()
|
||||
assert "user_extra.h" not in pch
|
||||
assert pch.splitlines()[-1] == '#include "esphome/core/defines.h"'
|
||||
assert "not precompiling non-src force-includes" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_pch_script_sum_is_device_independent(tmp_path: Path) -> None:
|
||||
"""Regression: identical configs in different dirs share cache keys."""
|
||||
sums = []
|
||||
for name in ("dev_a", "dev_b"):
|
||||
proj = tmp_path / name
|
||||
_run_script(
|
||||
tmp_path,
|
||||
flags=["-DX=1", "-I", str(proj / "include")],
|
||||
env_vars={"CCACHE_BASEDIR": str(proj)},
|
||||
name=name,
|
||||
)
|
||||
sums.append((proj / "esphome_pch.h.gch.sum").read_text())
|
||||
(tmp_path / "fake-gxx").unlink()
|
||||
(tmp_path / "fake-gxx.argv").unlink(missing_ok=True)
|
||||
assert sums[0] == sums[1]
|
||||
|
||||
|
||||
def test_pch_script_transient_compiler_failure_does_not_latch(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""ENOSPC-style failures clear on their own; no .failed marker."""
|
||||
scons_env = _run_script(tmp_path, fail=True, fail_msg="No space left on device")
|
||||
proj = tmp_path / "dev"
|
||||
assert not (proj / "esphome_pch.h.gch.failed").exists()
|
||||
assert scons_env.prepended == []
|
||||
|
||||
|
||||
def test_pch_script_failure_marker_suppresses_retry(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
scons_env = _run_script(tmp_path, fail=True)
|
||||
proj = tmp_path / "dev"
|
||||
assert (proj / "esphome_pch.h.gch.failed").is_file()
|
||||
assert not (proj / "esphome_pch.h.gch.sum").exists()
|
||||
assert scons_env.prepended == []
|
||||
# Second run: same checksum, no compile attempt, but says so
|
||||
attempts = (tmp_path / "fake-gxx.argv").read_text().count("c++-header")
|
||||
_run_script(tmp_path, fail=True)
|
||||
out = capsys.readouterr().out
|
||||
assert (tmp_path / "fake-gxx.argv").read_text().count("c++-header") == attempts
|
||||
assert "delete esphome_pch.h.gch.failed to retry" in out
|
||||
|
||||
|
||||
def test_pch_script_probe_rejection_falls_back(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""A toolchain that cannot load its own .gch (GCC 10 on macOS arm64)
|
||||
must not leave consumers paying for a pch every compile rejects."""
|
||||
scons_env = _run_script(tmp_path, reject_pch=True)
|
||||
proj = tmp_path / "dev"
|
||||
assert not (proj / "esphome_pch.h.gch").exists()
|
||||
assert not (proj / "esphome_pch.h.gch.sum").exists()
|
||||
assert (proj / "esphome_pch.h.gch.failed").is_file()
|
||||
assert scons_env.prepended == []
|
||||
assert "toolchain cannot load the pch" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_pch_script_spawn_failure_is_transient(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""A spawn failure must not latch a .failed marker (matches espidf)."""
|
||||
scons_env = _run_script(tmp_path, missing_cxx=True)
|
||||
proj = tmp_path / "dev"
|
||||
assert not (proj / "esphome_pch.h.gch.failed").exists()
|
||||
assert not (proj / "esphome_pch.h.gch.sum").exists()
|
||||
assert scons_env.prepended == []
|
||||
assert "did not run" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_pch_script_probe_baseline_failure_latches_with_honest_label(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""Probe AND baseline failing is a deterministic environment problem:
|
||||
latch, but blame the environment rather than the pch."""
|
||||
scons_env = _run_script(tmp_path, probe_exit=1)
|
||||
proj = tmp_path / "dev"
|
||||
assert not (proj / "esphome_pch.h.gch").exists()
|
||||
assert (proj / "esphome_pch.h.gch.failed").is_file()
|
||||
assert scons_env.prepended == []
|
||||
assert "probe cannot run at all" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_pch_script_unresolved_package_version_skips_pch(tmp_path: Path) -> None:
|
||||
"""A KeyError for an installed package is unresolved identity, not absence."""
|
||||
scons_env = _run_script(tmp_path, platform_cls=_UnresolvedPlatform)
|
||||
assert not (tmp_path / "dev" / "esphome_pch.h.gch").exists()
|
||||
assert scons_env.prepended == []
|
||||
|
||||
|
||||
def test_pch_script_package_version_error_skips_pch(tmp_path: Path) -> None:
|
||||
"""Without trustworthy package identity a stale .gch could survive an
|
||||
upgrade, so the script must not build one at all."""
|
||||
scons_env = _run_script(tmp_path, platform_cls=_BrokenPlatform)
|
||||
proj = tmp_path / "dev"
|
||||
assert not (proj / "esphome_pch.h.gch").exists()
|
||||
assert scons_env.prepended == []
|
||||
|
||||
|
||||
def test_pch_script_corrupt_sidecar_reads_as_stale(tmp_path: Path) -> None:
|
||||
"""A truncated/corrupt .failed marker must not disable the pch forever."""
|
||||
_run_script(tmp_path, fail=True)
|
||||
proj = tmp_path / "dev"
|
||||
(proj / "esphome_pch.h.gch.failed").write_bytes(b"\xff\xfe corrupt")
|
||||
_run_script(tmp_path)
|
||||
assert (proj / "esphome_pch.h.gch").is_file()
|
||||
assert (proj / "esphome_pch.h.gch.sum").is_file()
|
||||
|
||||
|
||||
def test_pch_script_rebuilds_when_header_missing(tmp_path: Path) -> None:
|
||||
_run_script(tmp_path)
|
||||
proj = tmp_path / "dev"
|
||||
(proj / "esphome_pch.h").unlink()
|
||||
_run_script(tmp_path)
|
||||
assert (proj / "esphome_pch.h").is_file()
|
||||
|
||||
|
||||
def test_copy_pch_script(tmp_path: Path) -> None:
|
||||
from esphome.core import CORE
|
||||
|
||||
CORE.build_path = tmp_path
|
||||
toolchain.copy_pch_script()
|
||||
assert (tmp_path / "pch.py").read_text() == _SCRIPT.read_text()
|
||||
|
||||
|
||||
def test_pch_script_unions_user_sloppiness(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""A user CCACHE_SLOPPINESS without the pch tokens gets them unioned on,
|
||||
mirroring ccache_pch_env, or every src TU is a permanent miss."""
|
||||
scons_env = _run_script(tmp_path, env_vars={"CCACHE_SLOPPINESS": "locale"})
|
||||
assert scons_env["ENV"]["CCACHE_SLOPPINESS"] == "locale,pch_defines,time_macros"
|
||||
assert "adding pch_defines,time_macros" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_pch_script_unmodelable_flag_skips_pch(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""Unbalanced quotes and multi-token elements cannot be reproduced as
|
||||
one argv; the pch is skipped rather than built with diverging flags."""
|
||||
for bad in ("-DFOO='bar", "-DA=1\t-DB=2"):
|
||||
scons_env = _run_script(tmp_path, flags=["-DX=1", bad])
|
||||
assert scons_env.prepended == []
|
||||
assert not (tmp_path / "dev" / "esphome_pch.h.gch").exists()
|
||||
assert "unmodelable flag" in capsys.readouterr().out
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
getattr(os, "geteuid", lambda: -1)() == 0, reason="root ignores file modes"
|
||||
)
|
||||
def test_pch_script_unlistable_include_dir_skips_pch(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""An unlistable subtree must not silently drop out of the digest."""
|
||||
proj = tmp_path / "dev"
|
||||
override = proj / "lwip_override"
|
||||
hidden = override / "hidden"
|
||||
hidden.mkdir(parents=True)
|
||||
(hidden / "gen.h").write_text("")
|
||||
hidden.chmod(0)
|
||||
try:
|
||||
scons_env = _run_script(tmp_path, flags=["-DX=1", "-I", str(override)])
|
||||
finally:
|
||||
hidden.chmod(0o755)
|
||||
assert scons_env.prepended == []
|
||||
assert "skipping precompiled header" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_pch_script_nobuild_without_projenv_is_noop(tmp_path: Path) -> None:
|
||||
"""-t nobuild never exports projenv; the script must not abort."""
|
||||
proj = tmp_path / "dev"
|
||||
(proj / "src").mkdir(parents=True)
|
||||
|
||||
def strict_import(*names: str) -> None:
|
||||
if "projenv" in names:
|
||||
raise RuntimeError("Import of non-existent variable 'projenv'")
|
||||
|
||||
env = _FakeSConsEnv(proj, proj / "src", "g++", ["-DX=1"])
|
||||
exec( # noqa: S102
|
||||
compile(_SCRIPT.read_text(), "pch.py", "exec"),
|
||||
{"Import": strict_import, "env": env},
|
||||
)
|
||||
assert not (proj / "esphome_pch.h").exists()
|
||||
|
||||
|
||||
def test_pch_script_ignores_library_trees_and_non_headers(tmp_path: Path) -> None:
|
||||
""".piolibdeps and non-header files must not enter the digest (or be
|
||||
read at all); package versions already cover library identity."""
|
||||
proj = tmp_path / "dev"
|
||||
libdeps = proj / ".piolibdeps" / "lib" / "src"
|
||||
libdeps.mkdir(parents=True)
|
||||
(libdeps / "lib.h").write_text("#define A 1\n")
|
||||
override = proj / "lwip_override"
|
||||
override.mkdir(parents=True)
|
||||
(override / "lwipopts.h").write_text("#define TCP_MSS 1460\n")
|
||||
(override / "notes.txt").write_text("v1\n")
|
||||
flags = ["-DX=1", "-I", str(libdeps), "-I", str(override)]
|
||||
_run_script(tmp_path, flags=flags)
|
||||
first = (proj / "esphome_pch.h.gch.sum").read_text()
|
||||
(libdeps / "lib.h").write_text("#define A 2\n")
|
||||
(override / "notes.txt").write_text("v2\n")
|
||||
(tmp_path / "fake-gxx.argv").unlink(missing_ok=True)
|
||||
_run_script(tmp_path, flags=flags)
|
||||
assert (proj / "esphome_pch.h.gch.sum").read_text() == first
|
||||
|
||||
|
||||
def test_pch_script_hashes_project_local_include_dirs(tmp_path: Path) -> None:
|
||||
"""Generated headers in project-local -I dirs (e.g. rp2's lwip_override)
|
||||
must invalidate the checksum when they change."""
|
||||
proj = tmp_path / "dev"
|
||||
override = proj / "lwip_override"
|
||||
override.mkdir(parents=True)
|
||||
(override / "lwipopts.h").write_text("#define TCP_MSS 1460\n")
|
||||
flags = ["-DX=1", "-I", str(override)]
|
||||
_run_script(tmp_path, flags=flags)
|
||||
first = (proj / "esphome_pch.h.gch.sum").read_text()
|
||||
(override / "lwipopts.h").write_text("#define TCP_MSS 536\n")
|
||||
(tmp_path / "fake-gxx.argv").unlink(missing_ok=True)
|
||||
_run_script(tmp_path, flags=flags)
|
||||
assert (proj / "esphome_pch.h.gch.sum").read_text() != first
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
getattr(os, "geteuid", lambda: -1)() == 0, reason="root ignores file modes"
|
||||
)
|
||||
def test_pch_script_unreadable_local_header_skips_pch(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""An unreadable generated header means unknown identity: no pch."""
|
||||
proj = tmp_path / "dev"
|
||||
override = proj / "lwip_override"
|
||||
override.mkdir(parents=True)
|
||||
secret = override / "lwipopts.h"
|
||||
secret.write_text("#define TCP_MSS 1460\n")
|
||||
secret.chmod(0)
|
||||
scons_env = _run_script(tmp_path, flags=["-DX=1", "-I", str(override)])
|
||||
assert not (proj / "esphome_pch.h.gch.sum").exists()
|
||||
assert scons_env.prepended == []
|
||||
assert "skipping precompiled header" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_pch_script_rejects_unrecognized_strict_value(tmp_path: Path) -> None:
|
||||
"""A typo'd knob must fail the build, not silently disable the gate."""
|
||||
with pytest.raises(RuntimeError, match="Unrecognized ESPHOME_PCH_STRICT"):
|
||||
_run_script(tmp_path, env_vars={"ESPHOME_PCH_STRICT": "yolo"})
|
||||
|
||||
|
||||
def test_pch_script_strict_tables_match_helpers(tmp_path: Path) -> None:
|
||||
"""The script's mirrored spelling tables must not drift."""
|
||||
from esphome.helpers import FALSY_ENV_STRINGS, TRUTHY_ENV_STRINGS
|
||||
|
||||
proj = tmp_path / "dev"
|
||||
(proj / "src").mkdir(parents=True)
|
||||
# Hermetic: the constants are module-level, but the exec still runs
|
||||
# _setup_pch, which must not touch the host toolchain
|
||||
cxx = _fake_cxx(tmp_path)
|
||||
env = _FakeSConsEnv(proj, proj / "src", str(cxx), ["-DX=1"])
|
||||
namespace = {"Import": lambda *_names: None, "env": env, "projenv": env}
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
exec(compile(_SCRIPT.read_text(), "pch.py", "exec"), namespace) # noqa: S102
|
||||
assert set(namespace["_TRUTHY"]) == set(TRUTHY_ENV_STRINGS)
|
||||
# parse_enable_env handles the empty string separately
|
||||
assert set(namespace["_FALSY"]) - {""} == set(FALSY_ENV_STRINGS)
|
||||
|
||||
|
||||
def test_pch_script_strict_reprobes_cached_gch(tmp_path: Path) -> None:
|
||||
"""Rejection is per-process: strict re-proves a cached .gch loads."""
|
||||
_run_script(tmp_path)
|
||||
proj = tmp_path / "dev"
|
||||
assert (proj / "esphome_pch.h.gch").is_file()
|
||||
(tmp_path / "fake-gxx.argv").unlink(missing_ok=True)
|
||||
# Second run: cache fresh, but the toolchain now rejects loads
|
||||
with pytest.raises(RuntimeError, match="not used"):
|
||||
_run_script(tmp_path, reject_pch=True, env_vars={"ESPHOME_PCH_STRICT": "1"})
|
||||
assert not (proj / "esphome_pch.h.gch").exists()
|
||||
|
||||
|
||||
def test_pch_script_strict_raises_when_pch_not_used(tmp_path: Path) -> None:
|
||||
"""ESPHOME_PCH_STRICT fails the build instead of degrading."""
|
||||
with pytest.raises(RuntimeError, match="ESPHOME_PCH_STRICT"):
|
||||
_run_script(tmp_path, fail=True, env_vars={"ESPHOME_PCH_STRICT": "1"})
|
||||
|
||||
|
||||
def test_pch_script_strict_fails_without_scons(tmp_path: Path) -> None:
|
||||
"""No SCons under PlatformIO is an anomaly; strict must not pass."""
|
||||
proj = tmp_path / "dev"
|
||||
(proj / "src").mkdir(parents=True)
|
||||
|
||||
def strict_import(*names: str) -> None:
|
||||
if "projenv" in names:
|
||||
raise RuntimeError("Import of non-existent variable 'projenv'")
|
||||
|
||||
import sys
|
||||
|
||||
env = _FakeSConsEnv(proj, proj / "src", "g++", ["-DX=1"])
|
||||
with (
|
||||
# None forces ImportError even where SCons is installed
|
||||
patch.dict(sys.modules, {"SCons.Script": None}),
|
||||
patch.dict(os.environ, {"ESPHOME_PCH_STRICT": "1"}, clear=True),
|
||||
pytest.raises(RuntimeError, match="not used"),
|
||||
):
|
||||
exec( # noqa: S102
|
||||
compile(_SCRIPT.read_text(), "pch.py", "exec"),
|
||||
{"Import": strict_import, "env": env},
|
||||
)
|
||||
|
||||
|
||||
def test_pch_script_strict_reraises_internal_errors(tmp_path: Path) -> None:
|
||||
"""The catch-all must not swallow programming errors in strict mode."""
|
||||
with pytest.raises(TypeError):
|
||||
_run_script(tmp_path, env_vars={"ESPHOME_PCH_STRICT": "1"}, platform_cls=None)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("targets", "passes"), [(["nobuild"], True), ([], False)])
|
||||
def test_pch_script_strict_projenv_skip_gated_on_nobuild(
|
||||
tmp_path: Path, targets: list[str], passes: bool
|
||||
) -> None:
|
||||
"""-t nobuild compiles nothing, so the skip passes strict; a missing
|
||||
projenv on a real compile must not."""
|
||||
import sys
|
||||
import types
|
||||
|
||||
proj = tmp_path / "dev"
|
||||
(proj / "src").mkdir(parents=True)
|
||||
|
||||
def strict_import(*names: str) -> None:
|
||||
if "projenv" in names:
|
||||
raise RuntimeError("Import of non-existent variable 'projenv'")
|
||||
|
||||
scons = types.ModuleType("SCons")
|
||||
scons_script = types.ModuleType("SCons.Script")
|
||||
scons_script.COMMAND_LINE_TARGETS = targets
|
||||
env = _FakeSConsEnv(proj, proj / "src", "g++", ["-DX=1"])
|
||||
with (
|
||||
patch.dict(sys.modules, {"SCons": scons, "SCons.Script": scons_script}),
|
||||
patch.dict(os.environ, {"ESPHOME_PCH_STRICT": "1"}, clear=True),
|
||||
):
|
||||
run = lambda: exec( # noqa: S102, E731
|
||||
compile(_SCRIPT.read_text(), "pch.py", "exec"),
|
||||
{"Import": strict_import, "env": env},
|
||||
)
|
||||
if passes:
|
||||
run()
|
||||
else:
|
||||
with pytest.raises(RuntimeError, match="not used"):
|
||||
run()
|
||||
assert not (proj / "esphome_pch.h").exists()
|
||||
|
||||
|
||||
def test_pch_script_strict_passes_on_success(tmp_path: Path) -> None:
|
||||
scons_env = _run_script(tmp_path, env_vars={"ESPHOME_PCH_STRICT": "1"})
|
||||
# Strict escalates the consumer edges too
|
||||
assert "-Werror=invalid-pch" in scons_env.prepended
|
||||
assert "-Wno-error=invalid-pch" not in scons_env.prepended
|
||||
@@ -516,13 +516,10 @@ def test_clean_build(
|
||||
dependencies_lock = tmp_path / "dependencies.lock"
|
||||
dependencies_lock.write_text("lock file")
|
||||
|
||||
# idedata caches live under the data dir, not the build path; the
|
||||
# .arduino.json variant is the native esp8266 toolchain's.
|
||||
# idedata cache lives under the data dir, not the build path.
|
||||
idedata_cache = tmp_path / "idedata" / "test.json"
|
||||
idedata_cache.parent.mkdir()
|
||||
idedata_cache.write_text("{}")
|
||||
arduino_idedata_cache = tmp_path / "idedata" / "test.arduino.json"
|
||||
arduino_idedata_cache.write_text("{}")
|
||||
|
||||
# Native ESP-IDF toolchain artifacts.
|
||||
idf_build_dir = tmp_path / "build"
|
||||
@@ -583,7 +580,6 @@ def test_clean_build(
|
||||
assert not piolibdeps_dir.exists()
|
||||
assert not dependencies_lock.exists()
|
||||
assert not idedata_cache.exists()
|
||||
assert not arduino_idedata_cache.exists()
|
||||
assert not idf_build_dir.exists()
|
||||
assert not managed_components_dir.exists()
|
||||
assert not pio_components_dir.exists()
|
||||
@@ -677,32 +673,6 @@ def test_clean_build_partial_exists(
|
||||
assert "dependencies.lock" not in caplog.text
|
||||
|
||||
|
||||
@patch("esphome.writer.CORE")
|
||||
def test_clean_build_partial_removes_pch_artifacts(
|
||||
mock_core: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""The PlatformIO pch sidecars live at the project root and must go in
|
||||
a partial clean, like the native backend's under .pioenvs."""
|
||||
names = (
|
||||
"esphome_pch.h",
|
||||
"esphome_pch.h.gch",
|
||||
"esphome_pch.h.gch.sum",
|
||||
"esphome_pch.h.gch.failed",
|
||||
)
|
||||
for name in names:
|
||||
(tmp_path / name).write_text("x")
|
||||
mock_core.relative_pioenvs_path.return_value = tmp_path / ".pioenvs"
|
||||
mock_core.relative_piolibdeps_path.return_value = tmp_path / ".piolibdeps"
|
||||
mock_core.relative_build_path.side_effect = lambda name: tmp_path / name
|
||||
mock_core.relative_internal_path.side_effect = tmp_path.joinpath
|
||||
|
||||
clean_build()
|
||||
|
||||
for name in names:
|
||||
assert not (tmp_path / name).exists()
|
||||
|
||||
|
||||
@patch("esphome.writer.CORE")
|
||||
def test_clean_build_nothing_exists(
|
||||
mock_core: MagicMock,
|
||||
|
||||
Reference in New Issue
Block a user