mirror of
https://github.com/esphome/esphome.git
synced 2026-09-09 06:18:46 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef7279341f | ||
|
|
69f2a7d556 | ||
|
|
008677298a | ||
|
|
d2bc056f0a | ||
|
|
42fffd16fe | ||
|
|
9c16aba6f7 | ||
|
|
ac79173f4a | ||
|
|
0a1e2acbcb | ||
|
|
9b6facb20d | ||
|
|
f89b9e704c | ||
|
|
f8b2e53609 | ||
|
|
7660dd7fa7 | ||
|
|
c3ce07755f | ||
|
|
f8a4cfa945 | ||
|
|
866ddb6e57 | ||
|
|
ca864c22b4 | ||
|
|
c9729244af | ||
|
|
442e4a1ec2 | ||
|
|
199acdf522 | ||
|
|
9340863652 | ||
|
|
d5cff6e9df | ||
|
|
e7f45a0d31 | ||
|
|
628ebe23ec | ||
|
|
823d79c948 | ||
|
|
b947094f45 | ||
|
|
6c5ab89d5f | ||
|
|
8f511a365a | ||
|
|
006f31af93 | ||
|
|
5bb112f407 | ||
|
|
4ab9298ab3 | ||
|
|
3926612281 | ||
|
|
1ce0bed3f6 |
@@ -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/core/**"
|
||||||
- "esphome/writer.py"
|
- "esphome/writer.py"
|
||||||
- "esphome/build_gen/**"
|
- "esphome/build_gen/**"
|
||||||
- "esphome/build_helpers/**"
|
|
||||||
- "esphome/espidf/**"
|
- "esphome/espidf/**"
|
||||||
- "esphome/platformio/**"
|
- "esphome/platformio/**"
|
||||||
- "esphome/components/bk72xx/**"
|
- "esphome/components/bk72xx/**"
|
||||||
@@ -198,7 +197,6 @@ jobs:
|
|||||||
# the default.
|
# the default.
|
||||||
id:
|
id:
|
||||||
- esp8266-arduino
|
- esp8266-arduino
|
||||||
- esp8266-arduino-native
|
|
||||||
- esp32-arduino-platformio
|
- esp32-arduino-platformio
|
||||||
- esp32-arduino-esp-idf
|
- esp32-arduino-esp-idf
|
||||||
- esp32-idf-platformio
|
- esp32-idf-platformio
|
||||||
@@ -209,16 +207,6 @@ jobs:
|
|||||||
- ln882x-arduino
|
- ln882x-arduino
|
||||||
- nrf52
|
- nrf52
|
||||||
- host
|
- 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:
|
steps:
|
||||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
- name: Download image artifact
|
- name: Download image artifact
|
||||||
@@ -230,7 +218,6 @@ jobs:
|
|||||||
- name: Compile ${{ matrix.id }}
|
- name: Compile ${{ matrix.id }}
|
||||||
run: |
|
run: |
|
||||||
docker run --rm \
|
docker run --rm \
|
||||||
-e ESPHOME_PCH_STRICT="${{ matrix.pch_strict || '1' }}" \
|
|
||||||
-v "${{ github.workspace }}/docker/test_configs:/config" \
|
-v "${{ github.workspace }}/docker/test_configs:/config" \
|
||||||
"ghcr.io/esphome/esphome-amd64:${{ needs.check-docker.outputs.tag }}" \
|
"ghcr.io/esphome/esphome-amd64:${{ needs.check-docker.outputs.tag }}" \
|
||||||
compile "${{ matrix.id }}.yaml"
|
compile "${{ matrix.id }}.yaml"
|
||||||
|
|||||||
@@ -102,8 +102,6 @@ jobs:
|
|||||||
device-builder: ${{ steps.determine.outputs.device-builder }}
|
device-builder: ${{ steps.determine.outputs.device-builder }}
|
||||||
esp32-platformio: ${{ steps.determine.outputs.esp32-platformio }}
|
esp32-platformio: ${{ steps.determine.outputs.esp32-platformio }}
|
||||||
esp32-platformio-components: ${{ steps.determine.outputs.esp32-platformio-components }}
|
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: ${{ steps.determine.outputs.changed-components }}
|
||||||
changed-components-with-tests: ${{ steps.determine.outputs.changed-components-with-tests }}
|
changed-components-with-tests: ${{ steps.determine.outputs.changed-components-with-tests }}
|
||||||
directly-changed-components-with-tests: ${{ steps.determine.outputs.directly-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 "device-builder=$(echo "$output" | jq -r '.device_builder')" >> $GITHUB_OUTPUT
|
||||||
echo "esp32-platformio=$(echo "$output" | jq -r '.esp32_platformio')" >> $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 "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=$(echo "$output" | jq -c '.changed_components')" >> $GITHUB_OUTPUT
|
||||||
echo "changed-components-with-tests=$(echo "$output" | jq -c '.changed_components_with_tests')" >> $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
|
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
|
path: .temp/components_graph.json
|
||||||
key: components-graph-${{ hashFiles('esphome/components/**/*.py') }}
|
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:
|
ci-custom:
|
||||||
name: Run script/ci-custom
|
name: Run script/ci-custom
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
@@ -1267,7 +1237,7 @@ jobs:
|
|||||||
|
|
||||||
# compile validates config first, so a separate config pass is
|
# compile validates config first, so a separate config pass is
|
||||||
# redundant for this smoke test. ESP-IDF framework via PlatformIO:
|
# 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 ""
|
||||||
echo "ESP-IDF-via-PlatformIO build passed! Starting Arduino smoke test..."
|
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):
|
# 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
|
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:
|
device-builder:
|
||||||
name: Test downstream esphome/device-builder
|
name: Test downstream esphome/device-builder
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
@@ -1674,7 +1608,6 @@ jobs:
|
|||||||
needs:
|
needs:
|
||||||
- common
|
- common
|
||||||
- seed-apt-cache
|
- seed-apt-cache
|
||||||
- seed-esp8266-native-cache
|
|
||||||
- determine-jobs
|
- determine-jobs
|
||||||
- ci-custom
|
- ci-custom
|
||||||
- pylint
|
- pylint
|
||||||
@@ -1690,7 +1623,6 @@ jobs:
|
|||||||
- clang-tidy-esp32-variants
|
- clang-tidy-esp32-variants
|
||||||
- test-build-components-split
|
- test-build-components-split
|
||||||
- test-esp32-platformio
|
- test-esp32-platformio
|
||||||
- test-esp8266-native
|
|
||||||
- device-builder
|
- device-builder
|
||||||
- memory-impact-target-branch
|
- memory-impact-target-branch
|
||||||
- memory-impact-pr-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
|
from esphome.build_gen import espidf
|
||||||
|
|
||||||
espidf.write_project()
|
espidf.write_project()
|
||||||
elif not CORE.using_native_toolchain:
|
else:
|
||||||
# Other native builds generate their project at compile time;
|
|
||||||
# never write a platformio.ini for them
|
|
||||||
from esphome.build_gen import platformio
|
from esphome.build_gen import platformio
|
||||||
|
|
||||||
platformio.write_project()
|
platformio.write_project()
|
||||||
@@ -861,14 +859,20 @@ def compile_program(args: ArgsProtocol, config: ConfigType) -> int:
|
|||||||
toolchain.create_factory_bin()
|
toolchain.create_factory_bin()
|
||||||
toolchain.create_ota_bin()
|
toolchain.create_ota_bin()
|
||||||
toolchain.create_elf_copy()
|
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)
|
try:
|
||||||
elif CORE.using_native_toolchain:
|
if toolchain.get_idedata() is None:
|
||||||
raise EsphomeError(
|
_LOGGER.warning("No idedata was generated for this build")
|
||||||
f"Toolchain '{CORE.toolchain.value}' resolved but no platform "
|
except IDEDATA_BEST_EFFORT_ERRORS as err:
|
||||||
"backend claimed the build"
|
# 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:
|
else:
|
||||||
from esphome.platformio import toolchain
|
from esphome.platformio import toolchain
|
||||||
|
|
||||||
@@ -971,15 +975,12 @@ def upload_using_esptool(
|
|||||||
|
|
||||||
if file is not None:
|
if file is not None:
|
||||||
flash_images = [FlashImage(path=file, offset="0x0")]
|
flash_images = [FlashImage(path=file, offset="0x0")]
|
||||||
elif (native := _native_toolchain_module()) is not None:
|
elif CORE.using_toolchain_esp_idf:
|
||||||
# Every native backend supplies its own 0x0 flash image (bootloader
|
from esphome.espidf import toolchain
|
||||||
# and partitions included where the target needs them)
|
|
||||||
image = native.get_factory_firmware_path()
|
flash_images = [
|
||||||
if not image.is_file():
|
FlashImage(path=toolchain.get_factory_firmware_path(), offset="0x0")
|
||||||
raise EsphomeError(
|
]
|
||||||
f"{image} does not exist; compile the configuration first"
|
|
||||||
)
|
|
||||||
flash_images = [FlashImage(path=image, offset="0x0")]
|
|
||||||
else:
|
else:
|
||||||
from esphome.platformio import toolchain
|
from esphome.platformio import toolchain
|
||||||
|
|
||||||
@@ -1961,39 +1962,15 @@ def command_update_all(args: ArgsProtocol) -> int | None:
|
|||||||
return run_multiple_configs(files, build_command)
|
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:
|
def command_idedata(args: ArgsProtocol, config: ConfigType) -> int:
|
||||||
import json
|
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:
|
idedata = espidf_toolchain.get_idedata()
|
||||||
# Native toolchains derive idedata from the build's
|
|
||||||
# compile_commands.json, so the configuration must already be compiled.
|
|
||||||
idedata = native_toolchain.get_idedata()
|
|
||||||
if idedata is None:
|
if idedata is None:
|
||||||
_LOGGER.error(
|
_LOGGER.error(
|
||||||
"No idedata available; compile the configuration first",
|
"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.cli import MemoryAnalyzerCLI
|
||||||
from esphome.analyze_memory.ram_strings import RamStringsAnalyzer
|
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)
|
# Always compile to ensure fresh data (fast if no changes - just relinks)
|
||||||
exit_code = write_cpp(config)
|
exit_code = write_cpp(config)
|
||||||
if exit_code != 0:
|
if exit_code != 0:
|
||||||
@@ -2054,31 +2020,13 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int:
|
|||||||
|
|
||||||
# Get idedata for analysis
|
# Get idedata for analysis
|
||||||
idedata = None
|
idedata = None
|
||||||
if native_toolchain is not None:
|
if CORE.using_toolchain_esp_idf:
|
||||||
objdump = native_toolchain.get_objdump_path()
|
from esphome.espidf import toolchain
|
||||||
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)
|
|
||||||
|
|
||||||
firmware_elf = native_toolchain.get_elf_path()
|
objdump_path = str(toolchain.get_objdump_path())
|
||||||
if not firmware_elf.is_file():
|
readelf_path = str(toolchain.get_readelf_path())
|
||||||
# The analyzer swallows tool failures, so a missing ELF would
|
|
||||||
# produce an exit-0 zeroed report
|
firmware_elf = toolchain.get_elf_path()
|
||||||
_LOGGER.error(
|
|
||||||
"%s is missing; compile the configuration first", firmware_elf
|
|
||||||
)
|
|
||||||
return 1
|
|
||||||
else:
|
else:
|
||||||
from esphome.platformio import toolchain
|
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.ccache import ccache_defaults_env
|
||||||
from esphome.build_helpers.ninja import find_ninja
|
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.build_helpers.tools_cache import ARDUINO8266_TOOLS_CACHE, tools_cache_path
|
||||||
from esphome.core import EsphomeError, Version
|
from esphome.core import EsphomeError, Version
|
||||||
from esphome.framework_helpers import str_to_lst_of_str
|
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:
|
if ccache is None:
|
||||||
return {}
|
return {}
|
||||||
env = ccache_defaults_env(get_arduino8266_tools_path() / "ccache")
|
return ccache_defaults_env(get_arduino8266_tools_path() / "ccache")
|
||||||
env.update(ccache_pch_env())
|
|
||||||
return env
|
|
||||||
|
|||||||
@@ -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:
|
Subcommands:
|
||||||
ar <ar-binary> <archive> <rspfile> remove stale archive, then ``ar rcs``
|
ar <ar-binary> <archive> <rspfile> remove stale archive, then ``ar rcs``
|
||||||
copy <src> <dst> copy a file
|
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
|
The ar rspfile carries one object path per line (the generating rule must
|
||||||
use ``$in_newline``, never ``$in``).
|
use ``$in_newline``, never ``$in``).
|
||||||
@@ -84,18 +83,9 @@ def _run_copy(src: str, dst: str) -> int:
|
|||||||
return 0
|
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
|
# mode -> (handler, expected operand count); surplus argv means a
|
||||||
# mis-specified ninja rule and must error, not silently drop operands
|
# 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:
|
def main() -> int:
|
||||||
|
|||||||
@@ -4,14 +4,6 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
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 (
|
from esphome.components.esp32 import (
|
||||||
get_esp32_variant,
|
get_esp32_variant,
|
||||||
get_excluded_builtin_components,
|
get_excluded_builtin_components,
|
||||||
@@ -287,47 +279,7 @@ idf_component_register(
|
|||||||
target_link_options(${{COMPONENT_LIB}} PUBLIC
|
target_link_options(${{COMPONENT_LIB}} PUBLIC
|
||||||
{link_opts_str}
|
{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(
|
def write_project(
|
||||||
@@ -349,14 +301,6 @@ def write_project(
|
|||||||
get_component_cmakelists(),
|
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
|
# Snapshot the exclusion set so has_outdated_files() can trigger a
|
||||||
# discovery reconfigure when it changes. Excluded components never
|
# discovery reconfigure when it changes. Excluded components never
|
||||||
# register in project_description.json, so re-including one (e.g. a
|
# 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.
|
"""Strictly parse an on/off environment knob; None when unset or invalid.
|
||||||
|
|
||||||
``bool(str)`` truthiness would flip ``no``/``off`` to enabled, so only
|
``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
|
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
|
as unset so the caller's default policy applies.
|
||||||
``strict`` (a typo must not silently disable a CI gate).
|
|
||||||
"""
|
"""
|
||||||
raw = os.environ.get(name)
|
raw = os.environ.get(name)
|
||||||
if raw is None:
|
if raw is None:
|
||||||
@@ -40,10 +39,6 @@ def parse_enable_env(name: str, strict: bool = False) -> bool | None:
|
|||||||
return True
|
return True
|
||||||
if lowered in FALSY_ENV_STRINGS:
|
if lowered in FALSY_ENV_STRINGS:
|
||||||
return False
|
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)
|
_LOGGER.warning("Ignoring unrecognized %s=%r; use 1 or 0", name, raw)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -92,19 +87,6 @@ def ccache_defaults_env(cache_dir: Path) -> dict[str, str]:
|
|||||||
"CCACHE_DIR": str(cache_dir),
|
"CCACHE_DIR": str(cache_dir),
|
||||||
"CCACHE_NOHASHDIR": "true",
|
"CCACHE_NOHASHDIR": "true",
|
||||||
"CCACHE_DEPEND": "1",
|
"CCACHE_DEPEND": "1",
|
||||||
# A user value wins via the filter below
|
"CCACHE_BASEDIR": str(Path(CORE.build_path).resolve()),
|
||||||
"CCACHE_BASEDIR": effective_ccache_basedir(),
|
|
||||||
}
|
}
|
||||||
return {k: v for k, v in defaults.items() if k not in os.environ}
|
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 __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Callable
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
@@ -22,8 +21,6 @@ import subprocess
|
|||||||
from esphome.core import EsphomeError
|
from esphome.core import EsphomeError
|
||||||
from esphome.helpers import write_file
|
from esphome.helpers import write_file
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Everything idedata generation may raise after a successful link; idedata
|
# Everything idedata generation may raise after a successful link; idedata
|
||||||
# is a bonus artifact, so consumers warn instead of failing the build
|
# is a bonus artifact, so consumers warn instead of failing the build
|
||||||
IDEDATA_BEST_EFFORT_ERRORS = (
|
IDEDATA_BEST_EFFORT_ERRORS = (
|
||||||
@@ -34,36 +31,13 @@ IDEDATA_BEST_EFFORT_ERRORS = (
|
|||||||
ValueError,
|
ValueError,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
def warn_if_idedata_missing(get_idedata: Callable[[], dict | None]) -> None:
|
# C++ translation-unit suffixes used to identify ESPHome source files.
|
||||||
"""Run an idedata generator, downgrading any failure to a warning.
|
_CXX_SUFFIXES = (".cpp", ".cc")
|
||||||
|
|
||||||
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")
|
|
||||||
# Suffixes of input/output files that appear bare on the command line (and so
|
# Suffixes of input/output files that appear bare on the command line (and so
|
||||||
# must not be mistaken for compiler flags).
|
# 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.
|
# Path marker identifying an ESPHome source translation unit.
|
||||||
_ESPHOME_SRC_MARKER = "/src/esphome/"
|
_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
|
"""Whether ``file`` is an ESPHome C++ translation unit; normalized to
|
||||||
``/`` first since Windows compile DBs use backslashes."""
|
``/`` first since Windows compile DBs use backslashes."""
|
||||||
return _ESPHOME_SRC_MARKER in file.replace("\\", "/") and file.endswith(
|
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.
|
r"""Tokenize a compile_commands.json / response-file command string.
|
||||||
|
|
||||||
On Windows, tokenize per Windows ``argv`` rules via ``CommandLineToArgvW``.
|
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)
|
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``).
|
"""Inline any ``@response-file`` arguments (paths relative to ``directory``).
|
||||||
|
|
||||||
GCC response files embed flags that must be expanded so GCC-only flags
|
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
|
rf = directory / rf
|
||||||
try:
|
try:
|
||||||
out.extend(
|
out.extend(
|
||||||
expand_response_files(
|
_expand_response_files(
|
||||||
split_command(rf.read_text(encoding="utf-8")), directory
|
_split_command(rf.read_text(encoding="utf-8")), directory
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
@@ -147,7 +121,7 @@ def _pick_entry(entries: list[dict]) -> dict:
|
|||||||
if _is_esphome_src(entry["file"]):
|
if _is_esphome_src(entry["file"]):
|
||||||
return entry
|
return entry
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
if entry["file"].endswith(CXX_SOURCE_SUFFIXES):
|
if entry["file"].endswith(_CXX_SUFFIXES):
|
||||||
return entry
|
return entry
|
||||||
raise ValueError("no C++ translation unit found in compile_commands.json")
|
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"})
|
_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
|
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(
|
def parse_entry(
|
||||||
entry: dict, launcher: str | None = None
|
entry: dict, launcher: str | None = None
|
||||||
) -> tuple[str, list[str], list[str], list[str]]:
|
) -> tuple[str, list[str], list[str], list[str]]:
|
||||||
"""Parse one compile_commands entry -> (cxx_path, defines, includes, cxx_flags)."""
|
"""Parse one compile_commands entry -> (cxx_path, defines, includes, cxx_flags)."""
|
||||||
directory = Path(entry["directory"])
|
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:
|
def _include(raw: str) -> str:
|
||||||
# Resolve against the entry's ``directory`` so cached idedata works
|
# Resolve against the entry's ``directory`` so cached idedata works
|
||||||
@@ -191,7 +156,7 @@ def parse_entry(
|
|||||||
if not tokens:
|
if not tokens:
|
||||||
# An empty command, or one that was only the launcher; fail by name
|
# An empty command, or one that was only the launcher; fail by name
|
||||||
raise ValueError(f"empty compile command for {entry.get('file')}")
|
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
|
# Stale DB built with a launcher this run no longer configures; the
|
||||||
# real compiler is the next token
|
# real compiler is the next token
|
||||||
_LOGGER.warning("Stripping unconfigured launcher %s", tokens[0])
|
_LOGGER.warning("Stripping unconfigured launcher %s", tokens[0])
|
||||||
@@ -204,23 +169,11 @@ def parse_entry(
|
|||||||
defines: list[str] = []
|
defines: list[str] = []
|
||||||
includes: list[str] = []
|
includes: list[str] = []
|
||||||
cxx_flags: list[str] = []
|
cxx_flags: list[str] = []
|
||||||
unresolved_force_includes: list[str] = []
|
|
||||||
|
|
||||||
it = iter(tokens[1:])
|
it = iter(tokens[1:])
|
||||||
for tok in it:
|
for tok in it:
|
||||||
if tok in ("-c", "-o"):
|
if tok in ("-c", "-o"):
|
||||||
next(it, None) # drop the flag and its argument (input/output)
|
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"):
|
elif tok.startswith("-D"):
|
||||||
# ``.strip()`` handles tokens like ``-D CONFIGURED=1`` (a single
|
# ``.strip()`` handles tokens like ``-D CONFIGURED=1`` (a single
|
||||||
# quoted arg with a space after -D) that some flags arrive as.
|
# quoted arg with a space after -D) that some flags arrive as.
|
||||||
@@ -239,14 +192,6 @@ def parse_entry(
|
|||||||
pass # input/output files
|
pass # input/output files
|
||||||
else:
|
else:
|
||||||
cxx_flags.append(tok)
|
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
|
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:
|
if not isinstance(cached, dict) or "cc_path" not in cached:
|
||||||
return False
|
return False
|
||||||
cxx_path = cached.get("cxx_path")
|
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
|
return False
|
||||||
includes = cached.get("includes")
|
includes = cached.get("includes")
|
||||||
return isinstance(includes, dict) and isinstance(includes.get("build"), list)
|
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:
|
def reject_launcher_compiler(cxx_path: str) -> None:
|
||||||
"""Reject a compile DB naming a launcher (ccache) as the compiler; it
|
"""Reject a compile DB naming a launcher (ccache) as the compiler; it
|
||||||
must never be probed, cached, or consumed."""
|
must never be probed, cached, or consumed."""
|
||||||
if is_launcher(cxx_path):
|
if _is_launcher(cxx_path):
|
||||||
raise EsphomeError(
|
raise EsphomeError(
|
||||||
f"compile_commands.json names the launcher {cxx_path} as the "
|
f"compile_commands.json names the launcher {cxx_path} as the "
|
||||||
"compiler; the compile database is unusable"
|
"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)
|
|
||||||
@@ -58,6 +58,9 @@ esp_err_t AudioReader::add_sink(const std::weak_ptr<ring_buffer::RingBuffer> &ou
|
|||||||
if (current_audio_file_ != nullptr) {
|
if (current_audio_file_ != nullptr) {
|
||||||
// A transfer buffer isn't ncessary for a local file
|
// A transfer buffer isn't ncessary for a local file
|
||||||
this->file_ring_buffer_ = output_ring_buffer.lock();
|
this->file_ring_buffer_ = output_ring_buffer.lock();
|
||||||
|
if (this->file_ring_buffer_ == nullptr) {
|
||||||
|
return ESP_ERR_INVALID_STATE;
|
||||||
|
}
|
||||||
return ESP_OK;
|
return ESP_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,14 +51,14 @@ void AudioTransferBuffer::increase_buffer_length(size_t bytes) { this->buffer_le
|
|||||||
|
|
||||||
void AudioTransferBuffer::clear_buffered_data() {
|
void AudioTransferBuffer::clear_buffered_data() {
|
||||||
this->buffer_length_ = 0;
|
this->buffer_length_ = 0;
|
||||||
if (this->ring_buffer_.use_count() > 0) {
|
if (this->ring_buffer_ != nullptr) {
|
||||||
this->ring_buffer_->reset();
|
this->ring_buffer_->reset();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void AudioSinkTransferBuffer::clear_buffered_data() {
|
void AudioSinkTransferBuffer::clear_buffered_data() {
|
||||||
this->buffer_length_ = 0;
|
this->buffer_length_ = 0;
|
||||||
if (this->ring_buffer_.use_count() > 0) {
|
if (this->ring_buffer_ != nullptr) {
|
||||||
this->ring_buffer_->reset();
|
this->ring_buffer_->reset();
|
||||||
}
|
}
|
||||||
#ifdef USE_SPEAKER
|
#ifdef USE_SPEAKER
|
||||||
@@ -69,7 +69,7 @@ void AudioSinkTransferBuffer::clear_buffered_data() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool AudioTransferBuffer::has_buffered_data() const {
|
bool AudioTransferBuffer::has_buffered_data() const {
|
||||||
if (this->ring_buffer_.use_count() > 0) {
|
if (this->ring_buffer_ != nullptr) {
|
||||||
return ((this->ring_buffer_->available() > 0) || (this->available() > 0));
|
return ((this->ring_buffer_->available() > 0) || (this->available() > 0));
|
||||||
}
|
}
|
||||||
return (this->available() > 0);
|
return (this->available() > 0);
|
||||||
@@ -144,7 +144,7 @@ size_t AudioSourceTransferBuffer::transfer_data_from_source(TickType_t ticks_to_
|
|||||||
size_t bytes_to_read = AudioTransferBuffer::free();
|
size_t bytes_to_read = AudioTransferBuffer::free();
|
||||||
size_t bytes_read = 0;
|
size_t bytes_read = 0;
|
||||||
if (bytes_to_read > 0) {
|
if (bytes_to_read > 0) {
|
||||||
if (this->ring_buffer_.use_count() > 0) {
|
if (this->ring_buffer_ != nullptr) {
|
||||||
bytes_read = this->ring_buffer_->read((void *) this->get_buffer_end(), bytes_to_read, ticks_to_wait);
|
bytes_read = this->ring_buffer_->read((void *) this->get_buffer_end(), bytes_to_read, ticks_to_wait);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,7 +161,7 @@ size_t AudioSinkTransferBuffer::transfer_data_to_sink(TickType_t ticks_to_wait,
|
|||||||
bytes_written = this->speaker_->play(this->data_start_, this->available(), ticks_to_wait);
|
bytes_written = this->speaker_->play(this->data_start_, this->available(), ticks_to_wait);
|
||||||
} else
|
} else
|
||||||
#endif
|
#endif
|
||||||
if (this->ring_buffer_.use_count() > 0) {
|
if (this->ring_buffer_ != nullptr) {
|
||||||
bytes_written =
|
bytes_written =
|
||||||
this->ring_buffer_->write_without_replacement((void *) this->data_start_, this->available(), ticks_to_wait);
|
this->ring_buffer_->write_without_replacement((void *) this->data_start_, this->available(), ticks_to_wait);
|
||||||
} else if (this->sink_callback_ != nullptr) {
|
} else if (this->sink_callback_ != nullptr) {
|
||||||
@@ -186,7 +186,7 @@ bool AudioSinkTransferBuffer::has_buffered_data() const {
|
|||||||
return (this->speaker_->has_buffered_data() || (this->available() > 0));
|
return (this->speaker_->has_buffered_data() || (this->available() > 0));
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
if (this->ring_buffer_.use_count() > 0) {
|
if (this->ring_buffer_ != nullptr) {
|
||||||
return ((this->ring_buffer_->available() > 0) || (this->available() > 0));
|
return ((this->ring_buffer_->available() > 0) || (this->available() > 0));
|
||||||
}
|
}
|
||||||
return (this->available() > 0);
|
return (this->available() > 0);
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import subprocess
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from esphome import yaml_util
|
from esphome import yaml_util
|
||||||
from esphome.build_helpers.pch import PCH_PREFIX_HEADER, pch_enabled, pch_extra_scripts
|
|
||||||
import esphome.codegen as cg
|
import esphome.codegen as cg
|
||||||
from esphome.components.const import CONF_ENABLE_OTA_DOWNGRADE_PROTECTION
|
from esphome.components.const import CONF_ENABLE_OTA_DOWNGRADE_PROTECTION
|
||||||
from esphome.config_helpers import filter_source_files_from_defines
|
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
|
from esphome.espidf.component import generate_idf_components
|
||||||
import esphome.final_validate as fv
|
import esphome.final_validate as fv
|
||||||
from esphome.helpers import copy_file_if_changed, rmtree, write_file_if_changed
|
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.schema_extractors import SCHEMA_EXTRACT, schema_extractor
|
||||||
from esphome.types import ConfigType
|
from esphome.types import ConfigType
|
||||||
from esphome.writer import clean_build, clean_cmake_cache
|
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_ldf_mode", "off")
|
||||||
cg.add_platformio_option("lib_compat_mode", "strict")
|
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("platform", conf[CONF_PLATFORM_VERSION])
|
||||||
cg.add_platformio_option("board", config[CONF_BOARD])
|
cg.add_platformio_option("board", config[CONF_BOARD])
|
||||||
cg.add_platformio_option("board_upload.flash_size", config[CONF_FLASH_SIZE])
|
cg.add_platformio_option("board_upload.flash_size", config[CONF_FLASH_SIZE])
|
||||||
@@ -3379,8 +3372,6 @@ def _write_idf_component_yml():
|
|||||||
def copy_files():
|
def copy_files():
|
||||||
_write_sdkconfig()
|
_write_sdkconfig()
|
||||||
_write_idf_component_yml()
|
_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]:
|
if "partitions.csv" not in CORE.data[KEY_ESP32][KEY_EXTRA_BUILD_FILES]:
|
||||||
flash_size = CORE.data[KEY_ESP32][KEY_FLASH_SIZE]
|
flash_size = CORE.data[KEY_ESP32][KEY_FLASH_SIZE]
|
||||||
|
|||||||
@@ -3,10 +3,8 @@ from pathlib import Path
|
|||||||
import platform
|
import platform
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from esphome.build_helpers.pch import pch_extra_scripts
|
|
||||||
import esphome.codegen as cg
|
import esphome.codegen as cg
|
||||||
import esphome.config_validation as cv
|
import esphome.config_validation as cv
|
||||||
from esphome.const import (
|
from esphome.const import (
|
||||||
@@ -16,7 +14,6 @@ from esphome.const import (
|
|||||||
CONF_FRAMEWORK,
|
CONF_FRAMEWORK,
|
||||||
CONF_PLATFORM_VERSION,
|
CONF_PLATFORM_VERSION,
|
||||||
CONF_SOURCE,
|
CONF_SOURCE,
|
||||||
CONF_TOOLCHAIN,
|
|
||||||
CONF_VERSION,
|
CONF_VERSION,
|
||||||
KEY_CORE,
|
KEY_CORE,
|
||||||
KEY_FRAMEWORK_VERSION,
|
KEY_FRAMEWORK_VERSION,
|
||||||
@@ -24,7 +21,6 @@ from esphome.const import (
|
|||||||
KEY_TARGET_PLATFORM,
|
KEY_TARGET_PLATFORM,
|
||||||
PLATFORM_ESP8266,
|
PLATFORM_ESP8266,
|
||||||
ThreadModel,
|
ThreadModel,
|
||||||
Toolchain,
|
|
||||||
)
|
)
|
||||||
from esphome.core import (
|
from esphome.core import (
|
||||||
CORE,
|
CORE,
|
||||||
@@ -35,13 +31,12 @@ from esphome.core import (
|
|||||||
)
|
)
|
||||||
from esphome.core.config import BOARD_MAX_LENGTH
|
from esphome.core.config import BOARD_MAX_LENGTH
|
||||||
from esphome.helpers import IS_MACOS, copy_file_if_changed
|
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.storage_json import StorageJSON
|
||||||
from esphome.types import ConfigType
|
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 (
|
from .const import (
|
||||||
BUILD_FLASH_MODES,
|
|
||||||
CONF_EARLY_PIN_INIT,
|
CONF_EARLY_PIN_INIT,
|
||||||
CONF_ENABLE_SERIAL,
|
CONF_ENABLE_SERIAL,
|
||||||
CONF_ENABLE_SERIAL1,
|
CONF_ENABLE_SERIAL1,
|
||||||
@@ -49,7 +44,6 @@ from .const import (
|
|||||||
KEY_BOARD,
|
KEY_BOARD,
|
||||||
KEY_ESP8266,
|
KEY_ESP8266,
|
||||||
KEY_PIN_INITIAL_STATES,
|
KEY_PIN_INITIAL_STATES,
|
||||||
KEY_SCANF_FLOAT,
|
|
||||||
KEY_SERIAL1_REQUIRED,
|
KEY_SERIAL1_REQUIRED,
|
||||||
KEY_SERIAL_REQUIRED,
|
KEY_SERIAL_REQUIRED,
|
||||||
KEY_WAVEFORM_REQUIRED,
|
KEY_WAVEFORM_REQUIRED,
|
||||||
@@ -109,53 +103,6 @@ def set_core_data(config: ConfigType) -> ConfigType:
|
|||||||
return config
|
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]]:
|
def get_download_types(storage_json: StorageJSON) -> list[dict[str, str]]:
|
||||||
"""Binary-download entries for a built ESP8266 firmware.
|
"""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
|
# a PIO platformio/framework-arduinoespressif8266 value
|
||||||
# List of package versions: https://api.registry.platformio.org/v3/packages/platformio/tool/framework-arduinoespressif8266
|
# 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
|
# 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
|
from esphome.arduino8266.framework import framework_package_version
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -247,7 +194,7 @@ def _arduino_check_versions(value: ConfigType) -> ConfigType:
|
|||||||
platform_version = value.get(CONF_PLATFORM_VERSION)
|
platform_version = value.get(CONF_PLATFORM_VERSION)
|
||||||
if platform_version is None:
|
if platform_version is None:
|
||||||
if version >= cv.Version(3, 1, 0):
|
if version >= cv.Version(3, 1, 0):
|
||||||
platform_version = _ARDUINO_4_PLATFORM_SPEC
|
platform_version = _parse_platform_version(str(ARDUINO_4_PLATFORM_VERSION))
|
||||||
else:
|
else:
|
||||||
platform_version = _parse_platform_version(str(ARDUINO_3_PLATFORM_VERSION))
|
platform_version = _parse_platform_version(str(ARDUINO_3_PLATFORM_VERSION))
|
||||||
value[CONF_PLATFORM_VERSION] = platform_version
|
value[CONF_PLATFORM_VERSION] = platform_version
|
||||||
@@ -270,10 +217,6 @@ def _parse_platform_version(value: Any) -> str:
|
|||||||
return value
|
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(
|
ARDUINO_FRAMEWORK_SCHEMA = cv.All(
|
||||||
cv.Schema(
|
cv.Schema(
|
||||||
{
|
{
|
||||||
@@ -290,6 +233,7 @@ ARDUINO_FRAMEWORK_SCHEMA = cv.All(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
BUILD_FLASH_MODES = ["qio", "qout", "dio", "dout"]
|
||||||
CONFIG_SCHEMA = cv.All(
|
CONFIG_SCHEMA = cv.All(
|
||||||
cv.Schema(
|
cv.Schema(
|
||||||
{
|
{
|
||||||
@@ -306,30 +250,15 @@ CONFIG_SCHEMA = cv.All(
|
|||||||
cv.Optional(CONF_ENABLE_SERIAL1): cv.boolean,
|
cv.Optional(CONF_ENABLE_SERIAL1): cv.boolean,
|
||||||
cv.Optional(CONF_ENABLE_FULL_PRINTF, default=False): cv.boolean,
|
cv.Optional(CONF_ENABLE_FULL_PRINTF, default=False): cv.boolean,
|
||||||
cv.Optional(CONF_ENABLE_SCANF_FLOAT): cv.boolean,
|
cv.Optional(CONF_ENABLE_SCANF_FLOAT): cv.boolean,
|
||||||
cv.Optional(
|
|
||||||
CONF_TOOLCHAIN, visibility=cv.Visibility.ADVANCED
|
|
||||||
): _validate_toolchain,
|
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
_resolve_toolchain,
|
# Until the native toolchain lands, PlatformIO is the only backend;
|
||||||
_validate_native_toolchain,
|
# reject a --toolchain this platform cannot serve yet.
|
||||||
|
cv.require_platformio_toolchain("ESP8266"),
|
||||||
set_core_data,
|
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:
|
def check_rosetta() -> None:
|
||||||
"""Fail fast when the x86_64 ESP8266 toolchain cannot run on this Mac.
|
"""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)
|
@coroutine_with_priority(CoroPriority.PLATFORM)
|
||||||
async def to_code(config: ConfigType) -> None:
|
async def to_code(config: ConfigType) -> None:
|
||||||
use_platformio = CORE.using_toolchain_platformio
|
|
||||||
cg.add(esp8266_ns.setup_preferences())
|
cg.add(esp8266_ns.setup_preferences())
|
||||||
|
|
||||||
if use_platformio:
|
cg.add_platformio_option("lib_ldf_mode", "off")
|
||||||
cg.add_platformio_option("lib_ldf_mode", "off")
|
cg.add_platformio_option("lib_compat_mode", "strict")
|
||||||
cg.add_platformio_option("lib_compat_mode", "strict")
|
|
||||||
cg.add_platformio_option("board", config[CONF_BOARD])
|
cg.add_platformio_option("board", config[CONF_BOARD])
|
||||||
cg.add_build_flag("-DUSE_ESP8266")
|
cg.add_build_flag("-DUSE_ESP8266")
|
||||||
cg.set_cpp_standard("gnu++20")
|
cg.set_cpp_standard("gnu++20")
|
||||||
cg.add_define("ESPHOME_BOARD", config[CONF_BOARD])
|
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)"
|
"enabling scanf float support (~8KB flash)"
|
||||||
)
|
)
|
||||||
|
|
||||||
# The native generator reads the same decision (KEY_SCANF_FLOAT)
|
extra_scripts = [
|
||||||
CORE.data[KEY_ESP8266][KEY_SCANF_FLOAT] = bool(enable_scanf_float)
|
"pre:ccache.py",
|
||||||
if use_platformio:
|
"pre:testing_mode.py",
|
||||||
extra_scripts = [
|
"pre:exclude_updater.py",
|
||||||
"pre:ccache.py",
|
"pre:exclude_waveform.py",
|
||||||
"pre:testing_mode.py",
|
"pre:relocate_ratetable.py",
|
||||||
"pre:exclude_updater.py",
|
]
|
||||||
"pre:exclude_waveform.py",
|
if not enable_scanf_float:
|
||||||
"pre:relocate_ratetable.py",
|
extra_scripts.append("pre:remove_float_scanf.py")
|
||||||
]
|
extra_scripts.append("post:post_build.py")
|
||||||
if not enable_scanf_float:
|
cg.add_platformio_option("extra_scripts", extra_scripts)
|
||||||
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)
|
|
||||||
|
|
||||||
conf = config[CONF_FRAMEWORK]
|
conf = config[CONF_FRAMEWORK]
|
||||||
|
cg.add_platformio_option("framework", "arduino")
|
||||||
cg.add_build_flag("-DUSE_ARDUINO")
|
cg.add_build_flag("-DUSE_ARDUINO")
|
||||||
cg.add_build_flag("-DUSE_ESP8266_FRAMEWORK_ARDUINO")
|
cg.add_build_flag("-DUSE_ESP8266_FRAMEWORK_ARDUINO")
|
||||||
cg.add_build_flag("-Wno-nonnull-compare")
|
cg.add_build_flag("-Wno-nonnull-compare")
|
||||||
if use_platformio:
|
cg.add_platformio_option("platform", conf[CONF_PLATFORM_VERSION])
|
||||||
cg.add_platformio_option("framework", "arduino")
|
cg.add_platformio_option(
|
||||||
cg.add_platformio_option("platform", conf[CONF_PLATFORM_VERSION])
|
"platform_packages",
|
||||||
cg.add_platformio_option(
|
[f"platformio/framework-arduinoespressif8266@{conf[CONF_SOURCE]}"],
|
||||||
"platform_packages",
|
)
|
||||||
[f"platformio/framework-arduinoespressif8266@{conf[CONF_SOURCE]}"],
|
|
||||||
)
|
|
||||||
|
|
||||||
# Default for platformio is LWIP2_LOW_MEMORY with:
|
# Default for platformio is LWIP2_LOW_MEMORY with:
|
||||||
# - MSS=536
|
# - 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
|
# Force-include inline std::__throw_* overrides so GCC dead-strips the unused
|
||||||
# libstdc++ error message strings (e.g. "basic_string::_M_create") from DRAM.
|
# 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
|
# See throw_stubs.h for details. Must be prepended before <string>, so this
|
||||||
# uses build_src_flags with -include. Unconditional: the native build
|
# uses build_src_flags with -include.
|
||||||
# generator reads the same option, keeping one source of truth.
|
|
||||||
cg.add_platformio_option(
|
cg.add_platformio_option(
|
||||||
"build_src_flags", "-include esphome/components/esp8266/throw_stubs.h"
|
"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.
|
# implementation in the Arduino ESP8266 core.
|
||||||
cg.add_build_flag("-Wl,--wrap=millis")
|
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])
|
cg.add_platformio_option("board_build.flash_mode", config[CONF_BOARD_FLASH_MODE])
|
||||||
|
|
||||||
ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]
|
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})"),
|
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(
|
cg.add_platformio_option(
|
||||||
"board_build.ldscript", _choose_ld_script(config[CONF_BOARD])
|
"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")
|
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
|
# Called by writer.py
|
||||||
def copy_files() -> None:
|
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
|
dir = Path(__file__).parent
|
||||||
for script in (
|
for script in (
|
||||||
"post_build",
|
"post_build",
|
||||||
@@ -565,7 +469,6 @@ def copy_files() -> None:
|
|||||||
CORE.relative_build_path(f"{script}.py"),
|
CORE.relative_build_path(f"{script}.py"),
|
||||||
)
|
)
|
||||||
copy_ccache_script()
|
copy_ccache_script()
|
||||||
copy_pch_script()
|
|
||||||
|
|
||||||
|
|
||||||
# ESP logs stack trace decoder, based on https://github.com/me-no-dev/EspExceptionDecoder
|
# 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
|
||||||
|
|
||||||
|
idedata = toolchain.get_idedata(config)
|
||||||
def _warn_decode_problem(key: str, message: str, *args) -> bool:
|
if not idedata.addr2line_path or not idedata.firmware_elf_path:
|
||||||
"""Warn, deduplicated briefly so a burst of stack-dump addresses warns
|
_LOGGER.debug("decode_pc no addr2line")
|
||||||
once but a later dump warns again; returns whether it warned so the
|
return
|
||||||
caller can mark suppressed addresses individually."""
|
command = [idedata.addr2line_path, "-pfiaC", "-e", idedata.firmware_elf_path, addr]
|
||||||
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]
|
|
||||||
try:
|
try:
|
||||||
translation = subprocess.check_output(command, close_fds=False).decode().strip()
|
translation = subprocess.check_output(command, close_fds=False).decode().strip()
|
||||||
except Exception as err: # noqa: BLE001 # pylint: disable=broad-except
|
except Exception: # 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)
|
|
||||||
_LOGGER.debug("Caught exception for command %s", command, exc_info=1)
|
_LOGGER.debug("Caught exception for command %s", command, exc_info=1)
|
||||||
return
|
return
|
||||||
|
|
||||||
if "?? ??:0" in translation:
|
if "?? ??:0" in translation:
|
||||||
# A named register that fails to decode is confusing silence; a
|
# Nothing useful
|
||||||
# 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)
|
|
||||||
return
|
return
|
||||||
translation = translation.replace(" at ??:?", "").replace(":?", "")
|
translation = translation.replace(" at ??:?", "").replace(":?", "")
|
||||||
_LOGGER.warning("Decoded %s", translation)
|
_LOGGER.warning("Decoded %s", translation)
|
||||||
@@ -746,6 +596,6 @@ def process_stacktrace(config: ConfigType, line: str, backtrace_state: bool) ->
|
|||||||
|
|
||||||
if backtrace_state:
|
if backtrace_state:
|
||||||
for addr in re.finditer(STACKTRACE_ESP8266_BACKTRACE_PC_RE, line):
|
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
|
return backtrace_state
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ KEY_WAVEFORM_REQUIRED = "waveform_required"
|
|||||||
KEY_SERIAL_REQUIRED = "serial_required"
|
KEY_SERIAL_REQUIRED = "serial_required"
|
||||||
KEY_SERIAL1_REQUIRED = "serial1_required"
|
KEY_SERIAL1_REQUIRED = "serial1_required"
|
||||||
# Set for the native (non-PlatformIO) toolchain's build generator
|
# Set for the native (non-PlatformIO) toolchain's build generator
|
||||||
|
KEY_FLASH_MODE = "flash_mode"
|
||||||
KEY_SCANF_FLOAT = "scanf_float"
|
KEY_SCANF_FLOAT = "scanf_float"
|
||||||
# Per-board flash-layout override consumed by board_ld_script()
|
# Per-board flash-layout override consumed by board_ld_script()
|
||||||
KEY_LDSCRIPT = "ldscript"
|
KEY_LDSCRIPT = "ldscript"
|
||||||
@@ -72,6 +73,3 @@ def enable_serial1() -> None:
|
|||||||
enable_serial1()
|
enable_serial1()
|
||||||
"""
|
"""
|
||||||
CORE.data.setdefault(KEY_ESP8266, {})[KEY_SERIAL1_REQUIRED] = True
|
CORE.data.setdefault(KEY_ESP8266, {})[KEY_SERIAL1_REQUIRED] = True
|
||||||
|
|
||||||
|
|
||||||
BUILD_FLASH_MODES = ("qio", "qout", "dio", "dout")
|
|
||||||
|
|||||||
@@ -41,7 +41,10 @@ const noise::NoiseContext &ESPHomeOTAComponent::noise_context_() const {
|
|||||||
#endif
|
#endif
|
||||||
static constexpr uint16_t OTA_BLOCK_SIZE = 8192;
|
static constexpr uint16_t OTA_BLOCK_SIZE = 8192;
|
||||||
static constexpr uint32_t OTA_SOCKET_TIMEOUT_HANDSHAKE = 20000; // milliseconds for initial handshake
|
static constexpr uint32_t OTA_SOCKET_TIMEOUT_HANDSHAKE = 20000; // milliseconds for initial handshake
|
||||||
static constexpr uint32_t OTA_SOCKET_TIMEOUT_DATA = 90000; // milliseconds for data transfer
|
// Milliseconds for data transfer. Covers the lwIP retransmit run seen in
|
||||||
|
// practice for a lost chunk ack (1.5 + 3 + 6 + 12 + 24 + 48 s); the CLI waits
|
||||||
|
// longer (espota2.DATA_PHASE_TIMEOUT) so the device is free before it retries
|
||||||
|
static constexpr uint32_t OTA_SOCKET_TIMEOUT_DATA = 105000;
|
||||||
|
|
||||||
// Single-instance pointer — multi-port configs are rejected in final_validate.
|
// Single-instance pointer — multi-port configs are rejected in final_validate.
|
||||||
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
|
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
|
||||||
|
|||||||
@@ -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.codegen as cg
|
||||||
import esphome.config_validation as cv
|
import esphome.config_validation as cv
|
||||||
from esphome.const import (
|
from esphome.const import (
|
||||||
@@ -11,7 +10,7 @@ from esphome.const import (
|
|||||||
ThreadModel,
|
ThreadModel,
|
||||||
)
|
)
|
||||||
from esphome.core import CORE
|
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 esphome.types import ConfigType
|
||||||
|
|
||||||
from .const import KEY_HOST
|
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("platform", "platformio/native")
|
||||||
cg.add_platformio_option("lib_ldf_mode", "off")
|
cg.add_platformio_option("lib_ldf_mode", "off")
|
||||||
cg.add_platformio_option("lib_compat_mode", "strict")
|
cg.add_platformio_option("lib_compat_mode", "strict")
|
||||||
cg.add_platformio_option("extra_scripts", ["pre:ccache.py", *pch_extra_scripts()])
|
cg.add_platformio_option("extra_scripts", ["pre:ccache.py"])
|
||||||
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}")
|
|
||||||
|
|
||||||
|
|
||||||
# Called by writer.py
|
# Called by writer.py
|
||||||
def copy_files() -> None:
|
def copy_files() -> None:
|
||||||
copy_ccache_script()
|
copy_ccache_script()
|
||||||
copy_pch_script()
|
|
||||||
|
|||||||
@@ -118,21 +118,24 @@ void I2SAudioSpeakerBase::loop() {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Still starting up or winding down from a previous run
|
||||||
|
if ((this->tx_handle_ != nullptr) || (this->speaker_task_handle_ != nullptr)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
if (this->start_i2s_driver(this->audio_stream_info_) != ESP_OK) {
|
if (this->start_i2s_driver(this->audio_stream_info_) != ESP_OK) {
|
||||||
ESP_LOGE(TAG, "Driver failed to start; retrying in 1 second");
|
ESP_LOGE(TAG, "Driver failed to start; retrying in 1 second");
|
||||||
this->status_momentary_error("driver-failure", 1000);
|
this->status_momentary_error("driver-failure", 1000);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this->speaker_task_handle_ == nullptr) {
|
xTaskCreate(I2SAudioSpeakerBase::speaker_task, "speaker_task", TASK_STACK_SIZE, (void *) this, TASK_PRIORITY,
|
||||||
xTaskCreate(I2SAudioSpeakerBase::speaker_task, "speaker_task", TASK_STACK_SIZE, (void *) this, TASK_PRIORITY,
|
&this->speaker_task_handle_);
|
||||||
&this->speaker_task_handle_);
|
|
||||||
|
|
||||||
if (this->speaker_task_handle_ == nullptr) {
|
if (this->speaker_task_handle_ == nullptr) {
|
||||||
ESP_LOGE(TAG, "Task failed to start, retrying in 1 second");
|
ESP_LOGE(TAG, "Task failed to start, retrying in 1 second");
|
||||||
this->status_momentary_error("task-failure", 1000);
|
this->status_momentary_error("task-failure", 1000);
|
||||||
this->stop_i2s_driver_(); // Stops the driver to return the lock; will be reloaded in next attempt
|
this->stop_i2s_driver_(); // Stops the driver to return the lock; will be reloaded in next attempt
|
||||||
}
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case speaker::STATE_RUNNING: // Intentional fallthrough
|
case speaker::STATE_RUNNING: // Intentional fallthrough
|
||||||
@@ -218,8 +221,8 @@ size_t I2SAudioSpeakerBase::play(const uint8_t *data, size_t length, TickType_t
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool I2SAudioSpeakerBase::has_buffered_data() const {
|
bool I2SAudioSpeakerBase::has_buffered_data() const {
|
||||||
if (this->audio_ring_buffer_.use_count() > 0) {
|
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->audio_ring_buffer_.lock();
|
||||||
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->audio_ring_buffer_.lock();
|
if (temp_ring_buffer != nullptr) {
|
||||||
return temp_ring_buffer->available() > 0;
|
return temp_ring_buffer->available() > 0;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from esphome.build_helpers.pch import pch_extra_scripts
|
|
||||||
import esphome.codegen as cg
|
import esphome.codegen as cg
|
||||||
import esphome.config_validation as cv
|
import esphome.config_validation as cv
|
||||||
from esphome.const import (
|
from esphome.const import (
|
||||||
@@ -27,7 +26,7 @@ from esphome.const import (
|
|||||||
from esphome.core import CORE
|
from esphome.core import CORE
|
||||||
from esphome.core.config import BOARD_MAX_LENGTH
|
from esphome.core.config import BOARD_MAX_LENGTH
|
||||||
from esphome.helpers import copy_file_if_changed
|
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 esphome.storage_json import StorageJSON
|
||||||
|
|
||||||
from . import gpio # noqa: F401
|
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.
|
# it for project source files only. GCC uses the last -O flag.
|
||||||
build_src_flags += " -Os"
|
build_src_flags += " -Os"
|
||||||
cg.add_platformio_option("build_src_flags", build_src_flags)
|
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).
|
# 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
|
# On other families, patch_linker.py routes .sram.text into the right
|
||||||
# RAM-executable output section and prints a post-link placement summary.
|
# 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"),
|
CORE.relative_build_path("patch_linker.py"),
|
||||||
)
|
)
|
||||||
copy_ccache_script()
|
copy_ccache_script()
|
||||||
copy_pch_script()
|
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ void MicroWakeWord::setup() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->ring_buffer_.lock();
|
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->ring_buffer_.lock();
|
||||||
if (this->ring_buffer_.use_count() > 1) {
|
if (temp_ring_buffer != nullptr) {
|
||||||
// Producer-only write: never touches consumer state. If the buffer is full, ask the inference task
|
// Producer-only write: never touches consumer state. If the buffer is full, ask the inference task
|
||||||
// to drain it - reset() is a consumer operation and must run on the inference task's thread.
|
// to drain it - reset() is a consumer operation and must run on the inference task's thread.
|
||||||
// Disable partial writes so audio chunks are either fully accepted or rejected and handled below.
|
// Disable partial writes so audio chunks are either fully accepted or rejected and handled below.
|
||||||
@@ -446,9 +446,9 @@ void MicroWakeWord::loop() {
|
|||||||
xEventGroupClearBits(this->event_group_, EventGroupBits::TASK_STOPPING);
|
xEventGroupClearBits(this->event_group_, EventGroupBits::TASK_STOPPING);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((event_group_bits & EventGroupBits::TASK_STOPPED)) {
|
// Retries on a subsequent loop if the task is still running on the other core
|
||||||
|
if ((event_group_bits & EventGroupBits::TASK_STOPPED) && this->inference_task_.deallocate()) {
|
||||||
ESP_LOGD(TAG, "Inference task is finished, freeing task resources");
|
ESP_LOGD(TAG, "Inference task is finished, freeing task resources");
|
||||||
this->inference_task_.deallocate();
|
|
||||||
xEventGroupClearBits(this->event_group_, ALL_BITS);
|
xEventGroupClearBits(this->event_group_, ALL_BITS);
|
||||||
xQueueReset(this->detection_queue_);
|
xQueueReset(this->detection_queue_);
|
||||||
this->set_state_(State::STOPPED);
|
this->set_state_(State::STOPPED);
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ class MicrophoneSource final {
|
|||||||
template<typename F> void add_data_callback(F &&data_callback) {
|
template<typename F> void add_data_callback(F &&data_callback) {
|
||||||
this->mic_->add_data_callback([this, data_callback](const std::vector<uint8_t> &data) {
|
this->mic_->add_data_callback([this, data_callback](const std::vector<uint8_t> &data) {
|
||||||
if (this->enabled_ || this->passive_) {
|
if (this->enabled_ || this->passive_) {
|
||||||
if (this->processed_samples_.use_count() == 0) {
|
if (this->processed_samples_ == nullptr) {
|
||||||
// Create vector if its unused
|
// Create vector if its unused
|
||||||
this->processed_samples_ = std::make_shared<std::vector<uint8_t>>();
|
this->processed_samples_ = std::make_shared<std::vector<uint8_t>>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -218,7 +218,7 @@ size_t SourceSpeaker::play(const uint8_t *data, size_t length, TickType_t ticks_
|
|||||||
}
|
}
|
||||||
size_t bytes_written = 0;
|
size_t bytes_written = 0;
|
||||||
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->ring_buffer_.lock();
|
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->ring_buffer_.lock();
|
||||||
if (temp_ring_buffer.use_count() > 0) {
|
if (temp_ring_buffer != nullptr) {
|
||||||
// Only write to the ring buffer if the reference is valid
|
// Only write to the ring buffer if the reference is valid
|
||||||
bytes_written = temp_ring_buffer->write_without_replacement(data, length, ticks_to_wait);
|
bytes_written = temp_ring_buffer->write_without_replacement(data, length, ticks_to_wait);
|
||||||
if (bytes_written > 0) {
|
if (bytes_written > 0) {
|
||||||
@@ -250,14 +250,14 @@ esp_err_t SourceSpeaker::start_() {
|
|||||||
// avoids unnecessary single-frame splices.
|
// avoids unnecessary single-frame splices.
|
||||||
const size_t ring_buffer_size =
|
const size_t ring_buffer_size =
|
||||||
(this->audio_stream_info_.ms_to_bytes(this->buffer_duration_ms_) / bytes_per_frame) * bytes_per_frame;
|
(this->audio_stream_info_.ms_to_bytes(this->buffer_duration_ms_) / bytes_per_frame) * bytes_per_frame;
|
||||||
if (this->audio_source_.use_count() == 0) {
|
if (this->audio_source_ == nullptr) {
|
||||||
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->ring_buffer_.lock();
|
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->ring_buffer_.lock();
|
||||||
if (!temp_ring_buffer) {
|
if (temp_ring_buffer == nullptr) {
|
||||||
temp_ring_buffer = ring_buffer::RingBuffer::create(ring_buffer_size);
|
temp_ring_buffer = ring_buffer::RingBuffer::create(ring_buffer_size);
|
||||||
this->ring_buffer_ = temp_ring_buffer;
|
this->ring_buffer_ = temp_ring_buffer;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!temp_ring_buffer) {
|
if (temp_ring_buffer == nullptr) {
|
||||||
return ESP_ERR_NO_MEM;
|
return ESP_ERR_NO_MEM;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,7 +278,7 @@ void SourceSpeaker::stop() { this->send_command_(SOURCE_SPEAKER_COMMAND_STOP); }
|
|||||||
void SourceSpeaker::finish() { this->send_command_(SOURCE_SPEAKER_COMMAND_FINISH); }
|
void SourceSpeaker::finish() { this->send_command_(SOURCE_SPEAKER_COMMAND_FINISH); }
|
||||||
|
|
||||||
bool SourceSpeaker::has_buffered_data() const {
|
bool SourceSpeaker::has_buffered_data() const {
|
||||||
return ((this->audio_source_.use_count() > 0) && this->audio_source_->has_buffered_data());
|
return ((this->audio_source_ != nullptr) && this->audio_source_->has_buffered_data());
|
||||||
}
|
}
|
||||||
|
|
||||||
void SourceSpeaker::set_mute_state(bool mute_state) {
|
void SourceSpeaker::set_mute_state(bool mute_state) {
|
||||||
@@ -382,8 +382,8 @@ void MixerSpeaker::loop() {
|
|||||||
ESP_LOGV(TAG, "Stopping");
|
ESP_LOGV(TAG, "Stopping");
|
||||||
xEventGroupClearBits(this->event_group_, MIXER_TASK_STATE_STOPPING);
|
xEventGroupClearBits(this->event_group_, MIXER_TASK_STATE_STOPPING);
|
||||||
}
|
}
|
||||||
if (event_group_bits & MIXER_TASK_STATE_STOPPED) {
|
// Retries on a subsequent loop if the task is still running on the other core
|
||||||
this->task_.deallocate();
|
if ((event_group_bits & MIXER_TASK_STATE_STOPPED) && this->task_.deallocate()) {
|
||||||
ESP_LOGD(TAG, "Stopped");
|
ESP_LOGD(TAG, "Stopped");
|
||||||
xEventGroupClearBits(this->event_group_, MIXER_TASK_ALL_BITS);
|
xEventGroupClearBits(this->event_group_, MIXER_TASK_ALL_BITS);
|
||||||
this->all_stopped_since_ms_ = 0;
|
this->all_stopped_since_ms_ = 0;
|
||||||
@@ -496,7 +496,7 @@ void MixerSpeaker::audio_mixer_task(void *params) {
|
|||||||
if (speaker->is_running() && !speaker->get_pause_state()) {
|
if (speaker->is_running() && !speaker->get_pause_state()) {
|
||||||
// Speaker is running and not paused, so it possibly can provide audio data
|
// Speaker is running and not paused, so it possibly can provide audio data
|
||||||
std::shared_ptr<audio::RingBufferAudioSource> audio_source = speaker->get_audio_source().lock();
|
std::shared_ptr<audio::RingBufferAudioSource> audio_source = speaker->get_audio_source().lock();
|
||||||
if (audio_source.use_count() == 0) {
|
if (audio_source == nullptr) {
|
||||||
// No audio source allocated, so skip processing this speaker
|
// No audio source allocated, so skip processing this speaker
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,12 +88,12 @@ def encryption_schema(config: ConfigType | None) -> ConfigType:
|
|||||||
|
|
||||||
async def to_code(config: ConfigType) -> None:
|
async def to_code(config: ConfigType) -> None:
|
||||||
cg.add_define("USE_NOISE")
|
cg.add_define("USE_NOISE")
|
||||||
cg.add_library("esphome/noise-c", "0.1.24")
|
cg.add_library("esphome/noise-c", "0.1.26")
|
||||||
# noise-c depends on libsodium, but declaring it here too lets the
|
# noise-c depends on libsodium, but declaring it here too lets the
|
||||||
# library manager see the full set up front instead of discovering
|
# library manager see the full set up front instead of discovering
|
||||||
# libsodium only after noise-c has downloaded, so the two can download
|
# libsodium only after noise-c has downloaded, so the two can download
|
||||||
# in parallel. The version must match noise-c's library.json.
|
# in parallel. The version must match noise-c's library.json.
|
||||||
cg.add_library("esphome/libsodium", "1.10021.6")
|
cg.add_library("esphome/libsodium", "1.10021.8")
|
||||||
# Enable optimized memzero/memcmp in libsodium instead of volatile byte loops
|
# Enable optimized memzero/memcmp in libsodium instead of volatile byte loops
|
||||||
cg.add_build_flag("-DHAVE_WEAK_SYMBOLS=1")
|
cg.add_build_flag("-DHAVE_WEAK_SYMBOLS=1")
|
||||||
cg.add_build_flag("-DHAVE_INLINE_ASM=1")
|
cg.add_build_flag("-DHAVE_INLINE_ASM=1")
|
||||||
|
|||||||
@@ -1,24 +1,13 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from functools import partial
|
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import stat
|
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
from esphome import pins
|
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
|
import esphome.codegen as cg
|
||||||
from esphome.components.zephyr import (
|
from esphome.components.zephyr import (
|
||||||
add_extra_script,
|
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:
|
if link_flags:
|
||||||
lines += [
|
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:
|
def _copy_if_exists(src: Path, dst: Path) -> None:
|
||||||
if src.is_file():
|
if src.is_file():
|
||||||
shutil.copy2(src, dst)
|
shutil.copy2(src, dst)
|
||||||
@@ -945,58 +871,6 @@ def run_compile(args, config: ConfigType) -> bool:
|
|||||||
str(source_dir),
|
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(
|
if not run_command_ok(
|
||||||
west_cmd,
|
west_cmd,
|
||||||
env=env,
|
env=env,
|
||||||
|
|||||||
@@ -153,8 +153,8 @@ void ResamplerSpeaker::loop() {
|
|||||||
ESP_LOGV(TAG, "Stopping");
|
ESP_LOGV(TAG, "Stopping");
|
||||||
xEventGroupClearBits(this->event_group_, ResamplingEventGroupBits::STATE_STOPPING);
|
xEventGroupClearBits(this->event_group_, ResamplingEventGroupBits::STATE_STOPPING);
|
||||||
}
|
}
|
||||||
if (event_group_bits & ResamplingEventGroupBits::STATE_STOPPED) {
|
// Retries on a subsequent loop if the task is still running on the other core
|
||||||
this->task_.deallocate();
|
if ((event_group_bits & ResamplingEventGroupBits::STATE_STOPPED) && this->task_.deallocate()) {
|
||||||
ESP_LOGD(TAG, "Stopped");
|
ESP_LOGD(TAG, "Stopped");
|
||||||
xEventGroupClearBits(this->event_group_, ResamplingEventGroupBits::ALL_BITS);
|
xEventGroupClearBits(this->event_group_, ResamplingEventGroupBits::ALL_BITS);
|
||||||
}
|
}
|
||||||
@@ -235,7 +235,7 @@ size_t ResamplerSpeaker::play(const uint8_t *data, size_t length, TickType_t tic
|
|||||||
bytes_written = this->output_speaker_->play(data, length, ticks_to_wait);
|
bytes_written = this->output_speaker_->play(data, length, ticks_to_wait);
|
||||||
} else {
|
} else {
|
||||||
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->ring_buffer_.lock();
|
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->ring_buffer_.lock();
|
||||||
if (temp_ring_buffer) {
|
if (temp_ring_buffer != nullptr) {
|
||||||
// Only write to the ring buffer if the reference is valid
|
// Only write to the ring buffer if the reference is valid
|
||||||
bytes_written = temp_ring_buffer->write_without_replacement(data, length, ticks_to_wait);
|
bytes_written = temp_ring_buffer->write_without_replacement(data, length, ticks_to_wait);
|
||||||
} else {
|
} else {
|
||||||
@@ -299,7 +299,7 @@ bool ResamplerSpeaker::has_buffered_data() const {
|
|||||||
bool has_ring_buffer_data = false;
|
bool has_ring_buffer_data = false;
|
||||||
if (this->requires_resampling_()) {
|
if (this->requires_resampling_()) {
|
||||||
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->ring_buffer_.lock();
|
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->ring_buffer_.lock();
|
||||||
if (temp_ring_buffer) {
|
if (temp_ring_buffer != nullptr) {
|
||||||
has_ring_buffer_data = (temp_ring_buffer->available() > 0);
|
has_ring_buffer_data = (temp_ring_buffer->available() > 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -342,7 +342,7 @@ void ResamplerSpeaker::resample_task(void *params) {
|
|||||||
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = ring_buffer::RingBuffer::create(
|
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = ring_buffer::RingBuffer::create(
|
||||||
this_resampler->audio_stream_info_.ms_to_bytes(this_resampler->buffer_duration_ms_));
|
this_resampler->audio_stream_info_.ms_to_bytes(this_resampler->buffer_duration_ms_));
|
||||||
|
|
||||||
if (!temp_ring_buffer) {
|
if (temp_ring_buffer == nullptr) {
|
||||||
err = ESP_ERR_NO_MEM;
|
err = ESP_ERR_NO_MEM;
|
||||||
} else {
|
} else {
|
||||||
this_resampler->ring_buffer_ = temp_ring_buffer;
|
this_resampler->ring_buffer_ = temp_ring_buffer;
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ from string import ascii_letters, digits
|
|||||||
import subprocess
|
import subprocess
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from esphome.build_helpers.pch import pch_extra_scripts
|
|
||||||
import esphome.codegen as cg
|
import esphome.codegen as cg
|
||||||
import esphome.config_validation as cv
|
import esphome.config_validation as cv
|
||||||
from esphome.const import (
|
from esphome.const import (
|
||||||
@@ -34,7 +33,7 @@ from esphome.core import (
|
|||||||
)
|
)
|
||||||
from esphome.core.config import BOARD_MAX_LENGTH
|
from esphome.core.config import BOARD_MAX_LENGTH
|
||||||
from esphome.helpers import copy_file_if_changed, read_file, write_file_if_changed
|
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.storage_json import StorageJSON
|
||||||
from esphome.types import ConfigType
|
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("ESPHOME_VARIANT", VARIANT_FRIENDLY[variant])
|
||||||
cg.add_define(ThreadModel.SINGLE)
|
cg.add_define(ThreadModel.SINGLE)
|
||||||
|
|
||||||
cg.add_platformio_option(
|
cg.add_platformio_option("extra_scripts", ["pre:ccache.py", "post:post_build.py"])
|
||||||
"extra_scripts",
|
|
||||||
["pre:ccache.py", *pch_extra_scripts(), "post:post_build.py"],
|
|
||||||
)
|
|
||||||
|
|
||||||
conf = config[CONF_FRAMEWORK]
|
conf = config[CONF_FRAMEWORK]
|
||||||
cg.add_platformio_option("framework", "arduino")
|
cg.add_platformio_option("framework", "arduino")
|
||||||
@@ -648,7 +644,6 @@ def copy_files() -> None:
|
|||||||
CORE.relative_build_path("inject_lwip_include.py"),
|
CORE.relative_build_path("inject_lwip_include.py"),
|
||||||
)
|
)
|
||||||
copy_ccache_script()
|
copy_ccache_script()
|
||||||
copy_pch_script()
|
|
||||||
_generate_lwipopts_h()
|
_generate_lwipopts_h()
|
||||||
if generate_pio_files():
|
if generate_pio_files():
|
||||||
path = CORE.relative_src_path("esphome.h")
|
path = CORE.relative_src_path("esphome.h")
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ CONF_SENDSPIN_ID = "sendspin_id"
|
|||||||
CONF_INITIAL_STATIC_DELAY = "initial_static_delay"
|
CONF_INITIAL_STATIC_DELAY = "initial_static_delay"
|
||||||
CONF_FIXED_DELAY = "fixed_delay"
|
CONF_FIXED_DELAY = "fixed_delay"
|
||||||
CONF_DECODE_MEMORY = "decode_memory"
|
CONF_DECODE_MEMORY = "decode_memory"
|
||||||
|
CONF_CODECS = "codecs"
|
||||||
|
|
||||||
# Matches ARTWORK_MAX_SLOTS in sendspin-cpp.
|
# Matches ARTWORK_MAX_SLOTS in sendspin-cpp.
|
||||||
MAX_ARTWORK_SLOTS = 4
|
MAX_ARTWORK_SLOTS = 4
|
||||||
@@ -44,6 +45,20 @@ CODEC_FORMAT_OPUS = SendspinCodecFormat.enum("OPUS")
|
|||||||
CODEC_FORMAT_PCM = SendspinCodecFormat.enum("PCM")
|
CODEC_FORMAT_PCM = SendspinCodecFormat.enum("PCM")
|
||||||
CODEC_FORMAT_UNSUPPORTED = SendspinCodecFormat.enum("UNSUPPORTED")
|
CODEC_FORMAT_UNSUPPORTED = SendspinCodecFormat.enum("UNSUPPORTED")
|
||||||
|
|
||||||
|
CODEC_FLAC = "flac"
|
||||||
|
CODEC_OPUS = "opus"
|
||||||
|
CODEC_PCM = "pcm"
|
||||||
|
|
||||||
|
CODECS = {
|
||||||
|
CODEC_FLAC: CODEC_FORMAT_FLAC,
|
||||||
|
CODEC_OPUS: CODEC_FORMAT_OPUS,
|
||||||
|
CODEC_PCM: CODEC_FORMAT_PCM,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Opus only supports 48 kHz audio, so it is left out of the default list at other rates.
|
||||||
|
DEFAULT_CODECS = [CODEC_FLAC, CODEC_OPUS, CODEC_PCM]
|
||||||
|
OPUS_SAMPLE_RATE = 48000
|
||||||
|
|
||||||
SendspinImageFormat = sendspin_library_ns.enum("SendspinImageFormat", is_class=True)
|
SendspinImageFormat = sendspin_library_ns.enum("SendspinImageFormat", is_class=True)
|
||||||
IMAGE_FORMAT_JPEG = SendspinImageFormat.enum("JPEG")
|
IMAGE_FORMAT_JPEG = SendspinImageFormat.enum("JPEG")
|
||||||
IMAGE_FORMAT_PNG = SendspinImageFormat.enum("PNG")
|
IMAGE_FORMAT_PNG = SendspinImageFormat.enum("PNG")
|
||||||
@@ -286,16 +301,13 @@ async def to_code(config: ConfigType) -> None:
|
|||||||
if data.player_support:
|
if data.player_support:
|
||||||
cg.add_define("USE_SENDSPIN_PLAYER", True)
|
cg.add_define("USE_SENDSPIN_PLAYER", True)
|
||||||
|
|
||||||
# Configures the player role. We always assume support for 16 bits per sample mono and stereo FLAC, Opus, and PCM at the configured sample rate
|
# Configures the player role. Each configured codec is advertised for 16 bits per sample
|
||||||
# (with Opus only supported at 48 kHz since that's the only sample rate it supports). Users can configure the specific formats via the Sendspin server
|
# mono and stereo at the configured sample rate. The order is a preference order, both for
|
||||||
|
# the codecs themselves and for stereo over mono.
|
||||||
player_cfg = data.player_config
|
player_cfg = data.player_config
|
||||||
sample_rate = player_cfg[CONF_SAMPLE_RATE]
|
sample_rate = player_cfg[CONF_SAMPLE_RATE]
|
||||||
|
|
||||||
# OPUS only supports 48 kHz audio
|
codecs = player_cfg[CONF_CODECS]
|
||||||
codecs = [CODEC_FORMAT_FLAC]
|
|
||||||
if sample_rate == 48000:
|
|
||||||
codecs.append(CODEC_FORMAT_OPUS)
|
|
||||||
codecs.append(CODEC_FORMAT_PCM)
|
|
||||||
|
|
||||||
def _audio_format(codec: MockObj, channels: int) -> cg.StructInitializer:
|
def _audio_format(codec: MockObj, channels: int) -> cg.StructInitializer:
|
||||||
return cg.StructInitializer(
|
return cg.StructInitializer(
|
||||||
|
|||||||
@@ -13,11 +13,16 @@ from esphome.cpp_generator import MockObj, TemplateArgsType
|
|||||||
from esphome.types import ConfigType
|
from esphome.types import ConfigType
|
||||||
|
|
||||||
from .. import (
|
from .. import (
|
||||||
|
CODEC_OPUS,
|
||||||
|
CODECS,
|
||||||
|
CONF_CODECS,
|
||||||
CONF_DECODE_MEMORY,
|
CONF_DECODE_MEMORY,
|
||||||
CONF_FIXED_DELAY,
|
CONF_FIXED_DELAY,
|
||||||
CONF_INITIAL_STATIC_DELAY,
|
CONF_INITIAL_STATIC_DELAY,
|
||||||
CONF_SENDSPIN_ID,
|
CONF_SENDSPIN_ID,
|
||||||
|
DEFAULT_CODECS,
|
||||||
MEMORY_LOCATIONS,
|
MEMORY_LOCATIONS,
|
||||||
|
OPUS_SAMPLE_RATE,
|
||||||
SendspinHub,
|
SendspinHub,
|
||||||
register_player_config,
|
register_player_config,
|
||||||
request_controller_support,
|
request_controller_support,
|
||||||
@@ -49,10 +54,32 @@ DisableStaticDelayAdjustmentAction = sendspin_ns.class_(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_codecs(config: ConfigType) -> ConfigType:
|
||||||
|
"""Validate the codec preference list, filling in the default when it is not set."""
|
||||||
|
sample_rate = config[CONF_SAMPLE_RATE]
|
||||||
|
if (codecs := config.get(CONF_CODECS)) is None:
|
||||||
|
config[CONF_CODECS] = [
|
||||||
|
codec
|
||||||
|
for codec in DEFAULT_CODECS
|
||||||
|
if codec != CODEC_OPUS or sample_rate == OPUS_SAMPLE_RATE
|
||||||
|
]
|
||||||
|
return config
|
||||||
|
|
||||||
|
if len(set(codecs)) != len(codecs):
|
||||||
|
raise cv.Invalid("Each codec may only be listed once", path=[CONF_CODECS])
|
||||||
|
if CODEC_OPUS in codecs and sample_rate != OPUS_SAMPLE_RATE:
|
||||||
|
raise cv.Invalid(
|
||||||
|
f"Codec '{CODEC_OPUS}' requires a {CONF_SAMPLE_RATE} of {OPUS_SAMPLE_RATE}",
|
||||||
|
path=[CONF_CODECS],
|
||||||
|
)
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
def _register(config: ConfigType) -> ConfigType:
|
def _register(config: ConfigType) -> ConfigType:
|
||||||
request_controller_support()
|
request_controller_support()
|
||||||
register_player_config(
|
register_player_config(
|
||||||
{
|
{
|
||||||
|
CONF_CODECS: config[CONF_CODECS],
|
||||||
CONF_SAMPLE_RATE: config[CONF_SAMPLE_RATE],
|
CONF_SAMPLE_RATE: config[CONF_SAMPLE_RATE],
|
||||||
CONF_BUFFER_SIZE: config[CONF_BUFFER_SIZE],
|
CONF_BUFFER_SIZE: config[CONF_BUFFER_SIZE],
|
||||||
CONF_INITIAL_STATIC_DELAY: config[CONF_INITIAL_STATIC_DELAY],
|
CONF_INITIAL_STATIC_DELAY: config[CONF_INITIAL_STATIC_DELAY],
|
||||||
@@ -85,9 +112,13 @@ CONFIG_SCHEMA = cv.All(
|
|||||||
min=16000, max=96000
|
min=16000, max=96000
|
||||||
),
|
),
|
||||||
cv.Optional(CONF_DECODE_MEMORY): cv.one_of(*MEMORY_LOCATIONS, lower=True),
|
cv.Optional(CONF_DECODE_MEMORY): cv.one_of(*MEMORY_LOCATIONS, lower=True),
|
||||||
|
cv.Optional(CONF_CODECS): cv.All(
|
||||||
|
cv.ensure_list(cv.enum(CODECS, lower=True)), cv.Length(min=1)
|
||||||
|
),
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
cv.only_on_esp32,
|
cv.only_on_esp32,
|
||||||
|
_resolve_codecs,
|
||||||
_register,
|
_register,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -202,8 +202,15 @@ AudioPipelineState AudioPipeline::process_state() {
|
|||||||
if (!this->is_playing_) {
|
if (!this->is_playing_) {
|
||||||
// The tasks have been stopped for two ``process_state`` calls in a row, so delete the tasks
|
// The tasks have been stopped for two ``process_state`` calls in a row, so delete the tasks
|
||||||
if (this->read_task_.is_created() || this->decode_task_.is_created()) {
|
if (this->read_task_.is_created() || this->decode_task_.is_created()) {
|
||||||
this->read_task_.deallocate();
|
// Both are attempted every time; a task that is still running on the other core is freed by a
|
||||||
this->decode_task_.deallocate();
|
// subsequent call, and freeing an already freed task succeeds without doing anything
|
||||||
|
bool read_task_freed = this->read_task_.deallocate();
|
||||||
|
bool decode_task_freed = this->decode_task_.deallocate();
|
||||||
|
if (!read_task_freed || !decode_task_freed) {
|
||||||
|
// A task is still running on the other core, so keep the pipeline in its current state and try
|
||||||
|
// again on the next call
|
||||||
|
return AudioPipelineState::PLAYING;
|
||||||
|
}
|
||||||
if (this->hard_stop_) {
|
if (this->hard_stop_) {
|
||||||
// Stop command was sent, so immediately end the playback
|
// Stop command was sent, so immediately end the playback
|
||||||
this->speaker_->stop();
|
this->speaker_->stop();
|
||||||
@@ -315,17 +322,17 @@ void AudioPipeline::read_task(void *params) {
|
|||||||
if (err == ESP_OK) {
|
if (err == ESP_OK) {
|
||||||
size_t file_ring_buffer_size = this_pipeline->buffer_size_;
|
size_t file_ring_buffer_size = this_pipeline->buffer_size_;
|
||||||
|
|
||||||
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer;
|
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this_pipeline->raw_file_ring_buffer_.lock();
|
||||||
|
|
||||||
if (!this_pipeline->raw_file_ring_buffer_.use_count()) {
|
if (temp_ring_buffer == nullptr) {
|
||||||
temp_ring_buffer = ring_buffer::RingBuffer::create(file_ring_buffer_size);
|
temp_ring_buffer = ring_buffer::RingBuffer::create(file_ring_buffer_size);
|
||||||
this_pipeline->raw_file_ring_buffer_ = temp_ring_buffer;
|
this_pipeline->raw_file_ring_buffer_ = temp_ring_buffer;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this_pipeline->raw_file_ring_buffer_.use_count()) {
|
if (temp_ring_buffer == nullptr) {
|
||||||
err = ESP_ERR_NO_MEM;
|
err = ESP_ERR_NO_MEM;
|
||||||
} else {
|
} else {
|
||||||
reader->add_sink(this_pipeline->raw_file_ring_buffer_);
|
err = reader->add_sink(temp_ring_buffer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -396,7 +403,9 @@ void AudioPipeline::decode_task(void *params) {
|
|||||||
make_unique<audio::AudioDecoder>(this_pipeline->transfer_buffer_size_, this_pipeline->transfer_buffer_size_);
|
make_unique<audio::AudioDecoder>(this_pipeline->transfer_buffer_size_, this_pipeline->transfer_buffer_size_);
|
||||||
|
|
||||||
esp_err_t err = decoder->start(this_pipeline->current_audio_file_type_);
|
esp_err_t err = decoder->start(this_pipeline->current_audio_file_type_);
|
||||||
decoder->add_source(this_pipeline->raw_file_ring_buffer_);
|
if (err == ESP_OK) {
|
||||||
|
err = decoder->add_source(this_pipeline->raw_file_ring_buffer_);
|
||||||
|
}
|
||||||
|
|
||||||
if (err != ESP_OK) {
|
if (err != ESP_OK) {
|
||||||
// Send specific error message
|
// Send specific error message
|
||||||
|
|||||||
@@ -1143,14 +1143,13 @@ class EsphomeCore:
|
|||||||
def add_platformio_option(
|
def add_platformio_option(
|
||||||
self, key: str, value: str | list[str], *, replace: bool = False
|
self, key: str, value: str | list[str], *, replace: bool = False
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set a platformio.ini option; values append to an existing list
|
"""Set a platformio.ini option; list values append to an existing list
|
||||||
(a string as one element) unless ``replace`` is True, which
|
unless ``replace`` is True, which overwrites any existing value."""
|
||||||
overwrites any existing value."""
|
|
||||||
new_val = value
|
new_val = value
|
||||||
old_val = self.platformio_options.get(key)
|
old_val = self.platformio_options.get(key)
|
||||||
if not replace and isinstance(old_val, list):
|
if not replace and isinstance(old_val, list):
|
||||||
# A user platformio_options string must merge, not assert
|
assert isinstance(value, list)
|
||||||
new_val = old_val + ([value] if isinstance(value, str) else value)
|
new_val = old_val + value
|
||||||
self.platformio_options[key] = new_val
|
self.platformio_options[key] = new_val
|
||||||
|
|
||||||
def _get_variable_generator(self, id):
|
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
|
# that is stored rather than translated away. Consumed by the esp8266 native
|
||||||
# backend (later in this chain) for its ignored-option warning; defined here
|
# backend (later in this chain) for its ignored-option warning; defined here
|
||||||
# so it stays adjacent to the routing.
|
# so it stays adjacent to the routing.
|
||||||
# build_src_flags and board_build.flash_mode: set unconditionally by
|
NATIVE_ARDUINO_CONSUMED_PIO_OPTIONS = NATIVE_ARDUINO_PIO_OPTIONS | {"lib_ignore"}
|
||||||
# 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",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@coroutine_with_priority(CoroPriority.FINAL)
|
@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
|
|
||||||
@@ -40,16 +40,31 @@ bool StaticTask::create(TaskFunction_t fn, const char *name, uint32_t stack_size
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
void StaticTask::destroy() {
|
bool StaticTask::destroy() {
|
||||||
if (this->handle_ != nullptr) {
|
if (this->handle_ == nullptr) {
|
||||||
TaskHandle_t handle = this->handle_;
|
return true;
|
||||||
this->handle_ = nullptr;
|
|
||||||
vTaskDelete(handle);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Suspending takes the task off the ready and event lists, so nothing can schedule it again. It only asks
|
||||||
|
// the other core to yield though, so the task may still be running on it for a moment.
|
||||||
|
vTaskSuspend(this->handle_);
|
||||||
|
if (eTaskGetState(this->handle_) != eSuspended) {
|
||||||
|
// The task is still running on the other core and using its stack. Deleting it now would only put it on
|
||||||
|
// the termination list and return, so the caller has to try again once it has been swapped out.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The task cannot run again, so the delete completes right away instead of being left to the idle task.
|
||||||
|
TaskHandle_t handle = this->handle_;
|
||||||
|
this->handle_ = nullptr;
|
||||||
|
vTaskDelete(handle);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
void StaticTask::deallocate() {
|
bool StaticTask::deallocate() {
|
||||||
this->destroy();
|
if (!this->destroy()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (this->stack_buffer_ != nullptr) {
|
if (this->stack_buffer_ != nullptr) {
|
||||||
RAMAllocator<StackType_t> allocator(this->use_psram_ ? RAMAllocator<StackType_t>::ALLOC_EXTERNAL
|
RAMAllocator<StackType_t> allocator(this->use_psram_ ? RAMAllocator<StackType_t>::ALLOC_EXTERNAL
|
||||||
: RAMAllocator<StackType_t>::ALLOC_INTERNAL);
|
: RAMAllocator<StackType_t>::ALLOC_INTERNAL);
|
||||||
@@ -57,6 +72,7 @@ void StaticTask::deallocate() {
|
|||||||
this->stack_buffer_ = nullptr;
|
this->stack_buffer_ = nullptr;
|
||||||
this->stack_size_ = 0;
|
this->stack_size_ = 0;
|
||||||
}
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace esphome
|
} // namespace esphome
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ namespace esphome {
|
|||||||
|
|
||||||
/** Helper for FreeRTOS static task management.
|
/** Helper for FreeRTOS static task management.
|
||||||
* Bundles TaskHandle_t, StaticTask_t, and the stack buffer into one object with create/destroy methods.
|
* Bundles TaskHandle_t, StaticTask_t, and the stack buffer into one object with create/destroy methods.
|
||||||
|
* Call destroy() and deallocate() from another task: a task cannot free the stack it is still running on.
|
||||||
*/
|
*/
|
||||||
class StaticTask {
|
class StaticTask {
|
||||||
public:
|
public:
|
||||||
@@ -23,7 +24,7 @@ class StaticTask {
|
|||||||
/// @brief Allocate stack and create task.
|
/// @brief Allocate stack and create task.
|
||||||
/// @param fn Task function
|
/// @param fn Task function
|
||||||
/// @param name Task name (for debug)
|
/// @param name Task name (for debug)
|
||||||
/// @param stack_size Stack size in StackType_t words
|
/// @param stack_size Stack size in bytes (StackType_t is a byte on ESP-IDF)
|
||||||
/// @param param Parameter passed to task function
|
/// @param param Parameter passed to task function
|
||||||
/// @param priority FreeRTOS task priority
|
/// @param priority FreeRTOS task priority
|
||||||
/// @param use_psram If true, allocate stack in PSRAM; otherwise internal RAM
|
/// @param use_psram If true, allocate stack in PSRAM; otherwise internal RAM
|
||||||
@@ -31,11 +32,17 @@ class StaticTask {
|
|||||||
bool create(TaskFunction_t fn, const char *name, uint32_t stack_size, void *param, UBaseType_t priority,
|
bool create(TaskFunction_t fn, const char *name, uint32_t stack_size, void *param, UBaseType_t priority,
|
||||||
bool use_psram);
|
bool use_psram);
|
||||||
|
|
||||||
/// @brief Delete the task but keep the stack buffer allocated for reuse by a subsequent create() call.
|
/// @brief Delete the task, keeping the stack buffer allocated for reuse by a subsequent create() call.
|
||||||
void destroy();
|
/// The task must have finished its work and parked itself, either suspended or blocked indefinitely: it is
|
||||||
|
/// suspended here so that it cannot be scheduled again, and it is given no chance to clean up.
|
||||||
|
/// @return true if the task was deleted; false if it is still running on another core, in which case the
|
||||||
|
/// caller should try again later.
|
||||||
|
bool destroy();
|
||||||
|
|
||||||
/// @brief Delete the task (if running) and free the stack buffer.
|
/// @brief Delete the task (if created) and free the stack buffer.
|
||||||
void deallocate();
|
/// @return true if the stack buffer was freed; false if the task is still running on another core, in
|
||||||
|
/// which case the caller should try again later.
|
||||||
|
bool deallocate();
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
TaskHandle_t handle_{nullptr};
|
TaskHandle_t handle_{nullptr};
|
||||||
|
|||||||
+17
-27
@@ -16,7 +16,6 @@ from esphome.build_helpers.ccache import (
|
|||||||
parse_enable_env,
|
parse_enable_env,
|
||||||
resolve_ccache_path,
|
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.build_helpers.tools_cache import IDF_TOOLS_CACHE, tools_cache_path
|
||||||
from esphome.core import Version
|
from esphome.core import Version
|
||||||
from esphome.framework_helpers import (
|
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
|
Only values the user has not already set in the environment are returned, so
|
||||||
a custom ``CCACHE_DIR`` / ``CCACHE_MAXSIZE`` / etc. is respected.
|
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")
|
idf_knob = parse_enable_env("IDF_CCACHE_ENABLE")
|
||||||
if idf_knob is False:
|
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:
|
if idf_knob is True:
|
||||||
# Forced on ignores the runnability verdict, but the outcome is
|
# Forced on ignores the runnability verdict, but the outcome is
|
||||||
# worth saying out loud. Probed directly (not via the resolver,
|
# 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 "
|
"IDF_CCACHE_ENABLE=1 forces on the ccache at %s even though "
|
||||||
"it failed to run; idf.py will use it anyway",
|
"it failed to run; idf.py will use it anyway",
|
||||||
)
|
)
|
||||||
return True
|
elif resolve_ccache_path() is None:
|
||||||
return resolve_ccache_path() is not 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(
|
def get_framework_env(
|
||||||
|
|||||||
@@ -528,12 +528,6 @@ def run_compile(config, verbose: bool) -> int:
|
|||||||
return result.returncode
|
return result.returncode
|
||||||
_patch_memory_segments()
|
_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
|
# Build
|
||||||
args = []
|
args = []
|
||||||
|
|
||||||
|
|||||||
+6
-3
@@ -96,6 +96,10 @@ UPLOAD_BUFFER_SIZE = UPLOAD_BLOCK_SIZE * 8
|
|||||||
# across the addresses on top of that.
|
# across the addresses on top of that.
|
||||||
EXTRA_UPLOAD_ATTEMPTS = 2
|
EXTRA_UPLOAD_ATTEMPTS = 2
|
||||||
UPLOAD_RETRY_DELAY = 5.0
|
UPLOAD_RETRY_DELAY = 5.0
|
||||||
|
# Data phase timeout; must stay longer than the device's OTA_SOCKET_TIMEOUT_DATA
|
||||||
|
# (105 s) so a stalled session is gone before a retry, and long enough for lwIP
|
||||||
|
# to get a lost chunk ack through after the retransmit run seen in practice
|
||||||
|
DATA_PHASE_TIMEOUT = 160.0
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -694,8 +698,7 @@ def perform_ota(
|
|||||||
|
|
||||||
_LOGGER.info("Handshake complete")
|
_LOGGER.info("Handshake complete")
|
||||||
|
|
||||||
# Timeout must match device-side OTA_SOCKET_TIMEOUT_DATA to prevent premature failures
|
sock.settimeout(DATA_PHASE_TIMEOUT)
|
||||||
sock.settimeout(90.0)
|
|
||||||
|
|
||||||
if extended_proto:
|
if extended_proto:
|
||||||
send_check(sock, ota_type, "ota type")
|
send_check(sock, ota_type, "ota type")
|
||||||
@@ -854,7 +857,7 @@ def run_ota_impl_(
|
|||||||
# clean up a half-open connection (its handshake watchdog runs at 20s);
|
# clean up a half-open connection (its handshake watchdog runs at 20s);
|
||||||
# moving on to the next address family stays immediate. Known limitation:
|
# moving on to the next address family stays immediate. Known limitation:
|
||||||
# a silent mid-transfer drop with no reset can wedge the device until its
|
# a silent mid-transfer drop with no reset can wedge the device until its
|
||||||
# 90s data timeout, which outlasts this budget; the retries target the
|
# 105s data timeout, which outlasts this budget; the retries target the
|
||||||
# common failures where the device resets or closes the link promptly.
|
# common failures where the device resets or closes the link promptly.
|
||||||
total_attempts = len(res) + EXTRA_UPLOAD_ATTEMPTS
|
total_attempts = len(res) + EXTRA_UPLOAD_ATTEMPTS
|
||||||
last_error = ""
|
last_error = ""
|
||||||
|
|||||||
@@ -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:
|
def default_libdeps_dir() -> str:
|
||||||
"""The PLATFORMIO_LIBDEPS_DIR value a pio run defaults to; the package
|
"""The PLATFORMIO_LIBDEPS_DIR value a pio run defaults to; the package
|
||||||
prefetch must resolve installed libraries against the same dir."""
|
prefetch must resolve installed libraries against the same dir."""
|
||||||
|
|||||||
+4
-15
@@ -7,7 +7,6 @@ import re
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
from esphome import loader
|
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.compiled_config import save_compiled_config
|
||||||
from esphome.config import iter_component_configs, iter_components
|
from esphome.config import iter_component_configs, iter_components
|
||||||
from esphome.const import (
|
from esphome.const import (
|
||||||
@@ -247,7 +246,6 @@ def copy_src_tree():
|
|||||||
Path(
|
Path(
|
||||||
"esphome/core/ring_buffer.h"
|
"esphome/core/ring_buffer.h"
|
||||||
), # moved to components/ring_buffer/, removed in 2026.11.0
|
), # moved to components/ring_buffer/, removed in 2026.11.0
|
||||||
Path(PCH_PREFIX_HEADER), # build machinery, not user API
|
|
||||||
}
|
}
|
||||||
include_l = []
|
include_l = []
|
||||||
for target, _ in source_files_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():
|
if idf_path.is_dir():
|
||||||
_LOGGER.info("Deleting %s", idf_path)
|
_LOGGER.info("Deleting %s", idf_path)
|
||||||
rmtree(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
|
# The idedata cache is derived from the build but lives under the data dir,
|
||||||
# dir, not the build path, so they must be removed separately in both
|
# not the build path, so it must be removed separately in both modes.
|
||||||
# modes. Globbed (name.json plus name.<backend>.json) so a future
|
idedata_cache = CORE.relative_internal_path("idedata", f"{CORE.name}.json")
|
||||||
# backend suffix cannot silently drift out of clean-all.
|
if idedata_cache.is_file():
|
||||||
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"),
|
|
||||||
):
|
|
||||||
_LOGGER.info("Deleting %s", idedata_cache)
|
_LOGGER.info("Deleting %s", idedata_cache)
|
||||||
idedata_cache.unlink()
|
idedata_cache.unlink()
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -45,7 +45,7 @@ lib_deps_base =
|
|||||||
lib_deps =
|
lib_deps =
|
||||||
${common.lib_deps_base}
|
${common.lib_deps_base}
|
||||||
https://github.com/dudanov/MideaUART.git#eeea6c3e9b4474f067054592b435be1c4e466815 ; midea
|
https://github.com/dudanov/MideaUART.git#eeea6c3e9b4474f067054592b435be1c4e466815 ; midea
|
||||||
esphome/noise-c@0.1.24 ; noise (api, ota)
|
esphome/noise-c@0.1.26 ; noise (api, ota)
|
||||||
improv/Improv@1.2.7 ; improv_serial / esp32_improv
|
improv/Improv@1.2.7 ; improv_serial / esp32_improv
|
||||||
kikuchan98/pngle@1.1.0 ; online_image
|
kikuchan98/pngle@1.1.0 ; online_image
|
||||||
; Using the repository directly, otherwise ESP-IDF can't use the library
|
; Using the repository directly, otherwise ESP-IDF can't use the library
|
||||||
@@ -244,7 +244,7 @@ lib_deps =
|
|||||||
${common:idf-component-libs.lib_deps}
|
${common:idf-component-libs.lib_deps}
|
||||||
ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base
|
ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base
|
||||||
droscy/esp_wireguard@0.4.5 ; wireguard
|
droscy/esp_wireguard@0.4.5 ; wireguard
|
||||||
esphome/noise-c@0.1.24 ; noise (api, ota)
|
esphome/noise-c@0.1.26 ; noise (api, ota)
|
||||||
ESP32Async/AsyncTCP@3.4.5 ; async_tcp
|
ESP32Async/AsyncTCP@3.4.5 ; async_tcp
|
||||||
DNSServer ; captive_portal
|
DNSServer ; captive_portal
|
||||||
heman/AsyncMqttClient-esphome@2.0.0 ; mqtt
|
heman/AsyncMqttClient-esphome@2.0.0 ; mqtt
|
||||||
@@ -641,7 +641,7 @@ build_unflags =
|
|||||||
extends = common
|
extends = common
|
||||||
platform = platformio/native
|
platform = platformio/native
|
||||||
lib_deps =
|
lib_deps =
|
||||||
esphome/noise-c@0.1.24 ; used by noise (api, ota)
|
esphome/noise-c@0.1.26 ; used by noise (api, ota)
|
||||||
lvgl/lvgl@9.5.0 ; lvgl
|
lvgl/lvgl@9.5.0 ; lvgl
|
||||||
build_flags =
|
build_flags =
|
||||||
${common.build_flags}
|
${common.build_flags}
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@ tzlocal==5.4.4 # from time
|
|||||||
tzdata>=2026.3 # from time
|
tzdata>=2026.3 # from time
|
||||||
pyserial==3.5
|
pyserial==3.5
|
||||||
platformio==6.1.19
|
platformio==6.1.19
|
||||||
esptool==5.3.1
|
esptool==5.4.0
|
||||||
click==8.3.3
|
click==8.3.3
|
||||||
aioesphomeapi==46.3.0
|
aioesphomeapi==46.3.0
|
||||||
aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi
|
aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi
|
||||||
|
|||||||
+24
-137
@@ -50,7 +50,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
from collections.abc import Callable
|
|
||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
from functools import cache
|
from functools import cache
|
||||||
import json
|
import json
|
||||||
@@ -532,58 +531,37 @@ ESP32_PLATFORMIO_TRIGGER_PATH_PREFIXES = ("esphome/platformio/",)
|
|||||||
# - esphome/build_gen/platformio.py -- the PlatformIO build generator
|
# - esphome/build_gen/platformio.py -- the PlatformIO build generator
|
||||||
# - script/test_build_components.py -- the harness the job invokes
|
# - script/test_build_components.py -- the harness the job invokes
|
||||||
# - .github/workflows/ci.yml -- the job's own definition
|
# - .github/workflows/ci.yml -- the job's own definition
|
||||||
# Shared by every toolchain smoke-test job: the harness it invokes and the
|
ESP32_PLATFORMIO_TRIGGER_FILES = frozenset(
|
||||||
# workflow that defines it
|
|
||||||
_SMOKE_HARNESS_TRIGGER_FILES = frozenset(
|
|
||||||
{
|
{
|
||||||
|
"esphome/build_gen/platformio.py",
|
||||||
"script/test_build_components.py",
|
"script/test_build_components.py",
|
||||||
".github/workflows/ci.yml",
|
".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:
|
def _esp32_platformio_path_or_file_trigger(files: list[str]) -> bool:
|
||||||
"""Whether any changed file is a PlatformIO infrastructure / harness trigger."""
|
"""Whether any changed file is a PlatformIO infrastructure / harness trigger."""
|
||||||
return _path_or_file_trigger(
|
for file in files:
|
||||||
files, ESP32_PLATFORMIO_TRIGGER_FILES, ESP32_PLATFORMIO_TRIGGER_PATH_PREFIXES
|
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:
|
def _esp_idf_infra_changed(files: list[str]) -> bool:
|
||||||
"""Whether any changed file is ESP-IDF build/runner infrastructure."""
|
"""Whether any changed file is ESP-IDF build/runner infrastructure."""
|
||||||
return _path_or_file_trigger(
|
for file in files:
|
||||||
files, ESP_IDF_INFRA_TRIGGER_FILES, ESP_IDF_INFRA_TRIGGER_PATH_PREFIXES
|
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]:
|
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:
|
Returns:
|
||||||
Sorted list of component names to compile.
|
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)
|
files = changed_files(branch)
|
||||||
|
|
||||||
if core_changed(files) or infra_trigger(files):
|
if core_changed(files) or _esp32_platformio_path_or_file_trigger(files):
|
||||||
return sorted(test_set)
|
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:
|
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))
|
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(
|
def determine_cpp_unit_tests(
|
||||||
branch: str | None = None,
|
branch: str | None = None,
|
||||||
) -> tuple[bool, list[str]]:
|
) -> tuple[bool, list[str]]:
|
||||||
@@ -1333,8 +1226,6 @@ def main() -> None:
|
|||||||
run_device_builder = True
|
run_device_builder = True
|
||||||
esp32_platformio_components = sorted(ESP32_PLATFORMIO_TEST_COMPONENTS)
|
esp32_platformio_components = sorted(ESP32_PLATFORMIO_TEST_COMPONENTS)
|
||||||
run_esp32_platformio = True
|
run_esp32_platformio = True
|
||||||
esp8266_native_components = sorted(ESP8266_NATIVE_TEST_COMPONENTS)
|
|
||||||
run_esp8266_native = True
|
|
||||||
else:
|
else:
|
||||||
integration_run_all, integration_test_files = determine_integration_tests(
|
integration_run_all, integration_test_files = determine_integration_tests(
|
||||||
args.branch
|
args.branch
|
||||||
@@ -1346,8 +1237,6 @@ def main() -> None:
|
|||||||
run_device_builder = should_run_device_builder(args.branch)
|
run_device_builder = should_run_device_builder(args.branch)
|
||||||
esp32_platformio_components = esp32_platformio_components_to_test(args.branch)
|
esp32_platformio_components = esp32_platformio_components_to_test(args.branch)
|
||||||
run_esp32_platformio = bool(esp32_platformio_components)
|
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(
|
run_integration, integration_test_buckets = _compute_integration_test_buckets(
|
||||||
integration_run_all, integration_test_files
|
integration_run_all, integration_test_files
|
||||||
)
|
)
|
||||||
@@ -1543,8 +1432,6 @@ def main() -> None:
|
|||||||
"device_builder": run_device_builder,
|
"device_builder": run_device_builder,
|
||||||
"esp32_platformio": run_esp32_platformio,
|
"esp32_platformio": run_esp32_platformio,
|
||||||
"esp32_platformio_components": ",".join(esp32_platformio_components),
|
"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": changed_components,
|
||||||
"changed_components_with_tests": changed_components_with_tests,
|
"changed_components_with_tests": changed_components_with_tests,
|
||||||
"directly_changed_components_with_tests": list(directly_changed_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+)")
|
_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
|
@cache
|
||||||
@@ -1123,6 +1127,19 @@ def get_fixture_to_test_files() -> dict[str, frozenset[str]]:
|
|||||||
for func in _TEST_FUNC_RE.findall(content):
|
for func in _TEST_FUNC_RE.findall(content):
|
||||||
base_name = func.replace("test_", "").partition("[")[0]
|
base_name = func.replace("test_", "").partition("[")[0]
|
||||||
result.setdefault(base_name, set()).add(rel_path)
|
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()}
|
return {k: frozenset(v) for k, v in result.items()}
|
||||||
|
|
||||||
|
|||||||
@@ -1027,7 +1027,6 @@ def test_components(
|
|||||||
isolated_components: set[str] | None = None,
|
isolated_components: set[str] | None = None,
|
||||||
base_only: bool = False,
|
base_only: bool = False,
|
||||||
toolchain: str | None = None,
|
toolchain: str | None = None,
|
||||||
fail_on_no_tests: bool = False,
|
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Test components with optional intelligent grouping.
|
"""Test components with optional intelligent grouping.
|
||||||
|
|
||||||
@@ -1062,34 +1061,20 @@ def test_components(
|
|||||||
# toolchain build.
|
# toolchain build.
|
||||||
include_validate = esphome_command != "compile"
|
include_validate = esphome_command != "compile"
|
||||||
|
|
||||||
# Find all component tests; remember which components each pattern
|
# Find all component tests
|
||||||
# (wildcards included) matched, for the deferred no-tests accounting
|
|
||||||
all_tests = {}
|
all_tests = {}
|
||||||
pattern_components: dict[str, set[str]] = {}
|
|
||||||
for pattern in component_patterns:
|
for pattern in component_patterns:
|
||||||
# Skip empty patterns (happens when components list is empty string)
|
# Skip empty patterns (happens when components list is empty string)
|
||||||
if not pattern:
|
if not pattern:
|
||||||
continue
|
continue
|
||||||
found = find_component_tests(
|
all_tests.update(
|
||||||
tests_dir, pattern, base_only, include_validate=include_validate
|
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:
|
if not all_tests:
|
||||||
print(f"No components found matching: {component_patterns}")
|
print(f"No components found matching: {component_patterns}")
|
||||||
print(
|
print(
|
||||||
@@ -1193,26 +1178,6 @@ def test_components(
|
|||||||
toolchain=toolchain,
|
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
|
# Separate results into passed and failed
|
||||||
passed_results = [r for r in test_results if r.success]
|
passed_results = [r for r in test_results if r.success]
|
||||||
failed_results = [r for r in test_results if not 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"):
|
if os.environ.get("GITHUB_STEP_SUMMARY"):
|
||||||
write_github_summary(test_results, toolchain=toolchain)
|
write_github_summary(test_results, toolchain=toolchain)
|
||||||
|
|
||||||
if failed_results or silent:
|
if failed_results:
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
@@ -1299,12 +1264,6 @@ def main() -> int:
|
|||||||
"--toolchain",
|
"--toolchain",
|
||||||
help="Select toolchain for compiling.",
|
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()
|
args = parser.parse_args()
|
||||||
|
|
||||||
@@ -1323,7 +1282,6 @@ def main() -> int:
|
|||||||
continue_on_fail=args.continue_on_fail,
|
continue_on_fail=args.continue_on_fail,
|
||||||
enable_grouping=not args.no_grouping,
|
enable_grouping=not args.no_grouping,
|
||||||
isolated_components=isolated_components,
|
isolated_components=isolated_components,
|
||||||
fail_on_no_tests=args.fail_on_no_tests,
|
|
||||||
base_only=args.base_only,
|
base_only=args.base_only,
|
||||||
toolchain=args.toolchain,
|
toolchain=args.toolchain,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"""Validation tests for the sendspin media_source platform.
|
||||||
|
|
||||||
|
These cover the codec preference list, whose rejection branches a compile test
|
||||||
|
cannot reach: a `test*.yaml` can only assert that a configuration is accepted.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from esphome import config_validation as cv
|
||||||
|
from esphome.components.sendspin import CONF_CODECS, _get_data
|
||||||
|
from esphome.components.sendspin.media_source import CONFIG_SCHEMA
|
||||||
|
from esphome.const import PlatformFramework
|
||||||
|
from esphome.types import ConfigType
|
||||||
|
from tests.component_tests.types import SetCoreConfigCallable
|
||||||
|
|
||||||
|
|
||||||
|
def _media_source_config(**overrides: Any) -> ConfigType:
|
||||||
|
"""Build a minimal valid media source config, allowing field overrides."""
|
||||||
|
config: ConfigType = {
|
||||||
|
"id": "sendspin_media_source",
|
||||||
|
"sendspin_id": "sendspin_hub",
|
||||||
|
}
|
||||||
|
config.update(overrides)
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_codecs_at_48_khz(set_core_config: SetCoreConfigCallable) -> None:
|
||||||
|
"""Every codec is advertised when the sample rate suits all of them."""
|
||||||
|
set_core_config(PlatformFramework.ESP32_IDF)
|
||||||
|
|
||||||
|
config = CONFIG_SCHEMA(_media_source_config())
|
||||||
|
|
||||||
|
assert config[CONF_CODECS] == ["flac", "opus", "pcm"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_codecs_drop_opus_at_other_rates(
|
||||||
|
set_core_config: SetCoreConfigCallable,
|
||||||
|
) -> None:
|
||||||
|
"""Opus only supports 48 kHz, so it leaves the default list at other rates."""
|
||||||
|
set_core_config(PlatformFramework.ESP32_IDF)
|
||||||
|
|
||||||
|
config = CONFIG_SCHEMA(_media_source_config(sample_rate=44100))
|
||||||
|
|
||||||
|
assert config[CONF_CODECS] == ["flac", "pcm"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_configured_order_is_preserved(set_core_config: SetCoreConfigCallable) -> None:
|
||||||
|
"""The list is a preference order, so it reaches the player role as written."""
|
||||||
|
set_core_config(PlatformFramework.ESP32_IDF)
|
||||||
|
|
||||||
|
CONFIG_SCHEMA(_media_source_config(codecs=["pcm", "flac"]))
|
||||||
|
|
||||||
|
assert _get_data().player_config[CONF_CODECS] == ["pcm", "flac"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_codec_list_rejected(set_core_config: SetCoreConfigCallable) -> None:
|
||||||
|
"""A player with no codecs at all could never be given a stream."""
|
||||||
|
set_core_config(PlatformFramework.ESP32_IDF)
|
||||||
|
|
||||||
|
with pytest.raises(cv.Invalid, match="length of value must be at least 1"):
|
||||||
|
CONFIG_SCHEMA(_media_source_config(codecs=[]))
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_codec_rejected(set_core_config: SetCoreConfigCallable) -> None:
|
||||||
|
"""A repeated codec has no meaning in a preference order."""
|
||||||
|
set_core_config(PlatformFramework.ESP32_IDF)
|
||||||
|
|
||||||
|
with pytest.raises(cv.Invalid, match="may only be listed once"):
|
||||||
|
CONFIG_SCHEMA(_media_source_config(codecs=["flac", "flac"]))
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_codec_rejected(set_core_config: SetCoreConfigCallable) -> None:
|
||||||
|
"""Only codecs the player role can decode are accepted."""
|
||||||
|
set_core_config(PlatformFramework.ESP32_IDF)
|
||||||
|
|
||||||
|
with pytest.raises(cv.Invalid, match="Unknown value"):
|
||||||
|
CONFIG_SCHEMA(_media_source_config(codecs=["mp3"]))
|
||||||
|
|
||||||
|
|
||||||
|
def test_opus_at_wrong_sample_rate_rejected(
|
||||||
|
set_core_config: SetCoreConfigCallable,
|
||||||
|
) -> None:
|
||||||
|
"""Asking for Opus at a rate it cannot handle fails rather than silently
|
||||||
|
dropping the stated preference."""
|
||||||
|
set_core_config(PlatformFramework.ESP32_IDF)
|
||||||
|
|
||||||
|
with pytest.raises(cv.Invalid, match="requires a sample_rate of 48000"):
|
||||||
|
CONFIG_SCHEMA(_media_source_config(codecs=["opus"], sample_rate=44100))
|
||||||
@@ -9,3 +9,4 @@ media_source:
|
|||||||
static_delay_adjustable: true
|
static_delay_adjustable: true
|
||||||
fixed_delay: 480us
|
fixed_delay: 480us
|
||||||
decode_memory: internal
|
decode_memory: internal
|
||||||
|
codecs: [pcm, opus, flac]
|
||||||
|
|||||||
@@ -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 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
|
- 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
|
### Key Fixtures
|
||||||
|
|
||||||
- `run_compiled` - Combines write, compile, and run operations into a single context manager
|
- `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
|
import asyncio
|
||||||
from collections.abc import AsyncGenerator, Callable, Generator
|
from collections.abc import AsyncGenerator, Callable, Generator
|
||||||
from contextlib import AbstractAsyncContextManager, asynccontextmanager
|
from contextlib import AbstractAsyncContextManager, asynccontextmanager, suppress
|
||||||
import fcntl
|
import fcntl
|
||||||
|
from functools import cache
|
||||||
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import platform
|
import platform
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
import signal
|
import signal
|
||||||
import socket
|
import socket
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import time
|
||||||
from typing import TextIO
|
from typing import TextIO
|
||||||
|
|
||||||
from aioesphomeapi import APIClient, APIConnectionError, LogParser, ReconnectLogic
|
from aioesphomeapi import APIClient, APIConnectionError, LogParser, ReconnectLogic
|
||||||
@@ -23,7 +28,13 @@ import pytest_asyncio
|
|||||||
|
|
||||||
import esphome.config
|
import esphome.config
|
||||||
from esphome.core import CORE
|
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 esphome.platformio.toolchain import get_idedata
|
||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
@@ -56,6 +67,21 @@ import pty # not available on Windows
|
|||||||
pytest.register_assert_rewrite("tests.integration.entity_utils")
|
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]:
|
def _get_platformio_env(cache_dir: Path) -> dict[str, str]:
|
||||||
"""Get environment variables for PlatformIO with shared cache."""
|
"""Get environment variables for PlatformIO with shared cache."""
|
||||||
env = os.environ.copy()
|
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
|
# Compile with THIS tree's esphome sources, not wherever the venv's editable
|
||||||
# install points (which may be a different git worktree or checkout).
|
# 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")
|
existing = env.get("PYTHONPATH")
|
||||||
env["PYTHONPATH"] = f"{repo_root}{os.pathsep}{existing}" if existing else repo_root
|
env["PYTHONPATH"] = f"{repo_root}{os.pathsep}{existing}" if existing else repo_root
|
||||||
return env
|
return env
|
||||||
@@ -88,8 +114,7 @@ def _get_platformio_env(cache_dir: Path) -> dict[str, str]:
|
|||||||
def shared_platformio_cache() -> Generator[Path]:
|
def shared_platformio_cache() -> Generator[Path]:
|
||||||
"""Initialize a shared PlatformIO cache for all integration tests."""
|
"""Initialize a shared PlatformIO cache for all integration tests."""
|
||||||
# Use a dedicated directory for integration tests to avoid conflicts.
|
# 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 = INTEGRATION_TESTS_ROOT
|
||||||
test_cache_dir = Path.home() / ".esphome-integration-tests"
|
|
||||||
cache_dir = test_cache_dir / "platformio"
|
cache_dir = test_cache_dir / "platformio"
|
||||||
|
|
||||||
# Use a lock file in the home directory to ensure only one process initializes the cache
|
# 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)
|
init_dir = Path(tmpdir)
|
||||||
fixture_path = Path(__file__).parent / "fixtures" / "cache_init.yaml"
|
fixture_path = Path(__file__).parent / "fixtures" / "cache_init.yaml"
|
||||||
config_path = init_dir / "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
|
# Run compilation to populate the cache
|
||||||
# We must succeed here to avoid race conditions where multiple
|
# We must succeed here to avoid race conditions where multiple
|
||||||
@@ -162,13 +189,6 @@ def integration_test_dir() -> Generator[Path]:
|
|||||||
yield Path(tmpdir)
|
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
|
@pytest.fixture
|
||||||
def reserved_tcp_port() -> Generator[tuple[int, socket.socket]]:
|
def reserved_tcp_port() -> Generator[tuple[int, socket.socket]]:
|
||||||
"""Reserve an unused TCP port by holding the socket open."""
|
"""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]
|
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
|
@pytest_asyncio.fixture
|
||||||
async def yaml_config(request: pytest.FixtureRequest, unused_tcp_port: int) -> str:
|
async def yaml_config(request: pytest.FixtureRequest, unused_tcp_port: int) -> str:
|
||||||
"""Load YAML configuration based on test name."""
|
"""Load YAML configuration based on test name."""
|
||||||
# Get the test function name
|
shared_name = _shared_yaml_name(request)
|
||||||
test_name: str = request.node.name
|
# Base test name: test_ prefix and any parametrization stripped
|
||||||
# Extract the base test name (remove test_ prefix and any parametrization)
|
base_name = shared_name or request.node.name.replace("test_", "").partition("[")[0]
|
||||||
base_name = test_name.replace("test_", "").partition("[")[0]
|
|
||||||
|
|
||||||
# Load the fixture file
|
# 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():
|
if not fixture_path.exists():
|
||||||
raise FileNotFoundError(f"Fixture file not found: {fixture_path}")
|
raise FileNotFoundError(f"Fixture file not found: {fixture_path}")
|
||||||
|
|
||||||
loop = asyncio.get_running_loop()
|
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
|
# Replace the port in the config if it contains api section
|
||||||
if "api:" in content:
|
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
|
# Replace external component path placeholder if present
|
||||||
if "EXTERNAL_COMPONENT_PATH" in content:
|
if "EXTERNAL_COMPONENT_PATH" in content:
|
||||||
external_components_path = str(
|
external_components_path = str(FIXTURES_DIR / "external_components")
|
||||||
Path(__file__).parent / "fixtures" / "external_components"
|
|
||||||
)
|
|
||||||
content = content.replace("EXTERNAL_COMPONENT_PATH", external_components_path)
|
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
|
return content
|
||||||
|
|
||||||
|
|
||||||
@@ -240,24 +270,218 @@ async def write_yaml_config(
|
|||||||
) -> AsyncGenerator[ConfigWriter]:
|
) -> AsyncGenerator[ConfigWriter]:
|
||||||
"""Write YAML configuration to a file."""
|
"""Write YAML configuration to a file."""
|
||||||
# Get the test name for default filename
|
# Get the test name for default filename
|
||||||
test_name = request.node.name
|
base_name = request.node.name.replace("test_", "").partition("[")[0]
|
||||||
base_name = test_name.replace("test_", "").split("[")[0]
|
|
||||||
|
|
||||||
async def _write_config(content: str, filename: str | None = None) -> Path:
|
async def _write_config(content: str, filename: str | None = None) -> Path:
|
||||||
if filename is None:
|
if filename is None:
|
||||||
filename = f"{base_name}.yaml"
|
filename = f"{base_name}.yaml"
|
||||||
config_path = integration_test_dir / filename
|
config_path = integration_test_dir / filename
|
||||||
loop = asyncio.get_running_loop()
|
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
|
return config_path
|
||||||
|
|
||||||
yield _write_config
|
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
|
@pytest_asyncio.fixture
|
||||||
async def compile_esphome(
|
async def compile_esphome(
|
||||||
integration_test_dir: Path,
|
integration_test_dir: Path,
|
||||||
shared_platformio_cache: Path,
|
shared_platformio_cache: Path,
|
||||||
|
request: pytest.FixtureRequest,
|
||||||
) -> AsyncGenerator[CompileFunction]:
|
) -> AsyncGenerator[CompileFunction]:
|
||||||
"""Compile an ESPHome configuration and return the binary path."""
|
"""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
|
# Use the shared PlatformIO cache for faster compilation
|
||||||
# This avoids re-downloading dependencies for each test
|
# This avoids re-downloading dependencies for each test
|
||||||
env = _get_platformio_env(shared_platformio_cache)
|
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()
|
loop = asyncio.get_running_loop()
|
||||||
|
|
||||||
def _read_config_and_get_binary():
|
name = _shared_yaml_name(request)
|
||||||
CORE.reset() # Reset CORE state between test runs
|
if name is None:
|
||||||
CORE.config_path = config_path
|
await _run_esphome_compile(config_path, integration_test_dir, env)
|
||||||
config = esphome.config.read_config(
|
return await loop.run_in_executor(
|
||||||
{"command": "compile", "config": str(config_path)}
|
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
|
# Shared fixture: build in a hash-keyed dir so tests sharing a config
|
||||||
idedata = get_idedata(config)
|
# pay one full compile and later only a main.cpp (port) rebuild + relink
|
||||||
return Path(idedata.firmware_elf_path)
|
shared_dir = _shared_build_dir(name)
|
||||||
|
shared_dir.mkdir(parents=True, exist_ok=True)
|
||||||
binary_path = await loop.run_in_executor(None, _read_config_and_get_binary)
|
# 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 not binary_path.exists():
|
# if a peer reaped it already, the guarded lock open recreates it
|
||||||
raise RuntimeError(f"Compiled binary not found at {binary_path}")
|
with suppress(FileNotFoundError):
|
||||||
|
os.utime(shared_dir)
|
||||||
return binary_path
|
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
|
yield _compile
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
esphome:
|
||||||
name: uart-mock-modbus-server-contro
|
name: uart-mock-modbus-mesh
|
||||||
|
|
||||||
host:
|
host:
|
||||||
api:
|
api:
|
||||||
@@ -17,13 +17,14 @@ uart:
|
|||||||
baud_rate: 115200
|
baud_rate: 115200
|
||||||
port: /dev/null
|
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:
|
uart_mock:
|
||||||
- id: virtual_uart_server
|
- id: virtual_uart_server
|
||||||
baud_rate: 9600
|
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
|
auto_start: true
|
||||||
debug:
|
debug:
|
||||||
on_tx:
|
on_tx:
|
||||||
@@ -31,35 +32,68 @@ uart_mock:
|
|||||||
- uart_mock.inject_rx:
|
- uart_mock.inject_rx:
|
||||||
id: virtual_uart_controller
|
id: virtual_uart_controller
|
||||||
data: !lambda return data;
|
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
|
baud_rate: 9600
|
||||||
auto_start: true # See comment on virtual_uart_server above
|
auto_start: true
|
||||||
debug:
|
debug:
|
||||||
on_tx:
|
on_tx:
|
||||||
- then:
|
- then:
|
||||||
- uart_mock.inject_rx:
|
- uart_mock.inject_rx:
|
||||||
id: virtual_uart_server
|
id: virtual_uart_server
|
||||||
data: !lambda return data;
|
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:
|
modbus:
|
||||||
- uart_id: virtual_uart_server
|
- uart_id: virtual_uart_server
|
||||||
id: virtual_modbus_server
|
id: virtual_modbus_server
|
||||||
role: server
|
role: server
|
||||||
|
- uart_id: virtual_uart_server_2
|
||||||
|
id: virtual_modbus_server_2
|
||||||
|
role: server
|
||||||
- uart_id: virtual_uart_controller
|
- uart_id: virtual_uart_controller
|
||||||
id: virtual_modbus_controller
|
id: virtual_modbus_client
|
||||||
role: client
|
role: client
|
||||||
turnaround_time: 10ms
|
turnaround_time: 10ms
|
||||||
|
|
||||||
modbus_controller:
|
modbus_controller:
|
||||||
- address: 1
|
- address: 1
|
||||||
modbus_id: virtual_modbus_controller
|
modbus_id: virtual_modbus_client
|
||||||
id: modbus_controller_1
|
id: modbus_controller_1
|
||||||
update_interval: 1s
|
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:
|
modbus_server:
|
||||||
- address: 1
|
- address: 1
|
||||||
modbus_id: virtual_modbus_server
|
modbus_id: virtual_modbus_server
|
||||||
id: modbus_server_1
|
|
||||||
registers:
|
registers:
|
||||||
- address: 0x01
|
- address: 0x01
|
||||||
value_type: U_WORD
|
value_type: U_WORD
|
||||||
@@ -103,6 +137,34 @@ modbus_server:
|
|||||||
- address: 0x28
|
- address: 0x28
|
||||||
value_type: FP32_R
|
value_type: FP32_R
|
||||||
read_lambda: return 3.14;
|
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:
|
sensor:
|
||||||
- platform: modbus_controller
|
- platform: modbus_controller
|
||||||
@@ -195,9 +257,46 @@ sensor:
|
|||||||
address: 0x28
|
address: 0x28
|
||||||
register_type: holding
|
register_type: holding
|
||||||
value_type: FP32_R
|
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:
|
button:
|
||||||
- platform: template
|
- platform: template
|
||||||
name: "Start Scenario"
|
name: "Start Scenario"
|
||||||
id: start_scenario_btn
|
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:
|
esphome:
|
||||||
name: uart-mock-modbus-srv-rw
|
name: uart-mock-modbus-srv-injected
|
||||||
|
|
||||||
host:
|
host:
|
||||||
api:
|
api:
|
||||||
@@ -17,6 +17,8 @@ uart:
|
|||||||
baud_rate: 115200
|
baud_rate: 115200
|
||||||
port: /dev/null
|
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:
|
uart_mock:
|
||||||
- id: virtual_uart_dev
|
- id: virtual_uart_dev
|
||||||
baud_rate: 9600
|
baud_rate: 9600
|
||||||
@@ -25,18 +27,31 @@ uart_mock:
|
|||||||
auto_start: false
|
auto_start: false
|
||||||
debug:
|
debug:
|
||||||
injections:
|
injections:
|
||||||
# FC 0x17 Read/Write Multiple Registers on device 1:
|
- delay: 100ms
|
||||||
# write reg 0x0001 = 0x1234 (qty 1), then read regs 0x0001..0x0002 (qty 2).
|
inject_rx: [0x01, 0x03, 0x00, 0x03, 0x00, 0x01, 0x74, 0x0A] # Read holding register 3 on device 1 (basic_read)
|
||||||
# Per Modbus 6.17 the write is performed before the read, so reg 0x0001 must
|
- delay: 100ms
|
||||||
# read back the just-written 0x1234 in the same request.
|
# 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
|
- delay: 100ms
|
||||||
inject_rx:
|
inject_rx:
|
||||||
[0x01, 0x17, 0x00, 0x01, 0x00, 0x02, 0x00, 0x01, 0x00, 0x01, 0x02, 0x12, 0x34, 0x49, 0xD8]
|
[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.
|
# a write and read targeting a different register block.
|
||||||
- delay: 100ms
|
- delay: 100ms
|
||||||
inject_rx:
|
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:
|
globals:
|
||||||
- id: stored_1
|
- id: stored_1
|
||||||
@@ -70,8 +85,18 @@ modbus_server:
|
|||||||
read_lambda: |-
|
read_lambda: |-
|
||||||
id(rw_read_2).publish_state(0x00AA);
|
id(rw_read_2).publish_state(0x00AA);
|
||||||
return 0x00AA;
|
return 0x00AA;
|
||||||
# Second writable + readable register, targeted by the second request.
|
|
||||||
- address: 0x03
|
- 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
|
value_type: U_WORD
|
||||||
read_lambda: |-
|
read_lambda: |-
|
||||||
id(rw_read_3).publish_state(id(stored_3));
|
id(rw_read_3).publish_state(id(stored_3));
|
||||||
@@ -80,8 +105,22 @@ modbus_server:
|
|||||||
id(stored_3) = x;
|
id(stored_3) = x;
|
||||||
id(rw_write_3).publish_state(x);
|
id(rw_write_3).publish_state(x);
|
||||||
return true;
|
return true;
|
||||||
|
- address: 0x0A
|
||||||
|
value_type: U_WORD
|
||||||
|
read_lambda: |-
|
||||||
|
id(read_after_peer_timeout).publish_state(1);
|
||||||
|
return 1;
|
||||||
|
|
||||||
sensor:
|
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
|
- platform: template
|
||||||
name: "rw_write_1"
|
name: "rw_write_1"
|
||||||
id: rw_write_1
|
id: rw_write_1
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Helpers for manipulating the host platform's preferences file.
|
"""Helpers for manipulating the host platform's preferences file.
|
||||||
|
|
||||||
ESPHome's host platform stores preferences in
|
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()``:
|
mirrors ``HostPreferences::sync()``:
|
||||||
``[uint32_t key][uint8_t len][uint8_t data[len]]`` per entry.
|
``[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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import struct
|
import struct
|
||||||
|
|
||||||
|
|
||||||
def host_prefs_path(device_name: str) -> Path:
|
def host_prefs_path(device_name: str) -> Path:
|
||||||
"""Return the on-disk prefs file path for a host-platform device."""
|
"""Return the on-disk prefs file path for a host-platform device.
|
||||||
return Path.home() / ".esphome" / "prefs" / f"{device_name}.prefs"
|
|
||||||
|
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:
|
def clear_host_prefs(device_name: str) -> None:
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ from .types import (
|
|||||||
RunCompiledFunction,
|
RunCompiledFunction,
|
||||||
)
|
)
|
||||||
|
|
||||||
pytestmark = pytest.mark.usefixtures("isolated_preferences")
|
|
||||||
NEW_KEY = PROVISIONING_PSK
|
NEW_KEY = PROVISIONING_PSK
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -41,15 +41,6 @@ async def _poll_until_exists(path: Path) -> None:
|
|||||||
await asyncio.sleep(0.05)
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_host_preferences_suspend_resume(
|
async def test_host_preferences_suspend_resume(
|
||||||
yaml_config: str,
|
yaml_config: str,
|
||||||
@@ -58,7 +49,7 @@ async def test_host_preferences_suspend_resume(
|
|||||||
isolated_preferences: Path,
|
isolated_preferences: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test that a running syncer flushes, a suspended one doesn't, and resume restores flushing."""
|
"""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()
|
loop = asyncio.get_running_loop()
|
||||||
saved_in_memory = loop.create_future()
|
saved_in_memory = loop.create_future()
|
||||||
|
|||||||
@@ -11,14 +11,6 @@ from .state_utils import InitialStateHelper, require_entity
|
|||||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_light_initial_state(
|
async def test_light_initial_state(
|
||||||
yaml_config: str,
|
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)
|
_assert_no_modbus_errors(error_log_lines, warning_log_lines)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.shared_yaml("uart_mock_modbus_server_injected")
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_uart_mock_modbus_server(
|
async def test_uart_mock_modbus_server(
|
||||||
yaml_config: str,
|
yaml_config: str,
|
||||||
@@ -203,6 +204,7 @@ async def test_uart_mock_modbus_server(
|
|||||||
_assert_no_modbus_errors(error_log_lines, warning_log_lines)
|
_assert_no_modbus_errors(error_log_lines, warning_log_lines)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.shared_yaml("uart_mock_modbus_server_injected")
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_uart_mock_modbus_server_read_write(
|
async def test_uart_mock_modbus_server_read_write(
|
||||||
yaml_config: str,
|
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_write_1": 4660, # 0x1234 written to reg 0x0001
|
||||||
"rw_read_1": 4660, # reg 0x0001 reads back the just-written value
|
"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_read_2": 170, # 0x00AA read from reg 0x0002 in the same request
|
||||||
"rw_write_3": 22136, # 0x5678 written to reg 0x0003
|
"rw_write_3": 22136, # 0x5678 written to reg 0x0006
|
||||||
"rw_read_3": 22136, # reg 0x0003 reads back the just-written value
|
"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,
|
api_client_connected() as client,
|
||||||
):
|
):
|
||||||
await tracker.setup_and_start_scenario(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)
|
_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
|
@pytest.mark.asyncio
|
||||||
async def test_uart_mock_modbus_server_controller(
|
async def test_uart_mock_modbus_server_controller(
|
||||||
yaml_config: str,
|
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)
|
_assert_no_modbus_errors(error_log_lines, warning_log_lines)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.shared_yaml("uart_mock_modbus_mesh")
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_uart_mock_modbus_server_controller_multiple(
|
async def test_uart_mock_modbus_server_controller_multiple(
|
||||||
yaml_config: str,
|
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()
|
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()))
|
tracker = SensorTracker(list(expected_values.keys()))
|
||||||
futures = tracker.expect_all(expected_values)
|
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)
|
_assert_no_modbus_errors(error_log_lines, warning_log_lines)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.shared_yaml("uart_mock_modbus_loopback")
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_uart_mock_modbus_custom_pdu(
|
async def test_uart_mock_modbus_custom_pdu(
|
||||||
yaml_config: str,
|
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)
|
_assert_no_modbus_errors(error_log_lines, warning_log_lines)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.shared_yaml("uart_mock_modbus_mesh")
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_uart_mock_modbus_client_read_write(
|
async def test_uart_mock_modbus_client_read_write(
|
||||||
yaml_config: str,
|
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()
|
line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback()
|
||||||
|
|
||||||
tracker = SensorTracker(
|
tracker = SensorTracker(["srv_write_1", "client_read_0", "client_read_1"])
|
||||||
["srv_write_1", "srv_read_1", "client_read_0", "client_read_1"]
|
|
||||||
)
|
|
||||||
futures = tracker.expect_all(
|
futures = tracker.expect_all(
|
||||||
{
|
{
|
||||||
"srv_write_1": 4660, # server wrote 0x1234 to reg 0x0001
|
"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)
|
_assert_no_modbus_errors(error_log_lines, warning_log_lines)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.shared_yaml("uart_mock_modbus_loopback")
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_uart_mock_modbus_register_offset(
|
async def test_uart_mock_modbus_register_offset(
|
||||||
yaml_config: str,
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_uart_mock_modbus_lambda_write(
|
async def test_uart_mock_modbus_lambda_write(
|
||||||
yaml_config: str,
|
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)
|
await tracker.await_change(wrote_30, "reg_30", timeout=4.0)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.shared_yaml("uart_mock_modbus_loopback")
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_uart_mock_modbus_lambda_invert(
|
async def test_uart_mock_modbus_lambda_invert(
|
||||||
yaml_config: str,
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_uart_mock_modbus_deprecated_write_buffer(
|
async def test_uart_mock_modbus_deprecated_write_buffer(
|
||||||
yaml_config: str,
|
yaml_config: str,
|
||||||
|
|||||||
@@ -78,17 +78,6 @@ def mock_esp32_platformio_components_to_test() -> Generator[Mock, None, None]:
|
|||||||
yield mock
|
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
|
@pytest.fixture
|
||||||
def mock_determine_cpp_unit_tests() -> Generator[Mock, None, None]:
|
def mock_determine_cpp_unit_tests() -> Generator[Mock, None, None]:
|
||||||
"""Mock determine_cpp_unit_tests from helpers."""
|
"""Mock determine_cpp_unit_tests from helpers."""
|
||||||
@@ -117,7 +106,6 @@ def clear_determine_jobs_caches() -> None:
|
|||||||
"""Clear all cached functions before each test."""
|
"""Clear all cached functions before each test."""
|
||||||
determine_jobs._is_clang_tidy_full_scan.cache_clear()
|
determine_jobs._is_clang_tidy_full_scan.cache_clear()
|
||||||
determine_jobs._component_has_tests.cache_clear()
|
determine_jobs._component_has_tests.cache_clear()
|
||||||
determine_jobs._cached_components_closure.cache_clear()
|
|
||||||
|
|
||||||
|
|
||||||
def test_main_all_tests_should_run(
|
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_import_time: Mock,
|
||||||
mock_should_run_device_builder: Mock,
|
mock_should_run_device_builder: Mock,
|
||||||
mock_esp32_platformio_components_to_test: Mock,
|
mock_esp32_platformio_components_to_test: Mock,
|
||||||
mock_esp8266_native_components_to_test: Mock,
|
|
||||||
mock_changed_files: Mock,
|
mock_changed_files: Mock,
|
||||||
mock_determine_cpp_unit_tests: Mock,
|
mock_determine_cpp_unit_tests: Mock,
|
||||||
capsys: pytest.CaptureFixture[str],
|
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_import_time.return_value = True
|
||||||
mock_should_run_device_builder.return_value = True
|
mock_should_run_device_builder.return_value = True
|
||||||
mock_esp32_platformio_components_to_test.return_value = ["api", "esp32"]
|
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_determine_cpp_unit_tests.return_value = (False, ["wifi", "api", "sensor"])
|
||||||
|
|
||||||
# Mock changed_files to return non-component files (to avoid memory impact)
|
# 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["device_builder"] is True
|
||||||
assert output["esp32_platformio"] is True
|
assert output["esp32_platformio"] is True
|
||||||
assert output["esp32_platformio_components"] == "api,esp32"
|
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"]
|
assert output["changed_components"] == ["wifi", "api", "sensor"]
|
||||||
# changed_components_with_tests will only include components that actually have test files
|
# changed_components_with_tests will only include components that actually have test files
|
||||||
assert "changed_components_with_tests" in output
|
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_import_time: Mock,
|
||||||
mock_should_run_device_builder: Mock,
|
mock_should_run_device_builder: Mock,
|
||||||
mock_esp32_platformio_components_to_test: Mock,
|
mock_esp32_platformio_components_to_test: Mock,
|
||||||
mock_esp8266_native_components_to_test: Mock,
|
|
||||||
mock_changed_files: Mock,
|
mock_changed_files: Mock,
|
||||||
mock_determine_cpp_unit_tests: Mock,
|
mock_determine_cpp_unit_tests: Mock,
|
||||||
capsys: pytest.CaptureFixture[str],
|
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_import_time.return_value = False
|
||||||
mock_should_run_device_builder.return_value = False
|
mock_should_run_device_builder.return_value = False
|
||||||
mock_esp32_platformio_components_to_test.return_value = []
|
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_determine_cpp_unit_tests.return_value = (False, [])
|
||||||
|
|
||||||
# Mock changed_files to return no component files
|
# 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["device_builder"] is False
|
||||||
assert output["esp32_platformio"] is False
|
assert output["esp32_platformio"] is False
|
||||||
assert output["esp32_platformio_components"] == ""
|
assert output["esp32_platformio_components"] == ""
|
||||||
assert output["esp8266_native"] is False
|
|
||||||
assert output["esp8266_native_components"] == ""
|
|
||||||
assert output["changed_components"] == []
|
assert output["changed_components"] == []
|
||||||
assert output["changed_components_with_tests"] == []
|
assert output["changed_components_with_tests"] == []
|
||||||
assert output["component_test_count"] == 0
|
assert output["component_test_count"] == 0
|
||||||
@@ -1013,10 +993,6 @@ _ESP32_PLATFORMIO_FULL_LIST_FILES = [
|
|||||||
# PlatformIO subsystem (path-prefix trigger) + build generator
|
# PlatformIO subsystem (path-prefix trigger) + build generator
|
||||||
["esphome/platformio/runner.py"],
|
["esphome/platformio/runner.py"],
|
||||||
["esphome/platformio/toolchain.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"],
|
["esphome/build_gen/platformio.py"],
|
||||||
# Workflow / harness files
|
# Workflow / harness files
|
||||||
["script/test_build_components.py"],
|
["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
|
# Non-PlatformIO files in esphome/build_gen/ do NOT trigger the
|
||||||
# full list -- only esphome/build_gen/platformio.py is a trigger.
|
# full list -- only esphome/build_gen/platformio.py is a trigger.
|
||||||
(["esphome/build_gen/espidf.py"], [], []),
|
(["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.
|
# Docs / unrelated files -> empty.
|
||||||
(["README.md"], [], []),
|
(["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"
|
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:
|
def test_compute_integration_test_buckets_no_durations_full_fanout() -> None:
|
||||||
"""Without recorded durations the fan-out stays at the maximum."""
|
"""Without recorded durations the fan-out stays at the maximum."""
|
||||||
files = [f"tests/integration/test_{i:03d}.py" for i in range(15)]
|
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"]
|
) == ["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:
|
def test_lpt_partition_balances_skewed_weights() -> None:
|
||||||
"""Heavy items spread across groups instead of clustering."""
|
"""Heavy items spread across groups instead of clustering."""
|
||||||
items = [f"i{n}" for n in range(6)]
|
items = [f"i{n}" for n in range(6)]
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ def _load_script():
|
|||||||
def test_spec_key_collapses_destinations() -> None:
|
def test_spec_key_collapses_destinations() -> None:
|
||||||
"""Two specs delivering one package share a directory and one key."""
|
"""Two specs delivering one package share a directory and one key."""
|
||||||
mod = _load_script()
|
mod = _load_script()
|
||||||
assert mod.spec_key("esphome/noise-c @ 0.1.24") == "noise-c"
|
assert mod.spec_key("esphome/noise-c @ 0.1.26") == "noise-c"
|
||||||
assert mod.spec_key("esphome/noise-c@0.1.24") == "noise-c"
|
assert mod.spec_key("esphome/noise-c@0.1.26") == "noise-c"
|
||||||
assert mod.spec_key("ESP32Async/AsyncTCP @ ^3.4.10") == mod.spec_key(
|
assert mod.spec_key("ESP32Async/AsyncTCP @ ^3.4.10") == mod.spec_key(
|
||||||
"esp32async/asynctcp @ 3.5.0"
|
"esp32async/asynctcp @ 3.5.0"
|
||||||
)
|
)
|
||||||
@@ -54,23 +54,23 @@ def test_parse_specs_and_cli_args(tmp_path: Path) -> None:
|
|||||||
"[env:a]\n"
|
"[env:a]\n"
|
||||||
"platform = fake/platform@1\n"
|
"platform = fake/platform@1\n"
|
||||||
"lib_deps =\n"
|
"lib_deps =\n"
|
||||||
" esphome/noise-c @ 0.1.24\n"
|
" esphome/noise-c @ 0.1.26\n"
|
||||||
" ${common.lib_deps}\n"
|
" ${common.lib_deps}\n"
|
||||||
" internal_lib\n"
|
" internal_lib\n"
|
||||||
"[env:b]\n"
|
"[env:b]\n"
|
||||||
"lib_deps =\n"
|
"lib_deps =\n"
|
||||||
" esphome/noise-c @ 0.1.24\n"
|
" esphome/noise-c @ 0.1.26\n"
|
||||||
)
|
)
|
||||||
mod = _load_script()
|
mod = _load_script()
|
||||||
args = Namespace(libraries=True, platforms=True, tools=False)
|
args = Namespace(libraries=True, platforms=True, tools=False)
|
||||||
libs, platforms, tools = mod.parse_specs(str(ini), args)
|
libs, platforms, tools = mod.parse_specs(str(ini), args)
|
||||||
# exact-string duplicates collapse; distinct version pins survive
|
# exact-string duplicates collapse; distinct version pins survive
|
||||||
assert libs == ["esphome/noise-c @ 0.1.24"]
|
assert libs == ["esphome/noise-c @ 0.1.26"]
|
||||||
assert platforms == ["fake/platform@1"]
|
assert platforms == ["fake/platform@1"]
|
||||||
assert tools == []
|
assert tools == []
|
||||||
assert mod.build_cli_args(libs, platforms, tools) == [
|
assert mod.build_cli_args(libs, platforms, tools) == [
|
||||||
"-l",
|
"-l",
|
||||||
"esphome/noise-c @ 0.1.24",
|
"esphome/noise-c @ 0.1.26",
|
||||||
"-p",
|
"-p",
|
||||||
"fake/platform@1",
|
"fake/platform@1",
|
||||||
]
|
]
|
||||||
@@ -162,13 +162,13 @@ def test_parallel_install_behavior(tmp_path: Path) -> None:
|
|||||||
mod.parallel_install(
|
mod.parallel_install(
|
||||||
cls,
|
cls,
|
||||||
[
|
[
|
||||||
"esphome/noise-c @ 0.1.24",
|
"esphome/noise-c @ 0.1.26",
|
||||||
"esphome/noise-c @ 0.1.24",
|
"esphome/noise-c @ 0.1.26",
|
||||||
"esphome/already @ 1.0",
|
"esphome/already @ 1.0",
|
||||||
"https://x/framework.tar.xz",
|
"https://x/framework.tar.xz",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
assert cls.calls == ["esphome/noise-c @ 0.1.24"]
|
assert cls.calls == ["esphome/noise-c @ 0.1.26"]
|
||||||
assert cls.lock_events == ["lock", "unlock"]
|
assert cls.lock_events == ["lock", "unlock"]
|
||||||
|
|
||||||
|
|
||||||
@@ -205,7 +205,7 @@ def test_parallel_install_runs_dependency_waves(tmp_path: Path) -> None:
|
|||||||
mod = _load_script()
|
mod = _load_script()
|
||||||
cls = _reset_fake(str(tmp_path))
|
cls = _reset_fake(str(tmp_path))
|
||||||
cls.deps = {
|
cls.deps = {
|
||||||
"esphome/noise-c @ 0.1.24": [
|
"esphome/noise-c @ 0.1.26": [
|
||||||
{"owner": "esphome", "name": "libsodium", "version": "^1.0"},
|
{"owner": "esphome", "name": "libsodium", "version": "^1.0"},
|
||||||
{"name": "SPI"},
|
{"name": "SPI"},
|
||||||
],
|
],
|
||||||
@@ -213,12 +213,12 @@ def test_parallel_install_runs_dependency_waves(tmp_path: Path) -> None:
|
|||||||
{"owner": "esphome", "name": "libsodium", "version": "^1.0"},
|
{"owner": "esphome", "name": "libsodium", "version": "^1.0"},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
mod.parallel_install(cls, ["esphome/noise-c @ 0.1.24", "esphome/wg @ 1.0"])
|
mod.parallel_install(cls, ["esphome/noise-c @ 0.1.26", "esphome/wg @ 1.0"])
|
||||||
assert len(cls.calls) == 3 # the shared dep installs exactly once
|
assert len(cls.calls) == 3 # the shared dep installs exactly once
|
||||||
assert {mod.spec_key(c) for c in cls.calls} == {"noise-c", "wg", "libsodium"}
|
assert {mod.spec_key(c) for c in cls.calls} == {"noise-c", "wg", "libsodium"}
|
||||||
# Wave-1 strings carry no compatibility; the dependency wave does
|
# Wave-1 strings carry no compatibility; the dependency wave does
|
||||||
compats = dict(cls.compat_calls)
|
compats = dict(cls.compat_calls)
|
||||||
assert compats["esphome/noise-c @ 0.1.24"] is None
|
assert compats["esphome/noise-c @ 0.1.26"] is None
|
||||||
dep_compat = next(v for k, v in cls.compat_calls if "libsodium" in k)
|
dep_compat = next(v for k, v in cls.compat_calls if "libsodium" in k)
|
||||||
assert dep_compat is not None # mirrors pio's install_dependency
|
assert dep_compat is not None # mirrors pio's install_dependency
|
||||||
|
|
||||||
@@ -229,11 +229,11 @@ def test_dependency_wave_excludes_url_specs(tmp_path: Path) -> None:
|
|||||||
mod = _load_script()
|
mod = _load_script()
|
||||||
cls = _reset_fake(str(tmp_path))
|
cls = _reset_fake(str(tmp_path))
|
||||||
cls.deps = {
|
cls.deps = {
|
||||||
"esphome/noise-c @ 0.1.24": [
|
"esphome/noise-c @ 0.1.26": [
|
||||||
{"name": "vendored", "version": "https://github.com/x/y.git"},
|
{"name": "vendored", "version": "https://github.com/x/y.git"},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
mod.parallel_install(cls, ["esphome/noise-c @ 0.1.24"])
|
mod.parallel_install(cls, ["esphome/noise-c @ 0.1.26"])
|
||||||
assert {mod.spec_key(c) for c in cls.calls} == {"noise-c"}
|
assert {mod.spec_key(c) for c in cls.calls} == {"noise-c"}
|
||||||
|
|
||||||
|
|
||||||
@@ -348,13 +348,13 @@ def test_warm_store_still_walks_dependencies(tmp_path: Path) -> None:
|
|||||||
"""Already-installed top-level packages still feed the dependency
|
"""Already-installed top-level packages still feed the dependency
|
||||||
wave; a warm store can be missing a transitive dep."""
|
wave; a warm store can be missing a transitive dep."""
|
||||||
mod = _load_script()
|
mod = _load_script()
|
||||||
cls = _reset_fake(str(tmp_path), installed={"esphome/noise-c @ 0.1.24"})
|
cls = _reset_fake(str(tmp_path), installed={"esphome/noise-c @ 0.1.26"})
|
||||||
cls.deps = {
|
cls.deps = {
|
||||||
"esphome/noise-c @ 0.1.24": [
|
"esphome/noise-c @ 0.1.26": [
|
||||||
{"owner": "esphome", "name": "libsodium", "version": "^1.0"},
|
{"owner": "esphome", "name": "libsodium", "version": "^1.0"},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
mod.parallel_install(cls, ["esphome/noise-c @ 0.1.24"])
|
mod.parallel_install(cls, ["esphome/noise-c @ 0.1.26"])
|
||||||
assert [mod.spec_key(c) for c in cls.calls] == ["libsodium"]
|
assert [mod.spec_key(c) for c in cls.calls] == ["libsodium"]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -236,98 +236,3 @@ def test_run_grouped_test_closes_group_when_subprocess_raises(
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert "::endgroup::" in capsys.readouterr().out
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
@@ -245,20 +244,3 @@ def test_copy_failure_leaves_no_partial_output(tmp_path: Path) -> None:
|
|||||||
):
|
):
|
||||||
assert build_tool.main() == 1
|
assert build_tool.main() == 1
|
||||||
assert not dst.exists()
|
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 json
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import subprocess
|
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -490,922 +488,3 @@ def test_get_component_cmakelists_no_compile_features() -> None:
|
|||||||
content = get_component_cmakelists()
|
content = get_component_cmakelists()
|
||||||
|
|
||||||
assert "target_compile_features" not in content
|
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
|
import pytest
|
||||||
|
|
||||||
from esphome.build_helpers import ccache
|
from esphome.build_helpers import ccache
|
||||||
from esphome.core import CORE
|
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_opt_out() -> None:
|
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."""
|
"""cv.boolean's spelling tables plus the 1/0 env convention."""
|
||||||
monkeypatch.setenv("ESPHOME_CCACHE_ENABLE", raw)
|
monkeypatch.setenv("ESPHOME_CCACHE_ENABLE", raw)
|
||||||
assert ccache.parse_enable_env("ESPHOME_CCACHE_ENABLE") is expected
|
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)
|
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:
|
def test_parse_entry_skips_dependency_flags() -> None:
|
||||||
"""Dependency-generation flags (and their args) are dropped."""
|
"""Dependency-generation flags (and their args) are dropped."""
|
||||||
entry = _entry(
|
entry = _entry(
|
||||||
@@ -185,7 +104,7 @@ def test_expand_response_files(tmp_path: Path) -> None:
|
|||||||
rsp = tmp_path / "flags.rsp"
|
rsp = tmp_path / "flags.rsp"
|
||||||
rsp.write_text("-DFROM_RSP -I/rsp/inc")
|
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
|
["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:
|
def test_expand_response_files_keeps_literal_when_missing(tmp_path: Path) -> None:
|
||||||
"""An unreadable ``@file`` token is kept verbatim rather than dropped."""
|
"""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
|
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."""
|
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"
|
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 tokens[0] == r"C:\esp\bin\riscv32-esp-elf-g++.exe"
|
||||||
assert '-DVER="1.2.3"' in tokens
|
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
|
Guards against ``CommandLineToArgvW("")`` returning the current process name
|
||||||
instead of an empty list.
|
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")
|
@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:
|
def test_is_launcher_matches_only_known_launchers() -> None:
|
||||||
"""Compilers of any shape pass; only the closed launcher set matches."""
|
"""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"):
|
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"):
|
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(
|
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 "board_build.ldscript is ignored" in caplog.text
|
||||||
assert "'arduino' toolchain" in caplog.text
|
assert "'arduino' toolchain" in caplog.text
|
||||||
assert "upload_speed" not 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")
|
target.config_path = Path("foo/config")
|
||||||
return target
|
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):
|
def test_reset(self, target):
|
||||||
"""Call reset on target and compare to new instance"""
|
"""Call reset on target and compare to new instance"""
|
||||||
other = core.EsphomeCore().__dict__
|
other = core.EsphomeCore().__dict__
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ from unittest.mock import MagicMock, patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from esphome.build_helpers.pch import _PCHData
|
|
||||||
from esphome.espidf.framework import (
|
from esphome.espidf.framework import (
|
||||||
ESPHOME_STAMP_FILE,
|
ESPHOME_STAMP_FILE,
|
||||||
STAMP_SCHEMA_VERSION,
|
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",
|
"esphome.espidf.framework.get_idf_tools_path",
|
||||||
return_value=tmp_path / "tools",
|
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(
|
patch(
|
||||||
"esphome.core.CORE",
|
"esphome.core.CORE",
|
||||||
SimpleNamespace(
|
SimpleNamespace(build_path=build_path),
|
||||||
build_path=build_path,
|
|
||||||
# Pre-marked: these env tests model a pch-emitting build
|
|
||||||
data={"pch": _PCHData(emitted=True)},
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1614,9 +1609,6 @@ def test_ccache_env_default_enabled_when_available(tmp_path: Path) -> None:
|
|||||||
assert env["CCACHE_NOHASHDIR"] == "true"
|
assert env["CCACHE_NOHASHDIR"] == "true"
|
||||||
assert env["CCACHE_DEPEND"] == "1"
|
assert env["CCACHE_DEPEND"] == "1"
|
||||||
assert env["CCACHE_BASEDIR"] == str((tmp_path / "build").resolve())
|
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:
|
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
|
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]:
|
def _setup_build(setup_core: Path) -> tuple[Path, Path]:
|
||||||
"""Point CORE at a build dir; return (compile_commands, idedata cache) paths."""
|
"""Point CORE at a build dir; return (compile_commands, idedata cache) paths."""
|
||||||
CORE.name = "test"
|
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)}}
|
CORE.data = {KEY_ESP32: {KEY_IDF_VERSION: cv.Version(5, 5, 4)}}
|
||||||
assert toolchain._get_core_framework_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()
|
|
||||||
|
|||||||
@@ -416,6 +416,9 @@ def test_perform_ota_no_auth(
|
|||||||
"Update took 14.00 seconds (prepare 2.00, upload 5.00, commit 7.00)"
|
"Update took 14.00 seconds (prepare 2.00, upload 5.00, commit 7.00)"
|
||||||
in caplog.text
|
in caplog.text
|
||||||
)
|
)
|
||||||
|
# The data phase timeout must outlast the device's 105 s data timeout
|
||||||
|
mock_socket.settimeout.assert_any_call(espota2.DATA_PHASE_TIMEOUT)
|
||||||
|
assert espota2.DATA_PHASE_TIMEOUT > 105.0
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.usefixtures("mock_time")
|
@pytest.mark.usefixtures("mock_time")
|
||||||
|
|||||||
@@ -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:
|
def test_api_client_does_not_import_heavy_modules() -> None:
|
||||||
"""``esphome.api_client`` is on the logs fast path and must stay light.
|
"""``esphome.api_client`` is on the logs fast path and must stay light.
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import sys
|
|||||||
import time
|
import time
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from typing import Any, Self
|
from typing import Any, Self
|
||||||
from unittest.mock import AsyncMock, MagicMock, Mock, PropertyMock, patch
|
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from pytest import CaptureFixture
|
from pytest import CaptureFixture
|
||||||
@@ -25,7 +25,6 @@ from esphome.__main__ import (
|
|||||||
Purpose,
|
Purpose,
|
||||||
_get_configured_xtal_freq,
|
_get_configured_xtal_freq,
|
||||||
_make_crystal_freq_callback,
|
_make_crystal_freq_callback,
|
||||||
_native_toolchain_module,
|
|
||||||
_redact_with_legacy_fallback,
|
_redact_with_legacy_fallback,
|
||||||
_resolve_network_devices,
|
_resolve_network_devices,
|
||||||
_should_subscribe_states,
|
_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:
|
def test_command_idedata_esp_idf_prints_json(capsys: CaptureFixture) -> None:
|
||||||
"""Under the native ESP-IDF toolchain, idedata is emitted as JSON."""
|
"""Under the native ESP-IDF toolchain, idedata is emitted as JSON."""
|
||||||
setup_core(platform=PLATFORM_ESP32)
|
setup_core()
|
||||||
CORE.toolchain = Toolchain.ESP_IDF
|
CORE.toolchain = Toolchain.ESP_IDF
|
||||||
data = {"cxx_path": "g++", "prog_path": "/build/firmware.elf"}
|
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:
|
def test_command_idedata_esp_idf_no_build_errors() -> None:
|
||||||
"""Under ESP-IDF, a missing build (no idedata) returns an error, not a crash."""
|
"""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
|
CORE.toolchain = Toolchain.ESP_IDF
|
||||||
|
|
||||||
with patch("esphome.espidf.toolchain.get_idedata", return_value=None):
|
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
|
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(
|
@pytest.mark.parametrize(
|
||||||
"error",
|
"error",
|
||||||
[
|
[
|
||||||
@@ -7592,46 +7396,6 @@ def test_compile_program_espidf_idedata_none_warns(
|
|||||||
assert "No idedata was generated" in caplog.text
|
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:
|
def test_cli_toolchain_skips_the_validated_config_cache(tmp_path: Path) -> None:
|
||||||
"""An explicit --toolchain must run the per-platform validators, so the
|
"""An explicit --toolchain must run the per-platform validators, so the
|
||||||
upload/logs fast path becomes a cache miss."""
|
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()
|
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(
|
def test_cli_toolchain_still_refreshes_the_validated_config_cache(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
) -> None:
|
) -> 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
|
|
||||||
@@ -1663,7 +1663,7 @@ def test_preinstall_runs_dependency_waves(tmp_path: Path) -> None:
|
|||||||
{"name": "SPI"},
|
{"name": "SPI"},
|
||||||
]
|
]
|
||||||
m.dependency_to_spec.side_effect = lambda dep: _FakeSpec(name=dep["name"])
|
m.dependency_to_spec.side_effect = lambda dep: _FakeSpec(name=dep["name"])
|
||||||
pf._preinstall(m, [("noise-c@0.1.24", _FakeSpec(name="noise-c"))])
|
pf._preinstall(m, [("noise-c@0.1.26", _FakeSpec(name="noise-c"))])
|
||||||
assert installed == ["noise-c", "libsodium"] # dep deduped, SPI left out
|
assert installed == ["noise-c", "libsodium"] # dep deduped, SPI left out
|
||||||
# The dep wave carries its compatibility so _install searches qualified
|
# The dep wave carries its compatibility so _install searches qualified
|
||||||
dep_call = m._install.call_args_list[-1]
|
dep_call = m._install.call_args_list[-1]
|
||||||
@@ -1683,7 +1683,7 @@ def test_preinstall_dependency_wave_skips_seen_names(tmp_path: Path) -> None:
|
|||||||
m._install.side_effect = lambda spec, skip_dependencies, compatibility=None: (
|
m._install.side_effect = lambda spec, skip_dependencies, compatibility=None: (
|
||||||
installed.append(getattr(spec, "name", str(spec)))
|
installed.append(getattr(spec, "name", str(spec)))
|
||||||
)
|
)
|
||||||
pf._preinstall(m, [("noise-c@0.1.24", _FakeSpec(name="noise-c"))])
|
pf._preinstall(m, [("noise-c@0.1.26", _FakeSpec(name="noise-c"))])
|
||||||
assert installed == ["noise-c"]
|
assert installed == ["noise-c"]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -516,13 +516,10 @@ def test_clean_build(
|
|||||||
dependencies_lock = tmp_path / "dependencies.lock"
|
dependencies_lock = tmp_path / "dependencies.lock"
|
||||||
dependencies_lock.write_text("lock file")
|
dependencies_lock.write_text("lock file")
|
||||||
|
|
||||||
# idedata caches live under the data dir, not the build path; the
|
# idedata cache lives under the data dir, not the build path.
|
||||||
# .arduino.json variant is the native esp8266 toolchain's.
|
|
||||||
idedata_cache = tmp_path / "idedata" / "test.json"
|
idedata_cache = tmp_path / "idedata" / "test.json"
|
||||||
idedata_cache.parent.mkdir()
|
idedata_cache.parent.mkdir()
|
||||||
idedata_cache.write_text("{}")
|
idedata_cache.write_text("{}")
|
||||||
arduino_idedata_cache = tmp_path / "idedata" / "test.arduino.json"
|
|
||||||
arduino_idedata_cache.write_text("{}")
|
|
||||||
|
|
||||||
# Native ESP-IDF toolchain artifacts.
|
# Native ESP-IDF toolchain artifacts.
|
||||||
idf_build_dir = tmp_path / "build"
|
idf_build_dir = tmp_path / "build"
|
||||||
@@ -583,7 +580,6 @@ def test_clean_build(
|
|||||||
assert not piolibdeps_dir.exists()
|
assert not piolibdeps_dir.exists()
|
||||||
assert not dependencies_lock.exists()
|
assert not dependencies_lock.exists()
|
||||||
assert not idedata_cache.exists()
|
assert not idedata_cache.exists()
|
||||||
assert not arduino_idedata_cache.exists()
|
|
||||||
assert not idf_build_dir.exists()
|
assert not idf_build_dir.exists()
|
||||||
assert not managed_components_dir.exists()
|
assert not managed_components_dir.exists()
|
||||||
assert not pio_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
|
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")
|
@patch("esphome.writer.CORE")
|
||||||
def test_clean_build_nothing_exists(
|
def test_clean_build_nothing_exists(
|
||||||
mock_core: MagicMock,
|
mock_core: MagicMock,
|
||||||
|
|||||||
Reference in New Issue
Block a user