mirror of
https://github.com/esphome/esphome.git
synced 2026-09-08 22:08:49 +00:00
Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9861753fb | ||
|
|
fbb29a2e76 | ||
|
|
e050d58875 | ||
|
|
6c5ab89d5f | ||
|
|
8f511a365a | ||
|
|
006f31af93 | ||
|
|
5bb112f407 | ||
|
|
4ab9298ab3 | ||
|
|
3926612281 | ||
|
|
1ce0bed3f6 | ||
|
|
472b5de30e | ||
|
|
bee0dd2926 | ||
|
|
157294a99c | ||
|
|
6146e625dd | ||
|
|
8e9e6bbdd7 | ||
|
|
773050d7c9 | ||
|
|
87dc5014a8 | ||
|
|
49f9eaacb1 | ||
|
|
cd4e07f2d0 | ||
|
|
7e1216fe01 | ||
|
|
8027a37d61 | ||
|
|
76e1df2c03 | ||
|
|
66a0485a5c | ||
|
|
3a83323c75 | ||
|
|
817fcb7881 | ||
|
|
08c7e3e9aa | ||
|
|
5b5b5e3cc1 | ||
|
|
eefddf85f1 | ||
|
|
d1665c423d | ||
|
|
db55c1d43f | ||
|
|
27a8768986 | ||
|
|
a0398d120c | ||
|
|
deb63ea092 |
@@ -1,39 +0,0 @@
|
||||
name: Cache Arduino ESP8266
|
||||
description: >
|
||||
Resolve the pinned Arduino core and xtensa toolchain versions and cache the
|
||||
native ESP8266 install (~110 MB framework + toolchain; no ccache store, the
|
||||
seed job saves before any compile runs). Exports
|
||||
ESPHOME_ARDUINO8266_PREFIX to the job so every later step installs into
|
||||
the cached path; the Python venv must already be restored. Mirrors
|
||||
cache-esp-idf: only dev-branch pushes write the shared cache, everything
|
||||
else restores.
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Resolve the native toolchain cache key
|
||||
# Versions are pinned in code, not a hashable file; resolve them so a
|
||||
# bump changes the cache key. Assignment form so errexit catches a
|
||||
# resolver failure.
|
||||
id: version
|
||||
shell: bash
|
||||
run: |
|
||||
# One owner for the install prefix: exported here and referenced by
|
||||
# the cache steps below via env, so the caller's install and the
|
||||
# cached path cannot diverge.
|
||||
echo "ESPHOME_ARDUINO8266_PREFIX=$HOME/.esphome-arduino8266" >> "$GITHUB_ENV"
|
||||
. venv/bin/activate
|
||||
key=$(python -c 'from esphome.components.esp8266 import RECOMMENDED_ARDUINO_FRAMEWORK_VERSION as f; from esphome.arduino8266.framework import TOOLCHAIN_VERSION as t; print(f"{f}-{t}")')
|
||||
[ -n "$key" ] || exit 1
|
||||
echo "key=$key" >> "$GITHUB_OUTPUT"
|
||||
- name: Cache the native toolchain (write on dev)
|
||||
if: github.ref == 'refs/heads/dev'
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ${{ env.ESPHOME_ARDUINO8266_PREFIX }}
|
||||
key: ${{ runner.os }}-esp8266-native-${{ steps.version.outputs.key }}
|
||||
- name: Restore the native toolchain (off dev)
|
||||
if: github.ref != 'refs/heads/dev'
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ${{ env.ESPHOME_ARDUINO8266_PREFIX }}
|
||||
key: ${{ runner.os }}-esp8266-native-${{ steps.version.outputs.key }}
|
||||
@@ -21,7 +21,6 @@ on:
|
||||
- "esphome/core/**"
|
||||
- "esphome/writer.py"
|
||||
- "esphome/build_gen/**"
|
||||
- "esphome/build_helpers/**"
|
||||
- "esphome/espidf/**"
|
||||
- "esphome/platformio/**"
|
||||
- "esphome/components/bk72xx/**"
|
||||
@@ -198,7 +197,6 @@ jobs:
|
||||
# the default.
|
||||
id:
|
||||
- esp8266-arduino
|
||||
- esp8266-arduino-native
|
||||
- esp32-arduino-platformio
|
||||
- esp32-arduino-esp-idf
|
||||
- esp32-idf-platformio
|
||||
@@ -209,16 +207,6 @@ jobs:
|
||||
- ln882x-arduino
|
||||
- nrf52
|
||||
- host
|
||||
# Strict by default so a new matrix id cannot silently join in the
|
||||
# degrade-quietly mode the knob exists to catch.
|
||||
# Opt-outs: libretiny GCC rejects its own pch until a toolchain bump.
|
||||
include:
|
||||
- id: bk72xx-arduino
|
||||
pch_strict: "0"
|
||||
- id: rtl87xx-arduino
|
||||
pch_strict: "0"
|
||||
- id: ln882x-arduino
|
||||
pch_strict: "0"
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Download image artifact
|
||||
@@ -230,7 +218,6 @@ jobs:
|
||||
- name: Compile ${{ matrix.id }}
|
||||
run: |
|
||||
docker run --rm \
|
||||
-e ESPHOME_PCH_STRICT="${{ matrix.pch_strict || '1' }}" \
|
||||
-v "${{ github.workspace }}/docker/test_configs:/config" \
|
||||
"ghcr.io/esphome/esphome-amd64:${{ needs.check-docker.outputs.tag }}" \
|
||||
compile "${{ matrix.id }}.yaml"
|
||||
|
||||
@@ -102,8 +102,6 @@ jobs:
|
||||
device-builder: ${{ steps.determine.outputs.device-builder }}
|
||||
esp32-platformio: ${{ steps.determine.outputs.esp32-platformio }}
|
||||
esp32-platformio-components: ${{ steps.determine.outputs.esp32-platformio-components }}
|
||||
esp8266-native: ${{ steps.determine.outputs.esp8266-native }}
|
||||
esp8266-native-components: ${{ steps.determine.outputs.esp8266-native-components }}
|
||||
changed-components: ${{ steps.determine.outputs.changed-components }}
|
||||
changed-components-with-tests: ${{ steps.determine.outputs.changed-components-with-tests }}
|
||||
directly-changed-components-with-tests: ${{ steps.determine.outputs.directly-changed-components-with-tests }}
|
||||
@@ -167,8 +165,6 @@ jobs:
|
||||
echo "device-builder=$(echo "$output" | jq -r '.device_builder')" >> $GITHUB_OUTPUT
|
||||
echo "esp32-platformio=$(echo "$output" | jq -r '.esp32_platformio')" >> $GITHUB_OUTPUT
|
||||
echo "esp32-platformio-components=$(echo "$output" | jq -r '.esp32_platformio_components')" >> $GITHUB_OUTPUT
|
||||
echo "esp8266-native=$(echo "$output" | jq -r '.esp8266_native')" >> $GITHUB_OUTPUT
|
||||
echo "esp8266-native-components=$(echo "$output" | jq -r '.esp8266_native_components')" >> $GITHUB_OUTPUT
|
||||
echo "changed-components=$(echo "$output" | jq -c '.changed_components')" >> $GITHUB_OUTPUT
|
||||
echo "changed-components-with-tests=$(echo "$output" | jq -c '.changed_components_with_tests')" >> $GITHUB_OUTPUT
|
||||
echo "directly-changed-components-with-tests=$(echo "$output" | jq -c '.directly_changed_components_with_tests')" >> $GITHUB_OUTPUT
|
||||
@@ -187,32 +183,6 @@ jobs:
|
||||
path: .temp/components_graph.json
|
||||
key: components-graph-${{ hashFiles('esphome/components/**/*.py') }}
|
||||
|
||||
seed-esp8266-native-cache:
|
||||
name: Seed the esp8266 native toolchain cache
|
||||
runs-on: ubuntu-24.04
|
||||
needs:
|
||||
- common
|
||||
# PR-branch cache saves are invisible to other PRs, so dev pushes seed
|
||||
# the shared entry test-esp8266-native restores. Only dev: the composite
|
||||
# action saves nowhere else, so a beta/release push would download the
|
||||
# toolchain and discard it.
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Restore Python
|
||||
uses: ./.github/actions/restore-python
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
cache-key: ${{ needs.common.outputs.cache-key }}
|
||||
- name: Cache the native toolchain
|
||||
uses: ./.github/actions/cache-arduino8266
|
||||
- name: Install the native toolchain
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
python -c "from esphome.arduino8266.framework import check_and_install; from esphome.components.esp8266 import RECOMMENDED_ARDUINO_FRAMEWORK_VERSION; check_and_install(RECOMMENDED_ARDUINO_FRAMEWORK_VERSION)"
|
||||
|
||||
ci-custom:
|
||||
name: Run script/ci-custom
|
||||
runs-on: ubuntu-24.04
|
||||
@@ -1267,7 +1237,7 @@ jobs:
|
||||
|
||||
# compile validates config first, so a separate config pass is
|
||||
# redundant for this smoke test. ESP-IDF framework via PlatformIO:
|
||||
python3 script/test_build_components.py -e compile -t esp32-idf -c "$TEST_COMPONENTS" -f --toolchain platformio --fail-on-no-tests
|
||||
python3 script/test_build_components.py -e compile -t esp32-idf -c "$TEST_COMPONENTS" -f --toolchain platformio
|
||||
|
||||
echo ""
|
||||
echo "ESP-IDF-via-PlatformIO build passed! Starting Arduino smoke test..."
|
||||
@@ -1276,42 +1246,6 @@ jobs:
|
||||
# Arduino framework via PlatformIO (only components with an esp32-ard test are built):
|
||||
python3 script/test_build_components.py -e compile -t esp32-ard -c "$TEST_COMPONENTS" -f --toolchain platformio
|
||||
|
||||
test-esp8266-native:
|
||||
name: Test esp8266 components with the native toolchain
|
||||
runs-on: ubuntu-24.04
|
||||
needs:
|
||||
- common
|
||||
- determine-jobs
|
||||
if: github.event_name == 'pull_request' && needs.determine-jobs.outputs.esp8266-native == 'true'
|
||||
env:
|
||||
# Computed by script/determine-jobs.py (ESP8266_NATIVE_TEST_COMPONENTS)
|
||||
TEST_COMPONENTS: ${{ needs.determine-jobs.outputs.esp8266-native-components }}
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Restore Python
|
||||
uses: ./.github/actions/restore-python
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
cache-key: ${{ needs.common.outputs.cache-key }}
|
||||
|
||||
- name: Cache the native toolchain
|
||||
uses: ./.github/actions/cache-arduino8266
|
||||
|
||||
- name: Run native toolchain compile test
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
|
||||
echo "Testing components: $TEST_COMPONENTS"
|
||||
echo ""
|
||||
|
||||
# ESP8266 Arduino built directly (no PlatformIO); compile validates
|
||||
# config first, so a separate config pass is redundant. Strict pch:
|
||||
# exercises the native ninja pch (and its probe edge) against real
|
||||
# component configs; the docker matrix smoke-tests both toolchains.
|
||||
ESPHOME_PCH_STRICT=1 python3 script/test_build_components.py -e compile -t esp8266-ard -c "$TEST_COMPONENTS" -f --toolchain arduino --fail-on-no-tests
|
||||
|
||||
device-builder:
|
||||
name: Test downstream esphome/device-builder
|
||||
runs-on: ubuntu-24.04
|
||||
@@ -1674,7 +1608,6 @@ jobs:
|
||||
needs:
|
||||
- common
|
||||
- seed-apt-cache
|
||||
- seed-esp8266-native-cache
|
||||
- determine-jobs
|
||||
- ci-custom
|
||||
- pylint
|
||||
@@ -1690,7 +1623,6 @@ jobs:
|
||||
- clang-tidy-esp32-variants
|
||||
- test-build-components-split
|
||||
- test-esp32-platformio
|
||||
- test-esp8266-native
|
||||
- device-builder
|
||||
- memory-impact-target-branch
|
||||
- memory-impact-pr-branch
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
esphome:
|
||||
name: docker-test-esp8266-native
|
||||
|
||||
esp8266:
|
||||
board: d1_mini
|
||||
toolchain: arduino
|
||||
|
||||
logger:
|
||||
+34
-87
@@ -817,9 +817,7 @@ def write_cpp_file() -> int:
|
||||
from esphome.build_gen import espidf
|
||||
|
||||
espidf.write_project()
|
||||
elif not CORE.using_native_toolchain:
|
||||
# Other native builds generate their project at compile time;
|
||||
# never write a platformio.ini for them
|
||||
else:
|
||||
from esphome.build_gen import platformio
|
||||
|
||||
platformio.write_project()
|
||||
@@ -861,14 +859,20 @@ def compile_program(args: ArgsProtocol, config: ConfigType) -> int:
|
||||
toolchain.create_factory_bin()
|
||||
toolchain.create_ota_bin()
|
||||
toolchain.create_elf_copy()
|
||||
from esphome.build_helpers.idedata import warn_if_idedata_missing
|
||||
from esphome.build_helpers.idedata import IDEDATA_BEST_EFFORT_ERRORS
|
||||
|
||||
warn_if_idedata_missing(toolchain.get_idedata)
|
||||
elif CORE.using_native_toolchain:
|
||||
raise EsphomeError(
|
||||
f"Toolchain '{CORE.toolchain.value}' resolved but no platform "
|
||||
"backend claimed the build"
|
||||
)
|
||||
try:
|
||||
if toolchain.get_idedata() is None:
|
||||
_LOGGER.warning("No idedata was generated for this build")
|
||||
except IDEDATA_BEST_EFFORT_ERRORS as err:
|
||||
# The firmware already built; an idedata failure must not fail
|
||||
# a successful build.
|
||||
_LOGGER.warning(
|
||||
"Could not generate idedata: %s (IDE, clang-tidy, and "
|
||||
"memory-analysis data will be unavailable for this build)",
|
||||
err,
|
||||
)
|
||||
_LOGGER.debug("Idedata failure detail", exc_info=True)
|
||||
else:
|
||||
from esphome.platformio import toolchain
|
||||
|
||||
@@ -971,15 +975,12 @@ def upload_using_esptool(
|
||||
|
||||
if file is not None:
|
||||
flash_images = [FlashImage(path=file, offset="0x0")]
|
||||
elif (native := _native_toolchain_module()) is not None:
|
||||
# Every native backend supplies its own 0x0 flash image (bootloader
|
||||
# and partitions included where the target needs them)
|
||||
image = native.get_factory_firmware_path()
|
||||
if not image.is_file():
|
||||
raise EsphomeError(
|
||||
f"{image} does not exist; compile the configuration first"
|
||||
)
|
||||
flash_images = [FlashImage(path=image, offset="0x0")]
|
||||
elif CORE.using_toolchain_esp_idf:
|
||||
from esphome.espidf import toolchain
|
||||
|
||||
flash_images = [
|
||||
FlashImage(path=toolchain.get_factory_firmware_path(), offset="0x0")
|
||||
]
|
||||
else:
|
||||
from esphome.platformio import toolchain
|
||||
|
||||
@@ -1288,10 +1289,9 @@ def _choose_ota_platform(config: ConfigType, requested: str | None) -> str:
|
||||
The native API uses challenge-response auth with MD5/SHA256 hashing of a
|
||||
server-issued nonce, so the password is never sent over the wire; the
|
||||
``web_server`` path uses HTTP Basic auth which transmits credentials in
|
||||
cleartext over the LAN. (The native path also supports gzip compression
|
||||
on ESP8266, where flash space is tight; on ESP32/RP2040/LibreTiny the
|
||||
backend reports ``supports_compression() == false`` and the firmware is
|
||||
sent uncompressed regardless of which platform is used.) Falls back to
|
||||
cleartext over the LAN. (The native path also compresses the upload:
|
||||
gzip on ESP8266 and RP2040, which inflate it at reboot, and a deflate
|
||||
stream on ESP32/LibreTiny, which inflate it as it arrives.) Falls back to
|
||||
``web_server`` only when that is the only available platform.
|
||||
"""
|
||||
# Use a dict (insertion-ordered) instead of a list so error messages and
|
||||
@@ -1961,39 +1961,15 @@ def command_update_all(args: ArgsProtocol) -> int | None:
|
||||
return run_multiple_configs(files, build_command)
|
||||
|
||||
|
||||
# Native build backend per (target platform, toolchain). Keyed here rather
|
||||
# than through a platform hook so the serial upload/logs fast path never
|
||||
# imports the platform component package (see the esp32 variant comment in
|
||||
# upload_using_esptool); the platform half comes from CORE.data the same way.
|
||||
_NATIVE_TOOLCHAIN_MODULES = {
|
||||
("esp32", Toolchain.ESP_IDF): "esphome.espidf.toolchain",
|
||||
("esp8266", Toolchain.ARDUINO): "esphome.arduino8266.toolchain",
|
||||
}
|
||||
|
||||
|
||||
def _native_toolchain_module():
|
||||
"""The native build backend module for the resolved toolchain."""
|
||||
if not CORE.using_native_toolchain:
|
||||
return None
|
||||
key = (CORE.target_platform, CORE.toolchain)
|
||||
if (module_path := _NATIVE_TOOLCHAIN_MODULES.get(key)) is None:
|
||||
# Degrading to the PlatformIO path would build with the wrong backend
|
||||
raise EsphomeError(
|
||||
f"Toolchain '{CORE.toolchain.value}' has no native build backend "
|
||||
f"module for platform {CORE.target_platform}"
|
||||
)
|
||||
return importlib.import_module(module_path)
|
||||
|
||||
|
||||
def command_idedata(args: ArgsProtocol, config: ConfigType) -> int:
|
||||
import json
|
||||
|
||||
native_toolchain = _native_toolchain_module()
|
||||
if CORE.using_toolchain_esp_idf:
|
||||
# Native ESP-IDF derives idedata from the build's compile_commands.json,
|
||||
# so the configuration must already be compiled.
|
||||
from esphome.espidf import toolchain as espidf_toolchain
|
||||
|
||||
if native_toolchain is not None:
|
||||
# Native toolchains derive idedata from the build's
|
||||
# compile_commands.json, so the configuration must already be compiled.
|
||||
idedata = native_toolchain.get_idedata()
|
||||
idedata = espidf_toolchain.get_idedata()
|
||||
if idedata is None:
|
||||
_LOGGER.error(
|
||||
"No idedata available; compile the configuration first",
|
||||
@@ -2032,17 +2008,6 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int:
|
||||
from esphome.analyze_memory.cli import MemoryAnalyzerCLI
|
||||
from esphome.analyze_memory.ram_strings import RamStringsAnalyzer
|
||||
|
||||
# Refuse an unsupported toolchain before paying for a full compile
|
||||
native_toolchain = _native_toolchain_module()
|
||||
if native_toolchain is None and not CORE.using_toolchain_platformio:
|
||||
_LOGGER.error(
|
||||
"analyze-memory is not supported with the '%s' toolchain on %s; "
|
||||
"re-run with --toolchain platformio",
|
||||
CORE.toolchain.value if CORE.toolchain else "unresolved",
|
||||
CORE.target_platform,
|
||||
)
|
||||
return 1
|
||||
|
||||
# Always compile to ensure fresh data (fast if no changes - just relinks)
|
||||
exit_code = write_cpp(config)
|
||||
if exit_code != 0:
|
||||
@@ -2054,31 +2019,13 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int:
|
||||
|
||||
# Get idedata for analysis
|
||||
idedata = None
|
||||
if native_toolchain is not None:
|
||||
objdump = native_toolchain.get_objdump_path()
|
||||
readelf = native_toolchain.get_readelf_path()
|
||||
for tool in (objdump, readelf):
|
||||
if not tool.is_file():
|
||||
# The analyzer would silently fall back to host binutils,
|
||||
# which cannot read the target ELF. clean-all is heavy for
|
||||
# ESP-IDF, so suggest a recompile first.
|
||||
_LOGGER.error(
|
||||
"%s is missing; the toolchain install may be incomplete "
|
||||
"(recompile, or run 'esphome clean-all' if it persists)",
|
||||
tool,
|
||||
)
|
||||
return 1
|
||||
objdump_path = str(objdump)
|
||||
readelf_path = str(readelf)
|
||||
if CORE.using_toolchain_esp_idf:
|
||||
from esphome.espidf import toolchain
|
||||
|
||||
firmware_elf = native_toolchain.get_elf_path()
|
||||
if not firmware_elf.is_file():
|
||||
# The analyzer swallows tool failures, so a missing ELF would
|
||||
# produce an exit-0 zeroed report
|
||||
_LOGGER.error(
|
||||
"%s is missing; compile the configuration first", firmware_elf
|
||||
)
|
||||
return 1
|
||||
objdump_path = str(toolchain.get_objdump_path())
|
||||
readelf_path = str(toolchain.get_readelf_path())
|
||||
|
||||
firmware_elf = toolchain.get_elf_path()
|
||||
else:
|
||||
from esphome.platformio import toolchain
|
||||
|
||||
|
||||
@@ -19,11 +19,10 @@ from typing import NamedTuple
|
||||
|
||||
from esphome.build_helpers.ccache import ccache_defaults_env
|
||||
from esphome.build_helpers.ninja import find_ninja
|
||||
from esphome.build_helpers.pch import ccache_pch_env
|
||||
from esphome.build_helpers.tools_cache import ARDUINO8266_TOOLS_CACHE, tools_cache_path
|
||||
from esphome.core import EsphomeError, Version
|
||||
from esphome.framework_helpers import str_to_lst_of_str
|
||||
from esphome.platformio.registry import install_packages, prefetch_packages
|
||||
from esphome.platformio.registry import install_package, prefetch_packages
|
||||
|
||||
FRAMEWORK_PACKAGE = "framework-arduinoespressif8266"
|
||||
TOOLCHAIN_PACKAGE = "toolchain-xtensa"
|
||||
@@ -116,9 +115,10 @@ def check_and_install(framework_version: Version) -> InstalledPaths:
|
||||
("bin", "xtensa-lx106-elf"),
|
||||
),
|
||||
)
|
||||
# Fetch both archives at once; the install verifies and extracts them
|
||||
# Fetch both archives at once; the installs below verify and extract
|
||||
prefetch_packages([spec[:4] for spec in specs], downloads_dir)
|
||||
install_packages(specs, downloads_dir)
|
||||
for name, version, dest, mirrors, expect in specs:
|
||||
install_package(name, version, dest, mirrors, downloads_dir, expect=expect)
|
||||
return InstalledPaths(
|
||||
framework=framework_path, toolchain=toolchain_path, ninja=ninja_path
|
||||
)
|
||||
@@ -156,6 +156,4 @@ def ccache_env(ccache: str | None) -> dict[str, str]:
|
||||
"""
|
||||
if ccache is None:
|
||||
return {}
|
||||
env = ccache_defaults_env(get_arduino8266_tools_path() / "ccache")
|
||||
env.update(ccache_pch_env())
|
||||
return env
|
||||
return ccache_defaults_env(get_arduino8266_tools_path() / "ccache")
|
||||
|
||||
@@ -1,329 +0,0 @@
|
||||
"""Native Arduino ESP8266 build driver (the PlatformIO ``run`` equivalent)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
from typing import Any
|
||||
|
||||
from esphome.arduino8266 import framework
|
||||
from esphome.build_helpers.ccache import resolve_ccache_path
|
||||
from esphome.const import (
|
||||
CONF_COMPILE_PROCESS_LIMIT,
|
||||
CONF_ESPHOME,
|
||||
KEY_CORE,
|
||||
KEY_FRAMEWORK_VERSION,
|
||||
)
|
||||
from esphome.core import CORE, EsphomeError
|
||||
from esphome.helpers import write_file_if_changed
|
||||
from esphome.types import ConfigType
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# ESP8266 user RAM (matches upload.maximum_ram_size in every board manifest)
|
||||
_MAX_RAM_SIZE = 81920
|
||||
|
||||
|
||||
def _warn_ignored_platformio_options() -> None:
|
||||
"""Warn for component-added platformio options the native build drops.
|
||||
|
||||
The consumed set is exported by core/config.py next to the routing that
|
||||
stores these options, so the two cannot drift; YAML upload_speed never
|
||||
reaches CORE.platformio_options here.
|
||||
"""
|
||||
from esphome.core.config import NATIVE_ARDUINO_CONSUMED_PIO_OPTIONS
|
||||
|
||||
consumed = NATIVE_ARDUINO_CONSUMED_PIO_OPTIONS
|
||||
for key in sorted(CORE.platformio_options or {}):
|
||||
if key not in consumed:
|
||||
_LOGGER.warning(
|
||||
"platformio_options->%s is ignored when building with the "
|
||||
"native 'arduino' toolchain",
|
||||
key,
|
||||
)
|
||||
|
||||
|
||||
_RAM_SECTIONS = (".data", ".rodata", ".bss")
|
||||
_FLASH_SECTIONS = (".irom0.text", ".text", ".text1", ".data", ".rodata")
|
||||
|
||||
|
||||
def get_build_dir() -> Path:
|
||||
return CORE.relative_pioenvs_path(CORE.name)
|
||||
|
||||
|
||||
def get_elf_path() -> Path:
|
||||
return get_build_dir() / "firmware.elf"
|
||||
|
||||
|
||||
def _toolchain_tool(name: str) -> Path:
|
||||
return framework.toolchain_tool(framework.get_toolchain_path(), name)
|
||||
|
||||
|
||||
def get_factory_firmware_path() -> Path:
|
||||
"""The image to serial-flash at 0x0 (same bytes as firmware.bin: the
|
||||
8266 factory copy exists for artifact-contract parity, not content)."""
|
||||
return get_build_dir() / "firmware.factory.bin"
|
||||
|
||||
|
||||
def get_addr2line_path() -> Path:
|
||||
return _toolchain_tool("addr2line")
|
||||
|
||||
|
||||
def get_objdump_path() -> Path:
|
||||
return _toolchain_tool("objdump")
|
||||
|
||||
|
||||
def get_readelf_path() -> Path:
|
||||
return _toolchain_tool("readelf")
|
||||
|
||||
|
||||
def run_compile(config: ConfigType, verbose: bool) -> int:
|
||||
from esphome.build_gen import arduino8266 as build_gen
|
||||
|
||||
_warn_ignored_platformio_options()
|
||||
paths = framework.check_and_install(CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION])
|
||||
# Resolved once per build: the resolution probes PATH and spawns the
|
||||
# runnability check, and three consumers need the same answer
|
||||
ccache = resolve_ccache_path()
|
||||
ninja_changed = build_gen.write_project(paths, ccache)
|
||||
|
||||
build_dir = get_build_dir()
|
||||
env = framework.get_build_env(paths.toolchain, ccache)
|
||||
|
||||
# Regenerate the compile DB before the build (a pure function of
|
||||
# build.ninja); skip only when it is at least as fresh as build.ninja
|
||||
# (an interrupted previous run may have rewritten the manifest without
|
||||
# regenerating the DB).
|
||||
compdb = build_dir / "compile_commands.json"
|
||||
compdb_stamp = build_dir / ".compile_commands.stamp"
|
||||
ninja_file = build_dir / "build.ninja"
|
||||
# Freshness rides a stamp: the DB itself is written through
|
||||
# write_file_if_changed (its mtime feeds get_idedata's cache), so a
|
||||
# regeneration with identical content would stay "stale" forever
|
||||
if (
|
||||
ninja_changed
|
||||
or not compdb.is_file()
|
||||
or not compdb_stamp.is_file()
|
||||
or compdb_stamp.stat().st_mtime < ninja_file.stat().st_mtime
|
||||
):
|
||||
_write_compile_commands(paths.ninja, build_dir, env)
|
||||
compdb_stamp.touch()
|
||||
|
||||
cmd = [str(paths.ninja)]
|
||||
if verbose:
|
||||
cmd.append("-v")
|
||||
if jobs := config[CONF_ESPHOME].get(CONF_COMPILE_PROCESS_LIMIT):
|
||||
cmd += ["-j", str(jobs)]
|
||||
# Explicit targets, not the default statement: a generator defect that
|
||||
# drops them fails loudly with "unknown target" instead of a green
|
||||
# no-op run that leaves stale artifacts in place
|
||||
targets = ["firmware.factory.bin", "firmware.ota.bin"]
|
||||
cmd += targets
|
||||
|
||||
# A dry-run probe keeps a no-op rebuild quiet: ninja would only print
|
||||
# "no work to do". A freshly rewritten manifest all but guarantees work,
|
||||
# so skip the probe (and its full stat pass) on that path. cwd instead
|
||||
# of -C also drops the "Entering directory" banner on real builds.
|
||||
skip_build = False
|
||||
if not ninja_changed:
|
||||
probe = subprocess.run(
|
||||
[str(paths.ninja), "-n", *targets],
|
||||
cwd=build_dir,
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
close_fds=False,
|
||||
)
|
||||
if probe.stderr.strip():
|
||||
# A load-time diagnostic (e.g. "multiple rules generate X")
|
||||
# flags a generator bug; the skip branch would otherwise
|
||||
# swallow it forever
|
||||
_LOGGER.warning("ninja: %s", probe.stderr.strip())
|
||||
if probe.returncode != 0:
|
||||
# An unknown target here is the defective-manifest case; fall
|
||||
# through to the real build so the error prints attributably
|
||||
_LOGGER.debug("ninja probe failed; running the full build")
|
||||
skip_build = probe.returncode == 0 and "no work to do" in probe.stdout
|
||||
if skip_build:
|
||||
_LOGGER.debug("ninja: nothing to rebuild")
|
||||
else:
|
||||
_LOGGER.debug("Running: %s", " ".join(cmd))
|
||||
rc = subprocess.run(
|
||||
cmd, cwd=build_dir, env=env, check=False, close_fds=False
|
||||
).returncode
|
||||
if rc != 0:
|
||||
return rc
|
||||
|
||||
# ninja already refused a manifest missing the explicit targets above;
|
||||
# existence covers the remaining hole (a rule that ran but wrote
|
||||
# elsewhere). The factory/ota copies are what upload and OTA consume.
|
||||
build_dir_artifacts = (
|
||||
get_elf_path(),
|
||||
build_dir / "firmware.bin",
|
||||
get_factory_firmware_path(),
|
||||
build_dir / "firmware.ota.bin",
|
||||
)
|
||||
for artifact in build_dir_artifacts:
|
||||
if not artifact.is_file():
|
||||
_LOGGER.error("Build produced no %s", artifact)
|
||||
return 1
|
||||
|
||||
if not _print_size_summary(build_dir, paths):
|
||||
# The cause was already warned; name the consequence so a build
|
||||
# contributing no RAM/Flash metric is visible to CI harnesses
|
||||
_LOGGER.warning("Firmware size summary unavailable for this build")
|
||||
from esphome.build_helpers.idedata import warn_if_idedata_missing
|
||||
|
||||
warn_if_idedata_missing(lambda: get_idedata(ccache))
|
||||
return 0
|
||||
|
||||
|
||||
def _write_compile_commands(
|
||||
ninja_path: Path, build_dir: Path, env: dict[str, str]
|
||||
) -> None:
|
||||
compdb = build_dir / "compile_commands.json"
|
||||
result = subprocess.run(
|
||||
[str(ninja_path), "-C", str(build_dir), "-t", "compdb", "c", "cxx", "asm"],
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
close_fds=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
# Drop any stale database so consumers (IDE integration, clang-tidy,
|
||||
# the memory analyzer) can't silently read outdated data.
|
||||
compdb.unlink(missing_ok=True)
|
||||
raise EsphomeError(f"Could not generate compile_commands.json: {result.stderr}")
|
||||
try:
|
||||
entries = json.loads(result.stdout)
|
||||
except ValueError as err:
|
||||
compdb.unlink(missing_ok=True)
|
||||
raise EsphomeError(
|
||||
f"ninja produced an unparsable compile database: {err} "
|
||||
f"(output starts {result.stdout[:120]!r})"
|
||||
) from err
|
||||
if not entries:
|
||||
# compdb exits 0 with [] for unknown rule names; a renamed compile
|
||||
# rule must fail the build, not silently strand every consumer
|
||||
compdb.unlink(missing_ok=True)
|
||||
raise EsphomeError(
|
||||
"ninja produced an empty compile database; the generator's rule "
|
||||
"names no longer match"
|
||||
)
|
||||
# write_file_if_changed keeps the mtime stable on no-op builds so the
|
||||
# idedata cache in get_idedata() stays valid.
|
||||
write_file_if_changed(compdb, result.stdout)
|
||||
|
||||
|
||||
def _parse_app_size(build_dir: Path, paths: framework.InstalledPaths) -> int | None:
|
||||
"""Read the app flash budget (irom0_0_seg length) from the linker script."""
|
||||
from esphome.build_gen.arduino8266 import get_flash_ld_path
|
||||
from esphome.components.esp8266.build_surgery import segment_length
|
||||
|
||||
# Warnings, not debug: without the app size the Flash summary line is
|
||||
# dropped and CI's memory-impact extraction loses its flash metric.
|
||||
ld_path = get_flash_ld_path(build_dir, paths)
|
||||
try:
|
||||
ld_text = ld_path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError) as err:
|
||||
# UnicodeDecodeError: a truncated/corrupt script must degrade to
|
||||
# the same warning, never abort an already-linked build
|
||||
_LOGGER.warning("Cannot read linker script for the Flash summary: %s", err)
|
||||
return None
|
||||
app_size = segment_length(ld_text, "irom0_0_seg")
|
||||
if app_size is None:
|
||||
_LOGGER.warning("irom0_0_seg not found in %s; skipping Flash summary", ld_path)
|
||||
return None
|
||||
if app_size == 0:
|
||||
_LOGGER.warning(
|
||||
"irom0_0_seg has zero length in %s; skipping Flash summary", ld_path
|
||||
)
|
||||
return None
|
||||
return app_size
|
||||
|
||||
|
||||
def _print_size_summary(build_dir: Path, paths: framework.InstalledPaths) -> bool:
|
||||
"""Print the PlatformIO-shaped RAM/Flash lines; False when skipped.
|
||||
|
||||
The exact shape (including the bar) is parsed by
|
||||
``script/ci_memory_impact_extract.py``; ``print_size_line`` matches it.
|
||||
"""
|
||||
from esphome.build_helpers.size_summary import print_size_line
|
||||
|
||||
size_tool = _toolchain_tool("size")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[str(size_tool), "-A", "-d", str(get_elf_path())],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
close_fds=False,
|
||||
)
|
||||
except OSError as err:
|
||||
# The summary is a bonus artifact like idedata; a truncated
|
||||
# toolchain extraction must not discard an already-linked build
|
||||
_LOGGER.warning("Could not summarize firmware size: %s", err)
|
||||
return False
|
||||
if result.returncode != 0:
|
||||
_LOGGER.warning("Could not summarize firmware size: %s", result.stderr)
|
||||
return False
|
||||
sections: dict[str, int] = {}
|
||||
for line in result.stdout.splitlines():
|
||||
parts = line.split()
|
||||
if len(parts) >= 2 and parts[0].startswith("."):
|
||||
try:
|
||||
sections[parts[0]] = int(parts[1])
|
||||
except ValueError:
|
||||
# An unparsed RAM/Flash section trips the missing-sections
|
||||
# guard below, so no total is built on a dropped value
|
||||
_LOGGER.warning("Unparsable size output for section %s", parts[0])
|
||||
if missing := set(_RAM_SECTIONS + _FLASH_SECTIONS) - set(sections):
|
||||
# A defaulted 0 would print a confidently wrong total for CI's metric
|
||||
_LOGGER.warning(
|
||||
"Size output is missing section(s) %s; skipping the size summary",
|
||||
", ".join(sorted(missing)),
|
||||
)
|
||||
return False
|
||||
# Resolve the flash budget before printing anything: a RAM line without
|
||||
# its Flash line would let CI's memory-impact extraction sum the two
|
||||
# metrics over different build counts (_parse_app_size already warned).
|
||||
app_size = _parse_app_size(build_dir, paths)
|
||||
if not app_size:
|
||||
return False
|
||||
ram = sum(sections[s] for s in _RAM_SECTIONS)
|
||||
flash = sum(sections[s] for s in _FLASH_SECTIONS)
|
||||
print_size_line("RAM", ram, _MAX_RAM_SIZE)
|
||||
print_size_line("Flash", flash, app_size)
|
||||
return True
|
||||
|
||||
|
||||
# Sentinel: "resolve for me"; None is a real value meaning disabled.
|
||||
_CCACHE_UNRESOLVED: Any = object()
|
||||
|
||||
|
||||
def get_idedata(ccache: str | None = _CCACHE_UNRESOLVED) -> dict | None:
|
||||
"""Derive idedata from the build's compile_commands.json.
|
||||
|
||||
Same contract as ``espidf.toolchain.get_idedata``: the fields IDE
|
||||
integrations, clang-tidy, and the memory analyzer expect.
|
||||
"""
|
||||
from esphome.build_helpers.idedata import load_or_build_idedata
|
||||
|
||||
if ccache is _CCACHE_UNRESOLVED:
|
||||
# Deliberately uncached: env/PATH can change between builds in a
|
||||
# long-lived host process
|
||||
ccache = resolve_ccache_path()
|
||||
return load_or_build_idedata(
|
||||
get_build_dir() / "compile_commands.json",
|
||||
get_elf_path(),
|
||||
# Suffixed so a platformio->arduino->platformio round trip on one
|
||||
# config never serves the other toolchain's cache shape
|
||||
CORE.relative_internal_path("idedata", f"{CORE.name}.arduino.json"),
|
||||
# The compile DB's commands carry the same ccache prefix the ninja
|
||||
# rules were generated with
|
||||
launcher=str(ccache) if ccache else None,
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,6 @@ started esphome and must not depend on the package being importable.
|
||||
Subcommands:
|
||||
ar <ar-binary> <archive> <rspfile> remove stale archive, then ``ar rcs``
|
||||
copy <src> <dst> copy a file
|
||||
touch <path> create/update a stamp file
|
||||
|
||||
The ar rspfile carries one object path per line (the generating rule must
|
||||
use ``$in_newline``, never ``$in``).
|
||||
@@ -84,18 +83,9 @@ def _run_copy(src: str, dst: str) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def _run_touch(path: str) -> int:
|
||||
try:
|
||||
Path(path).touch()
|
||||
except OSError as err:
|
||||
print(f"touch: {path} failed: {err}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
# mode -> (handler, expected operand count); surplus argv means a
|
||||
# mis-specified ninja rule and must error, not silently drop operands
|
||||
_MODES = {"ar": (_run_ar, 3), "copy": (_run_copy, 2), "touch": (_run_touch, 1)}
|
||||
_MODES = {"ar": (_run_ar, 3), "copy": (_run_copy, 2)}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
|
||||
@@ -4,14 +4,6 @@ import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from esphome.build_helpers import pch
|
||||
from esphome.build_helpers.pch import (
|
||||
PCH_DEFAULT_HEADERS,
|
||||
PCH_HEADER_NAME,
|
||||
mark_pch_emitted,
|
||||
pch_enabled,
|
||||
pch_header_text,
|
||||
)
|
||||
from esphome.components.esp32 import (
|
||||
get_esp32_variant,
|
||||
get_excluded_builtin_components,
|
||||
@@ -287,47 +279,7 @@ idf_component_register(
|
||||
target_link_options(${{COMPONENT_LIB}} PUBLIC
|
||||
{link_opts_str}
|
||||
)
|
||||
{_pch_cmake()}"""
|
||||
|
||||
|
||||
def _pch_cmake() -> str:
|
||||
"""Consumer block for the component CMakeLists. Baked at generation:
|
||||
a strict-knob flip takes effect on the next esphome compile; a
|
||||
hand-run idf.py keeps the old one."""
|
||||
return pch.pch_cmake_consumer("${COMPONENT_LIB}", "${app_sources}")
|
||||
|
||||
|
||||
def prepare_pch() -> None:
|
||||
"""Build the .gch right before ninja, after every reconfigure, so the
|
||||
compile_commands.json flags and the sdkconfig are the settled ones."""
|
||||
if not pch_enabled():
|
||||
# Self-cleaning escape hatch: drop any previously built .gch
|
||||
pch.discard_pch(CORE.relative_build_path("build"))
|
||||
pch.pch_disabled_degraded()
|
||||
return
|
||||
sdkconfig_path = CORE.relative_build_path(f"sdkconfig.{CORE.name}")
|
||||
try:
|
||||
sdkconfig = sdkconfig_path.read_text(encoding="utf-8")
|
||||
except OSError as err:
|
||||
# Fail closed: the sdkconfig is the .sum's only config identity for
|
||||
# sdkconfig.h-only options; a stand-in marker would collide
|
||||
_LOGGER.warning(
|
||||
"Could not read %s; compiling without the pch: %s", sdkconfig_path, err
|
||||
)
|
||||
pch.discard_pch(CORE.relative_build_path("build"))
|
||||
pch.pch_degraded(f"sdkconfig unreadable: {err}")
|
||||
return
|
||||
pch.prepare_pch(
|
||||
CORE.relative_build_path("build"),
|
||||
PCH_DEFAULT_HEADERS,
|
||||
(
|
||||
str(idf_version()),
|
||||
CORE.cpp_standard or "",
|
||||
sdkconfig,
|
||||
*get_project_compile_flags(),
|
||||
*get_project_cxx_compile_flags(),
|
||||
),
|
||||
)
|
||||
"""
|
||||
|
||||
|
||||
def write_project(
|
||||
@@ -349,14 +301,6 @@ def write_project(
|
||||
get_component_cmakelists(),
|
||||
)
|
||||
|
||||
if pch_enabled():
|
||||
write_file_if_changed(
|
||||
CORE.relative_build_path("build", PCH_HEADER_NAME),
|
||||
pch_header_text(PCH_DEFAULT_HEADERS),
|
||||
)
|
||||
# Consumers carry the -include; gate the ccache relaxation on it
|
||||
mark_pch_emitted()
|
||||
|
||||
# Snapshot the exclusion set so has_outdated_files() can trigger a
|
||||
# discovery reconfigure when it changes. Excluded components never
|
||||
# register in project_description.json, so re-including one (e.g. a
|
||||
|
||||
@@ -21,13 +21,12 @@ def _ccache_runs(ccache: str) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def parse_enable_env(name: str, strict: bool = False) -> bool | None:
|
||||
def parse_enable_env(name: str) -> bool | None:
|
||||
"""Strictly parse an on/off environment knob; None when unset or invalid.
|
||||
|
||||
``bool(str)`` truthiness would flip ``no``/``off`` to enabled, so only
|
||||
1/true/yes/on and 0/false/no/off count; anything else warns and reads
|
||||
as unset so the caller's default policy applies — or raises when
|
||||
``strict`` (a typo must not silently disable a CI gate).
|
||||
as unset so the caller's default policy applies.
|
||||
"""
|
||||
raw = os.environ.get(name)
|
||||
if raw is None:
|
||||
@@ -40,10 +39,6 @@ def parse_enable_env(name: str, strict: bool = False) -> bool | None:
|
||||
return True
|
||||
if lowered in FALSY_ENV_STRINGS:
|
||||
return False
|
||||
if strict:
|
||||
from esphome.core import EsphomeError
|
||||
|
||||
raise EsphomeError(f"Unrecognized {name}={raw!r}; use 1 or 0")
|
||||
_LOGGER.warning("Ignoring unrecognized %s=%r; use 1 or 0", name, raw)
|
||||
return None
|
||||
|
||||
@@ -92,19 +87,6 @@ def ccache_defaults_env(cache_dir: Path) -> dict[str, str]:
|
||||
"CCACHE_DIR": str(cache_dir),
|
||||
"CCACHE_NOHASHDIR": "true",
|
||||
"CCACHE_DEPEND": "1",
|
||||
# A user value wins via the filter below
|
||||
"CCACHE_BASEDIR": effective_ccache_basedir(),
|
||||
"CCACHE_BASEDIR": str(Path(CORE.build_path).resolve()),
|
||||
}
|
||||
return {k: v for k, v in defaults.items() if k not in os.environ}
|
||||
|
||||
|
||||
def effective_ccache_basedir() -> str:
|
||||
"""The prefix ccache rewrites out of hashed paths: a user CCACHE_BASEDIR
|
||||
wins, else the resolved build path (matching ccache_defaults_env)."""
|
||||
from esphome.core import CORE
|
||||
|
||||
raw = os.environ.get("CCACHE_BASEDIR")
|
||||
if raw is not None and Path(raw).is_absolute() and len(Path(raw).parts) > 1:
|
||||
return raw
|
||||
# Unset or degenerate ("", "/", relative): fall back to the build path
|
||||
return str(Path(CORE.build_path).resolve())
|
||||
|
||||
@@ -11,7 +11,6 @@ consumers (IDE integration, clang-tidy) expect:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -22,8 +21,6 @@ import subprocess
|
||||
from esphome.core import EsphomeError
|
||||
from esphome.helpers import write_file
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Everything idedata generation may raise after a successful link; idedata
|
||||
# is a bonus artifact, so consumers warn instead of failing the build
|
||||
IDEDATA_BEST_EFFORT_ERRORS = (
|
||||
@@ -34,36 +31,13 @@ IDEDATA_BEST_EFFORT_ERRORS = (
|
||||
ValueError,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
def warn_if_idedata_missing(get_idedata: Callable[[], dict | None]) -> None:
|
||||
"""Run an idedata generator, downgrading any failure to a warning.
|
||||
|
||||
Shared by the native backends: the firmware already built, so a missing
|
||||
or broken idedata must not fail a successful build.
|
||||
"""
|
||||
try:
|
||||
if get_idedata() is None:
|
||||
_LOGGER.warning("No idedata was generated for this build")
|
||||
except IDEDATA_BEST_EFFORT_ERRORS as err:
|
||||
_LOGGER.warning(
|
||||
"Could not generate idedata: %s (IDE, clang-tidy, and "
|
||||
"memory-analysis data will be unavailable for this build)",
|
||||
err,
|
||||
)
|
||||
if isinstance(err, (EsphomeError, OSError)):
|
||||
# Routine environmental failures keep the detail at debug
|
||||
_LOGGER.debug("Idedata failure detail", exc_info=True)
|
||||
else:
|
||||
# LookupError/ValueError/RuntimeError smell like a parsing bug;
|
||||
# a permanently masked traceback would hide it on every build
|
||||
_LOGGER.warning("Idedata failure detail", exc_info=True)
|
||||
|
||||
|
||||
# C++ translation-unit suffixes.
|
||||
CXX_SOURCE_SUFFIXES = (".cpp", ".cc", ".cxx")
|
||||
# C++ translation-unit suffixes used to identify ESPHome source files.
|
||||
_CXX_SUFFIXES = (".cpp", ".cc")
|
||||
# Suffixes of input/output files that appear bare on the command line (and so
|
||||
# must not be mistaken for compiler flags).
|
||||
_INPUT_FILE_SUFFIXES = (*CXX_SOURCE_SUFFIXES, ".c", ".o", ".S", ".s")
|
||||
_INPUT_FILE_SUFFIXES = (*_CXX_SUFFIXES, ".c", ".o", ".S", ".s")
|
||||
# Path marker identifying an ESPHome source translation unit.
|
||||
_ESPHOME_SRC_MARKER = "/src/esphome/"
|
||||
|
||||
@@ -72,11 +46,11 @@ def _is_esphome_src(file: str) -> bool:
|
||||
"""Whether ``file`` is an ESPHome C++ translation unit; normalized to
|
||||
``/`` first since Windows compile DBs use backslashes."""
|
||||
return _ESPHOME_SRC_MARKER in file.replace("\\", "/") and file.endswith(
|
||||
CXX_SOURCE_SUFFIXES
|
||||
_CXX_SUFFIXES
|
||||
)
|
||||
|
||||
|
||||
def split_command(command: str) -> list[str]:
|
||||
def _split_command(command: str) -> list[str]:
|
||||
r"""Tokenize a compile_commands.json / response-file command string.
|
||||
|
||||
On Windows, tokenize per Windows ``argv`` rules via ``CommandLineToArgvW``.
|
||||
@@ -112,7 +86,7 @@ def split_command(command: str) -> list[str]:
|
||||
ctypes.windll.kernel32.LocalFree(argv)
|
||||
|
||||
|
||||
def expand_response_files(tokens: list[str], directory: Path) -> list[str]:
|
||||
def _expand_response_files(tokens: list[str], directory: Path) -> list[str]:
|
||||
"""Inline any ``@response-file`` arguments (paths relative to ``directory``).
|
||||
|
||||
GCC response files embed flags that must be expanded so GCC-only flags
|
||||
@@ -127,8 +101,8 @@ def expand_response_files(tokens: list[str], directory: Path) -> list[str]:
|
||||
rf = directory / rf
|
||||
try:
|
||||
out.extend(
|
||||
expand_response_files(
|
||||
split_command(rf.read_text(encoding="utf-8")), directory
|
||||
_expand_response_files(
|
||||
_split_command(rf.read_text(encoding="utf-8")), directory
|
||||
)
|
||||
)
|
||||
continue
|
||||
@@ -147,7 +121,7 @@ def _pick_entry(entries: list[dict]) -> dict:
|
||||
if _is_esphome_src(entry["file"]):
|
||||
return entry
|
||||
for entry in entries:
|
||||
if entry["file"].endswith(CXX_SOURCE_SUFFIXES):
|
||||
if entry["file"].endswith(_CXX_SUFFIXES):
|
||||
return entry
|
||||
raise ValueError("no C++ translation unit found in compile_commands.json")
|
||||
|
||||
@@ -157,25 +131,16 @@ def _pick_entry(entries: list[dict]) -> dict:
|
||||
_LAUNCHER_STEMS = frozenset({"ccache", "sccache", "distcc", "icecc", "buildcache"})
|
||||
|
||||
|
||||
def is_launcher(token: str) -> bool:
|
||||
def _is_launcher(token: str) -> bool:
|
||||
return Path(token).stem.lower() in _LAUNCHER_STEMS
|
||||
|
||||
|
||||
def is_joined_include(tok: str) -> bool:
|
||||
"""The joined ``-includefoo.h`` spelling; excludes clang's -include-pch."""
|
||||
return (
|
||||
tok.startswith("-include")
|
||||
and tok != "-include"
|
||||
and not tok.startswith("-include-")
|
||||
)
|
||||
|
||||
|
||||
def parse_entry(
|
||||
entry: dict, launcher: str | None = None
|
||||
) -> tuple[str, list[str], list[str], list[str]]:
|
||||
"""Parse one compile_commands entry -> (cxx_path, defines, includes, cxx_flags)."""
|
||||
directory = Path(entry["directory"])
|
||||
tokens = expand_response_files(split_command(entry["command"]), directory)
|
||||
tokens = _expand_response_files(_split_command(entry["command"]), directory)
|
||||
|
||||
def _include(raw: str) -> str:
|
||||
# Resolve against the entry's ``directory`` so cached idedata works
|
||||
@@ -191,7 +156,7 @@ def parse_entry(
|
||||
if not tokens:
|
||||
# An empty command, or one that was only the launcher; fail by name
|
||||
raise ValueError(f"empty compile command for {entry.get('file')}")
|
||||
if is_launcher(tokens[0]) and len(tokens) > 1 and not tokens[1].startswith("-"):
|
||||
if _is_launcher(tokens[0]) and len(tokens) > 1 and not tokens[1].startswith("-"):
|
||||
# Stale DB built with a launcher this run no longer configures; the
|
||||
# real compiler is the next token
|
||||
_LOGGER.warning("Stripping unconfigured launcher %s", tokens[0])
|
||||
@@ -204,23 +169,11 @@ def parse_entry(
|
||||
defines: list[str] = []
|
||||
includes: list[str] = []
|
||||
cxx_flags: list[str] = []
|
||||
unresolved_force_includes: list[str] = []
|
||||
|
||||
it = iter(tokens[1:])
|
||||
for tok in it:
|
||||
if tok in ("-c", "-o"):
|
||||
next(it, None) # drop the flag and its argument (input/output)
|
||||
elif tok == "-include" or is_joined_include(tok):
|
||||
# Re-anchor only names next to the compile (the pch); a name
|
||||
# meant for the -I chain must stay untouched
|
||||
raw = next(it, "") if tok == "-include" else tok[len("-include") :]
|
||||
if not raw:
|
||||
_LOGGER.warning("Dropping -include with no argument")
|
||||
elif Path(resolved := _include(raw)).is_file():
|
||||
cxx_flags.extend(("-include", resolved))
|
||||
else:
|
||||
unresolved_force_includes.append(raw)
|
||||
cxx_flags.extend(("-include", raw))
|
||||
elif tok.startswith("-D"):
|
||||
# ``.strip()`` handles tokens like ``-D CONFIGURED=1`` (a single
|
||||
# quoted arg with a space after -D) that some flags arrive as.
|
||||
@@ -239,14 +192,6 @@ def parse_entry(
|
||||
pass # input/output files
|
||||
else:
|
||||
cxx_flags.append(tok)
|
||||
for raw in unresolved_force_includes:
|
||||
# A deleted build artifact would otherwise surface only downstream
|
||||
if not any((Path(inc) / raw).is_file() for inc in includes):
|
||||
_LOGGER.warning(
|
||||
"-include %s found neither next to the compile nor on the "
|
||||
"include path; cached idedata may not resolve it",
|
||||
raw,
|
||||
)
|
||||
return cxx_path, defines, includes, cxx_flags
|
||||
|
||||
|
||||
@@ -311,7 +256,7 @@ def _cache_usable(cached: object) -> bool:
|
||||
if not isinstance(cached, dict) or "cc_path" not in cached:
|
||||
return False
|
||||
cxx_path = cached.get("cxx_path")
|
||||
if not isinstance(cxx_path, str) or is_launcher(cxx_path):
|
||||
if not isinstance(cxx_path, str) or _is_launcher(cxx_path):
|
||||
return False
|
||||
includes = cached.get("includes")
|
||||
return isinstance(includes, dict) and isinstance(includes.get("build"), list)
|
||||
@@ -359,7 +304,7 @@ def load_or_build_idedata(
|
||||
def reject_launcher_compiler(cxx_path: str) -> None:
|
||||
"""Reject a compile DB naming a launcher (ccache) as the compiler; it
|
||||
must never be probed, cached, or consumed."""
|
||||
if is_launcher(cxx_path):
|
||||
if _is_launcher(cxx_path):
|
||||
raise EsphomeError(
|
||||
f"compile_commands.json names the launcher {cxx_path} as the "
|
||||
"compiler; the compile database is unusable"
|
||||
|
||||
@@ -1,603 +0,0 @@
|
||||
"""Shared precompiled-header policy for the build backends.
|
||||
|
||||
The prefix either mirrors the TUs' own force-includes (ESP8266) or is a
|
||||
curated core-header set (ESP-IDF). ``esphome: includes:`` sources receive
|
||||
it too; Arduino.h visibility there is intended (esphome#8693).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Iterable
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import posixpath
|
||||
import re
|
||||
import stat
|
||||
import subprocess
|
||||
|
||||
from esphome.build_helpers.ccache import effective_ccache_basedir, parse_enable_env
|
||||
from esphome.build_helpers.idedata import (
|
||||
CXX_SOURCE_SUFFIXES,
|
||||
expand_response_files,
|
||||
is_launcher,
|
||||
split_command,
|
||||
)
|
||||
|
||||
_DOMAIN = "pch"
|
||||
|
||||
|
||||
@dataclass
|
||||
class _PCHData:
|
||||
emitted: bool = False
|
||||
|
||||
|
||||
def _pch_data() -> _PCHData:
|
||||
from esphome.core import CORE
|
||||
|
||||
if _DOMAIN not in CORE.data:
|
||||
CORE.data[_DOMAIN] = _PCHData()
|
||||
return CORE.data[_DOMAIN]
|
||||
|
||||
|
||||
def mark_pch_emitted() -> None:
|
||||
"""Record that this build's consumers reference the pch."""
|
||||
_pch_data().emitted = True
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# The header and its .gch/.sum sidecars live in the build directory.
|
||||
PCH_HEADER_NAME = "esphome_pch.h"
|
||||
|
||||
# Every artifact the pch machinery can leave behind, for cleanup.
|
||||
PCH_ARTIFACT_NAMES = (
|
||||
PCH_HEADER_NAME,
|
||||
f"{PCH_HEADER_NAME}.gch",
|
||||
f"{PCH_HEADER_NAME}.gch.sum",
|
||||
f"{PCH_HEADER_NAME}.gch.failed",
|
||||
)
|
||||
|
||||
# The core defines header every backend anchors its prefix on.
|
||||
PCH_CORE_HEADER = "esphome/core/defines.h"
|
||||
|
||||
# Guarded curated-prefix wrapper for PlatformIO backends without framework
|
||||
# force-includes (host, esp32); folded by the pch script via build_src_flags.
|
||||
PCH_PREFIX_HEADER = "esphome/core/pch_prefix.h"
|
||||
|
||||
# Prefix-header contents for backends that inject a curated set (rather
|
||||
# than mirroring the TUs' own force-includes), defines.h first so USE_*
|
||||
# macros exist for the rest. Deliberately hard-coded: frequency-derived
|
||||
# sets measured no better and kept selecting headers that cannot compile
|
||||
# standalone (X-macro, platform-variant). Every entry must be safe to
|
||||
# include first in an empty TU. Caveat: application.h/automation.h become
|
||||
# ambiently visible, so a TU missing those #includes still builds on such
|
||||
# backends; ESPHOME_PCH_ENABLE=0 restores the strict view.
|
||||
PCH_DEFAULT_HEADERS = (
|
||||
PCH_CORE_HEADER,
|
||||
"esphome/core/component.h",
|
||||
"esphome/core/helpers.h",
|
||||
"esphome/core/log.h",
|
||||
"esphome/core/application.h",
|
||||
"esphome/core/automation.h",
|
||||
)
|
||||
|
||||
# ccache cannot hash through a .gch; CCACHE_PCH_EXTSUM makes it hash the
|
||||
# .sum sidecar instead of the .gch bytes, which are not reproducible.
|
||||
# Keep in sync with the literals in platformio/pch.py.script.
|
||||
_CCACHE_PCH_ENV = {
|
||||
"CCACHE_SLOPPINESS": "pch_defines,time_macros",
|
||||
"CCACHE_PCH_EXTSUM": "true",
|
||||
}
|
||||
|
||||
# Both include forms: an angle include resolving under src/ must enter the
|
||||
# digest too; ones that do not resolve simply end the walk
|
||||
# Compiler failures that clear on their own must not latch the .failed marker
|
||||
_TRANSIENT_ERRORS = ("No space left", "Cannot allocate", "Resource temporarily")
|
||||
|
||||
_INCLUDE_RE = re.compile(rb'^\s*#\s*include\s+["<]([^">]+)[">]', re.MULTILINE)
|
||||
|
||||
|
||||
def pch_enabled() -> bool:
|
||||
"""Precompiled-header knob: default on, ``ESPHOME_PCH_ENABLE=0`` opts out."""
|
||||
return parse_enable_env("ESPHOME_PCH_ENABLE") is not False
|
||||
|
||||
|
||||
def pch_strict() -> bool:
|
||||
"""CI knob: ``ESPHOME_PCH_STRICT=1`` turns pch degrade paths fatal.
|
||||
|
||||
A set-but-unrecognized value raises: a typo must not silently turn
|
||||
the gate into a no-op that proves nothing.
|
||||
"""
|
||||
return parse_enable_env("ESPHOME_PCH_STRICT", strict=True) is True
|
||||
|
||||
|
||||
def pch_degraded(reason: str) -> None:
|
||||
"""Every degrade path funnels through here; strict mode raises."""
|
||||
if pch_strict():
|
||||
from esphome.core import EsphomeError
|
||||
|
||||
raise EsphomeError(f"ESPHOME_PCH_STRICT: {reason}")
|
||||
|
||||
|
||||
def pch_disabled_degraded() -> None:
|
||||
"""Strict CI must not read "no pch at all" as success."""
|
||||
pch_degraded("pch disabled by ESPHOME_PCH_ENABLE")
|
||||
|
||||
|
||||
def pch_probe_tail(source: str = "-") -> list[str]:
|
||||
"""The syntax-only compile shared by the probe and its baseline."""
|
||||
return ["-fsyntax-only", "-x", "c++", source]
|
||||
|
||||
|
||||
def pch_probe_args(header: str, source: str = "-") -> list[str]:
|
||||
"""Flags that load-check a built .gch via a syntax-only compile.
|
||||
|
||||
Rejection must be a nonzero exit (never just a wording match), so the
|
||||
invalid-pch class is always escalated. ``source`` defaults to stdin
|
||||
(host independent); the ninja probe edge passes a real file.
|
||||
"""
|
||||
return [
|
||||
"-Winvalid-pch",
|
||||
"-Werror=invalid-pch",
|
||||
"-include",
|
||||
header,
|
||||
*pch_probe_tail(source),
|
||||
]
|
||||
|
||||
|
||||
def pch_consumer_escalation() -> str:
|
||||
"""Consumer-side invalid-pch flag: strict reds the build on rejection
|
||||
(per-process, so the probe alone cannot prove the consumers)."""
|
||||
return "-Werror=invalid-pch" if pch_strict() else "-Wno-error=invalid-pch"
|
||||
|
||||
|
||||
def pch_cmake_consumer(target: str, sources_var: str) -> str:
|
||||
"""Emit the CMake block making ``target``'s C++ sources consume the
|
||||
pch; empty when disabled. OBJECT_DEPENDS is on the header, not the
|
||||
.gch (pch-baked headers drop out of TU depfiles); the -include stays
|
||||
relative — an absolute path would poison ccache keys."""
|
||||
if not pch_enabled():
|
||||
return ""
|
||||
escalation = pch_consumer_escalation()
|
||||
return f"""
|
||||
# ESPHome precompiled header (see esphome/build_helpers/pch.py).
|
||||
# The touch keeps OBJECT_DEPENDS satisfiable when the build system itself
|
||||
# wiped the build dir after the header was written (west --pristine)
|
||||
if(NOT EXISTS "${{CMAKE_BINARY_DIR}}/{PCH_HEADER_NAME}")
|
||||
file(TOUCH "${{CMAKE_BINARY_DIR}}/{PCH_HEADER_NAME}")
|
||||
endif()
|
||||
target_compile_options({target} PRIVATE
|
||||
"$<$<COMPILE_LANGUAGE:CXX>:-Winvalid-pch>"
|
||||
"$<$<COMPILE_LANGUAGE:CXX>:{escalation}>"
|
||||
"$<$<COMPILE_LANGUAGE:CXX>:-include>"
|
||||
"$<$<COMPILE_LANGUAGE:CXX>:{PCH_HEADER_NAME}>"
|
||||
)
|
||||
set_source_files_properties({sources_var} PROPERTIES
|
||||
OBJECT_DEPENDS "${{CMAKE_BINARY_DIR}}/{PCH_HEADER_NAME}")
|
||||
"""
|
||||
|
||||
|
||||
def ccache_pch_env() -> dict[str, str]:
|
||||
"""Settings ccache needs to cache compiles that consume the .gch;
|
||||
empty unless this build actually emitted one. User-set values win.
|
||||
Native backends export these process-wide; only time_macros affects
|
||||
non-pch TUs."""
|
||||
if not (pch_enabled() and _pch_data().emitted):
|
||||
return {}
|
||||
extsum = os.environ.get("CCACHE_PCH_EXTSUM")
|
||||
if extsum is not None and extsum.strip().lower() not in ("1", "true", "yes", "on"):
|
||||
# ccache then hashes the non-reproducible .gch bytes: permanent misses
|
||||
_LOGGER.warning("CCACHE_PCH_EXTSUM=%s disables pch caching", extsum)
|
||||
env = {k: v for k, v in _CCACHE_PCH_ENV.items() if k not in os.environ}
|
||||
user_sloppiness = os.environ.get("CCACHE_SLOPPINESS")
|
||||
if user_sloppiness is not None and (
|
||||
missing := [
|
||||
t
|
||||
for t in ("pch_defines", "time_macros")
|
||||
if t not in {tok.strip() for tok in user_sloppiness.split(",")}
|
||||
]
|
||||
):
|
||||
# Without these ccache declines every pch-consuming compile
|
||||
env["CCACHE_SLOPPINESS"] = ",".join((user_sloppiness, *missing))
|
||||
_LOGGER.warning(
|
||||
"Adding %s to CCACHE_SLOPPINESS so ccache can cache compiles "
|
||||
"that use the precompiled header",
|
||||
",".join(missing),
|
||||
)
|
||||
return env
|
||||
|
||||
|
||||
def guarded_prepare(build_dir: Path, prepare: Callable[[], None]) -> None:
|
||||
"""Run a backend's pch preparation; an optional speedup must never
|
||||
abort the build. Strict is read first so its own knob error cannot
|
||||
mask the real failure; discard_pch raises if a stale .gch survives;
|
||||
the header is ensured so OBJECT_DEPENDS stays satisfiable."""
|
||||
try:
|
||||
prepare()
|
||||
except Exception: # noqa: BLE001 # pylint: disable=broad-exception-caught
|
||||
strict = pch_strict()
|
||||
discard_pch(build_dir)
|
||||
if strict:
|
||||
raise
|
||||
header = build_dir / PCH_HEADER_NAME
|
||||
if not header.exists():
|
||||
try:
|
||||
header.touch()
|
||||
except OSError as err:
|
||||
# The coming OBJECT_DEPENDS error would hide the real cause
|
||||
_LOGGER.warning("Could not create the pch placeholder: %s", err)
|
||||
_LOGGER.warning(
|
||||
"Precompiled header setup failed; compiling without it", exc_info=True
|
||||
)
|
||||
|
||||
|
||||
def pch_extra_scripts() -> list[str]:
|
||||
"""The extra_scripts entries a PlatformIO platform registers for the
|
||||
pch; empty when disabled (the script itself has no enable check)."""
|
||||
if not pch_enabled():
|
||||
pch_disabled_degraded()
|
||||
return []
|
||||
return ["post:pch.py"]
|
||||
|
||||
|
||||
def pch_header_text(include_headers: Iterable[str]) -> str:
|
||||
"""The prefix-header source: exactly these includes, in order."""
|
||||
return "".join(f'#include "{name}"\n' for name in include_headers)
|
||||
|
||||
|
||||
def _resolves(path: Path) -> bool:
|
||||
"""False when missing; other stat failures propagate (identity unknown,
|
||||
unlike is_file(), which would silently drop the header)."""
|
||||
try:
|
||||
return stat.S_ISREG(path.stat().st_mode)
|
||||
except (FileNotFoundError, NotADirectoryError):
|
||||
return False
|
||||
|
||||
|
||||
def _include_closure(src_dir: Path, roots: Iterable[str]) -> dict[str, bytes]:
|
||||
"""Include closure of ``roots``: src-relative name -> contents.
|
||||
|
||||
Resolution mirrors the compiler (includer's dir, then src root); names
|
||||
outside ``src_dir`` end the walk and are versioned by the caller. No
|
||||
#ifdef evaluation: over-approximating is the safe direction.
|
||||
"""
|
||||
seen: dict[str, bytes] = {}
|
||||
stack: list[tuple[str, str]] = [(name, "") for name in roots]
|
||||
while stack:
|
||||
name, from_dir = stack.pop()
|
||||
for candidate in (f"{from_dir}/{name}" if from_dir else name, name):
|
||||
rel = posixpath.normpath(candidate)
|
||||
if not rel.startswith("..") and _resolves(src_dir / rel):
|
||||
break
|
||||
else:
|
||||
continue
|
||||
if rel in seen:
|
||||
continue
|
||||
try:
|
||||
data = (src_dir / rel).read_bytes()
|
||||
except OSError as err:
|
||||
# A marker would truncate the transitive walk; fail closed
|
||||
_LOGGER.warning("Could not read %s for the pch checksum: %s", rel, err)
|
||||
raise
|
||||
seen[rel] = data
|
||||
parent = posixpath.dirname(rel)
|
||||
stack.extend(
|
||||
# surrogateescape: a non-UTF-8 name just fails to resolve
|
||||
(inc.decode(errors="surrogateescape"), parent)
|
||||
for inc in _INCLUDE_RE.findall(data)
|
||||
)
|
||||
return seen
|
||||
|
||||
|
||||
def pch_checksum(
|
||||
src_dir: Path, include_headers: Iterable[str], extra: Iterable[str]
|
||||
) -> str:
|
||||
"""Digest standing in for the .gch in ccache's hash: the include closure
|
||||
of the prefix header plus caller-supplied identity strings (versioned
|
||||
install paths, flags). Raises OSError when a header's identity cannot
|
||||
be established at all; callers must then compile without a pch."""
|
||||
digest = hashlib.sha256()
|
||||
closure = _include_closure(src_dir, include_headers)
|
||||
for name in sorted(closure):
|
||||
digest.update(name.encode(errors="surrogateescape"))
|
||||
digest.update(closure[name])
|
||||
digest.update(b"\0")
|
||||
for item in extra:
|
||||
digest.update(item.encode(errors="surrogateescape"))
|
||||
digest.update(b"\0")
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
# Tokens dropped when retargeting a TU's flags at the prefix header
|
||||
# (the pch compile must not touch depfiles)
|
||||
_PCH_STRIP_FLAGS_WITH_ARG = frozenset({"-o", "-c", "-MT", "-MF", "-MQ"})
|
||||
_PCH_STRIP_FLAGS = frozenset({"-MD", "-MMD", "-MP", "-MM", "-M"})
|
||||
|
||||
|
||||
def pch_compile_command(
|
||||
build_dir: Path, header: Path, gch: Path
|
||||
) -> tuple[list[str], Path] | None:
|
||||
"""The exact src C++ flags from compile_commands.json retargeted at the
|
||||
header, with the directory they resolve against (relative -I paths must
|
||||
be expanded and executed from the same root); None (logged) when no
|
||||
configured C++ TU is available yet."""
|
||||
from esphome.core import CORE
|
||||
|
||||
try:
|
||||
entries = json.loads(
|
||||
(build_dir / "compile_commands.json").read_text(encoding="utf-8")
|
||||
)
|
||||
except (OSError, json.JSONDecodeError) as err:
|
||||
# Configure already succeeded, so an unusable DB is a real anomaly
|
||||
_LOGGER.warning("No usable compile database, skipping pch: %s", err)
|
||||
return None
|
||||
if not isinstance(entries, list):
|
||||
_LOGGER.warning("Malformed compile database, skipping pch")
|
||||
return None
|
||||
# CMake may spell paths through a symlink differently than CORE does
|
||||
# (macOS /tmp vs /private/tmp), so compare resolved paths
|
||||
src_root = Path(CORE.relative_src_path()).resolve()
|
||||
entry = next(
|
||||
(
|
||||
e
|
||||
for e in entries
|
||||
if isinstance(e, dict)
|
||||
and isinstance(e.get("file"), str)
|
||||
and e["file"].endswith(CXX_SOURCE_SUFFIXES)
|
||||
and Path(e["file"]).resolve().is_relative_to(src_root)
|
||||
),
|
||||
None,
|
||||
)
|
||||
if entry is None:
|
||||
_LOGGER.warning("No src C++ entry in the compile database, skipping pch")
|
||||
return None
|
||||
directory = entry.get("directory")
|
||||
cmd_dir = Path(directory) if isinstance(directory, str) and directory else build_dir
|
||||
command = entry.get("command")
|
||||
tokens = expand_response_files(
|
||||
split_command(command if isinstance(command, str) else ""), cmd_dir
|
||||
)
|
||||
# A DB recorded with ccache enabled prefixes the compiler with the
|
||||
# launcher; the .gch must be compiled directly
|
||||
if tokens and is_launcher(tokens[0]):
|
||||
tokens = tokens[1:]
|
||||
if not tokens:
|
||||
# "arguments"-style or empty entries must skip, not spawn "-x ..."
|
||||
_LOGGER.warning("Compile database entry has no usable command, skipping pch")
|
||||
return None
|
||||
args: list[str] = []
|
||||
arg_it = iter(tokens)
|
||||
for tok in arg_it:
|
||||
if tok in _PCH_STRIP_FLAGS_WITH_ARG:
|
||||
next(arg_it, None)
|
||||
continue
|
||||
if tok in _PCH_STRIP_FLAGS:
|
||||
continue
|
||||
if tok == "-include":
|
||||
# Drop only the injected prefix; user force-includes must reach
|
||||
# the .gch compile or GCC rejects it over the macro mismatch
|
||||
inc = next(arg_it, "")
|
||||
if not inc.endswith(PCH_HEADER_NAME):
|
||||
args.extend(("-include", inc))
|
||||
continue
|
||||
args.append(tok)
|
||||
return [*args, "-x", "c++-header", "-c", str(header), "-o", str(gch)], cmd_dir
|
||||
|
||||
|
||||
def _log_pch_in_use() -> None:
|
||||
# The only place a user can discover the knob; emitted only once a
|
||||
# .gch is actually fresh or being built
|
||||
_LOGGER.info(
|
||||
"Compiling with a precompiled header (set ESPHOME_PCH_ENABLE=0 to disable)"
|
||||
)
|
||||
|
||||
|
||||
def _read_stamp(path: Path) -> str:
|
||||
"""A corrupt sidecar must read as stale, not kill the pch forever."""
|
||||
try:
|
||||
return path.read_text(encoding="utf-8").strip()
|
||||
except (OSError, UnicodeDecodeError):
|
||||
return ""
|
||||
|
||||
|
||||
def discard_pch(build_dir: Path) -> None:
|
||||
"""Remove the pch sidecars so a stale .gch is never consumed.
|
||||
|
||||
Bumps the header only when a .gch was actually removed: TUs compiled
|
||||
against it have incomplete depfiles, while a repeat failure with no
|
||||
.gch must not force a full rebuild every build. A .gch that survives
|
||||
an unlink failure would be consumed silently (wrong output, not a
|
||||
slow build), so that raises.
|
||||
"""
|
||||
header = build_dir / PCH_HEADER_NAME
|
||||
gch = Path(f"{header}.gch")
|
||||
had_gch = gch.is_file()
|
||||
errors = []
|
||||
for sidecar in (gch, Path(f"{gch}.sum")):
|
||||
try:
|
||||
sidecar.unlink(missing_ok=True)
|
||||
except OSError as err:
|
||||
if sidecar.is_file():
|
||||
from esphome.core import EsphomeError
|
||||
|
||||
raise EsphomeError(
|
||||
f"Could not discard the stale precompiled header: {err}"
|
||||
) from err
|
||||
errors.append(err)
|
||||
for err in errors:
|
||||
_LOGGER.warning("Could not discard the pch sidecars: %s", err)
|
||||
if had_gch and header.is_file():
|
||||
with suppress(OSError):
|
||||
os.utime(header)
|
||||
|
||||
|
||||
def prepare_pch(
|
||||
build_dir: Path, include_headers: tuple[str, ...], extra: Iterable[str]
|
||||
) -> None:
|
||||
"""Compile ``build_dir``'s .gch from compile_commands.json flags and
|
||||
write its ccache .sum.
|
||||
|
||||
The .sum doubles as the freshness stamp and folds in the compile
|
||||
command, so a flag-only change rebuilds the .gch; ``extra`` carries
|
||||
backend identity (framework version, sdkconfig, ...). A failed
|
||||
compile falls back to the plain header include.
|
||||
"""
|
||||
from esphome.core import CORE
|
||||
|
||||
header = build_dir / PCH_HEADER_NAME
|
||||
gch = Path(f"{header}.gch")
|
||||
sum_path = Path(f"{gch}.sum")
|
||||
cmd_and_dir = pch_compile_command(build_dir, header, gch)
|
||||
if cmd_and_dir is None:
|
||||
# Freshness cannot be validated; a leftover .gch must not be consumed
|
||||
discard_pch(build_dir)
|
||||
pch_degraded("no usable compile command")
|
||||
return
|
||||
cmd, cmd_dir = cmd_and_dir
|
||||
# Strip like ccache's rewriting (user CCACHE_BASEDIR wins); the raw
|
||||
# build path covers unresolved (symlinked) spellings
|
||||
cmd_id = (
|
||||
" ".join(cmd)
|
||||
.replace(effective_ccache_basedir(), "")
|
||||
.replace(str(CORE.build_path), "")
|
||||
)
|
||||
try:
|
||||
checksum = pch_checksum(
|
||||
CORE.relative_src_path(),
|
||||
include_headers,
|
||||
(
|
||||
# The closure is sorted, so root order only enters via the text
|
||||
pch_header_text(include_headers),
|
||||
*extra,
|
||||
cmd_id,
|
||||
),
|
||||
)
|
||||
except (OSError, UnicodeError) as err:
|
||||
# Identity unknown: a stale cache entry must never be served
|
||||
_LOGGER.warning(
|
||||
"Could not establish the pch identity; compiling without it: %s", err
|
||||
)
|
||||
discard_pch(build_dir)
|
||||
pch_degraded(f"identity unknown: {err}")
|
||||
return
|
||||
failed_marker = Path(f"{gch}.failed")
|
||||
|
||||
def _run(
|
||||
run_cmd: list[str], what: str, stdin: str | None = None
|
||||
) -> subprocess.CompletedProcess | None:
|
||||
"""Spawn one pch tool step; environmental failures discard and
|
||||
degrade (None): spawn/IO/timeout errors and signal kills never
|
||||
latch the marker."""
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
run_cmd,
|
||||
cwd=cmd_dir,
|
||||
# C locale keeps diagnostics matchable by _TRANSIENT_ERRORS
|
||||
env={**os.environ, "LC_ALL": "C"},
|
||||
input=stdin,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
timeout=300,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError) as err:
|
||||
_LOGGER.warning("Precompiled header %s did not run: %s", what, err)
|
||||
discard_pch(build_dir)
|
||||
pch_degraded(f"{what} did not run: {err}")
|
||||
return None
|
||||
if proc.returncode < 0:
|
||||
# Killed by a signal (OOM, ^C): environmental, do not latch
|
||||
_LOGGER.warning(
|
||||
"Precompiled header %s was killed (signal %d); retrying next build",
|
||||
what,
|
||||
-proc.returncode,
|
||||
)
|
||||
discard_pch(build_dir)
|
||||
pch_degraded(f"{what} killed by signal {-proc.returncode}")
|
||||
return None
|
||||
return proc
|
||||
|
||||
def _fail(error: str, reason: str, latch: bool) -> None:
|
||||
"""Discard and degrade; deterministic failures latch when asked."""
|
||||
_LOGGER.warning(
|
||||
"Precompiled header failed; compiling without it: %s", error[:400]
|
||||
)
|
||||
# Latching paths keep the full compiler output recoverable
|
||||
_LOGGER.debug("Full pch output: %s", error)
|
||||
discard_pch(build_dir)
|
||||
if latch and not any(m in error for m in _TRANSIENT_ERRORS):
|
||||
# Skip retries until a header/flag/backend-identity/command change
|
||||
failed_marker.write_text(checksum + "\n", encoding="utf-8")
|
||||
os.utime(header)
|
||||
pch_degraded(f"{reason}: {error[:200]}")
|
||||
|
||||
def _probe(latch: bool = True) -> None:
|
||||
"""Load-check the built .gch: some toolchains build one they then
|
||||
refuse to load (per-process ASLR). Dep flags are already stripped
|
||||
from cmd, so no -MF is needed; cmd ends with the fixed
|
||||
"-x c++-header -c -o" tail. A cached-header rejection may not
|
||||
reproduce (per-process), so that caller passes latch=False."""
|
||||
if cmd[-6:-4] != ["-x", "c++-header"]:
|
||||
# The slice below depends on pch_compile_command's fixed tail
|
||||
_LOGGER.warning("Unexpected pch command shape: %s", cmd[-6:])
|
||||
discard_pch(build_dir)
|
||||
pch_degraded("unexpected pch command shape")
|
||||
return
|
||||
base = cmd[:-6]
|
||||
probe = _run([*base, *pch_probe_args(str(header))], "probe", stdin="")
|
||||
if probe is None:
|
||||
return
|
||||
if probe.returncode != 0:
|
||||
# Disambiguate: only blame the pch when the same compile passes
|
||||
# without it; a failing baseline is its own (latchable) problem
|
||||
baseline = _run([*base, *pch_probe_tail()], "probe baseline", stdin="")
|
||||
if baseline is None:
|
||||
return
|
||||
if baseline.returncode == 0:
|
||||
error = probe.stderr.strip() or f"exit code {probe.returncode}"
|
||||
_fail(error, "toolchain cannot load the pch", latch=latch)
|
||||
else:
|
||||
error = baseline.stderr.strip() or f"exit code {baseline.returncode}"
|
||||
_fail(error, "probe cannot run at all", latch=latch)
|
||||
|
||||
if gch.is_file() and _read_stamp(sum_path) == checksum:
|
||||
_log_pch_in_use()
|
||||
if pch_strict():
|
||||
# Rejection is per-process, so a cached .gch must re-prove
|
||||
# loadability for the strict gate (CI-only cost); no latch,
|
||||
# since the rejection may not reproduce either
|
||||
_probe(latch=False)
|
||||
return
|
||||
if _read_stamp(failed_marker) == checksum:
|
||||
_LOGGER.info(
|
||||
"Precompiled header disabled after an earlier failure; delete %s to retry",
|
||||
failed_marker,
|
||||
)
|
||||
pch_degraded("earlier failure latched")
|
||||
return
|
||||
_log_pch_in_use()
|
||||
result = _run(cmd, "compile")
|
||||
if result is None:
|
||||
return
|
||||
error = None
|
||||
if result.returncode != 0:
|
||||
error = result.stderr.strip() or f"exit code {result.returncode}"
|
||||
elif not gch.is_file():
|
||||
error = "compiler produced no .gch"
|
||||
if error is not None:
|
||||
_fail(error, "compile failed", latch=True)
|
||||
return
|
||||
_probe()
|
||||
if not gch.is_file():
|
||||
# The probe discarded a rejected or unrunnable .gch
|
||||
return
|
||||
failed_marker.unlink(missing_ok=True)
|
||||
sum_path.write_text(checksum + "\n", encoding="utf-8")
|
||||
# Consumers depend on the header (depfiles cannot see through a .gch);
|
||||
# bump it so users of the previous .gch recompile
|
||||
os.utime(header)
|
||||
@@ -58,6 +58,9 @@ esp_err_t AudioReader::add_sink(const std::weak_ptr<ring_buffer::RingBuffer> &ou
|
||||
if (current_audio_file_ != nullptr) {
|
||||
// A transfer buffer isn't ncessary for a local file
|
||||
this->file_ring_buffer_ = output_ring_buffer.lock();
|
||||
if (this->file_ring_buffer_ == nullptr) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
|
||||
@@ -51,14 +51,14 @@ void AudioTransferBuffer::increase_buffer_length(size_t bytes) { this->buffer_le
|
||||
|
||||
void AudioTransferBuffer::clear_buffered_data() {
|
||||
this->buffer_length_ = 0;
|
||||
if (this->ring_buffer_.use_count() > 0) {
|
||||
if (this->ring_buffer_ != nullptr) {
|
||||
this->ring_buffer_->reset();
|
||||
}
|
||||
}
|
||||
|
||||
void AudioSinkTransferBuffer::clear_buffered_data() {
|
||||
this->buffer_length_ = 0;
|
||||
if (this->ring_buffer_.use_count() > 0) {
|
||||
if (this->ring_buffer_ != nullptr) {
|
||||
this->ring_buffer_->reset();
|
||||
}
|
||||
#ifdef USE_SPEAKER
|
||||
@@ -69,7 +69,7 @@ void AudioSinkTransferBuffer::clear_buffered_data() {
|
||||
}
|
||||
|
||||
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->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_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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
} else
|
||||
#endif
|
||||
if (this->ring_buffer_.use_count() > 0) {
|
||||
if (this->ring_buffer_ != nullptr) {
|
||||
bytes_written =
|
||||
this->ring_buffer_->write_without_replacement((void *) this->data_start_, this->available(), ticks_to_wait);
|
||||
} 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));
|
||||
}
|
||||
#endif
|
||||
if (this->ring_buffer_.use_count() > 0) {
|
||||
if (this->ring_buffer_ != nullptr) {
|
||||
return ((this->ring_buffer_->available() > 0) || (this->available() > 0));
|
||||
}
|
||||
return (this->available() > 0);
|
||||
|
||||
@@ -10,7 +10,6 @@ import subprocess
|
||||
from typing import Any
|
||||
|
||||
from esphome import yaml_util
|
||||
from esphome.build_helpers.pch import PCH_PREFIX_HEADER, pch_enabled, pch_extra_scripts
|
||||
import esphome.codegen as cg
|
||||
from esphome.components.const import CONF_ENABLE_OTA_DOWNGRADE_PROTECTION
|
||||
from esphome.config_helpers import filter_source_files_from_defines
|
||||
@@ -58,7 +57,6 @@ from esphome.coroutine import CoroPriority, coroutine_with_priority
|
||||
from esphome.espidf.component import generate_idf_components
|
||||
import esphome.final_validate as fv
|
||||
from esphome.helpers import copy_file_if_changed, rmtree, write_file_if_changed
|
||||
from esphome.platformio.toolchain import copy_pch_script
|
||||
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
|
||||
from esphome.types import ConfigType
|
||||
from esphome.writer import clean_build, clean_cmake_cache
|
||||
@@ -2457,11 +2455,6 @@ async def to_code(config):
|
||||
|
||||
cg.add_platformio_option("lib_ldf_mode", "off")
|
||||
cg.add_platformio_option("lib_compat_mode", "strict")
|
||||
# CI-speed only: this toolchain is being dropped, so the pch gets
|
||||
# the same curated prefix as host with no further investment
|
||||
cg.add_platformio_option("extra_scripts", pch_extra_scripts())
|
||||
if pch_enabled():
|
||||
cg.add_platformio_option("build_src_flags", f"-include {PCH_PREFIX_HEADER}")
|
||||
cg.add_platformio_option("platform", conf[CONF_PLATFORM_VERSION])
|
||||
cg.add_platformio_option("board", config[CONF_BOARD])
|
||||
cg.add_platformio_option("board_upload.flash_size", config[CONF_FLASH_SIZE])
|
||||
@@ -3379,8 +3372,6 @@ def _write_idf_component_yml():
|
||||
def copy_files():
|
||||
_write_sdkconfig()
|
||||
_write_idf_component_yml()
|
||||
if not CORE.using_toolchain_esp_idf:
|
||||
copy_pch_script()
|
||||
|
||||
if "partitions.csv" not in CORE.data[KEY_ESP32][KEY_EXTRA_BUILD_FILES]:
|
||||
flash_size = CORE.data[KEY_ESP32][KEY_FLASH_SIZE]
|
||||
|
||||
@@ -3,10 +3,8 @@ from pathlib import Path
|
||||
import platform
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from esphome.build_helpers.pch import pch_extra_scripts
|
||||
import esphome.codegen as cg
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
@@ -16,7 +14,6 @@ from esphome.const import (
|
||||
CONF_FRAMEWORK,
|
||||
CONF_PLATFORM_VERSION,
|
||||
CONF_SOURCE,
|
||||
CONF_TOOLCHAIN,
|
||||
CONF_VERSION,
|
||||
KEY_CORE,
|
||||
KEY_FRAMEWORK_VERSION,
|
||||
@@ -24,7 +21,6 @@ from esphome.const import (
|
||||
KEY_TARGET_PLATFORM,
|
||||
PLATFORM_ESP8266,
|
||||
ThreadModel,
|
||||
Toolchain,
|
||||
)
|
||||
from esphome.core import (
|
||||
CORE,
|
||||
@@ -35,13 +31,12 @@ from esphome.core import (
|
||||
)
|
||||
from esphome.core.config import BOARD_MAX_LENGTH
|
||||
from esphome.helpers import IS_MACOS, copy_file_if_changed
|
||||
from esphome.platformio.toolchain import copy_ccache_script, copy_pch_script
|
||||
from esphome.platformio.toolchain import copy_ccache_script
|
||||
from esphome.storage_json import StorageJSON
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .boards import BOARDS, ESP8266_BOARD_BUILD, board_ld_script
|
||||
from .boards import BOARDS, board_ld_script
|
||||
from .const import (
|
||||
BUILD_FLASH_MODES,
|
||||
CONF_EARLY_PIN_INIT,
|
||||
CONF_ENABLE_SERIAL,
|
||||
CONF_ENABLE_SERIAL1,
|
||||
@@ -49,7 +44,6 @@ from .const import (
|
||||
KEY_BOARD,
|
||||
KEY_ESP8266,
|
||||
KEY_PIN_INITIAL_STATES,
|
||||
KEY_SCANF_FLOAT,
|
||||
KEY_SERIAL1_REQUIRED,
|
||||
KEY_SERIAL_REQUIRED,
|
||||
KEY_WAVEFORM_REQUIRED,
|
||||
@@ -109,53 +103,6 @@ def set_core_data(config: ConfigType) -> ConfigType:
|
||||
return config
|
||||
|
||||
|
||||
_TOOLCHAINS = (Toolchain.PLATFORMIO, Toolchain.ARDUINO)
|
||||
_validate_toolchain = cv.toolchain_enum(_TOOLCHAINS)
|
||||
_resolve_toolchain = cv.resolve_toolchain("ESP8266", _TOOLCHAINS, Toolchain.PLATFORMIO)
|
||||
|
||||
|
||||
def _validate_native_toolchain(config: ConfigType) -> ConfigType:
|
||||
"""Constraints of the native (non-PlatformIO) Arduino toolchain."""
|
||||
if not CORE.using_toolchain_arduino:
|
||||
return config
|
||||
from esphome.arduino8266.framework import MIN_FRAMEWORK_VERSION
|
||||
|
||||
conf = config[CONF_FRAMEWORK]
|
||||
version = cv.Version.parse(conf[CONF_VERSION])
|
||||
if version < MIN_FRAMEWORK_VERSION:
|
||||
raise cv.Invalid(
|
||||
"'toolchain: arduino' requires framework version "
|
||||
f"{MIN_FRAMEWORK_VERSION} or newer"
|
||||
)
|
||||
# platform_version is a PlatformIO concept; drop it (as esp32's native
|
||||
# toolchain does), warning when a custom pin is discarded. The floor
|
||||
# above guarantees the schema-derived default is the ARDUINO_4 spec.
|
||||
if (
|
||||
conf.pop(CONF_PLATFORM_VERSION, _ARDUINO_4_PLATFORM_SPEC)
|
||||
!= _ARDUINO_4_PLATFORM_SPEC
|
||||
):
|
||||
_LOGGER.warning(
|
||||
"'platform_version' is ignored by 'toolchain: arduino'; the native "
|
||||
"toolchain downloads the framework and compiler directly"
|
||||
)
|
||||
if conf[CONF_SOURCE] != _format_framework_arduino_version(version):
|
||||
raise cv.Invalid(
|
||||
"'toolchain: arduino' does not support a custom framework source; "
|
||||
"use 'toolchain: platformio'"
|
||||
)
|
||||
# BOARDS is a subset of ESP8266_BOARD_BUILD today; the second clause is
|
||||
# a drift guard for the independently regenerated tables
|
||||
if (
|
||||
config[CONF_BOARD] not in BOARDS
|
||||
or config[CONF_BOARD] not in ESP8266_BOARD_BUILD
|
||||
):
|
||||
raise cv.Invalid(
|
||||
f"Board '{config[CONF_BOARD]}' is not supported by "
|
||||
"'toolchain: arduino'; use 'toolchain: platformio'"
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
def get_download_types(storage_json: StorageJSON) -> list[dict[str, str]]:
|
||||
"""Binary-download entries for a built ESP8266 firmware.
|
||||
|
||||
@@ -185,7 +132,7 @@ def _format_framework_arduino_version(ver: cv.Version) -> str:
|
||||
# a PIO platformio/framework-arduinoespressif8266 value
|
||||
# List of package versions: https://api.registry.platformio.org/v3/packages/platformio/tool/framework-arduinoespressif8266
|
||||
# Same encoding the native toolchain uses for its package download, so a
|
||||
# custom-source check against this value cannot drift from what it fetches.
|
||||
# version bump cannot drift between the two paths.
|
||||
from esphome.arduino8266.framework import framework_package_version
|
||||
|
||||
try:
|
||||
@@ -247,7 +194,7 @@ def _arduino_check_versions(value: ConfigType) -> ConfigType:
|
||||
platform_version = value.get(CONF_PLATFORM_VERSION)
|
||||
if platform_version is None:
|
||||
if version >= cv.Version(3, 1, 0):
|
||||
platform_version = _ARDUINO_4_PLATFORM_SPEC
|
||||
platform_version = _parse_platform_version(str(ARDUINO_4_PLATFORM_VERSION))
|
||||
else:
|
||||
platform_version = _parse_platform_version(str(ARDUINO_3_PLATFORM_VERSION))
|
||||
value[CONF_PLATFORM_VERSION] = platform_version
|
||||
@@ -270,10 +217,6 @@ def _parse_platform_version(value: Any) -> str:
|
||||
return value
|
||||
|
||||
|
||||
# The platform_version derived for every core >= 3.1.0 config
|
||||
_ARDUINO_4_PLATFORM_SPEC = _parse_platform_version(str(ARDUINO_4_PLATFORM_VERSION))
|
||||
|
||||
|
||||
ARDUINO_FRAMEWORK_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
@@ -290,6 +233,7 @@ ARDUINO_FRAMEWORK_SCHEMA = cv.All(
|
||||
)
|
||||
|
||||
|
||||
BUILD_FLASH_MODES = ["qio", "qout", "dio", "dout"]
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
@@ -306,30 +250,15 @@ CONFIG_SCHEMA = cv.All(
|
||||
cv.Optional(CONF_ENABLE_SERIAL1): cv.boolean,
|
||||
cv.Optional(CONF_ENABLE_FULL_PRINTF, default=False): cv.boolean,
|
||||
cv.Optional(CONF_ENABLE_SCANF_FLOAT): cv.boolean,
|
||||
cv.Optional(
|
||||
CONF_TOOLCHAIN, visibility=cv.Visibility.ADVANCED
|
||||
): _validate_toolchain,
|
||||
}
|
||||
),
|
||||
_resolve_toolchain,
|
||||
_validate_native_toolchain,
|
||||
# Until the native toolchain lands, PlatformIO is the only backend;
|
||||
# reject a --toolchain this platform cannot serve yet.
|
||||
cv.require_platformio_toolchain("ESP8266"),
|
||||
set_core_data,
|
||||
)
|
||||
|
||||
|
||||
def native_toolchain_module():
|
||||
"""The native build backend for the resolved toolchain, if any.
|
||||
|
||||
``__main__`` dispatches from its own toolchain-keyed table; this helper
|
||||
serves the component's internal callers.
|
||||
"""
|
||||
if not CORE.using_toolchain_arduino:
|
||||
return None
|
||||
from esphome.arduino8266 import toolchain
|
||||
|
||||
return toolchain
|
||||
|
||||
|
||||
def check_rosetta() -> None:
|
||||
"""Fail fast when the x86_64 ESP8266 toolchain cannot run on this Mac.
|
||||
|
||||
@@ -365,13 +294,12 @@ def _choose_ld_script(board: str) -> str:
|
||||
|
||||
@coroutine_with_priority(CoroPriority.PLATFORM)
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
use_platformio = CORE.using_toolchain_platformio
|
||||
cg.add(esp8266_ns.setup_preferences())
|
||||
|
||||
if use_platformio:
|
||||
cg.add_platformio_option("lib_ldf_mode", "off")
|
||||
cg.add_platformio_option("lib_compat_mode", "strict")
|
||||
cg.add_platformio_option("board", config[CONF_BOARD])
|
||||
cg.add_platformio_option("lib_ldf_mode", "off")
|
||||
cg.add_platformio_option("lib_compat_mode", "strict")
|
||||
|
||||
cg.add_platformio_option("board", config[CONF_BOARD])
|
||||
cg.add_build_flag("-DUSE_ESP8266")
|
||||
cg.set_cpp_standard("gnu++20")
|
||||
cg.add_define("ESPHOME_BOARD", config[CONF_BOARD])
|
||||
@@ -387,33 +315,28 @@ async def to_code(config: ConfigType) -> None:
|
||||
"enabling scanf float support (~8KB flash)"
|
||||
)
|
||||
|
||||
# The native generator reads the same decision (KEY_SCANF_FLOAT)
|
||||
CORE.data[KEY_ESP8266][KEY_SCANF_FLOAT] = bool(enable_scanf_float)
|
||||
if use_platformio:
|
||||
extra_scripts = [
|
||||
"pre:ccache.py",
|
||||
"pre:testing_mode.py",
|
||||
"pre:exclude_updater.py",
|
||||
"pre:exclude_waveform.py",
|
||||
"pre:relocate_ratetable.py",
|
||||
]
|
||||
if not enable_scanf_float:
|
||||
extra_scripts.append("pre:remove_float_scanf.py")
|
||||
extra_scripts.extend(pch_extra_scripts())
|
||||
extra_scripts.append("post:post_build.py")
|
||||
cg.add_platformio_option("extra_scripts", extra_scripts)
|
||||
extra_scripts = [
|
||||
"pre:ccache.py",
|
||||
"pre:testing_mode.py",
|
||||
"pre:exclude_updater.py",
|
||||
"pre:exclude_waveform.py",
|
||||
"pre:relocate_ratetable.py",
|
||||
]
|
||||
if not enable_scanf_float:
|
||||
extra_scripts.append("pre:remove_float_scanf.py")
|
||||
extra_scripts.append("post:post_build.py")
|
||||
cg.add_platformio_option("extra_scripts", extra_scripts)
|
||||
|
||||
conf = config[CONF_FRAMEWORK]
|
||||
cg.add_platformio_option("framework", "arduino")
|
||||
cg.add_build_flag("-DUSE_ARDUINO")
|
||||
cg.add_build_flag("-DUSE_ESP8266_FRAMEWORK_ARDUINO")
|
||||
cg.add_build_flag("-Wno-nonnull-compare")
|
||||
if use_platformio:
|
||||
cg.add_platformio_option("framework", "arduino")
|
||||
cg.add_platformio_option("platform", conf[CONF_PLATFORM_VERSION])
|
||||
cg.add_platformio_option(
|
||||
"platform_packages",
|
||||
[f"platformio/framework-arduinoespressif8266@{conf[CONF_SOURCE]}"],
|
||||
)
|
||||
cg.add_platformio_option("platform", conf[CONF_PLATFORM_VERSION])
|
||||
cg.add_platformio_option(
|
||||
"platform_packages",
|
||||
[f"platformio/framework-arduinoespressif8266@{conf[CONF_SOURCE]}"],
|
||||
)
|
||||
|
||||
# Default for platformio is LWIP2_LOW_MEMORY with:
|
||||
# - MSS=536
|
||||
@@ -451,8 +374,7 @@ async def to_code(config: ConfigType) -> None:
|
||||
# Force-include inline std::__throw_* overrides so GCC dead-strips the unused
|
||||
# libstdc++ error message strings (e.g. "basic_string::_M_create") from DRAM.
|
||||
# See throw_stubs.h for details. Must be prepended before <string>, so this
|
||||
# uses build_src_flags with -include. Unconditional: the native build
|
||||
# generator reads the same option, keeping one source of truth.
|
||||
# uses build_src_flags with -include.
|
||||
cg.add_platformio_option(
|
||||
"build_src_flags", "-include esphome/components/esp8266/throw_stubs.h"
|
||||
)
|
||||
@@ -482,8 +404,6 @@ async def to_code(config: ConfigType) -> None:
|
||||
# implementation in the Arduino ESP8266 core.
|
||||
cg.add_build_flag("-Wl,--wrap=millis")
|
||||
|
||||
# Unconditional: the native build generator reads the same option,
|
||||
# keeping one source of truth
|
||||
cg.add_platformio_option("board_build.flash_mode", config[CONF_BOARD_FLASH_MODE])
|
||||
|
||||
ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]
|
||||
@@ -492,7 +412,7 @@ async def to_code(config: ConfigType) -> None:
|
||||
cg.RawExpression(f"VERSION_CODE({ver.major}, {ver.minor}, {ver.patch})"),
|
||||
)
|
||||
|
||||
if use_platformio and config[CONF_BOARD] in BOARDS:
|
||||
if config[CONF_BOARD] in BOARDS:
|
||||
cg.add_platformio_option(
|
||||
"board_build.ldscript", _choose_ld_script(config[CONF_BOARD])
|
||||
)
|
||||
@@ -533,24 +453,8 @@ async def finalize_serial_config() -> None:
|
||||
cg.add_build_flag("-DNO_GLOBAL_SERIAL1")
|
||||
|
||||
|
||||
# Called by __main__.compile_program; returning False falls through to the
|
||||
# PlatformIO toolchain.
|
||||
def run_compile(args, config: ConfigType) -> bool:
|
||||
# Positive check: the native backend only runs when explicitly resolved
|
||||
toolchain = native_toolchain_module()
|
||||
if toolchain is None:
|
||||
return False
|
||||
if toolchain.run_compile(config, CORE.verbose) != 0:
|
||||
raise EsphomeError("ESP8266 native build failed")
|
||||
return True
|
||||
|
||||
|
||||
# Called by writer.py
|
||||
def copy_files() -> None:
|
||||
# Native builds skip the PlatformIO extra scripts; the build generator
|
||||
# carries their logic
|
||||
if CORE.using_toolchain_arduino:
|
||||
return
|
||||
dir = Path(__file__).parent
|
||||
for script in (
|
||||
"post_build",
|
||||
@@ -565,7 +469,6 @@ def copy_files() -> None:
|
||||
CORE.relative_build_path(f"{script}.py"),
|
||||
)
|
||||
copy_ccache_script()
|
||||
copy_pch_script()
|
||||
|
||||
|
||||
# ESP logs stack trace decoder, based on https://github.com/me-no-dev/EspExceptionDecoder
|
||||
@@ -608,75 +511,22 @@ ESP8266_EXCEPTION_CODES = {
|
||||
}
|
||||
|
||||
|
||||
_DECODE_WARNED_AT: dict[str, float] = {}
|
||||
def _decode_pc(config: ConfigType, addr: str) -> None:
|
||||
from esphome.platformio import toolchain
|
||||
|
||||
|
||||
def _warn_decode_problem(key: str, message: str, *args) -> bool:
|
||||
"""Warn, deduplicated briefly so a burst of stack-dump addresses warns
|
||||
once but a later dump warns again; returns whether it warned so the
|
||||
caller can mark suppressed addresses individually."""
|
||||
now = time.monotonic()
|
||||
last = _DECODE_WARNED_AT.get(key)
|
||||
if last is not None and now - last < 30:
|
||||
return False
|
||||
_DECODE_WARNED_AT[key] = now
|
||||
_LOGGER.warning(message, *args)
|
||||
return True
|
||||
|
||||
|
||||
def _decode_pc(config: ConfigType, addr: str, *, bulk: bool = False) -> None:
|
||||
"""Decode one crash address. ``bulk``: the caller is scanning every
|
||||
8-hex stack word, most of which are not code addresses -- unmappable
|
||||
ones log at debug so real frames are not buried."""
|
||||
if (native_toolchain := native_toolchain_module()) is not None:
|
||||
addr2line = native_toolchain.get_addr2line_path()
|
||||
elf = native_toolchain.get_elf_path()
|
||||
for path in (addr2line, elf):
|
||||
if not path.is_file():
|
||||
_warn_decode_problem(
|
||||
str(path), "Cannot decode crash addresses: %s missing", path
|
||||
)
|
||||
# The detailed warning names no address; mark named
|
||||
# registers, but bulk stack words at debug (~150 per dump)
|
||||
log = _LOGGER.debug if bulk else _LOGGER.warning
|
||||
log("Not decoded %s (toolchain file missing)", addr)
|
||||
return
|
||||
addr2line, elf = str(addr2line), str(elf)
|
||||
else:
|
||||
from esphome.platformio import toolchain
|
||||
|
||||
idedata = toolchain.get_idedata(config)
|
||||
if not idedata.addr2line_path or not idedata.firmware_elf_path:
|
||||
_warn_decode_problem(
|
||||
"no-addr2line",
|
||||
"Cannot decode crash addresses: no addr2line or ELF in idedata",
|
||||
)
|
||||
log = _LOGGER.debug if bulk else _LOGGER.warning
|
||||
log("Not decoded %s (no addr2line or ELF)", addr)
|
||||
return
|
||||
addr2line, elf = idedata.addr2line_path, idedata.firmware_elf_path
|
||||
command = [addr2line, "-pfiaC", "-e", elf, addr]
|
||||
idedata = toolchain.get_idedata(config)
|
||||
if not idedata.addr2line_path or not idedata.firmware_elf_path:
|
||||
_LOGGER.debug("decode_pc no addr2line")
|
||||
return
|
||||
command = [idedata.addr2line_path, "-pfiaC", "-e", idedata.firmware_elf_path, addr]
|
||||
try:
|
||||
translation = subprocess.check_output(command, close_fds=False).decode().strip()
|
||||
except Exception as err: # noqa: BLE001 # pylint: disable=broad-except
|
||||
# Warn, not debug: a failing addr2line must be visible. The warning
|
||||
# is rate-limited across a dump, so mark every undecoded address
|
||||
# inline or the rest read as merely unmappable
|
||||
if not _warn_decode_problem(
|
||||
"addr2line-failed", "Could not decode crash address %s (%s)", addr, err
|
||||
):
|
||||
# The detailed warning already named this address; mark only
|
||||
# the rate-limited ones, and bulk stack words at debug
|
||||
log = _LOGGER.debug if bulk else _LOGGER.warning
|
||||
log("Not decoded %s (addr2line failed)", addr)
|
||||
except Exception: # noqa: BLE001 # pylint: disable=broad-except
|
||||
_LOGGER.debug("Caught exception for command %s", command, exc_info=1)
|
||||
return
|
||||
|
||||
if "?? ??:0" in translation:
|
||||
# A named register that fails to decode is confusing silence; a
|
||||
# bulk stack word failing is the expected common case
|
||||
log = _LOGGER.debug if bulk else _LOGGER.warning
|
||||
log("Not decoded %s (address not in %s)", addr, elf)
|
||||
# Nothing useful
|
||||
return
|
||||
translation = translation.replace(" at ??:?", "").replace(":?", "")
|
||||
_LOGGER.warning("Decoded %s", translation)
|
||||
@@ -746,6 +596,6 @@ def process_stacktrace(config: ConfigType, line: str, backtrace_state: bool) ->
|
||||
|
||||
if backtrace_state:
|
||||
for addr in re.finditer(STACKTRACE_ESP8266_BACKTRACE_PC_RE, line):
|
||||
_decode_pc(config, addr.group(), bulk=True)
|
||||
_decode_pc(config, addr.group())
|
||||
|
||||
return backtrace_state
|
||||
|
||||
@@ -16,6 +16,7 @@ KEY_WAVEFORM_REQUIRED = "waveform_required"
|
||||
KEY_SERIAL_REQUIRED = "serial_required"
|
||||
KEY_SERIAL1_REQUIRED = "serial1_required"
|
||||
# Set for the native (non-PlatformIO) toolchain's build generator
|
||||
KEY_FLASH_MODE = "flash_mode"
|
||||
KEY_SCANF_FLOAT = "scanf_float"
|
||||
# Per-board flash-layout override consumed by board_ld_script()
|
||||
KEY_LDSCRIPT = "ldscript"
|
||||
@@ -72,6 +73,3 @@ def enable_serial1() -> None:
|
||||
enable_serial1()
|
||||
"""
|
||||
CORE.data.setdefault(KEY_ESP8266, {})[KEY_SERIAL1_REQUIRED] = True
|
||||
|
||||
|
||||
BUILD_FLASH_MODES = ("qio", "qout", "dio", "dout")
|
||||
|
||||
@@ -283,10 +283,18 @@ FINAL_VALIDATE_SCHEMA = ota_esphome_final_validate
|
||||
|
||||
|
||||
FILTER_SOURCE_FILES = filter_source_files_from_defines(
|
||||
{"ota_esphome_noise.cpp": "USE_OTA_ENCRYPTION"}
|
||||
{
|
||||
"ota_esphome_noise.cpp": "USE_OTA_ENCRYPTION",
|
||||
"ota_esphome_inflate.c": "USE_OTA_DEFLATE",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def enable_deflate() -> None:
|
||||
"""Compile the on-the-fly inflater for compressed uploads."""
|
||||
cg.add_define("USE_OTA_DEFLATE")
|
||||
|
||||
|
||||
@coroutine_with_priority(CoroPriority.OTA_UPDATES)
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
@@ -305,6 +313,10 @@ async def to_code(config: ConfigType) -> None:
|
||||
if config.get(CONF_ALLOW_PARTITION_ACCESS):
|
||||
cg.add_define("USE_OTA_PARTITIONS")
|
||||
|
||||
# ESP8266 and RP2040 inflate gzip at reboot; the rest inflate on the fly
|
||||
if not (CORE.is_esp8266 or CORE.is_rp2):
|
||||
enable_deflate()
|
||||
|
||||
# One key per device: an api encryption block supplies it (static or
|
||||
# runtime) and offers; the ota block only adds the requirement
|
||||
api_conf = CORE.config.get(CONF_API) or {}
|
||||
|
||||
@@ -24,6 +24,8 @@
|
||||
|
||||
#include <cerrno>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <new>
|
||||
#include <sys/time.h>
|
||||
|
||||
namespace esphome {
|
||||
@@ -41,7 +43,12 @@ const noise::NoiseContext &ESPHomeOTAComponent::noise_context_() const {
|
||||
#endif
|
||||
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_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;
|
||||
static constexpr uint32_t OTA_PROGRESS_INTERVAL_MS = 1000;
|
||||
static constexpr size_t OTA_SIZE_FIELD_BYTES = 4; // sizes on the wire are 4 bytes MSB first
|
||||
|
||||
// Single-instance pointer — multi-port configs are rejected in final_validate.
|
||||
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
@@ -179,12 +186,16 @@ static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_COMPRESSION = 0x01;
|
||||
static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_SHA256_AUTH = 0x02;
|
||||
static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL = 0x04;
|
||||
static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_NOISE = 0x08;
|
||||
static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_DEFLATE = 0x10;
|
||||
// Noise needs the extended protocol: the prologue binds the 2-byte feature ack
|
||||
static constexpr uint8_t CLIENT_NOISE_FEATURES =
|
||||
CLIENT_FEATURE_SUPPORTS_NOISE | CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL;
|
||||
static constexpr uint8_t SERVER_FEATURE_SUPPORTS_COMPRESSION = 0x01;
|
||||
static constexpr uint8_t SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS = 0x02;
|
||||
static constexpr uint8_t SERVER_FEATURE_SUPPORTS_NOISE = 0x04;
|
||||
// Raw deflate, window <= OTA_INFLATE_WINDOW_SIZE. Binding once offered: the
|
||||
// client must then send the image size frame and a deflate stream.
|
||||
static constexpr uint8_t SERVER_FEATURE_SUPPORTS_DEFLATE = 0x08;
|
||||
|
||||
inline bool ESPHomeOTAComponent::extended_proto_() const {
|
||||
#ifdef USE_OTA_ENCRYPTION_REQUIRED
|
||||
@@ -290,7 +301,7 @@ void ESPHomeOTAComponent::handle_handshake_() {
|
||||
this->transition_ota_state_(OTAState::FEATURE_ACK);
|
||||
|
||||
const bool supports_compression =
|
||||
(this->ota_features_ & CLIENT_FEATURE_SUPPORTS_COMPRESSION) != 0 && this->backend_->supports_compression();
|
||||
(this->ota_features_ & CLIENT_FEATURE_SUPPORTS_COMPRESSION) != 0 && ota::OTABackend::supports_compression();
|
||||
|
||||
// Compose the feature-ack response. When the client negotiates the extended protocol we emit
|
||||
// a 2-byte response (marker + server feature flags); otherwise we emit the single-byte
|
||||
@@ -310,6 +321,27 @@ void ESPHomeOTAComponent::handle_handshake_() {
|
||||
#elif defined(USE_OTA_ENCRYPTION)
|
||||
// A yaml key always exists: validation rejects the all-zeros key
|
||||
this->handshake_buf_[1] |= SERVER_FEATURE_SUPPORTS_NOISE;
|
||||
#endif
|
||||
#ifdef USE_OTA_ENCRYPTION
|
||||
// Reserve the noise session before the optional inflate buffer, so the
|
||||
// required allocation is not starved by the compression window. Gated
|
||||
// on the same condition that starts the session in FEATURE_ACK.
|
||||
if ((this->handshake_buf_[1] & SERVER_FEATURE_SUPPORTS_NOISE) != 0 &&
|
||||
(this->ota_features_ & CLIENT_NOISE_FEATURES) == CLIENT_NOISE_FEATURES) {
|
||||
// NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks)
|
||||
this->noise_ = std::unique_ptr<NoiseSession>(new (std::nothrow) NoiseSession);
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_OTA_DEFLATE
|
||||
// Offered only once the session memory is in hand; else uncompressed
|
||||
if ((this->ota_features_ & CLIENT_FEATURE_SUPPORTS_DEFLATE) != 0) {
|
||||
this->inflate_.reset(new (std::nothrow) InflateSession());
|
||||
if (this->inflate_ != nullptr) {
|
||||
this->handshake_buf_[1] |= SERVER_FEATURE_SUPPORTS_DEFLATE;
|
||||
} else {
|
||||
ESP_LOGW(TAG, "No memory to inflate");
|
||||
}
|
||||
}
|
||||
#endif
|
||||
} else {
|
||||
this->handshake_buf_[0] =
|
||||
@@ -427,16 +459,11 @@ void ESPHomeOTAComponent::handle_data_() {
|
||||
// Backend calls overwrite this with OK; reset to UNKNOWN before any
|
||||
// goto error that follows a successful begin()/write()
|
||||
ota::OTAResponseTypes error_code = ota::OTA_RESPONSE_ERROR_UNKNOWN;
|
||||
size_t total = 0;
|
||||
uint32_t last_progress = 0;
|
||||
uint32_t last_data_ms = 0;
|
||||
DataTransfer xfer;
|
||||
uint8_t buf[OTA_BUFFER_SIZE];
|
||||
char *sbuf = reinterpret_cast<char *>(buf);
|
||||
size_t ota_size;
|
||||
size_t image_size;
|
||||
ota::OTAType ota_type = ota::OTA_TYPE_UPDATE_APP;
|
||||
#if USE_OTA_VERSION == 2
|
||||
size_t size_acknowledged = 0;
|
||||
#endif
|
||||
|
||||
// Set socket timeouts and blocking mode (see strategy table above)
|
||||
struct timeval tv;
|
||||
@@ -459,14 +486,13 @@ void ESPHomeOTAComponent::handle_data_() {
|
||||
}
|
||||
ESP_LOGV(TAG, "OTA type is 0x%02x", ota_type);
|
||||
|
||||
// Read size, 4 bytes MSB first
|
||||
if (!this->data_readall_(buf, 4)) {
|
||||
this->log_read_error_(LOG_STR("size"));
|
||||
if (!this->read_size_(buf, xfer.ota_size, LOG_STR("size")))
|
||||
goto error; // NOLINT(cppcoreguidelines-avoid-goto)
|
||||
}
|
||||
ota_size = (static_cast<size_t>(buf[0]) << 24) | (static_cast<size_t>(buf[1]) << 16) |
|
||||
(static_cast<size_t>(buf[2]) << 8) | buf[3];
|
||||
ESP_LOGV(TAG, "Size is %zu bytes", ota_size);
|
||||
image_size = xfer.ota_size;
|
||||
#ifdef USE_OTA_DEFLATE
|
||||
if (this->inflate_ != nullptr && !this->read_size_(buf, image_size, LOG_STR("image size")))
|
||||
goto error; // NOLINT(cppcoreguidelines-avoid-goto)
|
||||
#endif
|
||||
|
||||
#ifndef USE_OTA_PARTITIONS
|
||||
if (ota_type != ota::OTA_TYPE_UPDATE_APP) {
|
||||
@@ -486,7 +512,7 @@ void ESPHomeOTAComponent::handle_data_() {
|
||||
#endif
|
||||
|
||||
// begin() returns quickly; flash sectors are erased incrementally during write().
|
||||
error_code = this->backend_->begin(ota_size, ota_type);
|
||||
error_code = this->backend_->begin(image_size, ota_type);
|
||||
if (error_code != ota::OTA_RESPONSE_OK)
|
||||
goto error; // NOLINT(cppcoreguidelines-avoid-goto)
|
||||
|
||||
@@ -506,75 +532,25 @@ void ESPHomeOTAComponent::handle_data_() {
|
||||
// Acknowledge MD5 OK - 1 byte
|
||||
this->data_write_byte_(ota::OTA_RESPONSE_BIN_MD5_OK);
|
||||
|
||||
// Track when we last received data so a silently-vanished peer (no FIN/RST
|
||||
// delivered, e.g. uploader killed mid-transfer or NAT/router dropped state)
|
||||
// can't wedge the device indefinitely. Without this, the loop only exits
|
||||
// on actual data, EOF, or a non-EWOULDBLOCK error from read(), and lwIP
|
||||
// TCP keepalive isn't enabled here.
|
||||
last_data_ms = millis();
|
||||
while (total < ota_size) {
|
||||
if (millis() - last_data_ms > OTA_SOCKET_TIMEOUT_DATA) {
|
||||
ESP_LOGW(TAG, "No data received for %u ms", (unsigned) OTA_SOCKET_TIMEOUT_DATA);
|
||||
error_code = ota::OTA_RESPONSE_ERROR_UNKNOWN;
|
||||
xfer.last_data_ms = millis();
|
||||
#ifdef USE_OTA_DEFLATE
|
||||
if (this->inflate_ != nullptr) {
|
||||
error_code = this->inflate_data_(buf, image_size, xfer);
|
||||
if (error_code != ota::OTA_RESPONSE_OK)
|
||||
goto error; // NOLINT(cppcoreguidelines-avoid-goto)
|
||||
}
|
||||
size_t remaining = ota_size - total;
|
||||
size_t requested = remaining < OTA_BUFFER_SIZE ? remaining : OTA_BUFFER_SIZE;
|
||||
ssize_t read;
|
||||
#ifdef USE_OTA_ENCRYPTION
|
||||
if (this->noise_ != nullptr) {
|
||||
// One frame per call; noise_read_data_ waits internally (readall_), so
|
||||
// there is no would-block retry here and failures are already logged.
|
||||
read = this->noise_read_data_(buf, requested);
|
||||
if (read <= 0) {
|
||||
} else
|
||||
#endif
|
||||
{
|
||||
while (xfer.total < xfer.ota_size) {
|
||||
ssize_t read = this->receive_data_(buf, xfer);
|
||||
if (read < 0) {
|
||||
error_code = ota::OTA_RESPONSE_ERROR_UNKNOWN;
|
||||
goto error; // NOLINT(cppcoreguidelines-avoid-goto)
|
||||
}
|
||||
} else
|
||||
#endif
|
||||
{
|
||||
read = this->client_->read(buf, requested);
|
||||
if (read == -1) {
|
||||
const int err = errno;
|
||||
if (this->would_block_(err)) {
|
||||
// read() already waited up to SO_RCVTIMEO for data, just feed WDT
|
||||
App.feed_wdt();
|
||||
continue;
|
||||
}
|
||||
ESP_LOGW(TAG, "Read err %d", err);
|
||||
error_code = ota::OTA_RESPONSE_ERROR_UNKNOWN;
|
||||
error_code = this->write_flash_(buf, read);
|
||||
if (error_code != ota::OTA_RESPONSE_OK)
|
||||
goto error; // NOLINT(cppcoreguidelines-avoid-goto)
|
||||
} else if (read == 0) {
|
||||
ESP_LOGW(TAG, "Remote closed");
|
||||
error_code = ota::OTA_RESPONSE_ERROR_UNKNOWN;
|
||||
goto error; // NOLINT(cppcoreguidelines-avoid-goto)
|
||||
}
|
||||
}
|
||||
|
||||
last_data_ms = millis();
|
||||
error_code = this->backend_->write(buf, read);
|
||||
if (error_code != ota::OTA_RESPONSE_OK) {
|
||||
ESP_LOGW(TAG, "Flash write err %d", error_code);
|
||||
goto error; // NOLINT(cppcoreguidelines-avoid-goto)
|
||||
}
|
||||
total += read;
|
||||
#if USE_OTA_VERSION == 2
|
||||
while (size_acknowledged + OTA_BLOCK_SIZE <= total || (total == ota_size && size_acknowledged < ota_size)) {
|
||||
this->data_write_byte_(ota::OTA_RESPONSE_CHUNK_OK);
|
||||
size_acknowledged += OTA_BLOCK_SIZE;
|
||||
}
|
||||
#endif
|
||||
|
||||
uint32_t now = millis();
|
||||
if (now - last_progress > 1000) {
|
||||
last_progress = now;
|
||||
float percentage = (total * 100.0f) / ota_size;
|
||||
ESP_LOGD(TAG, "Progress: %0.1f%%", percentage);
|
||||
#ifdef USE_OTA_STATE_LISTENER
|
||||
this->notify_state_(ota::OTA_IN_PROGRESS, percentage, 0);
|
||||
#endif
|
||||
// feed watchdog and give other tasks a chance to run
|
||||
this->yield_and_feed_watchdog_();
|
||||
this->ack_written_(xfer);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -771,6 +747,174 @@ bool ESPHomeOTAComponent::try_write_(size_t to_write, const LogString *desc) {
|
||||
return this->handshake_buf_pos_ >= to_write;
|
||||
}
|
||||
|
||||
bool ESPHomeOTAComponent::read_size_(uint8_t *buf, size_t &size, const LogString *desc) {
|
||||
if (!this->data_readall_(buf, OTA_SIZE_FIELD_BYTES)) {
|
||||
this->log_read_error_(desc);
|
||||
return false;
|
||||
}
|
||||
size = encode_uint32(buf[0], buf[1], buf[2], buf[3]);
|
||||
ESP_LOGV(TAG, "%s is %zu bytes", LOG_STR_ARG(desc), size);
|
||||
return true;
|
||||
}
|
||||
|
||||
ota::OTAResponseTypes ESPHomeOTAComponent::write_flash_(uint8_t *data, size_t len) {
|
||||
ota::OTAResponseTypes result = this->backend_->write(data, len);
|
||||
if (result != ota::OTA_RESPONSE_OK) {
|
||||
ESP_LOGW(TAG, "Flash write err %d", result);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
ssize_t ESPHomeOTAComponent::receive_data_(uint8_t *buf, DataTransfer &xfer) {
|
||||
const size_t remaining = xfer.ota_size - xfer.total;
|
||||
const size_t requested = std::min(remaining, OTA_BUFFER_SIZE);
|
||||
ssize_t read;
|
||||
for (;;) {
|
||||
// A silently-vanished peer (no FIN/RST delivered, e.g. uploader killed
|
||||
// mid-transfer or NAT/router dropped state) must not wedge the device:
|
||||
// read() only fails on EOF or a real error, and lwIP TCP keepalive isn't
|
||||
// enabled here.
|
||||
if (millis() - xfer.last_data_ms > OTA_SOCKET_TIMEOUT_DATA) {
|
||||
ESP_LOGW(TAG, "No data received for %u ms", (unsigned) OTA_SOCKET_TIMEOUT_DATA);
|
||||
return -1;
|
||||
}
|
||||
#ifdef USE_OTA_ENCRYPTION
|
||||
if (this->noise_ != nullptr) {
|
||||
// One frame per call; noise_read_data_ waits internally (readall_), so
|
||||
// there is no would-block retry here and failures are already logged.
|
||||
read = this->noise_read_data_(buf, requested);
|
||||
if (read <= 0)
|
||||
return -1;
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
read = this->client_->read(buf, requested);
|
||||
if (read > 0)
|
||||
break;
|
||||
if (read == 0) {
|
||||
this->log_remote_closed_(LOG_STR("data"));
|
||||
return -1;
|
||||
}
|
||||
if (!this->would_block_(errno)) {
|
||||
this->log_socket_error_(LOG_STR("data"));
|
||||
return -1;
|
||||
}
|
||||
// read() already waited up to SO_RCVTIMEO for data, just feed WDT
|
||||
App.feed_wdt();
|
||||
}
|
||||
|
||||
const uint32_t now = millis();
|
||||
xfer.last_data_ms = now;
|
||||
xfer.total += read;
|
||||
this->ack_received_(xfer);
|
||||
if (now - xfer.last_progress > OTA_PROGRESS_INTERVAL_MS) {
|
||||
xfer.last_progress = now;
|
||||
float percentage = (xfer.total * 100.0f) / xfer.ota_size;
|
||||
ESP_LOGD(TAG, "Progress: %0.1f%%", percentage);
|
||||
#ifdef USE_OTA_STATE_LISTENER
|
||||
this->notify_state_(ota::OTA_IN_PROGRESS, percentage, 0);
|
||||
#endif
|
||||
// feed watchdog and give other tasks a chance to run
|
||||
this->yield_and_feed_watchdog_();
|
||||
}
|
||||
return read;
|
||||
}
|
||||
|
||||
void ESPHomeOTAComponent::send_chunk_acks_(DataTransfer &xfer) {
|
||||
#if USE_OTA_VERSION == 2
|
||||
while (xfer.acknowledged + OTA_BLOCK_SIZE <= xfer.total ||
|
||||
(xfer.total == xfer.ota_size && xfer.acknowledged < xfer.ota_size)) {
|
||||
this->data_write_byte_(ota::OTA_RESPONSE_CHUNK_OK);
|
||||
xfer.acknowledged += OTA_BLOCK_SIZE;
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#ifdef USE_OTA_DEFLATE
|
||||
// The window doubles as the output buffer; flushed bytes stay as back
|
||||
// reference history for the next windowful.
|
||||
ota::OTAResponseTypes ESPHomeOTAComponent::inflate_flush_(InflateSession &session) {
|
||||
const size_t produced = session.dest - session.window;
|
||||
const size_t pending = produced - session.flushed;
|
||||
if (pending != 0) {
|
||||
if (pending > session.image_size - session.written) {
|
||||
ESP_LOGW(TAG, "Inflate overrun");
|
||||
return ota::OTA_RESPONSE_ERROR_UNKNOWN;
|
||||
}
|
||||
ota::OTAResponseTypes result = this->write_flash_(session.window + session.flushed, pending);
|
||||
if (result != ota::OTA_RESPONSE_OK)
|
||||
return result;
|
||||
session.flushed = produced;
|
||||
session.written += pending;
|
||||
// A compressible region yields many windows per socket read
|
||||
App.feed_wdt();
|
||||
}
|
||||
// Even with nothing new written: a block boundary can fall inside a header
|
||||
this->ack_written_(*session.xfer);
|
||||
return ota::OTA_RESPONSE_OK;
|
||||
}
|
||||
|
||||
ota::OTAResponseTypes ESPHomeOTAComponent::inflate_data_(uint8_t *in, size_t image_size, DataTransfer &xfer) {
|
||||
InflateSession &session = *this->inflate_;
|
||||
session.self = this;
|
||||
session.xfer = &xfer;
|
||||
session.in = in;
|
||||
session.image_size = image_size;
|
||||
session.written = 0;
|
||||
session.error = ota::OTA_RESPONSE_OK;
|
||||
// A corrupt stream may back reference the window before it is filled; zero it
|
||||
// so such a read copies zeros, never stale memory
|
||||
std::memset(session.window, 0, sizeof(session.window));
|
||||
ota_inflate_init(&session, session.window, OTA_INFLATE_WINDOW_SIZE);
|
||||
// Where the ack must follow the write, flush and ack before waiting for
|
||||
// input, or the client waits for an ack while the decoder waits for data
|
||||
session.source_read_cb = [](OtaInflateState *d) -> int {
|
||||
auto *s = static_cast<InflateSession *>(d);
|
||||
if (ACK_AFTER_WRITE) {
|
||||
s->error = s->self->inflate_flush_(*s);
|
||||
if (s->error != ota::OTA_RESPONSE_OK)
|
||||
return -1;
|
||||
}
|
||||
// More input than announced; reported by the size check below
|
||||
if (s->xfer->total >= s->xfer->ota_size)
|
||||
return -1;
|
||||
ssize_t read = s->self->receive_data_(s->in, *s->xfer);
|
||||
if (read <= 0) {
|
||||
// Already logged by receive_data_
|
||||
s->error = ota::OTA_RESPONSE_ERROR_UNKNOWN;
|
||||
return -1;
|
||||
}
|
||||
d->source = s->in + 1;
|
||||
d->source_limit = s->in + read;
|
||||
return s->in[0];
|
||||
};
|
||||
|
||||
int res;
|
||||
do {
|
||||
// The ring index wrapped to 0 exactly when the window filled
|
||||
session.dest = session.window;
|
||||
session.dest_limit = session.window + OTA_INFLATE_WINDOW_SIZE;
|
||||
session.flushed = 0;
|
||||
res = ota_inflate(&session);
|
||||
// A stored block keeps emitting zeros after a failed read, hence eof
|
||||
if (res < 0 || session.eof)
|
||||
break;
|
||||
session.error = this->inflate_flush_(session);
|
||||
} while (res != OTA_INFLATE_DONE && session.error == ota::OTA_RESPONSE_OK);
|
||||
|
||||
// Transport and flash failures are logged where they happen
|
||||
if (session.error != ota::OTA_RESPONSE_OK)
|
||||
return session.error;
|
||||
if (res != OTA_INFLATE_DONE || session.written != image_size || xfer.total != xfer.ota_size) {
|
||||
ESP_LOGW(TAG, "Inflate err %d, %zu of %zu B from %zu of %zu", res, session.written, image_size, xfer.total,
|
||||
xfer.ota_size);
|
||||
return ota::OTA_RESPONSE_ERROR_UNKNOWN;
|
||||
}
|
||||
ESP_LOGD(TAG, "Inflated %zu bytes from %zu", session.written, xfer.total);
|
||||
return ota::OTA_RESPONSE_OK;
|
||||
}
|
||||
#endif // USE_OTA_DEFLATE
|
||||
|
||||
void ESPHomeOTAComponent::cleanup_connection_() {
|
||||
this->client_->close();
|
||||
this->client_ = nullptr;
|
||||
@@ -784,6 +928,9 @@ void ESPHomeOTAComponent::cleanup_connection_() {
|
||||
#endif
|
||||
#ifdef USE_OTA_ENCRYPTION
|
||||
this->noise_ = nullptr;
|
||||
#endif
|
||||
#ifdef USE_OTA_DEFLATE
|
||||
this->inflate_ = nullptr;
|
||||
#endif
|
||||
// Intentionally no disable_loop() — letting loop() run one more iteration catches
|
||||
// any connection that queued on the listener mid-session (otherwise the wake flag,
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
#ifdef USE_OTA_ENCRYPTION
|
||||
#include "esphome/components/noise/noise_handshake.h"
|
||||
#endif
|
||||
#ifdef USE_OTA_DEFLATE
|
||||
#include "ota_esphome_inflate.h"
|
||||
#endif
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/preferences.h"
|
||||
@@ -119,6 +122,38 @@ class ESPHomeOTAComponent final : public ota::OTAComponent {
|
||||
return this->readall_(buf, len);
|
||||
}
|
||||
|
||||
// Upload accounting shared by the data loop and the inflate read callback
|
||||
struct DataTransfer {
|
||||
size_t ota_size{0}; // bytes the client sends
|
||||
size_t total{0}; // bytes received so far
|
||||
#if USE_OTA_VERSION == 2
|
||||
size_t acknowledged{0};
|
||||
#endif
|
||||
uint32_t last_data_ms{0};
|
||||
uint32_t last_progress{0};
|
||||
};
|
||||
// Up to OTA_BUFFER_SIZE bytes into buf; returns bytes read, -1 on failure (logged)
|
||||
inline ssize_t receive_data_(uint8_t *buf, DataTransfer &xfer);
|
||||
// Raw lwIP cannot service the radio during a sector write, so the ack waits
|
||||
// for the write there; a socket task lets the next block arrive meanwhile
|
||||
#ifdef USE_SOCKET_IMPL_LWIP_TCP
|
||||
static constexpr bool ACK_AFTER_WRITE = true;
|
||||
#else
|
||||
static constexpr bool ACK_AFTER_WRITE = false;
|
||||
#endif
|
||||
void send_chunk_acks_(DataTransfer &xfer);
|
||||
inline void ack_received_(DataTransfer &xfer) {
|
||||
if (!ACK_AFTER_WRITE)
|
||||
this->send_chunk_acks_(xfer);
|
||||
}
|
||||
inline void ack_written_(DataTransfer &xfer) {
|
||||
if (ACK_AFTER_WRITE)
|
||||
this->send_chunk_acks_(xfer);
|
||||
}
|
||||
inline bool read_size_(uint8_t *buf, size_t &size, const LogString *desc);
|
||||
// Writes to the backend and logs a failure
|
||||
inline ota::OTAResponseTypes write_flash_(uint8_t *data, size_t len);
|
||||
|
||||
bool try_read_(size_t to_read, const LogString *desc);
|
||||
bool try_write_(size_t to_write, const LogString *desc);
|
||||
|
||||
@@ -171,6 +206,31 @@ class ESPHomeOTAComponent final : public ota::OTAComponent {
|
||||
static_assert(OTA_BUFFER_SIZE >= NOISE_CLIENT_MAX_PLAINTEXT + noise::MAC_SIZE,
|
||||
"OTA_BUFFER_SIZE must fit a full encrypted data frame");
|
||||
#endif
|
||||
#ifdef USE_OTA_DEFLATE
|
||||
// At least 1 << espota2.DEFLATE_WINDOW_BITS; also the inflate output buffer
|
||||
static constexpr size_t OTA_INFLATE_WINDOW_SIZE = 4096;
|
||||
// Heap-allocated only while a deflate upload is negotiated; the decoder
|
||||
// state is the base so the read callback can recover the session
|
||||
struct InflateSession : OtaInflateState {
|
||||
ESPHomeOTAComponent *self;
|
||||
DataTransfer *xfer;
|
||||
uint8_t *in; // caller's buffer for the compressed input, valid during inflate_data_
|
||||
size_t image_size;
|
||||
size_t written; // inflated bytes in flash
|
||||
size_t flushed; // bytes of the current window already in flash
|
||||
ota::OTAResponseTypes error; // first failure inside the read callback
|
||||
uint8_t window[OTA_INFLATE_WINDOW_SIZE];
|
||||
};
|
||||
#ifndef CLANG_TIDY // static analysis sets every define at once
|
||||
static_assert(!ota::OTABackend::supports_compression(),
|
||||
"USE_OTA_DEFLATE is for backends that cannot store a gzip image");
|
||||
#endif
|
||||
// Writes the decoded bytes not yet in flash without moving dest
|
||||
ota::OTAResponseTypes inflate_flush_(InflateSession &session);
|
||||
ota::OTAResponseTypes inflate_data_(uint8_t *in, size_t image_size, DataTransfer &xfer);
|
||||
std::unique_ptr<InflateSession> inflate_;
|
||||
#endif
|
||||
|
||||
static constexpr uint8_t MAGIC_BYTES[5] = {0x6C, 0x26, 0xF7, 0x5C, 0x45};
|
||||
// Derived from the feature byte; storing it would pad the trailing bytes
|
||||
bool extended_proto_() const;
|
||||
|
||||
@@ -0,0 +1,498 @@
|
||||
/*
|
||||
* uzlib - tiny deflate/inflate library (deflate, gzip, zlib)
|
||||
*
|
||||
* Copyright (c) 2003 by Joergen Ibsen / Jibz
|
||||
* All Rights Reserved
|
||||
* http://www.ibsensoftware.com/
|
||||
*
|
||||
* Copyright (c) 2014-2018 by Paul Sokolovsky
|
||||
*
|
||||
* This software is provided 'as-is', without any express
|
||||
* or implied warranty. In no event will the authors be
|
||||
* held liable for any damages arising from the use of
|
||||
* this software.
|
||||
*
|
||||
* Permission is granted to anyone to use this software
|
||||
* for any purpose, including commercial applications,
|
||||
* and to alter it and redistribute it freely, subject to
|
||||
* the following restrictions:
|
||||
*
|
||||
* 1. The origin of this software must not be
|
||||
* misrepresented; you must not claim that you
|
||||
* wrote the original software. If you use this
|
||||
* software in a product, an acknowledgment in
|
||||
* the product documentation would be appreciated
|
||||
* but is not required.
|
||||
*
|
||||
* 2. Altered source versions must be plainly marked
|
||||
* as such, and must not be misrepresented as
|
||||
* being the original software.
|
||||
*
|
||||
* 3. This notice may not be removed or altered from
|
||||
* any source distribution.
|
||||
*/
|
||||
|
||||
/*
|
||||
* Altered for ESPHome: this is the raw deflate decoder from uzlib's
|
||||
* tinflate.c (v2.9.5) with the gzip/zlib header parsers, checksums,
|
||||
* runtime table builder and in-memory (non ring window) output path
|
||||
* removed, and the public names prefixed with ota_inflate.
|
||||
*/
|
||||
|
||||
#include "ota_esphome_inflate.h"
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
#define TINF_OK OTA_INFLATE_OK
|
||||
#define TINF_DONE OTA_INFLATE_DONE
|
||||
#define TINF_DATA_ERROR OTA_INFLATE_DATA_ERROR
|
||||
#define TINF_DICT_ERROR OTA_INFLATE_DICT_ERROR
|
||||
#define TINF_DATA struct OtaInflateState
|
||||
#define TINF_TREE struct OtaInflateTree
|
||||
#define TINF_ARRAY_SIZE(arr) (sizeof(arr) / sizeof(*(arr)))
|
||||
|
||||
/* every output byte also goes into the ring window */
|
||||
#define TINF_PUT(d, c) \
|
||||
{ \
|
||||
*d->dest++ = c; \
|
||||
d->dict_ring[d->dict_idx++] = c; \
|
||||
if (d->dict_idx == d->dict_size) \
|
||||
d->dict_idx = 0; \
|
||||
}
|
||||
|
||||
/* --------------------------------------------------- *
|
||||
* -- constant tables (upstream builds them at runtime) -- *
|
||||
* --------------------------------------------------- */
|
||||
|
||||
static const unsigned char LENGTH_BITS[30] = {0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2,
|
||||
2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5};
|
||||
static const unsigned short LENGTH_BASE[30] = {3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 15, 17, 19, 23, 27,
|
||||
31, 35, 43, 51, 59, 67, 83, 99, 115, 131, 163, 195, 227, 258};
|
||||
|
||||
static const unsigned char DIST_BITS[30] = {0, 0, 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6,
|
||||
6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, 12, 12, 13, 13};
|
||||
static const unsigned short DIST_BASE[30] = {1, 2, 3, 4, 5, 7, 9, 13, 17, 25,
|
||||
33, 49, 65, 97, 129, 193, 257, 385, 513, 769,
|
||||
1025, 1537, 2049, 3073, 4097, 6145, 8193, 12289, 16385, 24577};
|
||||
|
||||
/* special ordering of code length codes */
|
||||
static const unsigned char CLCIDX[] = {16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15};
|
||||
|
||||
/* ----------------------- *
|
||||
* -- utility functions -- *
|
||||
* ----------------------- */
|
||||
|
||||
/* given an array of code lengths, build a tree */
|
||||
static void tinf_build_tree(TINF_TREE *t, const unsigned char *lengths, unsigned int num) {
|
||||
unsigned short offs[16];
|
||||
unsigned int i, sum;
|
||||
|
||||
/* clear code length count table */
|
||||
for (i = 0; i < 16; ++i)
|
||||
t->table[i] = 0;
|
||||
|
||||
/* scan symbol lengths, and sum code length counts */
|
||||
for (i = 0; i < num; ++i)
|
||||
t->table[lengths[i]]++;
|
||||
|
||||
/* In the lengths array, 0 means unused code. So, t->table[0] now contains
|
||||
number of unused codes. But table's purpose is to contain # of codes of
|
||||
particular length, and there're 0 codes of length 0. */
|
||||
t->table[0] = 0;
|
||||
|
||||
/* compute offset table for distribution sort */
|
||||
for (sum = 0, i = 0; i < 16; ++i) {
|
||||
offs[i] = sum;
|
||||
sum += t->table[i];
|
||||
}
|
||||
|
||||
/* create code->symbol translation table (symbols sorted by code) */
|
||||
for (i = 0; i < num; ++i) {
|
||||
if (lengths[i])
|
||||
t->trans[offs[lengths[i]]++] = i;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------- *
|
||||
* -- decode functions -- *
|
||||
* ---------------------- */
|
||||
|
||||
static unsigned char uzlib_get_byte(TINF_DATA *d) {
|
||||
/* If end of source buffer is not reached, return next byte from source
|
||||
buffer. */
|
||||
if (d->source < d->source_limit) {
|
||||
return *d->source++;
|
||||
}
|
||||
|
||||
/* Otherwise if there's callback and we haven't seen EOF yet, try to
|
||||
read next byte using it. (Note: the callback can also update ->source
|
||||
and ->source_limit). */
|
||||
if (!d->eof) {
|
||||
int val = d->source_read_cb(d);
|
||||
if (val >= 0) {
|
||||
return (unsigned char) val;
|
||||
}
|
||||
}
|
||||
|
||||
/* Otherwise, we hit EOF (either from ->source_read_cb() or from exhaustion
|
||||
of the buffer), and it will be "sticky", i.e. further calls to this
|
||||
function will end up here too. */
|
||||
d->eof = true;
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* get one bit from source stream */
|
||||
static int tinf_getbit(TINF_DATA *d) {
|
||||
unsigned int bit;
|
||||
|
||||
/* check if tag is empty */
|
||||
if (!d->bitcount--) {
|
||||
/* load next tag */
|
||||
d->tag = uzlib_get_byte(d);
|
||||
d->bitcount = 7;
|
||||
}
|
||||
|
||||
/* shift bit out of tag */
|
||||
bit = d->tag & 0x01;
|
||||
d->tag >>= 1;
|
||||
|
||||
return bit;
|
||||
}
|
||||
|
||||
/* read a num bit value from a stream and add base */
|
||||
static unsigned int tinf_read_bits(TINF_DATA *d, int num, int base) {
|
||||
unsigned int val = 0;
|
||||
|
||||
/* read num bits */
|
||||
if (num) {
|
||||
unsigned int limit = 1 << (num);
|
||||
unsigned int mask;
|
||||
|
||||
for (mask = 1; mask < limit; mask *= 2)
|
||||
if (tinf_getbit(d))
|
||||
val += mask;
|
||||
}
|
||||
|
||||
return val + base;
|
||||
}
|
||||
|
||||
/* given a data stream and a tree, decode a symbol */
|
||||
static int tinf_decode_symbol(TINF_DATA *d, TINF_TREE *t) {
|
||||
int sum = 0, cur = 0, len = 0;
|
||||
|
||||
/* get more bits while code value is above sum */
|
||||
do {
|
||||
cur = 2 * cur + tinf_getbit(d);
|
||||
|
||||
if (++len == TINF_ARRAY_SIZE(t->table)) {
|
||||
return TINF_DATA_ERROR;
|
||||
}
|
||||
|
||||
sum += t->table[len];
|
||||
cur -= t->table[len];
|
||||
|
||||
} while (cur >= 0);
|
||||
|
||||
sum += cur;
|
||||
if (sum < 0 || sum >= t->size) {
|
||||
return TINF_DATA_ERROR;
|
||||
}
|
||||
|
||||
return t->trans[sum];
|
||||
}
|
||||
|
||||
/* given a data stream, decode dynamic trees from it */
|
||||
static int tinf_decode_trees(TINF_DATA *d, TINF_TREE *lt, TINF_TREE *dt) {
|
||||
/* code lengths for 288 literal/len symbols and 32 dist symbols */
|
||||
unsigned char lengths[288 + 32];
|
||||
unsigned int hlit, hdist, hclen, hlimit;
|
||||
unsigned int i, num, length;
|
||||
|
||||
/* get 5 bits HLIT (257-286) */
|
||||
hlit = tinf_read_bits(d, 5, 257);
|
||||
|
||||
/* get 5 bits HDIST (1-32) */
|
||||
hdist = tinf_read_bits(d, 5, 1);
|
||||
|
||||
/* get 4 bits HCLEN (4-19) */
|
||||
hclen = tinf_read_bits(d, 4, 4);
|
||||
|
||||
for (i = 0; i < 19; ++i)
|
||||
lengths[i] = 0;
|
||||
|
||||
/* read code lengths for code length alphabet */
|
||||
for (i = 0; i < hclen; ++i) {
|
||||
/* get 3 bits code length (0-7) */
|
||||
unsigned int clen = tinf_read_bits(d, 3, 0);
|
||||
|
||||
lengths[CLCIDX[i]] = clen;
|
||||
}
|
||||
|
||||
/* build code length tree, temporarily use length tree */
|
||||
tinf_build_tree(lt, lengths, 19);
|
||||
|
||||
/* decode code lengths for the dynamic trees */
|
||||
hlimit = hlit + hdist;
|
||||
for (num = 0; num < hlimit;) {
|
||||
int sym = tinf_decode_symbol(d, lt);
|
||||
unsigned char fill_value = 0;
|
||||
int lbits, lbase = 3;
|
||||
|
||||
/* error decoding */
|
||||
if (sym < 0)
|
||||
return sym;
|
||||
|
||||
switch (sym) {
|
||||
case 16:
|
||||
/* copy previous code length 3-6 times (read 2 bits) */
|
||||
if (num == 0)
|
||||
return TINF_DATA_ERROR;
|
||||
fill_value = lengths[num - 1];
|
||||
lbits = 2;
|
||||
break;
|
||||
case 17:
|
||||
/* repeat code length 0 for 3-10 times (read 3 bits) */
|
||||
lbits = 3;
|
||||
break;
|
||||
case 18:
|
||||
/* repeat code length 0 for 11-138 times (read 7 bits) */
|
||||
lbits = 7;
|
||||
lbase = 11;
|
||||
break;
|
||||
default:
|
||||
/* values 0-15 represent the actual code lengths */
|
||||
lengths[num++] = sym;
|
||||
/* continue the for loop */
|
||||
continue;
|
||||
}
|
||||
|
||||
/* special code length 16-18 are handled here */
|
||||
length = tinf_read_bits(d, lbits, lbase);
|
||||
if (num + length > hlimit)
|
||||
return TINF_DATA_ERROR;
|
||||
for (; length; --length) {
|
||||
lengths[num++] = fill_value;
|
||||
}
|
||||
}
|
||||
|
||||
/* Check that there's "end of block" symbol */
|
||||
if (lengths[256] == 0) {
|
||||
return TINF_DATA_ERROR;
|
||||
}
|
||||
|
||||
/* build dynamic trees */
|
||||
tinf_build_tree(lt, lengths, hlit);
|
||||
tinf_build_tree(dt, lengths + hlit, hdist);
|
||||
|
||||
return TINF_OK;
|
||||
}
|
||||
|
||||
/* build the fixed huffman trees (RFC 1951 3.2.6) through the generic tree
|
||||
builder; altered from upstream, which unrolls them by hand */
|
||||
static void tinf_build_fixed_trees(TINF_TREE *lt, TINF_TREE *dt) {
|
||||
unsigned char lengths[288];
|
||||
unsigned int i;
|
||||
|
||||
for (i = 0; i < 144; ++i)
|
||||
lengths[i] = 8;
|
||||
for (; i < 256; ++i)
|
||||
lengths[i] = 9;
|
||||
for (; i < 280; ++i)
|
||||
lengths[i] = 7;
|
||||
for (; i < 288; ++i)
|
||||
lengths[i] = 8;
|
||||
tinf_build_tree(lt, lengths, 288);
|
||||
|
||||
for (i = 0; i < 32; ++i)
|
||||
lengths[i] = 5;
|
||||
tinf_build_tree(dt, lengths, 32);
|
||||
}
|
||||
|
||||
/* ----------------------------- *
|
||||
* -- block inflate functions -- *
|
||||
* ----------------------------- */
|
||||
|
||||
/* given a stream and two trees, inflate next chunk of output (a byte or more) */
|
||||
static int tinf_inflate_block_data(TINF_DATA *d, TINF_TREE *lt, TINF_TREE *dt) {
|
||||
if (d->curlen == 0) {
|
||||
unsigned int offs;
|
||||
int dist;
|
||||
int sym = tinf_decode_symbol(d, lt);
|
||||
|
||||
if (d->eof) {
|
||||
return TINF_DATA_ERROR;
|
||||
}
|
||||
|
||||
if (sym < 0) {
|
||||
return sym;
|
||||
}
|
||||
|
||||
/* literal byte */
|
||||
if (sym < 256) {
|
||||
TINF_PUT(d, sym);
|
||||
return TINF_OK;
|
||||
}
|
||||
|
||||
/* end of block */
|
||||
if (sym == 256) {
|
||||
return TINF_DONE;
|
||||
}
|
||||
|
||||
/* substring from sliding dictionary */
|
||||
sym -= 257;
|
||||
if (sym >= 29) {
|
||||
return TINF_DATA_ERROR;
|
||||
}
|
||||
|
||||
/* possibly get more bits from length code */
|
||||
d->curlen = tinf_read_bits(d, LENGTH_BITS[sym], LENGTH_BASE[sym]);
|
||||
|
||||
dist = tinf_decode_symbol(d, dt);
|
||||
if (dist < 0 || dist >= 30) {
|
||||
return TINF_DATA_ERROR;
|
||||
}
|
||||
|
||||
/* possibly get more bits from distance code */
|
||||
offs = tinf_read_bits(d, DIST_BITS[dist], DIST_BASE[dist]);
|
||||
|
||||
/* calculate and validate actual LZ offset to use */
|
||||
if (offs > d->dict_size) {
|
||||
return TINF_DICT_ERROR;
|
||||
}
|
||||
/* Note: we don't try to catch offset which points to not yet filled
|
||||
part of the dictionary here. Doing so would require keeping another
|
||||
variable to track "filled in" size of the dictionary. Appearance of
|
||||
such an offset cannot lead to accessing memory outside of the
|
||||
dictionary buffer, and clients which don't want to leak unrelated
|
||||
information, should explicitly initialize dictionary buffer passed
|
||||
to uzlib. */
|
||||
|
||||
d->lz_off = d->dict_idx - offs;
|
||||
if (d->lz_off < 0) {
|
||||
d->lz_off += d->dict_size;
|
||||
}
|
||||
}
|
||||
|
||||
/* copy next byte from dict substring */
|
||||
TINF_PUT(d, d->dict_ring[d->lz_off]);
|
||||
if ((unsigned) ++d->lz_off == d->dict_size) {
|
||||
d->lz_off = 0;
|
||||
}
|
||||
d->curlen--;
|
||||
return TINF_OK;
|
||||
}
|
||||
|
||||
/* inflate next byte from uncompressed block of data */
|
||||
static int tinf_inflate_uncompressed_block(TINF_DATA *d) {
|
||||
if (d->curlen == 0) {
|
||||
unsigned int length, invlength;
|
||||
|
||||
/* get length */
|
||||
length = uzlib_get_byte(d);
|
||||
length += 256 * uzlib_get_byte(d);
|
||||
/* get one's complement of length */
|
||||
invlength = uzlib_get_byte(d);
|
||||
invlength += 256 * uzlib_get_byte(d);
|
||||
/* check length */
|
||||
if (length != (~invlength & 0x0000ffff))
|
||||
return TINF_DATA_ERROR;
|
||||
|
||||
/* increment length to properly return TINF_DONE below, without
|
||||
producing data at the same time */
|
||||
d->curlen = length + 1;
|
||||
|
||||
/* make sure we start next block on a byte boundary */
|
||||
d->bitcount = 0;
|
||||
}
|
||||
|
||||
if (--d->curlen == 0) {
|
||||
return TINF_DONE;
|
||||
}
|
||||
|
||||
unsigned char c = uzlib_get_byte(d);
|
||||
TINF_PUT(d, c);
|
||||
return TINF_OK;
|
||||
}
|
||||
|
||||
/* ---------------------- *
|
||||
* -- public functions -- *
|
||||
* ---------------------- */
|
||||
|
||||
/* initialize decompression structure */
|
||||
void ota_inflate_init(TINF_DATA *d, unsigned char *dict, unsigned int dict_len) {
|
||||
d->source = NULL;
|
||||
d->source_limit = NULL;
|
||||
d->tag = 0;
|
||||
d->eof = 0;
|
||||
d->bitcount = 0;
|
||||
d->lz_off = 0;
|
||||
d->bfinal = 0;
|
||||
d->btype = -1;
|
||||
d->dict_size = dict_len;
|
||||
d->dict_ring = dict;
|
||||
d->dict_idx = 0;
|
||||
d->curlen = 0;
|
||||
d->ltree.trans = d->ltrans;
|
||||
d->ltree.size = TINF_ARRAY_SIZE(d->ltrans);
|
||||
d->dtree.trans = d->dtrans;
|
||||
d->dtree.size = TINF_ARRAY_SIZE(d->dtrans);
|
||||
}
|
||||
|
||||
/* inflate next output bytes from compressed stream */
|
||||
int ota_inflate(TINF_DATA *d) {
|
||||
do {
|
||||
int res;
|
||||
|
||||
/* start a new block */
|
||||
if (d->btype == -1) {
|
||||
int old_btype;
|
||||
next_blk:
|
||||
old_btype = d->btype;
|
||||
/* read final block flag */
|
||||
d->bfinal = tinf_getbit(d);
|
||||
/* read block type (2 bits) */
|
||||
d->btype = tinf_read_bits(d, 2, 0);
|
||||
|
||||
if (d->btype == 1 && old_btype != 1) {
|
||||
/* build fixed huffman trees */
|
||||
tinf_build_fixed_trees(&d->ltree, &d->dtree);
|
||||
} else if (d->btype == 2) {
|
||||
/* decode trees from stream */
|
||||
res = tinf_decode_trees(d, &d->ltree, &d->dtree);
|
||||
if (res != TINF_OK) {
|
||||
return res;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* process current block */
|
||||
switch (d->btype) {
|
||||
case 0:
|
||||
/* decompress uncompressed block */
|
||||
res = tinf_inflate_uncompressed_block(d);
|
||||
break;
|
||||
case 1:
|
||||
case 2:
|
||||
/* decompress block with fixed/dynamic huffman trees */
|
||||
/* trees were decoded previously, so it's the same routine for both */
|
||||
res = tinf_inflate_block_data(d, &d->ltree, &d->dtree);
|
||||
break;
|
||||
default:
|
||||
return TINF_DATA_ERROR;
|
||||
}
|
||||
|
||||
if (res == TINF_DONE && !d->bfinal) {
|
||||
/* the block has ended (without producing more data), but we
|
||||
can't return without data, so start procesing next block */
|
||||
goto next_blk;
|
||||
}
|
||||
|
||||
if (res != TINF_OK) {
|
||||
return res;
|
||||
}
|
||||
|
||||
} while (d->dest < d->dest_limit);
|
||||
|
||||
return TINF_OK;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
#pragma once
|
||||
// Raw deflate decoder cut down from uzlib (https://github.com/pfalcon/uzlib,
|
||||
// zlib licence, see the .c file); output goes through a ring window.
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
enum OtaInflateResult {
|
||||
OTA_INFLATE_OK = 0, /* more data produced, call again */
|
||||
OTA_INFLATE_DONE = 1, /* end of compressed stream reached */
|
||||
OTA_INFLATE_DATA_ERROR = -3,
|
||||
OTA_INFLATE_DICT_ERROR = -5,
|
||||
};
|
||||
|
||||
struct OtaInflateTree {
|
||||
uint16_t table[16]; /* table of code length counts */
|
||||
uint16_t *trans; /* code -> symbol translation table, size entries */
|
||||
uint16_t size;
|
||||
};
|
||||
|
||||
struct OtaInflateState {
|
||||
/* Next byte in the input buffer and one past its end */
|
||||
const unsigned char *source;
|
||||
const unsigned char *source_limit;
|
||||
/* Called when source is exhausted; returns the next byte or -1 at EOF.
|
||||
It may refill source/source_limit for buffered operation. */
|
||||
int (*source_read_cb)(struct OtaInflateState *d);
|
||||
|
||||
unsigned int tag;
|
||||
unsigned int bitcount;
|
||||
|
||||
/* Output cursor and one past the end of the output buffer */
|
||||
unsigned char *dest;
|
||||
unsigned char *dest_limit;
|
||||
|
||||
bool eof;
|
||||
|
||||
int btype;
|
||||
int bfinal;
|
||||
unsigned int curlen;
|
||||
int lz_off;
|
||||
/* Ring window holding the last dict_size output bytes for back references */
|
||||
unsigned char *dict_ring;
|
||||
unsigned int dict_size;
|
||||
unsigned int dict_idx;
|
||||
|
||||
struct OtaInflateTree ltree; /* dynamic length/symbol tree */
|
||||
struct OtaInflateTree dtree; /* dynamic distance tree */
|
||||
uint16_t ltrans[288];
|
||||
uint16_t dtrans[32]; /* the distance alphabet has 30 symbols, so the tree is kept small */
|
||||
};
|
||||
|
||||
/* dict must cover the encoder's window (its largest back reference) */
|
||||
void ota_inflate_init(struct OtaInflateState *d, unsigned char *dict, unsigned int dict_len);
|
||||
/* Fills dest up to dest_limit (OK) or to the end of the stream (DONE). dest may
|
||||
alias dict only if dest_limit - dest == dict_len and dest is reset to dict
|
||||
exactly when a call returns OK, so the ring index and dest stay in lockstep */
|
||||
int ota_inflate(struct OtaInflateState *d);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -43,9 +43,12 @@ ESPHomeOTAComponent::NoiseSession::~NoiseSession() {
|
||||
bool ESPHomeOTAComponent::noise_start_session_(uint8_t server_feature_flags) {
|
||||
// A provisioned key cleared between the offer and here is not guarded: the
|
||||
// session runs on the zero key load_psk fills in and fails the client's MAC.
|
||||
// Default-init: the frame buffer is written before it is read
|
||||
// NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks)
|
||||
this->noise_ = std::unique_ptr<NoiseSession>(new (std::nothrow) NoiseSession);
|
||||
// Reuse the session reserved at offer time, else allocate now. Default-init:
|
||||
// the frame buffer is written before it is read
|
||||
if (this->noise_ == nullptr) {
|
||||
// NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks)
|
||||
this->noise_ = std::unique_ptr<NoiseSession>(new (std::nothrow) NoiseSession);
|
||||
}
|
||||
static constexpr size_t PROLOGUE_ACK_LEN = 2; // OTA_RESPONSE_OK + version
|
||||
static constexpr size_t PROLOGUE_CLIENT_FEATURES_LEN = 1;
|
||||
static constexpr size_t PROLOGUE_FEATURE_ACK_LEN = 2; // OTA_RESPONSE_FEATURE_FLAGS + server flags
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
from esphome.build_helpers.pch import PCH_PREFIX_HEADER, pch_enabled, pch_extra_scripts
|
||||
import esphome.codegen as cg
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
@@ -11,7 +10,7 @@ from esphome.const import (
|
||||
ThreadModel,
|
||||
)
|
||||
from esphome.core import CORE
|
||||
from esphome.platformio.toolchain import copy_ccache_script, copy_pch_script
|
||||
from esphome.platformio.toolchain import copy_ccache_script
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .const import KEY_HOST
|
||||
@@ -56,18 +55,9 @@ async def to_code(config: ConfigType) -> None:
|
||||
cg.add_platformio_option("platform", "platformio/native")
|
||||
cg.add_platformio_option("lib_ldf_mode", "off")
|
||||
cg.add_platformio_option("lib_compat_mode", "strict")
|
||||
cg.add_platformio_option("extra_scripts", ["pre:ccache.py", *pch_extra_scripts()])
|
||||
if pch_enabled():
|
||||
# Curated prefix for the pch (the script folds it plus defines.h):
|
||||
# host has no framework force-includes, and the per-TU cost is the
|
||||
# STL closure behind the core headers. Measured -43% compile CPU.
|
||||
# Gated so ESPHOME_PCH_ENABLE=0 restores the strict view. When the
|
||||
# .gch fails to build or load, the force-include stays and every TU
|
||||
# parses the closure as text: correct, but slower than no pch.
|
||||
cg.add_platformio_option("build_src_flags", f"-include {PCH_PREFIX_HEADER}")
|
||||
cg.add_platformio_option("extra_scripts", ["pre:ccache.py"])
|
||||
|
||||
|
||||
# Called by writer.py
|
||||
def copy_files() -> None:
|
||||
copy_ccache_script()
|
||||
copy_pch_script()
|
||||
|
||||
@@ -118,21 +118,24 @@ void I2SAudioSpeakerBase::loop() {
|
||||
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) {
|
||||
ESP_LOGE(TAG, "Driver failed to start; retrying in 1 second");
|
||||
this->status_momentary_error("driver-failure", 1000);
|
||||
break;
|
||||
}
|
||||
|
||||
if (this->speaker_task_handle_ == nullptr) {
|
||||
xTaskCreate(I2SAudioSpeakerBase::speaker_task, "speaker_task", TASK_STACK_SIZE, (void *) this, TASK_PRIORITY,
|
||||
&this->speaker_task_handle_);
|
||||
xTaskCreate(I2SAudioSpeakerBase::speaker_task, "speaker_task", TASK_STACK_SIZE, (void *) this, TASK_PRIORITY,
|
||||
&this->speaker_task_handle_);
|
||||
|
||||
if (this->speaker_task_handle_ == nullptr) {
|
||||
ESP_LOGE(TAG, "Task failed to start, retrying in 1 second");
|
||||
this->status_momentary_error("task-failure", 1000);
|
||||
this->stop_i2s_driver_(); // Stops the driver to return the lock; will be reloaded in next attempt
|
||||
}
|
||||
if (this->speaker_task_handle_ == nullptr) {
|
||||
ESP_LOGE(TAG, "Task failed to start, retrying in 1 second");
|
||||
this->status_momentary_error("task-failure", 1000);
|
||||
this->stop_i2s_driver_(); // Stops the driver to return the lock; will be reloaded in next attempt
|
||||
}
|
||||
break;
|
||||
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 {
|
||||
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 false;
|
||||
|
||||
@@ -2,7 +2,6 @@ import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from esphome.build_helpers.pch import pch_extra_scripts
|
||||
import esphome.codegen as cg
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
@@ -27,7 +26,7 @@ from esphome.const import (
|
||||
from esphome.core import CORE
|
||||
from esphome.core.config import BOARD_MAX_LENGTH
|
||||
from esphome.helpers import copy_file_if_changed
|
||||
from esphome.platformio.toolchain import copy_ccache_script, copy_pch_script
|
||||
from esphome.platformio.toolchain import copy_ccache_script
|
||||
from esphome.storage_json import StorageJSON
|
||||
|
||||
from . import gpio # noqa: F401
|
||||
@@ -514,7 +513,7 @@ async def component_to_code(config):
|
||||
# it for project source files only. GCC uses the last -O flag.
|
||||
build_src_flags += " -Os"
|
||||
cg.add_platformio_option("build_src_flags", build_src_flags)
|
||||
cg.add_platformio_option("extra_scripts", ["pre:ccache.py", *pch_extra_scripts()])
|
||||
cg.add_platformio_option("extra_scripts", ["pre:ccache.py"])
|
||||
# IRAM_ATTR is a no-op on BK72xx (SDK masks FIQ+IRQ around flash ops).
|
||||
# On other families, patch_linker.py routes .sram.text into the right
|
||||
# RAM-executable output section and prints a post-link placement summary.
|
||||
@@ -620,4 +619,3 @@ def copy_files() -> None:
|
||||
CORE.relative_build_path("patch_linker.py"),
|
||||
)
|
||||
copy_ccache_script()
|
||||
copy_pch_script()
|
||||
|
||||
@@ -129,7 +129,7 @@ void MicroWakeWord::setup() {
|
||||
return;
|
||||
}
|
||||
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
|
||||
// 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.
|
||||
@@ -446,9 +446,9 @@ void MicroWakeWord::loop() {
|
||||
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");
|
||||
this->inference_task_.deallocate();
|
||||
xEventGroupClearBits(this->event_group_, ALL_BITS);
|
||||
xQueueReset(this->detection_queue_);
|
||||
this->set_state_(State::STOPPED);
|
||||
|
||||
@@ -48,7 +48,7 @@ class MicrophoneSource final {
|
||||
template<typename F> void add_data_callback(F &&data_callback) {
|
||||
this->mic_->add_data_callback([this, data_callback](const std::vector<uint8_t> &data) {
|
||||
if (this->enabled_ || this->passive_) {
|
||||
if (this->processed_samples_.use_count() == 0) {
|
||||
if (this->processed_samples_ == nullptr) {
|
||||
// Create vector if its unused
|
||||
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;
|
||||
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
|
||||
bytes_written = temp_ring_buffer->write_without_replacement(data, length, ticks_to_wait);
|
||||
if (bytes_written > 0) {
|
||||
@@ -250,14 +250,14 @@ esp_err_t SourceSpeaker::start_() {
|
||||
// avoids unnecessary single-frame splices.
|
||||
const size_t ring_buffer_size =
|
||||
(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();
|
||||
if (!temp_ring_buffer) {
|
||||
if (temp_ring_buffer == nullptr) {
|
||||
temp_ring_buffer = ring_buffer::RingBuffer::create(ring_buffer_size);
|
||||
this->ring_buffer_ = temp_ring_buffer;
|
||||
}
|
||||
|
||||
if (!temp_ring_buffer) {
|
||||
if (temp_ring_buffer == nullptr) {
|
||||
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); }
|
||||
|
||||
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) {
|
||||
@@ -382,8 +382,8 @@ void MixerSpeaker::loop() {
|
||||
ESP_LOGV(TAG, "Stopping");
|
||||
xEventGroupClearBits(this->event_group_, MIXER_TASK_STATE_STOPPING);
|
||||
}
|
||||
if (event_group_bits & MIXER_TASK_STATE_STOPPED) {
|
||||
this->task_.deallocate();
|
||||
// Retries on a subsequent loop if the task is still running on the other core
|
||||
if ((event_group_bits & MIXER_TASK_STATE_STOPPED) && this->task_.deallocate()) {
|
||||
ESP_LOGD(TAG, "Stopped");
|
||||
xEventGroupClearBits(this->event_group_, MIXER_TASK_ALL_BITS);
|
||||
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()) {
|
||||
// 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();
|
||||
if (audio_source.use_count() == 0) {
|
||||
if (audio_source == nullptr) {
|
||||
// No audio source allocated, so skip processing this speaker
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -88,12 +88,12 @@ def encryption_schema(config: ConfigType | None) -> ConfigType:
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
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
|
||||
# library manager see the full set up front instead of discovering
|
||||
# libsodium only after noise-c has downloaded, so the two can download
|
||||
# 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
|
||||
cg.add_build_flag("-DHAVE_WEAK_SYMBOLS=1")
|
||||
cg.add_build_flag("-DHAVE_INLINE_ASM=1")
|
||||
|
||||
@@ -1,24 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from functools import partial
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
|
||||
from esphome import pins
|
||||
from esphome.build_helpers import pch
|
||||
from esphome.build_helpers.pch import (
|
||||
PCH_DEFAULT_HEADERS,
|
||||
PCH_HEADER_NAME,
|
||||
mark_pch_emitted,
|
||||
pch_cmake_consumer,
|
||||
pch_enabled,
|
||||
pch_header_text,
|
||||
)
|
||||
import esphome.codegen as cg
|
||||
from esphome.components.zephyr import (
|
||||
add_extra_script,
|
||||
@@ -816,9 +805,6 @@ def _generate_cmake_lists() -> bool:
|
||||
")",
|
||||
]
|
||||
|
||||
if consumer := pch_cmake_consumer("app", "${APP_SOURCES}"):
|
||||
lines += consumer.splitlines()
|
||||
|
||||
if link_flags:
|
||||
lines += [
|
||||
"",
|
||||
@@ -833,66 +819,6 @@ def _generate_cmake_lists() -> bool:
|
||||
)
|
||||
|
||||
|
||||
def _app_build_dir(build_dir: Path) -> Path:
|
||||
"""The CMake binary dir of the app image: sysbuild nests it in a
|
||||
domain dir named after the app source dir. Probed on disk (the
|
||||
non-sysbuild zephyr/ output dir has no CMakeCache.txt) so it stays
|
||||
truthful mid-build, unlike an SDK-version check."""
|
||||
sysbuild_app = build_dir / "zephyr"
|
||||
try:
|
||||
cache = (sysbuild_app / "CMakeCache.txt").stat()
|
||||
except (FileNotFoundError, NotADirectoryError):
|
||||
return build_dir
|
||||
# Other stat errors propagate; is_file() would silently mislocate the pch
|
||||
return sysbuild_app if stat.S_ISREG(cache.st_mode) else build_dir
|
||||
|
||||
|
||||
def _prepare_pch(app_dir: Path) -> None:
|
||||
"""Build the .gch between the cmake and compile phases of west."""
|
||||
if not pch_enabled():
|
||||
pch.discard_pch(app_dir)
|
||||
pch.pch_disabled_degraded()
|
||||
return
|
||||
# First, so OBJECT_DEPENDS is satisfied even when the pch degrades
|
||||
app_dir.mkdir(parents=True, exist_ok=True)
|
||||
write_file_if_changed(
|
||||
app_dir / PCH_HEADER_NAME, pch_header_text(PCH_DEFAULT_HEADERS)
|
||||
)
|
||||
# New layout first (Zephyr >= 3.4 nests under zephyr/); fixed candidates
|
||||
# keep the .sum identity deterministic and skip walking generated/
|
||||
generated = app_dir / "zephyr" / "include" / "generated"
|
||||
autoconf = None
|
||||
for candidate in (generated / "zephyr" / "autoconf.h", generated / "autoconf.h"):
|
||||
if candidate.exists():
|
||||
autoconf = candidate
|
||||
break
|
||||
if autoconf is None:
|
||||
# Fail closed: autoconf.h is the .sum's Kconfig identity
|
||||
_LOGGER.warning("No autoconf.h found; compiling without the pch")
|
||||
pch.discard_pch(app_dir)
|
||||
pch.pch_degraded("autoconf.h missing")
|
||||
return
|
||||
try:
|
||||
autoconf_text = autoconf.read_text(encoding="utf-8")
|
||||
except OSError as err:
|
||||
_LOGGER.warning(
|
||||
"Could not read %s; compiling without the pch: %s", autoconf, err
|
||||
)
|
||||
pch.discard_pch(app_dir)
|
||||
pch.pch_degraded(f"autoconf unreadable: {err}")
|
||||
return
|
||||
pch.prepare_pch(
|
||||
app_dir,
|
||||
PCH_DEFAULT_HEADERS,
|
||||
(
|
||||
str(CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]),
|
||||
zephyr_data()[KEY_BOARD],
|
||||
autoconf_text,
|
||||
*get_project_compile_flags(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _copy_if_exists(src: Path, dst: Path) -> None:
|
||||
if src.is_file():
|
||||
shutil.copy2(src, dst)
|
||||
@@ -945,58 +871,6 @@ def run_compile(args, config: ConfigType) -> bool:
|
||||
str(source_dir),
|
||||
]
|
||||
|
||||
if pch_enabled():
|
||||
# Consumers carry the -include; gate the ccache relaxation on it
|
||||
# (Zephyr auto-enables ccache as the compiler launcher when found)
|
||||
mark_pch_emitted()
|
||||
env.update(pch.ccache_pch_env())
|
||||
|
||||
# Configure first so the .gch compiles from settled compile DB
|
||||
# flags. Only when the app DB is missing: input changes wipe the
|
||||
# build dir, so an existing DB is settled and --cmake-only would
|
||||
# reconfigure for nothing.
|
||||
prepare = True
|
||||
app_dir = _app_build_dir(build_dir)
|
||||
if not (app_dir / "compile_commands.json").is_file():
|
||||
if not run_command_ok(
|
||||
west_cmd + ["--cmake-only", "--", "-DCMAKE_EXPORT_COMPILE_COMMANDS=ON"],
|
||||
env=env,
|
||||
stream_output=True,
|
||||
cwd=str(paths["framework_path"]),
|
||||
):
|
||||
raise EsphomeError("nRF52 native build configure failed")
|
||||
# The configure phase creates the sysbuild domain dir: re-resolve
|
||||
app_dir = _app_build_dir(build_dir)
|
||||
# kernel.h needs the build-time syscall headers; under sysbuild
|
||||
# the target exists only in the app domain's ninja
|
||||
if not run_command_ok(
|
||||
[
|
||||
"cmake",
|
||||
"--build",
|
||||
str(app_dir),
|
||||
"--target",
|
||||
"zephyr_generated_headers",
|
||||
],
|
||||
env=env,
|
||||
stream_output=True,
|
||||
cwd=str(paths["framework_path"]),
|
||||
):
|
||||
# A pch-only prerequisite: degrade, let the real build report.
|
||||
# Also skip the .gch compile: it would fail on the missing
|
||||
# headers and latch .gch.failed until an identity change
|
||||
_LOGGER.warning(
|
||||
"Zephyr header generation failed; compiling without the pch"
|
||||
)
|
||||
pch.discard_pch(app_dir)
|
||||
pch.pch_degraded("zephyr_generated_headers failed")
|
||||
prepare = False
|
||||
else:
|
||||
prepare = True
|
||||
app_dir = _app_build_dir(build_dir)
|
||||
|
||||
if prepare:
|
||||
pch.guarded_prepare(app_dir, partial(_prepare_pch, app_dir))
|
||||
|
||||
if not run_command_ok(
|
||||
west_cmd,
|
||||
env=env,
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
#include <concepts>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <type_traits>
|
||||
|
||||
#ifdef USE_OTA_STATE_LISTENER
|
||||
#include <vector>
|
||||
@@ -102,6 +103,8 @@ enum OTAType : uint8_t {
|
||||
// - set_update_md5: expected digest of the incoming image, hex string.
|
||||
// - write: consume the next chunk; end: finalize and mark bootable.
|
||||
// - abort: safe to call in any state, including after end().
|
||||
// - supports_compression: constexpr, whether a gzip image is stored as is and
|
||||
// inflated at reboot.
|
||||
template<typename T>
|
||||
concept OTABackendContract = requires(T backend, size_t image_size, uint8_t *data, size_t len, const char *md5) {
|
||||
{ backend.begin(image_size, OTA_TYPE_UPDATE_APP) } -> std::same_as<OTAResponseTypes>;
|
||||
@@ -110,7 +113,9 @@ concept OTABackendContract = requires(T backend, size_t image_size, uint8_t *dat
|
||||
{ backend.write(data, len) } -> std::same_as<OTAResponseTypes>;
|
||||
{ backend.end() } -> std::same_as<OTAResponseTypes>;
|
||||
backend.abort();
|
||||
{ backend.supports_compression() } -> std::same_as<bool>;
|
||||
{ T::supports_compression() } -> std::same_as<bool>;
|
||||
// The value must be a constant expression
|
||||
typename std::bool_constant<T::supports_compression()>;
|
||||
};
|
||||
|
||||
/** Listener interface for OTA state changes.
|
||||
|
||||
@@ -13,7 +13,7 @@ class ArduinoLibreTinyOTABackend final {
|
||||
OTAResponseTypes write(uint8_t *data, size_t len);
|
||||
OTAResponseTypes end();
|
||||
void abort();
|
||||
bool supports_compression() { return false; }
|
||||
static constexpr bool supports_compression() { return false; }
|
||||
|
||||
private:
|
||||
bool md5_set_{false};
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
#include "esphome/core/defines.h"
|
||||
#include "esphome/core/macros.h"
|
||||
|
||||
#include <RP2040Version.h>
|
||||
|
||||
namespace esphome::ota {
|
||||
|
||||
class ArduinoRP2OTABackend final {
|
||||
@@ -15,7 +17,12 @@ class ArduinoRP2OTABackend final {
|
||||
OTAResponseTypes write(uint8_t *data, size_t len);
|
||||
OTAResponseTypes end();
|
||||
void abort();
|
||||
bool supports_compression() { return false; }
|
||||
// The core's OTA stub inflates a staged gzip image at reboot, on every chip
|
||||
// from 4.0.3 (ESPHome pins 6.0.0). begin() only sees the gzip size; the
|
||||
// inflated size is known when the stub reads the trailer.
|
||||
static constexpr bool supports_compression() {
|
||||
return VERSION_CODE(ARDUINO_PICO_MAJOR, ARDUINO_PICO_MINOR, ARDUINO_PICO_REVISION) >= VERSION_CODE(4, 0, 3);
|
||||
}
|
||||
|
||||
private:
|
||||
bool md5_set_{false};
|
||||
|
||||
@@ -20,7 +20,7 @@ class ESP8266OTABackend final {
|
||||
OTAResponseTypes end();
|
||||
void abort();
|
||||
// Compression supported in all ESP8266 Arduino versions ESPHome supports (>= 2.7.0)
|
||||
bool supports_compression() { return true; }
|
||||
static constexpr bool supports_compression() { return true; }
|
||||
|
||||
protected:
|
||||
/// Erase flash sector if current address is at sector boundary
|
||||
|
||||
@@ -33,7 +33,7 @@ class IDFOTABackend final {
|
||||
OTAResponseTypes write(uint8_t *data, size_t len);
|
||||
OTAResponseTypes end();
|
||||
void abort();
|
||||
bool supports_compression() { return false; }
|
||||
static constexpr bool supports_compression() { return false; }
|
||||
|
||||
protected:
|
||||
#ifdef USE_OTA_PARTITIONS
|
||||
|
||||
@@ -25,7 +25,7 @@ struct StubOTABackend {
|
||||
OTAResponseTypes write(uint8_t *data, size_t len) { return OTA_RESPONSE_ERROR_UNKNOWN; }
|
||||
OTAResponseTypes end() { return OTA_RESPONSE_ERROR_UNKNOWN; }
|
||||
void abort() {}
|
||||
bool supports_compression() { return false; }
|
||||
static constexpr bool supports_compression() { return false; }
|
||||
};
|
||||
std::unique_ptr<StubOTABackend> make_ota_backend();
|
||||
} // namespace esphome::ota
|
||||
@@ -33,6 +33,7 @@ std::unique_ptr<StubOTABackend> make_ota_backend();
|
||||
|
||||
namespace esphome::ota {
|
||||
using OTABackendPtr = decltype(make_ota_backend());
|
||||
static_assert(OTABackendContract<OTABackendPtr::element_type>,
|
||||
using OTABackend = OTABackendPtr::element_type;
|
||||
static_assert(OTABackendContract<OTABackend>,
|
||||
"The platform's OTA backend is missing part of the backend surface (ota_backend.h)");
|
||||
} // namespace esphome::ota
|
||||
|
||||
@@ -19,7 +19,7 @@ class HostOTABackend final {
|
||||
OTAResponseTypes write(uint8_t *data, size_t len);
|
||||
OTAResponseTypes end();
|
||||
void abort();
|
||||
bool supports_compression() { return false; }
|
||||
static constexpr bool supports_compression() { return false; }
|
||||
|
||||
protected:
|
||||
md5::MD5Digest md5_{};
|
||||
|
||||
@@ -153,8 +153,8 @@ void ResamplerSpeaker::loop() {
|
||||
ESP_LOGV(TAG, "Stopping");
|
||||
xEventGroupClearBits(this->event_group_, ResamplingEventGroupBits::STATE_STOPPING);
|
||||
}
|
||||
if (event_group_bits & ResamplingEventGroupBits::STATE_STOPPED) {
|
||||
this->task_.deallocate();
|
||||
// Retries on a subsequent loop if the task is still running on the other core
|
||||
if ((event_group_bits & ResamplingEventGroupBits::STATE_STOPPED) && this->task_.deallocate()) {
|
||||
ESP_LOGD(TAG, "Stopped");
|
||||
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);
|
||||
} else {
|
||||
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
|
||||
bytes_written = temp_ring_buffer->write_without_replacement(data, length, ticks_to_wait);
|
||||
} else {
|
||||
@@ -299,7 +299,7 @@ bool ResamplerSpeaker::has_buffered_data() const {
|
||||
bool has_ring_buffer_data = false;
|
||||
if (this->requires_resampling_()) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -342,7 +342,7 @@ void ResamplerSpeaker::resample_task(void *params) {
|
||||
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_));
|
||||
|
||||
if (!temp_ring_buffer) {
|
||||
if (temp_ring_buffer == nullptr) {
|
||||
err = ESP_ERR_NO_MEM;
|
||||
} else {
|
||||
this_resampler->ring_buffer_ = temp_ring_buffer;
|
||||
|
||||
@@ -6,7 +6,6 @@ from string import ascii_letters, digits
|
||||
import subprocess
|
||||
from typing import Any
|
||||
|
||||
from esphome.build_helpers.pch import pch_extra_scripts
|
||||
import esphome.codegen as cg
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
@@ -34,7 +33,7 @@ from esphome.core import (
|
||||
)
|
||||
from esphome.core.config import BOARD_MAX_LENGTH
|
||||
from esphome.helpers import copy_file_if_changed, read_file, write_file_if_changed
|
||||
from esphome.platformio.toolchain import copy_ccache_script, copy_pch_script
|
||||
from esphome.platformio.toolchain import copy_ccache_script
|
||||
from esphome.storage_json import StorageJSON
|
||||
from esphome.types import ConfigType
|
||||
|
||||
@@ -341,10 +340,7 @@ async def to_code(config: ConfigType) -> None:
|
||||
cg.add_define("ESPHOME_VARIANT", VARIANT_FRIENDLY[variant])
|
||||
cg.add_define(ThreadModel.SINGLE)
|
||||
|
||||
cg.add_platformio_option(
|
||||
"extra_scripts",
|
||||
["pre:ccache.py", *pch_extra_scripts(), "post:post_build.py"],
|
||||
)
|
||||
cg.add_platformio_option("extra_scripts", ["pre:ccache.py", "post:post_build.py"])
|
||||
|
||||
conf = config[CONF_FRAMEWORK]
|
||||
cg.add_platformio_option("framework", "arduino")
|
||||
@@ -648,7 +644,6 @@ def copy_files() -> None:
|
||||
CORE.relative_build_path("inject_lwip_include.py"),
|
||||
)
|
||||
copy_ccache_script()
|
||||
copy_pch_script()
|
||||
_generate_lwipopts_h()
|
||||
if generate_pio_files():
|
||||
path = CORE.relative_src_path("esphome.h")
|
||||
|
||||
@@ -202,8 +202,15 @@ AudioPipelineState AudioPipeline::process_state() {
|
||||
if (!this->is_playing_) {
|
||||
// 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()) {
|
||||
this->read_task_.deallocate();
|
||||
this->decode_task_.deallocate();
|
||||
// Both are attempted every time; a task that is still running on the other core is freed by a
|
||||
// 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_) {
|
||||
// Stop command was sent, so immediately end the playback
|
||||
this->speaker_->stop();
|
||||
@@ -315,17 +322,17 @@ void AudioPipeline::read_task(void *params) {
|
||||
if (err == ESP_OK) {
|
||||
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);
|
||||
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;
|
||||
} 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_);
|
||||
|
||||
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) {
|
||||
// Send specific error message
|
||||
|
||||
@@ -1143,14 +1143,13 @@ class EsphomeCore:
|
||||
def add_platformio_option(
|
||||
self, key: str, value: str | list[str], *, replace: bool = False
|
||||
) -> None:
|
||||
"""Set a platformio.ini option; values append to an existing list
|
||||
(a string as one element) unless ``replace`` is True, which
|
||||
overwrites any existing value."""
|
||||
"""Set a platformio.ini option; list values append to an existing list
|
||||
unless ``replace`` is True, which overwrites any existing value."""
|
||||
new_val = value
|
||||
old_val = self.platformio_options.get(key)
|
||||
if not replace and isinstance(old_val, list):
|
||||
# A user platformio_options string must merge, not assert
|
||||
new_val = old_val + ([value] if isinstance(value, str) else value)
|
||||
assert isinstance(value, list)
|
||||
new_val = old_val + value
|
||||
self.platformio_options[key] = new_val
|
||||
|
||||
def _get_variable_generator(self, id):
|
||||
|
||||
@@ -555,14 +555,7 @@ NATIVE_ARDUINO_PIO_OPTIONS = frozenset({"board_build.f_cpu", "board_build.ldscri
|
||||
# that is stored rather than translated away. Consumed by the esp8266 native
|
||||
# backend (later in this chain) for its ignored-option warning; defined here
|
||||
# so it stays adjacent to the routing.
|
||||
# build_src_flags and board_build.flash_mode: set unconditionally by
|
||||
# esp8266/__init__ and read by the native generator; not user-routable, so
|
||||
# not in the set above
|
||||
NATIVE_ARDUINO_CONSUMED_PIO_OPTIONS = NATIVE_ARDUINO_PIO_OPTIONS | {
|
||||
"lib_ignore",
|
||||
"build_src_flags",
|
||||
"board_build.flash_mode",
|
||||
}
|
||||
NATIVE_ARDUINO_CONSUMED_PIO_OPTIONS = NATIVE_ARDUINO_PIO_OPTIONS | {"lib_ignore"}
|
||||
|
||||
|
||||
@coroutine_with_priority(CoroPriority.FINAL)
|
||||
|
||||
@@ -244,6 +244,7 @@
|
||||
#define USE_RUNTIME_IMAGE_QOI
|
||||
#define USE_RUNTIME_STATS
|
||||
#define USE_OTA
|
||||
#define USE_OTA_DEFLATE
|
||||
#define USE_OTA_ENCRYPTION
|
||||
#define USE_OTA_ENCRYPTION_FROM_API
|
||||
#define USE_OTA_ENCRYPTION_PROVISIONED
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
void StaticTask::destroy() {
|
||||
if (this->handle_ != nullptr) {
|
||||
TaskHandle_t handle = this->handle_;
|
||||
this->handle_ = nullptr;
|
||||
vTaskDelete(handle);
|
||||
bool StaticTask::destroy() {
|
||||
if (this->handle_ == nullptr) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 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() {
|
||||
this->destroy();
|
||||
bool StaticTask::deallocate() {
|
||||
if (!this->destroy()) {
|
||||
return false;
|
||||
}
|
||||
if (this->stack_buffer_ != nullptr) {
|
||||
RAMAllocator<StackType_t> allocator(this->use_psram_ ? RAMAllocator<StackType_t>::ALLOC_EXTERNAL
|
||||
: RAMAllocator<StackType_t>::ALLOC_INTERNAL);
|
||||
@@ -57,6 +72,7 @@ void StaticTask::deallocate() {
|
||||
this->stack_buffer_ = nullptr;
|
||||
this->stack_size_ = 0;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace esphome
|
||||
|
||||
@@ -11,6 +11,7 @@ namespace esphome {
|
||||
|
||||
/** Helper for FreeRTOS static task management.
|
||||
* 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 {
|
||||
public:
|
||||
@@ -23,7 +24,7 @@ class StaticTask {
|
||||
/// @brief Allocate stack and create task.
|
||||
/// @param fn Task function
|
||||
/// @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 priority FreeRTOS task priority
|
||||
/// @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 use_psram);
|
||||
|
||||
/// @brief Delete the task but keep the stack buffer allocated for reuse by a subsequent create() call.
|
||||
void destroy();
|
||||
/// @brief Delete the task, keeping the stack buffer allocated for reuse by a subsequent create() call.
|
||||
/// 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.
|
||||
void deallocate();
|
||||
/// @brief Delete the task (if created) and free the stack buffer.
|
||||
/// @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:
|
||||
TaskHandle_t handle_{nullptr};
|
||||
|
||||
+19
-29
@@ -16,7 +16,6 @@ from esphome.build_helpers.ccache import (
|
||||
parse_enable_env,
|
||||
resolve_ccache_path,
|
||||
)
|
||||
from esphome.build_helpers.pch import ccache_pch_env
|
||||
from esphome.build_helpers.tools_cache import IDF_TOOLS_CACHE, tools_cache_path
|
||||
from esphome.core import Version
|
||||
from esphome.framework_helpers import (
|
||||
@@ -34,7 +33,7 @@ from esphome.framework_helpers import (
|
||||
run_command_ok,
|
||||
str_to_lst_of_str,
|
||||
tool_version_runs,
|
||||
warn_batch_failures,
|
||||
warn_prefetch_failures,
|
||||
)
|
||||
from esphome.helpers import write_file_if_changed
|
||||
|
||||
@@ -774,7 +773,7 @@ def _prefetch_idf_tool_archives(
|
||||
for entry in entries
|
||||
],
|
||||
)
|
||||
warn_batch_failures(failures)
|
||||
warn_prefetch_failures(failures)
|
||||
if len(failures) == len(entries):
|
||||
# A systematic fault, not one flaky mirror: the resume
|
||||
# workaround (#17703) is off for this whole install
|
||||
@@ -1206,33 +1205,15 @@ def _ccache_env() -> dict[str, str]:
|
||||
|
||||
Only values the user has not already set in the environment are returned, so
|
||||
a custom ``CCACHE_DIR`` / ``CCACHE_MAXSIZE`` / etc. is respected.
|
||||
The pch settings add ``time_macros`` sloppiness process-wide; the visible
|
||||
effect is a cached TU can keep an older ``esp_app_desc`` build timestamp.
|
||||
"""
|
||||
if not _ccache_enabled():
|
||||
# The raw knob value (e.g. "disable") is still inherited by idf.py
|
||||
# via os.environ, where a non-false-constant string reads as
|
||||
# truthy; export the canonical off spelling instead
|
||||
return {"IDF_CCACHE_ENABLE": "0"}
|
||||
|
||||
env = ccache_defaults_env(get_idf_tools_path() / "ccache")
|
||||
env.update(ccache_pch_env())
|
||||
# Exactly one canonical spelling ever reaches idf.py, whatever the
|
||||
# accepted input spelling was ("enable", "yes", ...)
|
||||
env["IDF_CCACHE_ENABLE"] = "1"
|
||||
return env
|
||||
|
||||
|
||||
def _ccache_enabled() -> bool:
|
||||
"""Whether ESP-IDF compiles run under ccache.
|
||||
|
||||
IDF_CCACHE_ENABLE (the backend-native knob) wins over the shared
|
||||
ESPHOME_CCACHE_ENABLE; when unset, enabled iff a runnable binary is
|
||||
on PATH.
|
||||
"""
|
||||
# IDF_CCACHE_ENABLE (the backend-native knob) wins over the shared
|
||||
# ESPHOME_CCACHE_ENABLE.
|
||||
idf_knob = parse_enable_env("IDF_CCACHE_ENABLE")
|
||||
if idf_knob is False:
|
||||
return False
|
||||
# The raw value (e.g. "disable") is still inherited by idf.py via
|
||||
# os.environ, where a non-false-constant string reads as truthy;
|
||||
# export the canonical off spelling instead
|
||||
return {"IDF_CCACHE_ENABLE": "0"}
|
||||
if idf_knob is True:
|
||||
# Forced on ignores the runnability verdict, but the outcome is
|
||||
# worth saying out loud. Probed directly (not via the resolver,
|
||||
@@ -1252,8 +1233,17 @@ def _ccache_enabled() -> bool:
|
||||
"IDF_CCACHE_ENABLE=1 forces on the ccache at %s even though "
|
||||
"it failed to run; idf.py will use it anyway",
|
||||
)
|
||||
return True
|
||||
return resolve_ccache_path() is not None
|
||||
elif resolve_ccache_path() is None:
|
||||
# ESP-IDF silently skips ccache without the binary; export the
|
||||
# canonical off spelling so an unparsable inherited value (or a
|
||||
# probe-rejected ccache idf.py would still find) cannot enable it
|
||||
return {"IDF_CCACHE_ENABLE": "0"}
|
||||
|
||||
env = ccache_defaults_env(get_idf_tools_path() / "ccache")
|
||||
# Exactly one canonical spelling ever reaches idf.py, whatever the
|
||||
# accepted input spelling was ("enable", "yes", ...)
|
||||
env["IDF_CCACHE_ENABLE"] = "1"
|
||||
return env
|
||||
|
||||
|
||||
def get_framework_env(
|
||||
|
||||
@@ -528,12 +528,6 @@ def run_compile(config, verbose: bool) -> int:
|
||||
return result.returncode
|
||||
_patch_memory_segments()
|
||||
|
||||
# After every reconfigure so compile_commands and sdkconfig are settled
|
||||
from esphome.build_gen.espidf import prepare_pch
|
||||
from esphome.build_helpers.pch import guarded_prepare
|
||||
|
||||
guarded_prepare(CORE.relative_build_path("build"), prepare_pch)
|
||||
|
||||
# Build
|
||||
args = []
|
||||
|
||||
|
||||
+33
-13
@@ -11,6 +11,7 @@ import secrets
|
||||
import socket
|
||||
import time
|
||||
from typing import Any
|
||||
import zlib
|
||||
|
||||
from esphome.core import EsphomeError
|
||||
from esphome.helpers import ProgressBar, resolve_ip_address
|
||||
@@ -65,9 +66,15 @@ CLIENT_FEATURE_SUPPORTS_COMPRESSION = 0x01
|
||||
CLIENT_FEATURE_SUPPORTS_SHA256_AUTH = 0x02
|
||||
CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL = 0x04
|
||||
CLIENT_FEATURE_SUPPORTS_NOISE = 0x08
|
||||
CLIENT_FEATURE_SUPPORTS_DEFLATE = 0x10
|
||||
SERVER_FEATURE_SUPPORTS_COMPRESSION = 0x01
|
||||
SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS = 0x02
|
||||
SERVER_FEATURE_SUPPORTS_NOISE = 0x04
|
||||
# Binding once offered: the device then expects the image size and a deflate stream
|
||||
SERVER_FEATURE_SUPPORTS_DEFLATE = 0x08
|
||||
|
||||
# Wire constant: the deflate bit promises a 4 KB window (OTA_INFLATE_WINDOW_SIZE)
|
||||
DEFLATE_WINDOW_BITS = 12
|
||||
|
||||
NOISE_FRAME_INDICATOR = 0x01
|
||||
NOISE_HANDSHAKE_OK = 0x00
|
||||
@@ -87,6 +94,9 @@ _SUPPORTED_OTA_TYPES: frozenset[int] = frozenset(
|
||||
)
|
||||
|
||||
UPLOAD_BLOCK_SIZE = 8192
|
||||
# Sizes on the wire are 4 bytes MSB first
|
||||
SIZE_FIELD_BYTES = 4
|
||||
COMPRESS_LEVEL = 9
|
||||
UPLOAD_BUFFER_SIZE = UPLOAD_BLOCK_SIZE * 8
|
||||
|
||||
# Flaky Wi-Fi links often drop the first OTA attempt, and the device may need time
|
||||
@@ -96,6 +106,10 @@ UPLOAD_BUFFER_SIZE = UPLOAD_BLOCK_SIZE * 8
|
||||
# across the addresses on top of that.
|
||||
EXTRA_UPLOAD_ATTEMPTS = 2
|
||||
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__)
|
||||
|
||||
@@ -547,6 +561,7 @@ def perform_ota(
|
||||
CLIENT_FEATURE_SUPPORTS_COMPRESSION
|
||||
| CLIENT_FEATURE_SUPPORTS_SHA256_AUTH
|
||||
| CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL
|
||||
| CLIENT_FEATURE_SUPPORTS_DEFLATE
|
||||
)
|
||||
if noise_psk:
|
||||
features_to_send |= CLIENT_FEATURE_SUPPORTS_NOISE
|
||||
@@ -640,8 +655,16 @@ def perform_ota(
|
||||
f"retry {flag_name}."
|
||||
)
|
||||
|
||||
if features & SERVER_FEATURE_SUPPORTS_COMPRESSION:
|
||||
upload_contents = gzip.compress(file_contents, compresslevel=9)
|
||||
deflate = bool(extended_proto and features & SERVER_FEATURE_SUPPORTS_DEFLATE)
|
||||
if deflate:
|
||||
# The device inflates while receiving through a small ring window
|
||||
upload_contents = zlib.compress(
|
||||
file_contents, COMPRESS_LEVEL, wbits=-DEFLATE_WINDOW_BITS
|
||||
)
|
||||
_LOGGER.info("Compressed to %s bytes (deflate)", len(upload_contents))
|
||||
elif features & SERVER_FEATURE_SUPPORTS_COMPRESSION:
|
||||
# The device stores the gzip file and inflates it when it reboots
|
||||
upload_contents = gzip.compress(file_contents, compresslevel=COMPRESS_LEVEL)
|
||||
_LOGGER.info("Compressed to %s bytes", len(upload_contents))
|
||||
else:
|
||||
upload_contents = file_contents
|
||||
@@ -694,29 +717,26 @@ def perform_ota(
|
||||
|
||||
_LOGGER.info("Handshake complete")
|
||||
|
||||
# Timeout must match device-side OTA_SOCKET_TIMEOUT_DATA to prevent premature failures
|
||||
sock.settimeout(90.0)
|
||||
sock.settimeout(DATA_PHASE_TIMEOUT)
|
||||
|
||||
if extended_proto:
|
||||
send_check(sock, ota_type, "ota type")
|
||||
|
||||
upload_size = len(upload_contents)
|
||||
upload_size_encoded = [
|
||||
(upload_size >> 24) & 0xFF,
|
||||
(upload_size >> 16) & 0xFF,
|
||||
(upload_size >> 8) & 0xFF,
|
||||
(upload_size >> 0) & 0xFF,
|
||||
]
|
||||
# The device erases flash between receiving the size and acking the
|
||||
# prepare, so this window shows the erase cost (near zero when the
|
||||
# device erases lazily during the upload)
|
||||
prepare_start = time.perf_counter()
|
||||
send_check(sock, upload_size_encoded, "binary size")
|
||||
send_check(sock, upload_size.to_bytes(SIZE_FIELD_BYTES, "big"), "binary size")
|
||||
if deflate:
|
||||
# Own frame: an encrypted session carries one field per frame
|
||||
send_check(sock, file_size.to_bytes(SIZE_FIELD_BYTES, "big"), "image size")
|
||||
receive_exactly(sock, 1, "update prepare result", RESPONSE_UPDATE_PREPARE_OK)
|
||||
prepare_duration = time.perf_counter() - prepare_start
|
||||
_LOGGER.info("Preparing for upload took %.2f seconds", prepare_duration)
|
||||
|
||||
upload_md5 = hashlib.md5(upload_contents).hexdigest()
|
||||
# The device hashes what it writes: the inflated image, else the received bytes
|
||||
upload_md5 = hashlib.md5(file_contents if deflate else upload_contents).hexdigest()
|
||||
_LOGGER.debug("MD5 of upload is %s", upload_md5)
|
||||
|
||||
send_check(sock, upload_md5, "file checksum")
|
||||
@@ -854,7 +874,7 @@ def run_ota_impl_(
|
||||
# clean up a half-open connection (its handshake watchdog runs at 20s);
|
||||
# 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
|
||||
# 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.
|
||||
total_attempts = len(res) + EXTRA_UPLOAD_ATTEMPTS
|
||||
last_error = ""
|
||||
|
||||
@@ -288,25 +288,10 @@ def _detect_archive_root(names: Iterable[str]) -> str | None:
|
||||
return root if has_descendant else None
|
||||
|
||||
|
||||
def _resolve_progress(
|
||||
progress: Callable[[float], None] | None,
|
||||
progress_header: str | None,
|
||||
has_work: bool,
|
||||
) -> Callable[[float], None] | None:
|
||||
"""Fraction reporter for an extractor: the caller's callback wins over a
|
||||
private ``progress_header`` bar."""
|
||||
if progress is not None:
|
||||
return progress
|
||||
if progress_header and has_work:
|
||||
return ProgressBar(progress_header).update
|
||||
return None
|
||||
|
||||
|
||||
def _tar_extract_all(
|
||||
data: io.BufferedIOBase,
|
||||
extract_dir: PathType = ".",
|
||||
progress_header: str | None = None,
|
||||
progress: Callable[[float], None] | None = None,
|
||||
):
|
||||
"""
|
||||
Extract a TAR archive to the specified directory.
|
||||
@@ -321,7 +306,6 @@ def _tar_extract_all(
|
||||
data: File-like object containing the TAR archive
|
||||
extract_dir: Directory to extract contents to
|
||||
progress_header: If set, show a progress bar with this header
|
||||
progress: fraction callback (0..1, ends at 1.0); overrides progress_header
|
||||
"""
|
||||
import tarfile
|
||||
|
||||
@@ -380,20 +364,21 @@ def _tar_extract_all(
|
||||
safe_members.append(member)
|
||||
|
||||
total = len(safe_members)
|
||||
report = _resolve_progress(progress, progress_header, total > 0)
|
||||
progress = (
|
||||
ProgressBar(progress_header) if progress_header and total > 0 else None
|
||||
)
|
||||
for i, member in enumerate(safe_members, 1):
|
||||
tar_ref.extract(member, abs_dest)
|
||||
if report is not None:
|
||||
report(i / total)
|
||||
if report is not None:
|
||||
report(1)
|
||||
if progress is not None:
|
||||
progress.update(i / total)
|
||||
if progress is not None:
|
||||
progress.update(1)
|
||||
|
||||
|
||||
def _zip_extract_all(
|
||||
data: io.BufferedIOBase,
|
||||
extract_dir: PathType = ".",
|
||||
progress_header: str | None = None,
|
||||
progress: Callable[[float], None] | None = None,
|
||||
):
|
||||
"""
|
||||
Extract a ZIP archive to the specified directory.
|
||||
@@ -402,7 +387,6 @@ def _zip_extract_all(
|
||||
data: File-like object containing the ZIP archive
|
||||
extract_dir: Directory to extract contents to
|
||||
progress_header: If set, show a progress bar with this header
|
||||
progress: fraction callback (0..1, ends at 1.0); overrides progress_header
|
||||
"""
|
||||
import zipfile
|
||||
|
||||
@@ -419,7 +403,9 @@ def _zip_extract_all(
|
||||
strip_prefix = f"{strip_root}/" if strip_root is not None else None
|
||||
|
||||
total = len(all_members)
|
||||
report = _resolve_progress(progress, progress_header, total > 0)
|
||||
progress = (
|
||||
ProgressBar(progress_header) if progress_header and total > 0 else None
|
||||
)
|
||||
|
||||
for i, member in enumerate(all_members, 1):
|
||||
# 1. Normalize name
|
||||
@@ -452,10 +438,10 @@ def _zip_extract_all(
|
||||
# 6. Extract
|
||||
zip_ref.extract(member, extract_dir)
|
||||
|
||||
if report is not None:
|
||||
report(i / total)
|
||||
if report is not None:
|
||||
report(1)
|
||||
if progress is not None:
|
||||
progress.update(i / total)
|
||||
if progress is not None:
|
||||
progress.update(1)
|
||||
|
||||
|
||||
def _rename_with_retry(
|
||||
@@ -486,7 +472,6 @@ def _7z_extract_all(
|
||||
data: io.BufferedIOBase,
|
||||
extract_dir: PathType = ".",
|
||||
progress_header: str | None = None,
|
||||
progress: Callable[[float], None] | None = None,
|
||||
):
|
||||
"""
|
||||
Extract a 7z archive to the specified directory.
|
||||
@@ -501,7 +486,6 @@ def _7z_extract_all(
|
||||
data: File-like object containing the 7z archive (must be seekable)
|
||||
extract_dir: Directory to extract contents to
|
||||
progress_header: If set, show a progress bar with this header
|
||||
progress: called with 1.0 on completion; overrides progress_header
|
||||
"""
|
||||
import py7zr
|
||||
|
||||
@@ -540,15 +524,19 @@ def _7z_extract_all(
|
||||
continue
|
||||
safe_targets.append(raw)
|
||||
|
||||
report = _resolve_progress(progress, progress_header, bool(safe_targets))
|
||||
progress = (
|
||||
ProgressBar(progress_header)
|
||||
if progress_header and safe_targets
|
||||
else None
|
||||
)
|
||||
|
||||
if len(safe_targets) == len(all_names):
|
||||
z.extractall(path=staging)
|
||||
else:
|
||||
z.extract(path=staging, targets=safe_targets)
|
||||
|
||||
if report is not None:
|
||||
report(1)
|
||||
if progress is not None:
|
||||
progress.update(1)
|
||||
|
||||
src_root = staging / strip_root if strip_root else staging
|
||||
for item in src_root.iterdir():
|
||||
@@ -579,7 +567,6 @@ def archive_extract_all(
|
||||
archive: PathType | io.RawIOBase | IO[bytes],
|
||||
extract_dir: PathType = ".",
|
||||
progress_header: str | None = None,
|
||||
progress: Callable[[float], None] | None = None,
|
||||
):
|
||||
"""
|
||||
Extract an archive file to the specified directory.
|
||||
@@ -588,7 +575,6 @@ def archive_extract_all(
|
||||
archive: Path to archive file or file-like object
|
||||
extract_dir: Directory to extract contents to
|
||||
progress_header: If set, show a progress bar with this header
|
||||
progress: fraction callback (0..1, ends at 1.0); overrides progress_header
|
||||
|
||||
Raises:
|
||||
TypeError: If archive is not a valid type
|
||||
@@ -619,9 +605,7 @@ def archive_extract_all(
|
||||
break
|
||||
if matched_fct is None:
|
||||
raise ValueError("Unsupported archive format")
|
||||
matched_fct(
|
||||
archive_ref, extract_dir, progress_header=progress_header, progress=progress
|
||||
)
|
||||
matched_fct(archive_ref, extract_dir, progress_header=progress_header)
|
||||
|
||||
|
||||
def _open_ranged(
|
||||
@@ -785,17 +769,13 @@ def _stream_response_to_file(
|
||||
# hammering the host or the mirrors.
|
||||
BATCH_DOWNLOAD_WORKERS = 4
|
||||
|
||||
# Concurrent archive extractions per batch; unpacking stops scaling well
|
||||
# before high core counts since the workers share one disk.
|
||||
BATCH_EXTRACT_WORKERS = 10
|
||||
|
||||
|
||||
def run_batch_downloads(
|
||||
header: str,
|
||||
jobs: list[tuple[str, int, Callable[[Callable[[int], None]], None]]],
|
||||
max_workers: int = BATCH_DOWNLOAD_WORKERS,
|
||||
) -> list[tuple[str, BaseException]]:
|
||||
"""Run ``(name, size, fetch)`` jobs concurrently under one bar.
|
||||
"""Run ``(name, size, fetch)`` download jobs concurrently under one bar.
|
||||
|
||||
Each ``fetch(tracker)`` reports absolute byte counts; the bar total is
|
||||
the sum of the sizes. Failures are returned after the bar is done so
|
||||
@@ -1025,26 +1005,15 @@ def resume_fetch_job(
|
||||
return fetch
|
||||
|
||||
|
||||
def is_expected_fetch_error(err: BaseException) -> bool:
|
||||
"""Download failures the callers degrade on, vs programming errors."""
|
||||
from esphome.core import EsphomeError # local import avoids circular dependency
|
||||
|
||||
return isinstance(err, (EsphomeError, OSError))
|
||||
|
||||
|
||||
def warn_batch_failures(
|
||||
def warn_prefetch_failures(
|
||||
failures: list[tuple[str, BaseException]],
|
||||
message: str = "Could not prefetch %s: %s",
|
||||
) -> None:
|
||||
"""Warn per failed batch job, keeping the traceback of unexpected errors."""
|
||||
"""Warn per failed batch-prefetch job; the caller's installer retries them."""
|
||||
for name, err in failures:
|
||||
# failure_reason: a message-less exception must not log blank
|
||||
if is_expected_fetch_error(err):
|
||||
_LOGGER.warning(message, name, failure_reason(err))
|
||||
_LOGGER.debug("Failure detail", exc_info=err)
|
||||
else:
|
||||
# A programming error must not be reduced to a bare message
|
||||
_LOGGER.warning(message, name, failure_reason(err), exc_info=err)
|
||||
_LOGGER.warning(message, name, failure_reason(err))
|
||||
_LOGGER.debug("Prefetch failure detail", exc_info=err)
|
||||
|
||||
|
||||
def download_with_resume(
|
||||
|
||||
@@ -35,7 +35,7 @@ from esphome.framework_helpers import (
|
||||
failure_reason,
|
||||
rmdir,
|
||||
run_batch_downloads,
|
||||
warn_batch_failures,
|
||||
warn_prefetch_failures,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -1095,7 +1095,7 @@ def _prefetch_wave(
|
||||
+ [(c.name, 0, partial(_clone_source, c, salt, namespace)) for c in clones],
|
||||
)
|
||||
# The sequential call below retries and raises the real error
|
||||
warn_batch_failures(
|
||||
warn_prefetch_failures(
|
||||
failures, "Prefetch of %s failed (retrying sequentially): %s"
|
||||
)
|
||||
except Exception as err: # noqa: BLE001 # pylint: disable=broad-exception-caught
|
||||
|
||||
@@ -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")
|
||||
@@ -33,7 +33,6 @@ import time
|
||||
from typing import Any, NamedTuple
|
||||
|
||||
from esphome.framework_helpers import (
|
||||
BATCH_EXTRACT_WORKERS,
|
||||
DownloadLockUnavailable,
|
||||
content_length,
|
||||
discard_partial_download,
|
||||
@@ -42,7 +41,7 @@ from esphome.framework_helpers import (
|
||||
resume_fetch_job,
|
||||
run_batch_downloads,
|
||||
wait_for_download_lock,
|
||||
warn_batch_failures,
|
||||
warn_prefetch_failures,
|
||||
)
|
||||
from esphome.helpers import get_bool_env, get_usable_cpu_count, rmtree
|
||||
|
||||
@@ -703,7 +702,7 @@ def _preinstall(
|
||||
would hang, not fail). Waves skip dependencies; the installed
|
||||
manifests feed the next wave. Any failure falls back to pio run.
|
||||
"""
|
||||
workers = min(get_usable_cpu_count(), len(entries), BATCH_EXTRACT_WORKERS)
|
||||
workers = min(get_usable_cpu_count(), len(entries))
|
||||
# One manager per worker (_install mutates instance state); built
|
||||
# serially because construction rewires the shared manager logger
|
||||
managers: SimpleQueue = SimpleQueue()
|
||||
@@ -892,7 +891,7 @@ def _prefetch(build_dir: Path, env: str) -> None:
|
||||
)
|
||||
# PlatformIO retries failed packages itself, without resume
|
||||
failures = run_batch_downloads("Downloading PlatformIO packages", jobs)
|
||||
warn_batch_failures(failures)
|
||||
warn_prefetch_failures(failures)
|
||||
failed_names = {name for name, _ in failures}
|
||||
elif not groups and not unresolved:
|
||||
# Record the no-work run so the parent skips the next spawn.
|
||||
|
||||
@@ -14,18 +14,14 @@ from typing import NamedTuple
|
||||
|
||||
from esphome.core import EsphomeError
|
||||
from esphome.framework_helpers import (
|
||||
BATCH_EXTRACT_WORKERS,
|
||||
archive_extract_all,
|
||||
download_from_mirrors,
|
||||
download_with_resume,
|
||||
downloaded_bytes,
|
||||
is_expected_fetch_error,
|
||||
rmdir,
|
||||
run_batch_downloads,
|
||||
wait_for_download_lock,
|
||||
warn_batch_failures,
|
||||
)
|
||||
from esphome.helpers import get_usable_cpu_count
|
||||
from esphome.net_retry import fetch_with_retry, http_request
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -166,10 +162,6 @@ def _check_layout(name: str, dest: Path, expect: Collection[str]) -> None:
|
||||
)
|
||||
|
||||
|
||||
# (name, version, dest, mirrors, expect) as accepted by install_packages
|
||||
PackageSpec = tuple[str, str, Path, list[str], Collection[str]]
|
||||
|
||||
|
||||
class _PendingArchive(NamedTuple):
|
||||
name: str
|
||||
version: str
|
||||
@@ -190,26 +182,6 @@ def _already_installed(dest: Path) -> bool:
|
||||
return (dest / ".esphome_extracted").is_file()
|
||||
|
||||
|
||||
def _batched_download_progress(
|
||||
name: str, version: str, size: int | None, extract_progress: Callable[[float], None]
|
||||
) -> Callable[[int], None]:
|
||||
"""Download tracker for a batched install: no private bar, no bytes (the
|
||||
shared bar must never run backwards); the zero tick keeps cancellation
|
||||
observable and a real refetch is announced once."""
|
||||
announced = False
|
||||
|
||||
def progress(done: int) -> None:
|
||||
nonlocal announced
|
||||
# size-less registry entries still announce: streaming starts at
|
||||
# done=0, while a verify no-op credits the full file in one tick
|
||||
if not announced and done < (size or 1):
|
||||
_LOGGER.info("Re-downloading %s %s ...", name, version)
|
||||
announced = True
|
||||
extract_progress(0.0)
|
||||
|
||||
return progress
|
||||
|
||||
|
||||
def prefetch_packages(
|
||||
packages: list[tuple[str, str, Path, list[str]]], downloads_dir: Path
|
||||
) -> None:
|
||||
@@ -293,7 +265,7 @@ def prefetch_packages(
|
||||
[(entry.name, entry.size, partial(_fetch, entry)) for entry in pending],
|
||||
)
|
||||
for name, err in failures:
|
||||
if is_expected_fetch_error(err):
|
||||
if isinstance(err, (EsphomeError, OSError)):
|
||||
# Expected download failures: install_package retries this one
|
||||
# itself, with a visible bar
|
||||
_LOGGER.debug("Prefetch of %s failed: %s", name, err)
|
||||
@@ -310,7 +282,6 @@ def install_package(
|
||||
mirrors: list[str],
|
||||
downloads_dir: Path,
|
||||
expect: Collection[str],
|
||||
extract_progress: Callable[[float], None] | None = None,
|
||||
) -> None:
|
||||
"""Download, verify, and extract one package if not already installed.
|
||||
|
||||
@@ -318,9 +289,6 @@ def install_package(
|
||||
publishes; a mirror override (URL templates with ``{VERSION}``/``{SYSTEM}``
|
||||
substitution) is trusted as configured. ``downloads_dir`` holds the
|
||||
archive between runs so an interrupted download resumes.
|
||||
|
||||
``extract_progress`` receives extraction fractions in [0, 1] instead of
|
||||
the private per-file bars (see ``install_packages``).
|
||||
"""
|
||||
if not expect:
|
||||
# Layout validation before marker.touch() is the only guard against
|
||||
@@ -344,14 +312,7 @@ def install_package(
|
||||
# Persistent location so an interrupted download resumes across runs.
|
||||
downloads_dir.mkdir(parents=True, exist_ok=True)
|
||||
archive = _archive_path(downloads_dir, name, version)
|
||||
# Batch header names each package; keep INFO when an archive
|
||||
# unexpectedly needs a real download (the shared bar won't move)
|
||||
log = (
|
||||
_LOGGER.debug
|
||||
if extract_progress is not None and archive.is_file()
|
||||
else _LOGGER.info
|
||||
)
|
||||
log("Downloading %s %s ...", name, version)
|
||||
_LOGGER.info("Downloading %s %s ...", name, version)
|
||||
if mirrors:
|
||||
_LOGGER.warning(
|
||||
"Downloading %s from a mirror override; checksum verification "
|
||||
@@ -363,85 +324,11 @@ def install_package(
|
||||
)
|
||||
else:
|
||||
url, sha256, size = registry_download(name, version)
|
||||
download_with_resume(
|
||||
url,
|
||||
archive,
|
||||
sha256=sha256,
|
||||
size=size,
|
||||
progress=None
|
||||
if extract_progress is None
|
||||
else _batched_download_progress(name, version, size, extract_progress),
|
||||
)
|
||||
log("Extracting %s ...", name)
|
||||
archive_extract_all(
|
||||
archive, dest, progress_header="Extracting", progress=extract_progress
|
||||
)
|
||||
download_with_resume(url, archive, sha256=sha256, size=size)
|
||||
_LOGGER.info("Extracting %s ...", name)
|
||||
archive_extract_all(archive, dest, progress_header="Extracting")
|
||||
# Validate the layout before recording success, so an unexpected
|
||||
# package is never cached as a working install.
|
||||
_check_layout(name, dest, expect)
|
||||
marker.touch()
|
||||
archive.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def install_packages(specs: Collection[PackageSpec], downloads_dir: Path) -> None:
|
||||
"""Install several packages; prefetched archives extract in parallel under
|
||||
one shared bar, the rest take the sequential ``install_package`` path.
|
||||
The first failure is re-raised."""
|
||||
pending: list[tuple[PackageSpec, int]] = []
|
||||
rest: list[PackageSpec] = []
|
||||
seen: set[str] = set()
|
||||
for spec in specs:
|
||||
name, version, dest, mirrors, _expect = spec
|
||||
archive = _archive_path(downloads_dir, name, version)
|
||||
# Duplicate entries share one archive and would race each other
|
||||
# between two workers; mirror prefetch_packages' dedupe
|
||||
if _already_installed(dest) or mirrors or archive.name in seen:
|
||||
rest.append(spec)
|
||||
continue
|
||||
try:
|
||||
# An archive at its final name already passed sha256/size
|
||||
# verification
|
||||
size = archive.stat().st_size
|
||||
except FileNotFoundError:
|
||||
rest.append(spec)
|
||||
continue
|
||||
seen.add(archive.name)
|
||||
pending.append((spec, size))
|
||||
if len(pending) < 2:
|
||||
for name, version, dest, mirrors, expect in specs:
|
||||
install_package(name, version, dest, mirrors, downloads_dir, expect=expect)
|
||||
return
|
||||
workers = min(get_usable_cpu_count(), len(pending), BATCH_EXTRACT_WORKERS)
|
||||
_LOGGER.info(
|
||||
"Extracting %d package archive(s) with %d worker(s): %s",
|
||||
len(pending),
|
||||
workers,
|
||||
", ".join(spec[0] for spec, _ in pending),
|
||||
)
|
||||
|
||||
def _install(spec: PackageSpec, size: int, tracker: Callable[[int], None]) -> None:
|
||||
name, version, dest, mirrors, expect = spec
|
||||
install_package(
|
||||
name,
|
||||
version,
|
||||
dest,
|
||||
mirrors,
|
||||
downloads_dir,
|
||||
expect=expect,
|
||||
extract_progress=lambda frac: tracker(int(frac * size)),
|
||||
)
|
||||
|
||||
failures = run_batch_downloads(
|
||||
"Extracting packages",
|
||||
[(spec[0], size, partial(_install, spec, size)) for spec, size in pending],
|
||||
max_workers=workers,
|
||||
)
|
||||
if failures:
|
||||
# Warn on the first failure too: the raised exception's message may
|
||||
# not name which package failed
|
||||
warn_batch_failures(failures, "Could not install %s: %s")
|
||||
raise failures[0][1]
|
||||
# Sequential remainder after the batch, so a duplicate spec cannot
|
||||
# unlink the archive its batched twin was sized from
|
||||
for name, version, dest, mirrors, expect in rest:
|
||||
install_package(name, version, dest, mirrors, downloads_dir, expect=expect)
|
||||
|
||||
@@ -289,15 +289,6 @@ def copy_ccache_script() -> None:
|
||||
)
|
||||
|
||||
|
||||
def copy_pch_script() -> None:
|
||||
"""Copy the shared precompiled-header SCons post-script into the build
|
||||
dir; platform components pair it with ``post:pch.py`` in extra_scripts."""
|
||||
copy_file_if_changed(
|
||||
Path(__file__).parent / "pch.py.script",
|
||||
CORE.relative_build_path("pch.py"),
|
||||
)
|
||||
|
||||
|
||||
def default_libdeps_dir() -> str:
|
||||
"""The PLATFORMIO_LIBDEPS_DIR value a pio run defaults to; the package
|
||||
prefetch must resolve installed libraries against the same dir."""
|
||||
|
||||
+4
-15
@@ -7,7 +7,6 @@ import re
|
||||
import time
|
||||
|
||||
from esphome import loader
|
||||
from esphome.build_helpers.pch import PCH_ARTIFACT_NAMES, PCH_PREFIX_HEADER
|
||||
from esphome.compiled_config import save_compiled_config
|
||||
from esphome.config import iter_component_configs, iter_components
|
||||
from esphome.const import (
|
||||
@@ -247,7 +246,6 @@ def copy_src_tree():
|
||||
Path(
|
||||
"esphome/core/ring_buffer.h"
|
||||
), # moved to components/ring_buffer/, removed in 2026.11.0
|
||||
Path(PCH_PREFIX_HEADER), # build machinery, not user API
|
||||
}
|
||||
include_l = []
|
||||
for target, _ in source_files_l:
|
||||
@@ -611,20 +609,11 @@ def clean_build(clear_pio_cache: bool = True, *, full: bool = False):
|
||||
if idf_path.is_dir():
|
||||
_LOGGER.info("Deleting %s", idf_path)
|
||||
rmtree(idf_path)
|
||||
# The PlatformIO pch artifacts live at the project root so the
|
||||
# relative -include resolves; a partial clean must drop them too
|
||||
for name in PCH_ARTIFACT_NAMES:
|
||||
CORE.relative_build_path(name).unlink(missing_ok=True)
|
||||
|
||||
# The idedata caches are derived from the build but live under the data
|
||||
# dir, not the build path, so they must be removed separately in both
|
||||
# modes. Globbed (name.json plus name.<backend>.json) so a future
|
||||
# backend suffix cannot silently drift out of clean-all.
|
||||
idedata_dir = CORE.relative_internal_path("idedata")
|
||||
for idedata_cache in (
|
||||
*idedata_dir.glob(f"{CORE.name}.json"),
|
||||
*idedata_dir.glob(f"{CORE.name}.*.json"),
|
||||
):
|
||||
# The idedata cache is derived from the build but lives under the data dir,
|
||||
# not the build path, so it must be removed separately in both modes.
|
||||
idedata_cache = CORE.relative_internal_path("idedata", f"{CORE.name}.json")
|
||||
if idedata_cache.is_file():
|
||||
_LOGGER.info("Deleting %s", idedata_cache)
|
||||
idedata_cache.unlink()
|
||||
|
||||
|
||||
+3
-3
@@ -45,7 +45,7 @@ lib_deps_base =
|
||||
lib_deps =
|
||||
${common.lib_deps_base}
|
||||
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
|
||||
kikuchan98/pngle@1.1.0 ; online_image
|
||||
; Using the repository directly, otherwise ESP-IDF can't use the library
|
||||
@@ -244,7 +244,7 @@ lib_deps =
|
||||
${common:idf-component-libs.lib_deps}
|
||||
ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base
|
||||
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
|
||||
DNSServer ; captive_portal
|
||||
heman/AsyncMqttClient-esphome@2.0.0 ; mqtt
|
||||
@@ -641,7 +641,7 @@ build_unflags =
|
||||
extends = common
|
||||
platform = platformio/native
|
||||
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
|
||||
build_flags =
|
||||
${common.build_flags}
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ tzlocal==5.4.4 # from time
|
||||
tzdata>=2026.3 # from time
|
||||
pyserial==3.5
|
||||
platformio==6.1.19
|
||||
esptool==5.3.1
|
||||
esptool==5.4.0
|
||||
click==8.3.3
|
||||
aioesphomeapi==46.3.0
|
||||
aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi
|
||||
|
||||
@@ -823,6 +823,8 @@ def lint_relative_py_import(fname: Path, line, col, content):
|
||||
# neither can live in a C++ namespace.
|
||||
"esphome/components/esp32_hosted/esp_now_hosted.cpp",
|
||||
"esphome/components/esp32_hosted/esp_now_hosted_rpc.h",
|
||||
# C header shared with the vendored decoder
|
||||
"esphome/components/esphome/ota/ota_esphome_inflate.h",
|
||||
],
|
||||
)
|
||||
def lint_namespace(fname: Path, content: str) -> str | None:
|
||||
|
||||
+24
-137
@@ -50,7 +50,6 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from collections import Counter
|
||||
from collections.abc import Callable
|
||||
from enum import StrEnum
|
||||
from functools import cache
|
||||
import json
|
||||
@@ -532,58 +531,37 @@ ESP32_PLATFORMIO_TRIGGER_PATH_PREFIXES = ("esphome/platformio/",)
|
||||
# - esphome/build_gen/platformio.py -- the PlatformIO build generator
|
||||
# - script/test_build_components.py -- the harness the job invokes
|
||||
# - .github/workflows/ci.yml -- the job's own definition
|
||||
# Shared by every toolchain smoke-test job: the harness it invokes and the
|
||||
# workflow that defines it
|
||||
_SMOKE_HARNESS_TRIGGER_FILES = frozenset(
|
||||
ESP32_PLATFORMIO_TRIGGER_FILES = frozenset(
|
||||
{
|
||||
"esphome/build_gen/platformio.py",
|
||||
"script/test_build_components.py",
|
||||
".github/workflows/ci.yml",
|
||||
}
|
||||
)
|
||||
|
||||
ESP32_PLATFORMIO_TRIGGER_FILES = _SMOKE_HARNESS_TRIGGER_FILES | {
|
||||
"esphome/build_gen/platformio.py",
|
||||
# The pch machinery the strict smoke job polices, and the modules it
|
||||
# imports; the rest of build_helpers/ does not affect PlatformIO builds
|
||||
"esphome/build_helpers/pch.py",
|
||||
"esphome/build_helpers/ccache.py",
|
||||
"esphome/build_helpers/idedata.py",
|
||||
}
|
||||
|
||||
|
||||
def _path_or_file_trigger(
|
||||
files: list[str],
|
||||
trigger_files: frozenset[str],
|
||||
trigger_prefixes: tuple[str, ...],
|
||||
) -> bool:
|
||||
"""Whether any changed file matches the given infrastructure triggers."""
|
||||
return any(
|
||||
file in trigger_files or file.startswith(trigger_prefixes) for file in files
|
||||
)
|
||||
|
||||
|
||||
@cache
|
||||
def _cached_components_closure(files: tuple[str, ...]) -> frozenset[str]:
|
||||
"""Dependency closure of the changed components, from the changed files.
|
||||
|
||||
The walk is expensive and every toolchain smoke-test job asks for the
|
||||
same file list, so compute it once per run."""
|
||||
component_files = [f for f in files if filter_component_and_test_files(f)]
|
||||
return frozenset(get_components_with_dependencies(component_files, True))
|
||||
|
||||
|
||||
def _esp32_platformio_path_or_file_trigger(files: list[str]) -> bool:
|
||||
"""Whether any changed file is a PlatformIO infrastructure / harness trigger."""
|
||||
return _path_or_file_trigger(
|
||||
files, ESP32_PLATFORMIO_TRIGGER_FILES, ESP32_PLATFORMIO_TRIGGER_PATH_PREFIXES
|
||||
)
|
||||
for file in files:
|
||||
if file in ESP32_PLATFORMIO_TRIGGER_FILES:
|
||||
return True
|
||||
if any(
|
||||
file.startswith(prefix) for prefix in ESP32_PLATFORMIO_TRIGGER_PATH_PREFIXES
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _esp_idf_infra_changed(files: list[str]) -> bool:
|
||||
"""Whether any changed file is ESP-IDF build/runner infrastructure."""
|
||||
return _path_or_file_trigger(
|
||||
files, ESP_IDF_INFRA_TRIGGER_FILES, ESP_IDF_INFRA_TRIGGER_PATH_PREFIXES
|
||||
)
|
||||
for file in files:
|
||||
if file in ESP_IDF_INFRA_TRIGGER_FILES:
|
||||
return True
|
||||
if any(
|
||||
file.startswith(prefix) for prefix in ESP_IDF_INFRA_TRIGGER_PATH_PREFIXES
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def esp32_platformio_components_to_test(branch: str | None = None) -> list[str]:
|
||||
@@ -621,23 +599,15 @@ def esp32_platformio_components_to_test(branch: str | None = None) -> list[str]:
|
||||
Returns:
|
||||
Sorted list of component names to compile.
|
||||
"""
|
||||
return _toolchain_components_to_test(
|
||||
branch, ESP32_PLATFORMIO_TEST_COMPONENTS, _esp32_platformio_path_or_file_trigger
|
||||
)
|
||||
|
||||
|
||||
def _toolchain_components_to_test(
|
||||
branch: str | None,
|
||||
test_set: frozenset[str],
|
||||
infra_trigger: Callable[[list[str]], bool],
|
||||
) -> list[str]:
|
||||
"""The shared narrowing rule for the per-toolchain smoke-test jobs."""
|
||||
files = changed_files(branch)
|
||||
|
||||
if core_changed(files) or infra_trigger(files):
|
||||
return sorted(test_set)
|
||||
if core_changed(files) or _esp32_platformio_path_or_file_trigger(files):
|
||||
return sorted(ESP32_PLATFORMIO_TEST_COMPONENTS)
|
||||
|
||||
return sorted(test_set & _cached_components_closure(tuple(files)))
|
||||
component_files = [f for f in files if filter_component_and_test_files(f)]
|
||||
changed = get_components_with_dependencies(component_files, True)
|
||||
|
||||
return sorted(ESP32_PLATFORMIO_TEST_COMPONENTS & set(changed))
|
||||
|
||||
|
||||
def should_run_esp32_platformio(branch: str | None = None) -> bool:
|
||||
@@ -658,83 +628,6 @@ def should_run_esp32_platformio(branch: str | None = None) -> bool:
|
||||
return bool(esp32_platformio_components_to_test(branch))
|
||||
|
||||
|
||||
# The `--toolchain arduino` smoke-test set: covers the core, the bundled and
|
||||
# converted registry libraries, and the waveform path.
|
||||
ESP8266_NATIVE_TEST_COMPONENTS = frozenset(
|
||||
{
|
||||
"esp8266",
|
||||
"api",
|
||||
"web_server",
|
||||
"captive_portal",
|
||||
"mqtt",
|
||||
"esp8266_pwm",
|
||||
"neopixelbus",
|
||||
"bme280_i2c",
|
||||
"uart",
|
||||
}
|
||||
)
|
||||
|
||||
# Infrastructure whose changes always trigger the native ESP8266 compile
|
||||
# test. esphome/build_helpers/ holds the idedata and size-summary helpers
|
||||
# the backend shares with the native ESP-IDF build.
|
||||
ESP8266_NATIVE_TRIGGER_PATH_PREFIXES = (
|
||||
"esphome/arduino8266/",
|
||||
"esphome/arduino/",
|
||||
"esphome/build_helpers/",
|
||||
)
|
||||
# Shared library-conversion modules every native build imports; espidf-only
|
||||
# infra (build_gen/espidf.py) deliberately stays out of the esp8266 set.
|
||||
_NATIVE_SHARED_TRIGGER_FILES = frozenset(
|
||||
{
|
||||
"esphome/framework_helpers.py",
|
||||
"esphome/platformio/library.py",
|
||||
"esphome/platformio/extra_script.py",
|
||||
}
|
||||
)
|
||||
# Tripwire: the shared modules must stay in the ESP-IDF trigger set too
|
||||
# (now defined in clang_tidy_hash), or its smoke test silently skips them
|
||||
assert _NATIVE_SHARED_TRIGGER_FILES <= ESP_IDF_INFRA_TRIGGER_FILES
|
||||
ESP8266_NATIVE_TRIGGER_FILES = (
|
||||
_NATIVE_SHARED_TRIGGER_FILES
|
||||
| _SMOKE_HARNESS_TRIGGER_FILES
|
||||
| {
|
||||
"esphome/build_gen/arduino8266.py",
|
||||
"esphome/build_gen/build_tool.py",
|
||||
"esphome/components/esp8266/build_surgery.py",
|
||||
"esphome/components/esp8266/boards.py",
|
||||
"esphome/platformio/registry.py",
|
||||
# esp8266/__init__.py imports copy_ccache_script from it
|
||||
"esphome/platformio/toolchain.py",
|
||||
".github/actions/cache-arduino8266/action.yml",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _esp8266_native_path_or_file_trigger(files: list[str]) -> bool:
|
||||
"""Whether any changed file is native-ESP8266 infrastructure / harness."""
|
||||
# base_python_changed covers the top-level esphome/*.py modules the
|
||||
# native backend imports directly (framework_helpers, helpers, writer,
|
||||
# __main__); without it a change there would silently skip this job.
|
||||
# base_python_changed is deliberately broad (any top-level esphome/*.py)
|
||||
# as belt-and-braces while the backend is new; narrow it to the modules
|
||||
# the backend imports once the toolchain has soaked a few releases
|
||||
return base_python_changed(files) or _path_or_file_trigger(
|
||||
files, ESP8266_NATIVE_TRIGGER_FILES, ESP8266_NATIVE_TRIGGER_PATH_PREFIXES
|
||||
)
|
||||
|
||||
|
||||
def esp8266_native_components_to_test(branch: str | None = None) -> list[str]:
|
||||
"""Subset of ``ESP8266_NATIVE_TEST_COMPONENTS`` the job needs to compile.
|
||||
|
||||
Same narrowing logic as ``esp32_platformio_components_to_test``: the full
|
||||
list on core or infrastructure changes, otherwise the intersection with
|
||||
the changed-component dependency closure (empty list skips the job).
|
||||
"""
|
||||
return _toolchain_components_to_test(
|
||||
branch, ESP8266_NATIVE_TEST_COMPONENTS, _esp8266_native_path_or_file_trigger
|
||||
)
|
||||
|
||||
|
||||
def determine_cpp_unit_tests(
|
||||
branch: str | None = None,
|
||||
) -> tuple[bool, list[str]]:
|
||||
@@ -1333,8 +1226,6 @@ def main() -> None:
|
||||
run_device_builder = True
|
||||
esp32_platformio_components = sorted(ESP32_PLATFORMIO_TEST_COMPONENTS)
|
||||
run_esp32_platformio = True
|
||||
esp8266_native_components = sorted(ESP8266_NATIVE_TEST_COMPONENTS)
|
||||
run_esp8266_native = True
|
||||
else:
|
||||
integration_run_all, integration_test_files = determine_integration_tests(
|
||||
args.branch
|
||||
@@ -1346,8 +1237,6 @@ def main() -> None:
|
||||
run_device_builder = should_run_device_builder(args.branch)
|
||||
esp32_platformio_components = esp32_platformio_components_to_test(args.branch)
|
||||
run_esp32_platformio = bool(esp32_platformio_components)
|
||||
esp8266_native_components = esp8266_native_components_to_test(args.branch)
|
||||
run_esp8266_native = bool(esp8266_native_components)
|
||||
run_integration, integration_test_buckets = _compute_integration_test_buckets(
|
||||
integration_run_all, integration_test_files
|
||||
)
|
||||
@@ -1543,8 +1432,6 @@ def main() -> None:
|
||||
"device_builder": run_device_builder,
|
||||
"esp32_platformio": run_esp32_platformio,
|
||||
"esp32_platformio_components": ",".join(esp32_platformio_components),
|
||||
"esp8266_native": run_esp8266_native,
|
||||
"esp8266_native_components": ",".join(esp8266_native_components),
|
||||
"changed_components": changed_components,
|
||||
"changed_components_with_tests": changed_components_with_tests,
|
||||
"directly_changed_components_with_tests": list(directly_changed_with_tests),
|
||||
|
||||
@@ -1104,6 +1104,10 @@ def get_components_per_integration_fixture() -> dict[str, set[str]]:
|
||||
|
||||
|
||||
_TEST_FUNC_RE = re.compile(r"async def (test_\w+)")
|
||||
# Any usage form (decorator, pytestmark assignment or list element); only
|
||||
# test_*.py files are scanned, so the marker docs elsewhere cannot false-hit
|
||||
_SHARED_YAML_USE_RE = re.compile(r"\bmark\.shared_yaml")
|
||||
_SHARED_YAML_ARG_RE = re.compile(r"\(\s*[\"'](\w+)[\"']\s*\)")
|
||||
|
||||
|
||||
@cache
|
||||
@@ -1123,6 +1127,19 @@ def get_fixture_to_test_files() -> dict[str, frozenset[str]]:
|
||||
for func in _TEST_FUNC_RE.findall(content):
|
||||
base_name = func.replace("test_", "").partition("[")[0]
|
||||
result.setdefault(base_name, set()).add(rel_path)
|
||||
# Shared fixtures are named by marker, not by a test function; each
|
||||
# decorator must carry a string literal or its fixture would silently
|
||||
# map to no tests
|
||||
for use in _SHARED_YAML_USE_RE.finditer(content):
|
||||
arg = _SHARED_YAML_ARG_RE.match(content, use.end())
|
||||
if arg is None:
|
||||
line = content.count("\n", 0, use.start()) + 1
|
||||
raise ValueError(
|
||||
f"{rel_path}:{line}: shared_yaml marker must take a "
|
||||
"single-line string literal so CI test selection can map "
|
||||
"its fixture"
|
||||
)
|
||||
result.setdefault(arg.group(1), set()).add(rel_path)
|
||||
|
||||
return {k: frozenset(v) for k, v in result.items()}
|
||||
|
||||
|
||||
@@ -1027,7 +1027,6 @@ def test_components(
|
||||
isolated_components: set[str] | None = None,
|
||||
base_only: bool = False,
|
||||
toolchain: str | None = None,
|
||||
fail_on_no_tests: bool = False,
|
||||
) -> int:
|
||||
"""Test components with optional intelligent grouping.
|
||||
|
||||
@@ -1062,34 +1061,20 @@ def test_components(
|
||||
# toolchain build.
|
||||
include_validate = esphome_command != "compile"
|
||||
|
||||
# Find all component tests; remember which components each pattern
|
||||
# (wildcards included) matched, for the deferred no-tests accounting
|
||||
# Find all component tests
|
||||
all_tests = {}
|
||||
pattern_components: dict[str, set[str]] = {}
|
||||
for pattern in component_patterns:
|
||||
# Skip empty patterns (happens when components list is empty string)
|
||||
if not pattern:
|
||||
continue
|
||||
found = find_component_tests(
|
||||
tests_dir, pattern, base_only, include_validate=include_validate
|
||||
all_tests.update(
|
||||
find_component_tests(
|
||||
tests_dir, pattern, base_only, include_validate=include_validate
|
||||
)
|
||||
)
|
||||
pattern_components[pattern] = set(found)
|
||||
all_tests.update(found)
|
||||
|
||||
# The flag's contract is "no test matched fails": a fully blank pattern
|
||||
# list would otherwise slide into the reference-baseline fallback and
|
||||
# exit green while building nothing a caller asked for
|
||||
if fail_on_no_tests and not any(component_patterns):
|
||||
print("No components requested (blank component list)")
|
||||
return 1
|
||||
|
||||
if fail_on_no_tests and not all_tests:
|
||||
# Nothing matched at all: fail before the synthetic baseline build,
|
||||
# which would spend a compile reporting success on nothing. Partial
|
||||
# matches defer to the per-pattern accounting after the summary.
|
||||
print(f"No components found matching: {component_patterns}")
|
||||
return 1
|
||||
|
||||
# If no components found, build a reference configuration for baseline comparison
|
||||
# Create a synthetic "empty" component test that will build just the base config
|
||||
if not all_tests:
|
||||
print(f"No components found matching: {component_patterns}")
|
||||
print(
|
||||
@@ -1193,26 +1178,6 @@ def test_components(
|
||||
toolchain=toolchain,
|
||||
)
|
||||
|
||||
silent: list[str] = []
|
||||
if fail_on_no_tests:
|
||||
# A green run that built nothing for a requested pattern (renamed
|
||||
# fixture, missing base file, version-suffix mismatch, a wildcard
|
||||
# matching no component) must not pass CI. Per pattern: an
|
||||
# all-or-nothing check would let one silent pattern hide behind the
|
||||
# others. Opt-in: some legs (the esp32-ard smoke subset)
|
||||
# legitimately match nothing. Failing is deferred past the summary
|
||||
# so a real failure's reproduce commands still print.
|
||||
built = {c for r in test_results for c in r.components}
|
||||
# A pattern is silent when it matched no fixture, or when none of
|
||||
# its matched components produced a build (wildcards included)
|
||||
silent = [
|
||||
p
|
||||
for p in component_patterns
|
||||
if p and not (pattern_components.get(p, set()) & built)
|
||||
]
|
||||
if silent:
|
||||
print(f"No tests ran for requested pattern(s): {', '.join(silent)}")
|
||||
|
||||
# Separate results into passed and failed
|
||||
passed_results = [r for r in test_results if r.success]
|
||||
failed_results = [r for r in test_results if not r.success]
|
||||
@@ -1244,7 +1209,7 @@ def test_components(
|
||||
if os.environ.get("GITHUB_STEP_SUMMARY"):
|
||||
write_github_summary(test_results, toolchain=toolchain)
|
||||
|
||||
if failed_results or silent:
|
||||
if failed_results:
|
||||
return 1
|
||||
|
||||
return 0
|
||||
@@ -1299,12 +1264,6 @@ def main() -> int:
|
||||
"--toolchain",
|
||||
help="Select toolchain for compiling.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--fail-on-no-tests",
|
||||
action="store_true",
|
||||
help="Exit non-zero when no test matched (for CI legs whose "
|
||||
"components must all have fixtures)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
@@ -1323,7 +1282,6 @@ def main() -> int:
|
||||
continue_on_fail=args.continue_on_fail,
|
||||
enable_grouping=not args.no_grouping,
|
||||
isolated_components=isolated_components,
|
||||
fail_on_no_tests=args.fail_on_no_tests,
|
||||
base_only=args.base_only,
|
||||
toolchain=args.toolchain,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
from esphome.loader import FileResource
|
||||
from tests.testing_helpers import ComponentManifestOverride
|
||||
|
||||
|
||||
def override_manifest(manifest: ComponentManifestOverride) -> None:
|
||||
# to_code emits the component count the application needs
|
||||
manifest.enable_codegen()
|
||||
# Only the decoder is under test; its ota platform is not in this build
|
||||
manifest.resources = manifest.resources + [
|
||||
FileResource("esphome.components.esphome", "ota/ota_esphome_inflate.c"),
|
||||
FileResource("esphome.components.esphome", "ota/ota_esphome_inflate.h"),
|
||||
]
|
||||
@@ -0,0 +1,323 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
#include "esphome/components/esphome/ota/ota_esphome_inflate.h"
|
||||
|
||||
namespace esphome::testing {
|
||||
|
||||
// build_plain() compressed with the CLI's window (espota2.DEFLATE_WINDOW_BITS):
|
||||
// DEFLATED = zlib.compress(plain, 9, wbits=-12)
|
||||
// STORED = zlib.compress(plain[:300], 0, wbits=-12)
|
||||
static const uint8_t DEFLATED[] = {
|
||||
0xed, 0xc8, 0xf7, 0x3f, 0xd4, 0x0f, 0x03, 0x00, 0x70, 0x67, 0xaf, 0x4b, 0x67, 0x66, 0x9f, 0x90, 0x91, 0x11, 0xc2,
|
||||
0x11, 0x91, 0xb8, 0xb3, 0xf7, 0x3a, 0xd9, 0x5f, 0x4e, 0x99, 0x67, 0x1e, 0xce, 0x8a, 0xac, 0xec, 0x59, 0xb8, 0xc2,
|
||||
0x95, 0x5d, 0x56, 0x42, 0x67, 0x73, 0x46, 0xf6, 0xca, 0xce, 0xc8, 0xc8, 0xc8, 0x91, 0x8a, 0x7c, 0x2f, 0x7a, 0xfe,
|
||||
0x86, 0xe7, 0x87, 0xe7, 0x87, 0xe7, 0xf5, 0xfa, 0xbc, 0x7f, 0x7c, 0xbb, 0x07, 0xa2, 0x1f, 0xfa, 0xf9, 0xb8, 0x43,
|
||||
0xfd, 0x82, 0x5c, 0xa0, 0x6e, 0xee, 0x28, 0x6f, 0x97, 0x20, 0x77, 0xa8, 0x3b, 0x70, 0xc0, 0x01, 0x07, 0x1c, 0x70,
|
||||
0xc0, 0x01, 0x07, 0x1c, 0x70, 0xc0, 0x01, 0x07, 0x1c, 0x70, 0xc0, 0x01, 0x07, 0x1c, 0x70, 0xc0, 0x01, 0x07, 0x1c,
|
||||
0x70, 0xc0, 0x01, 0xf7, 0x5f, 0xdd, 0x40, 0xd4, 0x63, 0x2f, 0x03, 0xf2, 0x17, 0xb2, 0xe5, 0x69, 0xc7, 0x5a, 0x04,
|
||||
0xdf, 0x46, 0x22, 0xa8, 0x4b, 0x6e, 0xd5, 0x3a, 0x80, 0x05, 0xe1, 0x81, 0x4a, 0x44, 0x56, 0x27, 0xa9, 0x21, 0xf7,
|
||||
0xad, 0xd9, 0xb0, 0xd3, 0xf1, 0xa5, 0x0b, 0x6a, 0x96, 0x56, 0xb2, 0xca, 0xb5, 0xee, 0x0f, 0x96, 0x28, 0xc3, 0x9e,
|
||||
0x8f, 0x6e, 0xf2, 0x84, 0xa4, 0x3b, 0x2d, 0x16, 0x64, 0x08, 0x6a, 0x28, 0x91, 0xee, 0x87, 0x1a, 0x81, 0xff, 0xed,
|
||||
0x2c, 0x5f, 0xda, 0x4a, 0x48, 0x5f, 0x91, 0xf0, 0x31, 0xfe, 0x6b, 0xbd, 0x81, 0x86, 0x92, 0x33, 0x1a, 0x6c, 0x69,
|
||||
0x32, 0xfb, 0x19, 0x56, 0x2c, 0xc6, 0xaa, 0xef, 0xe8, 0x16, 0x98, 0xcf, 0x98, 0xbf, 0xa0, 0x2d, 0xde, 0x7f, 0xdf,
|
||||
0x4b, 0xcb, 0xf6, 0x5c, 0xaf, 0x11, 0x24, 0xf0, 0x46, 0x3e, 0x49, 0x0e, 0x67, 0x0e, 0xcf, 0xf3, 0x57, 0xca, 0xb0,
|
||||
0x39, 0x55, 0xe1, 0xe7, 0xfc, 0x37, 0x29, 0xe8, 0xd7, 0xf3, 0x08, 0x2e, 0xfb, 0x7b, 0x6d, 0x3e, 0xfe, 0xe9, 0x2c,
|
||||
0x68, 0xe4, 0x20, 0x88, 0x57, 0x56, 0x41, 0x3d, 0xab, 0x9b, 0xbf, 0x0c, 0x0c, 0xae, 0x51, 0x48, 0x8b, 0x72, 0xcc,
|
||||
0xb8, 0xbb, 0xf3, 0x30, 0xa8, 0x5c, 0xb5, 0xa1, 0x2f, 0x07, 0x52, 0xe9, 0x36, 0xb8, 0x3c, 0xbc, 0xee, 0xbc, 0xf1,
|
||||
0xa3, 0x8b, 0x81, 0xc2, 0x03, 0xbf, 0x29, 0x5a, 0x22, 0x24, 0x97, 0xf8, 0xc9, 0xb5, 0xbf, 0xd2, 0x24, 0x4a, 0x86,
|
||||
0xc1, 0x2b, 0xb7, 0xb8, 0xde, 0xa7, 0xea, 0xa5, 0x1d, 0x13, 0x1c, 0x1d, 0xd3, 0x3c, 0x81, 0x60, 0x2e, 0x2f, 0x93,
|
||||
0x5c, 0x78, 0x43, 0x2e, 0x39, 0x68, 0x09, 0x13, 0x09, 0x10, 0xce, 0xd6, 0x8d, 0xe9, 0x2f, 0xaf, 0x88, 0x6f, 0x99,
|
||||
0x4f, 0xcd, 0xdc, 0xaa, 0xf9, 0x47, 0xdb, 0x1c, 0xb5, 0x89, 0x53, 0x10, 0x3d, 0x77, 0xac, 0x26, 0x04, 0x3a, 0x3b,
|
||||
0x18, 0xf8, 0x47, 0x75, 0x56, 0xa5, 0xda, 0x70, 0xfb, 0xf7, 0x0d, 0x3b, 0x6e, 0x4b, 0x2a, 0xbc, 0x49, 0x32, 0x43,
|
||||
0x95, 0x62, 0x83, 0x3d, 0xdc, 0x0a, 0x1f, 0x1d, 0xf9, 0x59, 0x6b, 0x95, 0xf0, 0x9b, 0xf5, 0x53, 0x9e, 0xb5, 0x65,
|
||||
0xeb, 0x74, 0xfa, 0x81, 0x9b, 0x61, 0xa3, 0x57, 0x2f, 0xda, 0x2c, 0xcd, 0xf8, 0xb0, 0x74, 0xb9, 0x62, 0x39, 0x91,
|
||||
0xd9, 0x7d, 0xb3, 0x03, 0x65, 0x2e, 0x66, 0x20, 0x18, 0xac, 0xa2, 0xeb, 0x3b, 0x35, 0x98, 0xa3, 0x28, 0x46, 0x30,
|
||||
0xee, 0xd3, 0xeb, 0x47, 0x49, 0x11, 0xc5, 0xcd, 0xa0, 0xa5, 0x1c, 0x2e, 0x9d, 0x14, 0xd6, 0x46, 0x4a, 0x05, 0x7b,
|
||||
0xd3, 0xb9, 0x0d, 0xeb, 0xd7, 0x6f, 0xb5, 0xf4, 0xed, 0x3f, 0x27, 0xb8, 0xec, 0x51, 0xb1, 0x6e, 0xb0, 0x14, 0xed,
|
||||
0xdf, 0x90, 0x72, 0x59, 0x71, 0xd5, 0xf5, 0xf2, 0x61, 0x5f, 0xa7, 0x8a, 0xd7, 0x0f, 0x80, 0x2f, 0xe5, 0x0f, 0x45,
|
||||
0xf2, 0x4d, 0xd1, 0xdc, 0xdd, 0xce, 0x46, 0xdf, 0x77, 0xf2, 0xa6, 0xa2, 0x79, 0x77, 0xf0, 0x0e, 0xaa, 0x68, 0x14,
|
||||
0x85, 0x32, 0x05, 0x29, 0x75, 0x2b, 0x6c, 0xb0, 0x7c, 0x84, 0xc9, 0xec, 0x49, 0x70, 0x9b, 0x38, 0x36, 0x4c, 0xe9,
|
||||
0xa5, 0xdc, 0xb1, 0xb8, 0x28, 0xd6, 0x20, 0x89, 0x8a, 0x2a, 0x62, 0xc1, 0x90, 0x3a, 0x43, 0x48, 0xaa, 0xfc, 0xec,
|
||||
0x52, 0xf4, 0x02, 0xa7, 0xcd, 0x46, 0xf9, 0x78, 0x64, 0x4f, 0xad, 0x6b, 0x17, 0xdb, 0x56, 0xa2, 0xa1, 0x6a, 0xcf,
|
||||
0x3b, 0x66, 0x64, 0x01, 0xc2, 0xd6, 0xae, 0x23, 0x34, 0x82, 0x60, 0x0a, 0x3a, 0xe4, 0xdd, 0x7f, 0xaa, 0x97, 0x44,
|
||||
0x9a, 0x63, 0x8f, 0xaf, 0xa9, 0x97, 0x5a, 0x55, 0xcc, 0x81, 0x48, 0x83, 0xf8, 0xe6, 0xc5, 0xef, 0xdf, 0xfa, 0x5a,
|
||||
0x4f, 0x7f, 0x18, 0x56, 0xc0, 0x66, 0x36, 0xad, 0x8a, 0x79, 0xab, 0xde, 0xf4, 0x7b, 0x70, 0x98, 0xea, 0xfa, 0xf4,
|
||||
0xc7, 0x01, 0x31, 0x16, 0xea, 0xf1, 0x70, 0x8d, 0xee, 0x87, 0x52, 0x13, 0x01, 0x9e, 0x0c, 0xbd, 0x5a, 0x14, 0x01,
|
||||
0x8e, 0xf7, 0x6e, 0xff, 0xce, 0x7f, 0xa4, 0xd1, 0xa1, 0x6a, 0x9b, 0x95, 0xb2, 0x31, 0x8a, 0x6f, 0xc8, 0xfe, 0x8c,
|
||||
0x29, 0x55, 0xbc, 0xfc, 0x61, 0xd0, 0xde, 0xb0, 0xa9, 0x0c, 0x01, 0x0f, 0xba, 0x77, 0x7e, 0x77, 0xd9, 0x76, 0xa4,
|
||||
0xfd, 0xab, 0x38, 0x18, 0x92, 0xec, 0xf0, 0xd3, 0x57, 0x7f, 0xf4, 0xbd, 0x65, 0xd4, 0x77, 0x8e, 0xa1, 0x92, 0x82,
|
||||
0x0c, 0x7b, 0x34, 0xd3, 0x11, 0xfb, 0xc0, 0x36, 0x23, 0x8e, 0xbb, 0x89, 0xdf, 0x70, 0xdc, 0x9a, 0x76, 0x45, 0x0b,
|
||||
0x22, 0xfa, 0x35, 0xdf, 0x8f, 0x45, 0x48, 0x7d, 0xed, 0xdc, 0x06, 0x01, 0x5e, 0xbb, 0xe9, 0xf9, 0x07, 0x93, 0xf6,
|
||||
0x69, 0x6f, 0xf1, 0xfc, 0xfd, 0xfa, 0x41, 0xc0, 0x13, 0x9e, 0x74, 0x2d, 0x76, 0xfe, 0xa7, 0xbe, 0x4e, 0xd9, 0xaa,
|
||||
0x64, 0xa6, 0xed, 0xd3, 0xad, 0xee, 0x62, 0x9d, 0x39, 0xc9, 0xb8, 0xae, 0x86, 0x31, 0x4f, 0x62, 0x57, 0xea, 0xea,
|
||||
0x5a, 0xe3, 0x59, 0xd8, 0x99, 0xb3, 0xcd, 0x9f, 0x3b, 0xea, 0x58, 0x99, 0x11, 0xdc, 0x3d, 0xac, 0x58, 0xd9, 0xa6,
|
||||
0xae, 0x2d, 0x07, 0x1d, 0xd7, 0xfa, 0x87, 0x78, 0xa7, 0x7f, 0x2a, 0x4f, 0x25, 0x58, 0xef, 0x53, 0x78, 0x2e, 0x93,
|
||||
0xdc, 0x44, 0xcc, 0x53, 0x88, 0x77, 0x1c, 0xda, 0x14, 0xaf, 0xe1, 0x67, 0x92, 0xff, 0x36, 0x96, 0x20, 0x6f, 0x9d,
|
||||
0x2c, 0x7f, 0xea, 0x31, 0xf2, 0x34, 0x50, 0xc2, 0x39, 0x84, 0xee, 0x4c, 0xbe, 0xca, 0x06, 0x6f, 0x67, 0x42, 0xea,
|
||||
0x13, 0x58, 0xee, 0xdd, 0x8e, 0x29, 0x4f, 0xee, 0xd8, 0x93, 0x0d, 0x45, 0x80, 0x4d, 0xf3, 0x12, 0x79, 0xbb, 0x36,
|
||||
0xa3, 0x73, 0x95, 0x95, 0xb6, 0xfc, 0x54, 0x60, 0xb2, 0xcc, 0x71, 0xaa, 0xf1, 0x6b, 0x66, 0xed, 0xba, 0x8b, 0xd6,
|
||||
0x6d, 0x61, 0x79, 0x61, 0x1d, 0xb3, 0xba, 0xa6, 0x2f, 0xaa, 0xdc, 0x1d, 0xb8, 0x22, 0xd8, 0x98, 0x58, 0xed, 0x4d,
|
||||
0x3c, 0xea, 0xa9, 0x37, 0x5e, 0x5e, 0x7b, 0x47, 0xa1, 0x7a, 0x39, 0x42, 0xe4, 0x3b, 0xbb, 0x69, 0x0a, 0x8b, 0x32,
|
||||
0x6e, 0x63, 0xeb, 0x87, 0xf6, 0x5d, 0xaa, 0xbf, 0xbe, 0xc5, 0xb2, 0x85, 0x60, 0xdc, 0x32, 0x07, 0x85, 0x73, 0x3d,
|
||||
0x96, 0x8b, 0x89, 0x71, 0x52, 0xb4, 0x93, 0xe6, 0x18, 0xad, 0xbf, 0xce, 0x21, 0x1d, 0x33, 0xb5, 0xb3, 0x35, 0x6a,
|
||||
0x5b, 0xe7, 0x47, 0x13, 0x19, 0x8f, 0x53, 0xf4, 0x0c, 0x2a, 0x39, 0x16, 0x37, 0x39, 0x3b, 0x5f, 0x81, 0x51, 0xbc,
|
||||
0x23, 0x92, 0x2c, 0x8d, 0xbf, 0x2f, 0xee, 0xbf, 0xed, 0x9d, 0x3f, 0xe0, 0x16, 0x21, 0x5a, 0x57, 0xa6, 0x8c, 0x58,
|
||||
0x7a, 0xd5, 0xa1, 0x6d, 0xed, 0xe8, 0x90, 0x97, 0x14, 0xb4, 0x6b, 0xa5, 0x3b, 0xd7, 0x90, 0x84, 0x9e, 0x07, 0xc2,
|
||||
0x7f, 0x1e, 0x08, 0xa0, 0x6f, 0x69, 0xf1, 0xcc, 0x4e, 0xca, 0x07, 0x64, 0xa2, 0xb4, 0xbc, 0x5f, 0xe0, 0xa7, 0x0e,
|
||||
0x31, 0x77, 0x6f, 0x35, 0xd7, 0x66, 0x83, 0x5d, 0x64, 0x4e, 0xf1, 0x3c, 0x7a, 0xcb, 0xa5, 0xfc, 0xc9, 0x95, 0xab,
|
||||
0x27, 0x30, 0x6b, 0x82, 0x57, 0xcd, 0xb0, 0x85, 0x9d, 0xc5, 0xa9, 0x0f, 0xdb, 0xe3, 0x5a, 0xc1, 0xb2, 0x3f, 0xfa,
|
||||
0xea, 0xf3, 0x3a, 0xa0, 0xe4, 0xad, 0xd7, 0x14, 0xcf, 0x4f, 0xc9, 0x46, 0x09, 0x6e, 0x70, 0x27, 0xe4, 0x9b, 0x5f,
|
||||
0x61, 0x4a, 0xb2, 0xfb, 0xc3, 0xf0, 0x3a, 0x53, 0x38, 0x2a, 0x59, 0x48, 0x2b, 0xab, 0x21, 0x64, 0x1a, 0x1c, 0x90,
|
||||
0xb6, 0x69, 0xbb, 0x89, 0xe3, 0x9a, 0x12, 0xb7, 0xfd, 0x47, 0xcc, 0x57, 0xdb, 0x2b, 0xf6, 0x35, 0xe1, 0x79, 0xe0,
|
||||
0xbf, 0x6d, 0x7f, 0x71, 0x3c, 0x21, 0x27, 0x82, 0x12, 0x05, 0xae, 0x43, 0x11, 0x0f, 0xc8, 0xe7, 0x3c, 0xf0, 0x0b,
|
||||
0xe4, 0x69, 0xff, 0xb2, 0xda, 0x9e, 0x7f, 0xfe, 0xa4, 0x3f, 0xdf, 0x02, 0x58, 0xaa, 0x60, 0xd2, 0x8e, 0xd5, 0x6c,
|
||||
0x92, 0x22, 0x38, 0xb1, 0xd2, 0x84, 0x80, 0xea, 0xce, 0xfe, 0x34, 0x79, 0xd6, 0x94, 0xb0, 0xad, 0x51, 0x67, 0x65,
|
||||
0xf5, 0xab, 0xd5, 0x18, 0x05, 0x92, 0x8a, 0x24, 0x36, 0xc2, 0xc4, 0x99, 0xad, 0x0d, 0x26, 0xb8, 0x70, 0xea, 0x5b,
|
||||
0x74, 0x35, 0x6a, 0xfa, 0xac, 0x97, 0xd8, 0x4c, 0x08, 0x6d, 0xc1, 0xfe, 0x60, 0xe4, 0xd4, 0x8f, 0x93, 0xd4, 0xaf,
|
||||
0xa2, 0xec, 0xb8, 0x71, 0xf6, 0xcb, 0x3f, 0xdc, 0x3e, 0x42, 0xca, 0xe2, 0x8d, 0xb9, 0x0a, 0x2c, 0xef, 0xb6, 0x18,
|
||||
0x75, 0x46, 0x8a, 0x2b, 0x21, 0xeb, 0xc5, 0x12, 0x4d, 0xd7, 0xb8, 0x8e, 0x6c, 0xec, 0x9d, 0x12, 0xde, 0xa5, 0x45,
|
||||
0xab, 0xc4, 0x26, 0x47, 0xfa, 0x57, 0xad, 0x78, 0x4c, 0x5e, 0x87, 0x0b, 0x42, 0x6a, 0xbe, 0xef, 0xbd, 0xbc, 0xe5,
|
||||
0xd7, 0x4c, 0x3a, 0x0f, 0x96, 0x29, 0xcb, 0x45, 0x46, 0x3f, 0x8a, 0x31, 0xda, 0x8d, 0x7f, 0x89, 0xa4, 0xd6, 0xb4,
|
||||
0xe5, 0x99, 0x27, 0xa7, 0xab, 0x15, 0x82, 0x75, 0x3e, 0xc0, 0x9d, 0xae, 0x8c, 0x7f, 0x19, 0x80, 0xf5, 0xa6, 0x89,
|
||||
0xbc, 0xb7, 0x36, 0x50, 0x1f, 0x63, 0xca, 0x40, 0x3e, 0xe1, 0x4c, 0x2d, 0x1e, 0x15, 0x51, 0x93, 0xf2, 0x1a, 0x97,
|
||||
0x3e, 0xad, 0x5e, 0xba, 0xe2, 0xd6, 0xa7, 0x7c, 0x51, 0x45, 0xdf, 0x92, 0xbe, 0x35, 0xf3, 0xb4, 0xe8, 0x5c, 0x2e,
|
||||
0x6a, 0x5c, 0xda, 0xe8, 0xf7, 0x51, 0xdc, 0xcb, 0x06, 0xd2, 0xd2, 0xa4, 0x7c, 0x7d, 0x5f, 0x20, 0xb8, 0x50, 0x04,
|
||||
0xb5, 0xb1, 0x9a, 0x4e, 0xc9, 0x94, 0xe4, 0x40, 0x2b, 0x85, 0xc8, 0xd3, 0x71, 0xfa, 0x83, 0x81, 0x28, 0x53, 0xef,
|
||||
0xa0, 0xb0, 0xf0, 0xc8, 0xb9, 0x52, 0xb2, 0x0f, 0x8c, 0x49, 0x93, 0xeb, 0x2a, 0x95, 0x83, 0x4f, 0x47, 0xe8, 0x0c,
|
||||
0x83, 0x7b, 0x88, 0x18, 0x63, 0xa9, 0x87, 0x7f, 0xe3, 0x57, 0xa6, 0x57, 0xdd, 0x6d, 0x2a, 0x85, 0xe9, 0x79, 0x16,
|
||||
0x3b, 0x07, 0x59, 0x4f, 0xed, 0x9a, 0x17, 0xe7, 0xcd, 0x35, 0xa6, 0x9b, 0x06, 0x1d, 0x18, 0x5e, 0xb2, 0x9b, 0xac,
|
||||
0x18, 0xc6, 0x5b, 0xec, 0xbe, 0x29, 0xa3, 0x57, 0xc2, 0x6b, 0x3f, 0x11, 0x89, 0xd1, 0x57, 0xe4, 0xb8, 0xaf, 0x11,
|
||||
0xba, 0x90, 0x93, 0x4f, 0x5e, 0x0c, 0x6b, 0x9f, 0x61, 0x5c, 0xdd, 0xb6, 0xcf, 0x18, 0xcc, 0x9a, 0x95, 0x32, 0x5f,
|
||||
0x6f, 0xf1, 0x43, 0x17, 0x2d, 0xbc, 0x70, 0xbe, 0xd4, 0x76, 0x38, 0x42, 0xcc, 0xdd, 0xf0, 0xff, 0x5b, 0xe8, 0x19,
|
||||
0xc9, 0xf4, 0x25, 0xda, 0xdb, 0x5f, 0xd2, 0x3c, 0xf3, 0xe0, 0xed, 0xb0, 0xa2, 0x4c, 0xdf, 0x05, 0xf2, 0x0c, 0xc4,
|
||||
0x21, 0xb9, 0xc4, 0x02, 0xd2, 0x1e, 0x46, 0x2c, 0x57, 0x78, 0xb0, 0xf1, 0x85, 0x33, 0x51, 0xc5, 0x3e, 0xce, 0x69,
|
||||
0x68, 0x77, 0x3e, 0xb4, 0x5a, 0x4e, 0x43, 0x89, 0x85, 0x71, 0x36, 0x84, 0x56, 0x5c, 0x6b, 0xce, 0x78, 0x4e, 0x86,
|
||||
0x6a, 0x5f, 0xf1, 0x2d, 0x73, 0x4c, 0xba, 0xc1, 0x5e, 0x2b, 0x6e, 0x0d, 0xdd, 0x45, 0x1d, 0x92, 0xb4, 0x31, 0xd9,
|
||||
0xce, 0x0b, 0x58, 0x67, 0xc1, 0xa6, 0x47, 0x2e, 0x5e, 0x6d, 0x6d, 0xa3, 0x78, 0xed, 0xc5, 0xcb, 0xac, 0x20, 0xb8,
|
||||
0x6a, 0x14, 0x32, 0x55, 0xe8, 0xfb, 0xe0, 0xee, 0x31, 0xfc, 0x47, 0x33, 0xf6, 0xe7, 0x54, 0xd1, 0x61, 0x91, 0x74,
|
||||
0x90, 0xfa, 0x44, 0x0a, 0xc8, 0xaf, 0xa2, 0xdb, 0x2c, 0x83, 0xda, 0xe8, 0xd0, 0xb7, 0x34, 0x8a, 0xc8, 0xbb, 0x86,
|
||||
0x58, 0x82, 0x1a, 0x2f, 0xd6, 0xc5, 0x92, 0xe9, 0xd0, 0x21, 0x11, 0xf6, 0xeb, 0x9b, 0xed, 0xbf, 0xa9, 0x71, 0x89,
|
||||
0xb2, 0x59, 0x76, 0xb3, 0xeb, 0x9c, 0xe1, 0x73, 0x79, 0x7b, 0x11, 0x3d, 0x4e, 0x33, 0xca, 0xf6, 0x7e, 0xd8, 0xcf,
|
||||
0x06, 0x41, 0x39, 0xef, 0x78, 0x5a, 0x89, 0x69, 0x0c, 0xff, 0xda, 0x31, 0x16, 0x32, 0xd4, 0xe5, 0x2d, 0x8b, 0x22,
|
||||
0xb9, 0x82, 0x38, 0xd8, 0x22, 0xfc, 0xe8, 0xbd, 0xbf, 0x0d, 0x4b, 0xc9, 0x0d, 0x74, 0x5e, 0x2d, 0xb2, 0x1f, 0x23,
|
||||
0xa8, 0xf8, 0xa7, 0x27, 0xe1, 0x02, 0x61, 0x83, 0xc4, 0xd0, 0x63, 0x0e, 0x23, 0xf2, 0x34, 0x06, 0x69, 0xd1, 0xa3,
|
||||
0xa2, 0x76, 0x76, 0x94, 0xbc, 0x1f, 0x78, 0x73, 0x5a, 0x48, 0x82, 0xb4, 0x68, 0x3d, 0x24, 0x52, 0xe8, 0x92, 0x4e,
|
||||
0x58, 0xdf, 0x55, 0x3f, 0xa8, 0x48, 0x59, 0x35, 0x90, 0x9b, 0x55, 0xc9, 0xdc, 0xff, 0xcc, 0x22, 0x7c, 0xd1, 0x29,
|
||||
0xe0, 0xd5, 0xd8, 0x31, 0x3d, 0x9b, 0xae, 0x38, 0x52, 0x2a, 0xa1, 0xc5, 0x86, 0x19, 0x93, 0xe7, 0x2d, 0x9b, 0xa9,
|
||||
0x09, 0xbf, 0xdd, 0x89, 0x43, 0x35, 0xb2, 0x6f, 0x91, 0xba, 0x28, 0xfa, 0x2f, 0x2c, 0xab, 0xe8, 0x58, 0xe6, 0x4c,
|
||||
0xbf, 0xd5, 0xaf, 0x49, 0xcd, 0x7f, 0x18, 0xa4, 0x69, 0xd3, 0xff, 0x99, 0xad, 0x9d, 0xb0, 0x93, 0x3b, 0x04, 0x01,
|
||||
0xd5, 0x7d, 0x33, 0xe0, 0x66, 0xe2, 0xd4, 0xeb, 0xae, 0xcd, 0x7f, 0x66, 0xf2, 0x2c, 0xa7, 0xfe, 0x7d, 0x51, 0x8b,
|
||||
0x2a, 0x25, 0xa8, 0x90, 0xb2, 0x71, 0xe5, 0x98, 0x40, 0xb8, 0xe9, 0x1f, 0x64, 0xd9, 0x90, 0xf2, 0x39, 0x25, 0x6f,
|
||||
0x58, 0x35, 0x9e, 0x40, 0x25, 0x92, 0xbb, 0xa2, 0x4d, 0x45, 0xf6, 0xc3, 0x1a, 0xc6, 0xc0, 0xf6, 0xc2, 0x9b, 0x2b,
|
||||
0xb5, 0xc7, 0xe9, 0xc8, 0x75, 0x03, 0x71, 0x1c, 0x76, 0x0e, 0xde, 0x5c, 0xe7, 0xf4, 0x91, 0xc4, 0x1d, 0x9f, 0xcb,
|
||||
0x67, 0x1c, 0x1b, 0xc9, 0x25, 0x6d, 0x95, 0x05, 0xd7, 0x3b, 0xbf, 0x2d, 0x90, 0x96, 0xc3, 0x44, 0xa3, 0x0a, 0xee,
|
||||
0x7a, 0x4c, 0x7e, 0x7f, 0xc2, 0x4e, 0x4f, 0x38, 0x88, 0xe9, 0x9f, 0x93, 0xd8, 0xd1, 0x10, 0x8c, 0x85, 0x89, 0xb2,
|
||||
0xd0, 0x95, 0xc3, 0xe4, 0xd2, 0x8f, 0x2b, 0x0c, 0x82, 0xbd, 0x25, 0x74, 0x39, 0xe7, 0x3e, 0xef, 0xd2, 0x2f, 0x6f,
|
||||
0xdc, 0xe0, 0x75, 0x6b, 0xf2, 0x66, 0x7d, 0x65, 0x18, 0x26, 0xb0, 0x7a, 0x7a, 0x45, 0xf0, 0xf2, 0xc4, 0xbe, 0x70,
|
||||
0x6e, 0x58, 0x3a, 0x54, 0x31, 0x64, 0xf6, 0x49, 0xb5, 0xdc, 0x1c, 0x9e, 0x87, 0xdb, 0x6a, 0x92, 0x2a, 0x59, 0x73,
|
||||
0x35, 0x4d, 0x59, 0xed, 0xd0, 0x8a, 0xd9, 0xac, 0xea, 0x7c, 0xc0, 0x0a, 0xfe, 0x8f, 0x29, 0xb7, 0xda, 0xdc, 0x8a,
|
||||
0x55, 0x0f, 0x61, 0xc0, 0x82, 0x49, 0x4c, 0xc2, 0x51, 0x4b, 0x41, 0x7d, 0x1f, 0xf1, 0x07, 0x13, 0x23, 0x16, 0xe4,
|
||||
0x88, 0x76, 0x38, 0x92, 0xbd, 0x3d, 0xe2, 0x7b, 0xab, 0x1c, 0x53, 0x35, 0xfb, 0x85, 0x76, 0x35, 0xf7, 0xfc, 0xdc,
|
||||
0x3e, 0xc9, 0xae, 0x6a, 0x7c, 0x10, 0x44, 0xa5, 0xfb, 0xc4, 0x81, 0x9a, 0x6d, 0x9b, 0xf2, 0x87, 0x0a, 0x9e, 0xcd,
|
||||
0x75, 0xc2, 0xd5, 0xe7, 0x0b, 0xcd, 0x5c, 0x6e, 0xf2, 0x51, 0x96, 0xa3, 0xe8, 0xd5, 0x5a, 0x17, 0xf5, 0x1a, 0x28,
|
||||
0xff, 0xc8, 0xf0, 0xfc, 0x7e, 0x3b, 0xd3, 0xf3, 0x12, 0x61, 0xe9, 0x02, 0xfa, 0x7b, 0x8e, 0x6b, 0x34, 0x4a, 0x5a,
|
||||
0xb6, 0xb1, 0x71, 0x7d, 0xad, 0xbc, 0x88, 0x22, 0xf6, 0x14, 0xc5, 0x57, 0x1a, 0x0b, 0xb6, 0x8b, 0xbb, 0x9a, 0x09,
|
||||
0xf2, 0xe8, 0x8d, 0x32, 0xd1, 0x54, 0xa9, 0xd2, 0x9f, 0xc5, 0xf4, 0x12, 0xad, 0xb3, 0xfd, 0x52, 0xb7, 0xff, 0x0e,
|
||||
0x72, 0x59, 0xcb, 0x1d, 0x8d, 0xa4, 0xf2, 0x0f, 0x39, 0x8f, 0xc9, 0x84, 0xe0, 0x5c, 0xdd, 0x9c, 0xe4, 0x42, 0x4f,
|
||||
0xf2, 0x09, 0x1e, 0x05, 0xcc, 0x41, 0xdd, 0xbd, 0xc1, 0xed, 0xdc, 0x8e, 0x64, 0xc3, 0x67, 0x51, 0xe5, 0x8a, 0x9a,
|
||||
0x0d, 0x09, 0xb8, 0x4b, 0xa1, 0xb2, 0xdb, 0x01, 0xde, 0xf5, 0xd1, 0x51, 0xf7, 0x9d, 0x88, 0x59, 0xfd, 0xd5, 0x13,
|
||||
0x0d, 0x05, 0xd5, 0x5e, 0xfa, 0x63, 0x26, 0x25, 0xad, 0x01, 0x83, 0x6f, 0xf3, 0x7f, 0x56, 0x11, 0x89, 0xaa, 0xdb,
|
||||
0x49, 0xaa, 0x8f, 0x7d, 0x10, 0x99, 0xbf, 0x8b, 0xcf, 0xf4, 0xf8, 0xc2, 0x4e, 0x36, 0xa7, 0xb1, 0xfe, 0xc5, 0xf0,
|
||||
0x07, 0xc3, 0x57, 0xe3, 0xbd, 0x1e, 0xa1, 0x56, 0x46, 0x32, 0x43, 0x8f, 0x8d, 0xc7, 0x73, 0x0a, 0xa5, 0x35, 0xa9,
|
||||
0x93, 0x7c, 0x53, 0x3c, 0xf2, 0x58, 0xf3, 0x6a, 0x37, 0x41, 0xb4, 0xc7, 0x9d, 0xcd, 0xb1, 0x67, 0xb7, 0xaa, 0x5f,
|
||||
0x25, 0xb9, 0xc5, 0xb6, 0xa2, 0xdb, 0xce, 0x34, 0x2c, 0xea, 0x93, 0x45, 0x6e, 0x7b, 0x6f, 0xb6, 0xf6, 0x89, 0x83,
|
||||
0xd1, 0x9e, 0x08, 0x4b, 0x57, 0x4e, 0xd9, 0xf3, 0xe8, 0x81, 0x03, 0x5c, 0xd6, 0x6c, 0x2c, 0xea, 0x27, 0x2d, 0xfb,
|
||||
0x39, 0xbb, 0x25, 0x81, 0xbb, 0x1a, 0xc3, 0xcb, 0xf5, 0x5e, 0x69, 0x1c, 0xa4, 0x8a, 0x0e, 0x4a, 0x69, 0xdf, 0xcd,
|
||||
0x6c, 0xc7, 0xce, 0x48, 0xdb, 0x7a, 0x0b, 0xcb, 0x40, 0xc8, 0x59, 0x81, 0xde, 0x71, 0x3e, 0x0d, 0x41, 0xc9, 0xa2,
|
||||
0x57, 0xae, 0xb7, 0x3f, 0x8b, 0x4e, 0x7c, 0xcb, 0xf9, 0xa5, 0xcb, 0xf8, 0xee, 0x52, 0xbe, 0x54, 0xa8, 0xef, 0x99,
|
||||
0xb7, 0xa1, 0x0b, 0x77, 0xa2, 0xa7, 0x89, 0x47, 0x7f, 0x48, 0xaa, 0x02, 0x36, 0xbf, 0x9d, 0x97, 0xba, 0x43, 0x5e,
|
||||
0x39, 0xa6, 0x99, 0x81, 0xde, 0xe4, 0x98, 0x06, 0x6a, 0xc8, 0x2c, 0x96, 0xf5, 0x51, 0x8c, 0x95, 0xd3, 0xe4, 0x2b,
|
||||
0x94, 0x9a, 0x15, 0x71, 0x9f, 0xa4, 0x24, 0xfc, 0x92, 0x97, 0x18, 0xb7, 0x56, 0xe5, 0x59, 0xe1, 0x1a, 0x79, 0x3a,
|
||||
0x47, 0x77, 0x92, 0xf5, 0x75, 0x42, 0xbd, 0x2d, 0x55, 0x05, 0xcf, 0xe8, 0xea, 0x64, 0x8b, 0xff, 0x30, 0xa1, 0x1e,
|
||||
0x28, 0x2c, 0xa9, 0xaa, 0xd4, 0x19, 0x1d, 0x24, 0x7a, 0xcf, 0x5d, 0x39, 0xf2, 0xe3, 0xd2, 0xab, 0x26, 0x97, 0x35,
|
||||
0xf6, 0x70, 0x8b, 0x09, 0x8b, 0x00, 0xb9, 0xb8, 0xa8, 0xb7, 0x78, 0xf1, 0x96, 0x03, 0xd2, 0x72, 0x46, 0x26, 0x82,
|
||||
0x41, 0xce, 0xe3, 0x73, 0xb2, 0x31, 0x4f, 0x49, 0xc8, 0xa8, 0xcf, 0xcc, 0xb8, 0xcc, 0x5d, 0xab, 0xd1, 0x1e, 0xf5,
|
||||
0x5a, 0x9a, 0x54, 0xaf, 0x15, 0xed, 0x5f, 0x96, 0x2b, 0x62, 0x0a, 0x4c, 0x67, 0x21, 0x98, 0xfd, 0x29, 0xf8, 0x12,
|
||||
0x9d, 0xc2, 0x34, 0x4d, 0x70, 0x0e, 0x3b, 0x5e, 0xe3, 0x91, 0x58, 0x82, 0xa8, 0x9c, 0x7d, 0x11, 0xfb, 0x5d, 0x81,
|
||||
0x0c, 0x0f, 0x9d, 0x84, 0xbb, 0xeb, 0x65, 0x1d, 0xeb, 0x6e, 0x18, 0x8e, 0x93, 0x70, 0xbb, 0x9d, 0xed, 0x58, 0xb2,
|
||||
0x64, 0xf2, 0x7b, 0x8f, 0x05, 0xd8, 0xfa, 0x55, 0x50, 0xef, 0x7a, 0x49, 0x0d, 0xdf, 0xca, 0x63, 0xff, 0x41, 0x1d,
|
||||
0x89, 0x79, 0xca, 0x8c, 0x1b, 0xbe, 0x75, 0x6f, 0x0e, 0x6b, 0x68, 0x8c, 0x5c, 0x93, 0xc8, 0xf3, 0x62, 0x0b, 0x68,
|
||||
0xb1, 0x32, 0x2f, 0x1e, 0xda, 0xe5, 0x6d, 0x45, 0xaf, 0x3d, 0xa7, 0xaf, 0x81, 0x0f, 0xda, 0x82, 0x85, 0x94, 0x06,
|
||||
0xc5, 0x3f, 0xf5, 0x73, 0x5f, 0xa4, 0x43, 0x5f, 0xb3, 0xac, 0x3d, 0x16, 0xe8, 0x6f, 0x84, 0xb3, 0xb6, 0x75, 0xf7,
|
||||
0x13, 0x03, 0x46, 0x67, 0x87, 0x9a, 0xf1, 0x6c, 0x2e, 0x33, 0x33, 0x14, 0x85, 0x18, 0xe9, 0xd6, 0x1b, 0xee, 0x17,
|
||||
0xe9, 0xe7, 0x6b, 0xd4, 0x46, 0xaf, 0x26, 0x55, 0x8d, 0xfc, 0x3a, 0xb4, 0x61, 0x1a, 0x9e, 0x2d, 0x85, 0xa2, 0x86,
|
||||
0x8c, 0x92, 0xbd, 0x8d, 0x0e, 0x4b, 0x4e, 0x0d, 0xdf, 0x4a, 0xc6, 0x66, 0xcd, 0x28, 0xff, 0xc4, 0x44, 0xba, 0x39,
|
||||
0x4a, 0x04, 0x43, 0xce, 0x95, 0x47, 0x99, 0x8c, 0x9b, 0xb7, 0x72, 0x48, 0xdd, 0x9b, 0x98, 0x4f, 0xd5, 0x18, 0x81,
|
||||
0xb4, 0xef, 0xfa, 0x96, 0x54, 0x9b, 0xda, 0x73, 0xe2, 0xe0, 0x27, 0x2f, 0x10, 0x0f, 0xf9, 0x0e, 0x24, 0xb4, 0xd2,
|
||||
0x39, 0xf7, 0x49, 0x8c, 0x5c, 0x05, 0x37, 0xdd, 0xa7, 0x73, 0xac, 0x16, 0x85, 0x71, 0x46, 0xd1, 0x6f, 0x7a, 0x25,
|
||||
0xd2, 0x5c, 0x4e, 0x70, 0x26, 0x93, 0x36, 0x22, 0x5f, 0xe5, 0x42, 0x4e, 0x89, 0xf2, 0x4c, 0x55, 0x9b, 0xd1, 0xa5,
|
||||
0xaf, 0x87, 0xd8, 0x7e, 0xe8, 0xdd, 0xb9, 0x47, 0xb3, 0xda, 0xf7, 0x98, 0x52, 0x73, 0x8b, 0x3c, 0x0b, 0xe7, 0xfa,
|
||||
0x14, 0x7f, 0x9a, 0xa6, 0x8e, 0xf4, 0x7c, 0xad, 0x58, 0x11, 0x73, 0xe3, 0x3a, 0x56, 0x47, 0x9b, 0x95, 0x24, 0x68,
|
||||
0x35, 0xa9, 0xd9, 0x6e, 0x5c, 0x4d, 0xe8, 0xa4, 0xb8, 0x80, 0x0b, 0x73, 0xc4, 0x0f, 0x87, 0x0c, 0xa1, 0xf4, 0x5a,
|
||||
0x3c, 0xce, 0x3f, 0x30, 0x3d, 0xb5, 0xc1, 0x0b, 0x43, 0x2a, 0xb1, 0xbe, 0x6b, 0x12, 0xc4, 0x4b, 0xdb, 0xcb, 0x2f,
|
||||
0x77, 0x8a, 0x3a, 0xba, 0xf4, 0xa3, 0x9b, 0x05, 0xee, 0x98, 0x50, 0x77, 0xfe, 0x82, 0x5b, 0x29, 0x6d, 0x7c, 0x0c,
|
||||
0x4b, 0x41, 0xa2, 0x6a, 0xb2, 0x27, 0x32, 0xf1, 0x0c, 0x1d, 0xd7, 0x6c, 0x02, 0x4d, 0xbd, 0xf2, 0x5a, 0x66, 0xd3,
|
||||
0xb5, 0x30, 0xab, 0x4b, 0x7e, 0x72, 0x77, 0x30, 0x42, 0xd7, 0x70, 0x05, 0x9f, 0xeb, 0x2f, 0x22, 0x7f, 0x49, 0x2d,
|
||||
0x5d, 0xcf, 0x4b, 0x84, 0x0c, 0xa5, 0xb9, 0xe5, 0x49, 0xae, 0x54, 0x2c, 0x86, 0x53, 0x4c, 0x9c, 0x42, 0x29, 0x78,
|
||||
0xf0, 0x0a, 0x8a, 0x36, 0x53, 0x4d, 0x2b, 0xc9, 0xe2, 0x69, 0x0e, 0x9a, 0x37, 0x0c, 0x91, 0x2d, 0xac, 0x2f, 0x43,
|
||||
0x6a, 0xf1, 0xe9, 0xfb, 0xb4, 0x68, 0xec, 0x90, 0x00, 0xff, 0x32, 0xf8, 0x58, 0xab, 0xd1, 0x73, 0x9f, 0x77, 0x72,
|
||||
0x22, 0xed, 0x64, 0xac, 0x7d, 0xd0, 0xa8, 0x1f, 0x01, 0x99, 0x9f, 0xfd, 0xa6, 0xcb, 0x62, 0xca, 0x65, 0xd8, 0xf8,
|
||||
0x31, 0xf0, 0xc1, 0x5e, 0xef, 0xf1, 0x4d, 0x79, 0xa9, 0xfc, 0x8f, 0x57, 0xcb, 0xc1, 0xb7, 0x03, 0x30, 0xbb, 0xad,
|
||||
0xdb, 0x88, 0xf7, 0xcc, 0xa1, 0x25, 0xfd, 0x37, 0x03, 0xc5, 0xdb, 0x7f, 0xb4, 0xe0, 0xe6, 0xa3, 0x7e, 0xa4, 0x52,
|
||||
0x68, 0xa7, 0x2e, 0xe6, 0x1c, 0xea, 0x91, 0x48, 0x67, 0xbd, 0x3d, 0x6d, 0xcc, 0x09, 0x49, 0x17, 0x50, 0x16, 0xb5,
|
||||
0x65, 0x63, 0xd4, 0x84, 0x2c, 0x3a, 0x5f, 0x6a, 0x3c, 0x66, 0xfa, 0x24, 0xaf, 0xd1, 0xfd, 0x18, 0x66, 0xe4, 0xe8,
|
||||
0x3c, 0x6c, 0x74, 0xb6, 0xc5, 0x4c, 0xf8, 0xa8, 0x15, 0x3c, 0xd0, 0xb8, 0x4a, 0x53, 0x15, 0x4b, 0x1e, 0x56, 0x7f,
|
||||
0xd8, 0x40, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0xfe, 0xe7, 0xdc, 0x03, 0xd1, 0x0f, 0xfd, 0x7c, 0xdc, 0xa1, 0x7e, 0x41, 0x2e, 0x50, 0x37, 0x77, 0x94, 0xb7, 0x4b,
|
||||
0x90, 0x3b, 0x14, 0x38, 0xe0, 0x80, 0x03, 0x0e, 0x38, 0xe0, 0x80, 0xfb, 0xff, 0xba, 0xff, 0x00,
|
||||
};
|
||||
|
||||
static const uint8_t STORED[] = {
|
||||
0x01, 0x2c, 0x01, 0xd3, 0xfe, 0x65, 0x73, 0x70, 0x68, 0x6f, 0x6d, 0x65, 0x20, 0x6f, 0x74, 0x61, 0x20, 0x64,
|
||||
0x65, 0x66, 0x6c, 0x61, 0x74, 0x65, 0x20, 0x65, 0x73, 0x70, 0x68, 0x6f, 0x6d, 0x65, 0x20, 0x6f, 0x74, 0x61,
|
||||
0x20, 0x64, 0x65, 0x66, 0x6c, 0x61, 0x74, 0x65, 0x20, 0x65, 0x73, 0x70, 0x68, 0x6f, 0x6d, 0x65, 0x20, 0x6f,
|
||||
0x74, 0x61, 0x20, 0x64, 0x65, 0x66, 0x6c, 0x61, 0x74, 0x65, 0x20, 0x65, 0x73, 0x70, 0x68, 0x6f, 0x6d, 0x65,
|
||||
0x20, 0x6f, 0x74, 0x61, 0x20, 0x64, 0x65, 0x66, 0x6c, 0x61, 0x74, 0x65, 0x20, 0x65, 0x73, 0x70, 0x68, 0x6f,
|
||||
0x6d, 0x65, 0x20, 0x6f, 0x74, 0x61, 0x20, 0x64, 0x65, 0x66, 0x6c, 0x61, 0x74, 0x65, 0x20, 0x65, 0x73, 0x70,
|
||||
0x68, 0x6f, 0x6d, 0x65, 0x20, 0x6f, 0x74, 0x61, 0x20, 0x64, 0x65, 0x66, 0x6c, 0x61, 0x74, 0x65, 0x20, 0x65,
|
||||
0x73, 0x70, 0x68, 0x6f, 0x6d, 0x65, 0x20, 0x6f, 0x74, 0x61, 0x20, 0x64, 0x65, 0x66, 0x6c, 0x61, 0x74, 0x65,
|
||||
0x20, 0x65, 0x73, 0x70, 0x68, 0x6f, 0x6d, 0x65, 0x20, 0x6f, 0x74, 0x61, 0x20, 0x64, 0x65, 0x66, 0x6c, 0x61,
|
||||
0x74, 0x65, 0x20, 0x65, 0x73, 0x70, 0x68, 0x6f, 0x6d, 0x65, 0x20, 0x6f, 0x74, 0x61, 0x20, 0x64, 0x65, 0x66,
|
||||
0x6c, 0x61, 0x74, 0x65, 0x20, 0x65, 0x73, 0x70, 0x68, 0x6f, 0x6d, 0x65, 0x20, 0x6f, 0x74, 0x61, 0x20, 0x64,
|
||||
0x65, 0x66, 0x6c, 0x61, 0x74, 0x65, 0x20, 0x65, 0x73, 0x70, 0x68, 0x6f, 0x6d, 0x65, 0x20, 0x6f, 0x74, 0x61,
|
||||
0x20, 0x64, 0x65, 0x66, 0x6c, 0x61, 0x74, 0x65, 0x20, 0x65, 0x73, 0x70, 0x68, 0x6f, 0x6d, 0x65, 0x20, 0x6f,
|
||||
0x74, 0x61, 0x20, 0x64, 0x65, 0x66, 0x6c, 0x61, 0x74, 0x65, 0x20, 0x65, 0x73, 0x70, 0x68, 0x6f, 0x6d, 0x65,
|
||||
0x20, 0x6f, 0x74, 0x61, 0x20, 0x64, 0x65, 0x66, 0x6c, 0x61, 0x74, 0x65, 0x20, 0x65, 0x73, 0x70, 0x68, 0x6f,
|
||||
0x6d, 0x65, 0x20, 0x6f, 0x74, 0x61, 0x20, 0x64, 0x65, 0x66, 0x6c, 0x61, 0x74, 0x65, 0x20, 0x65, 0x73, 0x70,
|
||||
0x68, 0x6f, 0x6d, 0x65, 0x20, 0x6f, 0x74, 0x61, 0x20, 0x64, 0x65, 0x66, 0x6c, 0x61, 0x74, 0x65, 0x20,
|
||||
};
|
||||
|
||||
static constexpr size_t WINDOW = 4096;
|
||||
static constexpr size_t PLAIN_SIZE = 16000;
|
||||
|
||||
static uint8_t lcg_next(uint32_t &x) {
|
||||
x = (x * 1103515245u + 12345u) & 0x7fffffffu;
|
||||
return (x >> 16) & 0xff;
|
||||
}
|
||||
|
||||
static std::vector<uint8_t> build_plain() {
|
||||
std::vector<uint8_t> plain;
|
||||
const char *text = "esphome ota deflate ";
|
||||
for (int i = 0; i < 300; i++)
|
||||
plain.insert(plain.end(), text, text + strlen(text));
|
||||
uint32_t x = 1;
|
||||
for (int i = 0; i < 3000; i++)
|
||||
plain.push_back(lcg_next(x));
|
||||
plain.insert(plain.end(), 5000, 0);
|
||||
for (int i = 0; i < 100; i++)
|
||||
plain.insert(plain.end(), text, text + strlen(text));
|
||||
return plain;
|
||||
}
|
||||
|
||||
// Mirrors the OTA session: chunked input through the read callback, window as output
|
||||
struct Session : OtaInflateState {
|
||||
const uint8_t *in;
|
||||
size_t in_len;
|
||||
size_t in_pos;
|
||||
size_t chunk;
|
||||
std::vector<uint8_t> out;
|
||||
uint8_t window[WINDOW];
|
||||
};
|
||||
|
||||
static int read_cb(OtaInflateState *d) {
|
||||
auto *s = static_cast<Session *>(d);
|
||||
if (s->in_pos >= s->in_len)
|
||||
return -1;
|
||||
size_t n = std::min(s->chunk, s->in_len - s->in_pos);
|
||||
d->source = s->in + s->in_pos + 1;
|
||||
d->source_limit = s->in + s->in_pos + n;
|
||||
s->in_pos += n;
|
||||
return s->in[s->in_pos - n];
|
||||
}
|
||||
|
||||
// Inflates the whole input; returns the decoder result and fills s.out
|
||||
static int inflate_all(Session &s, const uint8_t *in, size_t in_len, size_t chunk) {
|
||||
s.in = in;
|
||||
s.in_len = in_len;
|
||||
s.in_pos = 0;
|
||||
s.chunk = chunk;
|
||||
s.out.clear();
|
||||
memset(s.window, 0, sizeof(s.window));
|
||||
ota_inflate_init(&s, s.window, WINDOW);
|
||||
s.source_read_cb = read_cb;
|
||||
int res;
|
||||
do {
|
||||
s.dest = s.window;
|
||||
s.dest_limit = s.window + WINDOW;
|
||||
res = ota_inflate(&s);
|
||||
if (res < 0 || s.eof)
|
||||
return res < 0 ? res : OTA_INFLATE_DATA_ERROR;
|
||||
s.out.insert(s.out.end(), s.window, s.dest);
|
||||
if (s.out.size() > PLAIN_SIZE)
|
||||
return OTA_INFLATE_DATA_ERROR;
|
||||
} while (res != OTA_INFLATE_DONE);
|
||||
return res;
|
||||
}
|
||||
|
||||
TEST(OtaInflate, RoundTripThroughWindow) {
|
||||
auto s = std::make_unique<Session>();
|
||||
ASSERT_EQ(inflate_all(*s, DEFLATED, sizeof(DEFLATED), 1040), OTA_INFLATE_DONE);
|
||||
EXPECT_EQ(s->out, build_plain());
|
||||
EXPECT_EQ(s->in_pos, sizeof(DEFLATED));
|
||||
}
|
||||
|
||||
TEST(OtaInflate, SmallReadChunks) {
|
||||
auto s = std::make_unique<Session>();
|
||||
ASSERT_EQ(inflate_all(*s, DEFLATED, sizeof(DEFLATED), 7), OTA_INFLATE_DONE);
|
||||
EXPECT_EQ(s->out, build_plain());
|
||||
}
|
||||
|
||||
TEST(OtaInflate, StoredBlock) {
|
||||
auto s = std::make_unique<Session>();
|
||||
ASSERT_EQ(inflate_all(*s, STORED, sizeof(STORED), 64), OTA_INFLATE_DONE);
|
||||
auto plain = build_plain();
|
||||
plain.resize(300);
|
||||
EXPECT_EQ(s->out, plain);
|
||||
}
|
||||
|
||||
TEST(OtaInflate, TruncatedStreamFails) {
|
||||
auto s = std::make_unique<Session>();
|
||||
for (size_t cut : {size_t{1}, size_t{100}, size_t{1000}, sizeof(DEFLATED) - 1}) {
|
||||
EXPECT_LT(inflate_all(*s, DEFLATED, cut, 1040), 0) << "cut at " << cut;
|
||||
EXPECT_LE(s->out.size(), PLAIN_SIZE);
|
||||
}
|
||||
}
|
||||
|
||||
TEST(OtaInflate, TruncatedStoredBlockFails) {
|
||||
auto s = std::make_unique<Session>();
|
||||
EXPECT_LT(inflate_all(*s, STORED, sizeof(STORED) - 50, 64), 0);
|
||||
}
|
||||
|
||||
TEST(OtaInflate, CorruptStreamsNeverEscapeTheWindow) {
|
||||
// Flipped bytes and garbage; the sanitizers check the decoder stays in bounds
|
||||
auto s = std::make_unique<Session>();
|
||||
std::vector<uint8_t> bad(DEFLATED, DEFLATED + sizeof(DEFLATED));
|
||||
for (size_t i = 0; i < bad.size(); i += 3) {
|
||||
bad[i] ^= 0x5a;
|
||||
inflate_all(*s, bad.data(), bad.size(), 1040);
|
||||
bad[i] ^= 0x5a;
|
||||
}
|
||||
uint32_t x = 99;
|
||||
std::vector<uint8_t> garbage(2000);
|
||||
for (int round = 0; round < 50; round++) {
|
||||
for (auto &b : garbage)
|
||||
b = lcg_next(x);
|
||||
inflate_all(*s, garbage.data(), garbage.size(), 1040);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace esphome::testing
|
||||
@@ -0,0 +1 @@
|
||||
<<: !include common.yaml
|
||||
@@ -14,7 +14,7 @@ struct MinimalBackend {
|
||||
OTAResponseTypes write(uint8_t *data, size_t len) { return OTA_RESPONSE_OK; }
|
||||
OTAResponseTypes end() { return OTA_RESPONSE_OK; }
|
||||
void abort() {}
|
||||
bool supports_compression() { return false; }
|
||||
static constexpr bool supports_compression() { return false; }
|
||||
};
|
||||
static_assert(OTABackendContract<MinimalBackend>);
|
||||
|
||||
|
||||
@@ -21,6 +21,13 @@ The `yaml_config` fixture automatically loads YAML configurations based on the t
|
||||
- The fixture file must exist or the test will fail with a clear error message
|
||||
- The fixture automatically injects a dynamic port number into the API configuration
|
||||
|
||||
Tests marked `@pytest.mark.shared_yaml("name")` load `fixtures/name.yaml` instead
|
||||
of the test-named file and compile it in a shared, hash-keyed build directory, so
|
||||
the whole group pays one full compile and each test only a relink. The marker
|
||||
argument must be a single-line string literal (CI test selection maps fixtures to
|
||||
test files by scanning for it), and marked tests must hand the `yaml_config`
|
||||
content to `run_compiled` unmodified.
|
||||
|
||||
### Key Fixtures
|
||||
|
||||
- `run_compiled` - Combines write, compile, and run operations into a single context manager
|
||||
|
||||
+335
-81
@@ -4,17 +4,22 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncGenerator, Callable, Generator
|
||||
from contextlib import AbstractAsyncContextManager, asynccontextmanager
|
||||
from contextlib import AbstractAsyncContextManager, asynccontextmanager, suppress
|
||||
import fcntl
|
||||
from functools import cache
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from typing import TextIO
|
||||
|
||||
from aioesphomeapi import APIClient, APIConnectionError, LogParser, ReconnectLogic
|
||||
@@ -23,7 +28,13 @@ import pytest_asyncio
|
||||
|
||||
import esphome.config
|
||||
from esphome.core import CORE
|
||||
from esphome.helpers import get_usable_cpu_count
|
||||
from esphome.helpers import (
|
||||
get_usable_cpu_count,
|
||||
read_file,
|
||||
rmtree,
|
||||
write_file,
|
||||
write_file_if_changed,
|
||||
)
|
||||
from esphome.platformio.toolchain import get_idedata
|
||||
|
||||
from .const import (
|
||||
@@ -56,6 +67,21 @@ import pty # not available on Windows
|
||||
pytest.register_assert_rewrite("tests.integration.entity_utils")
|
||||
|
||||
|
||||
def pytest_configure(config: pytest.Config) -> None:
|
||||
config.addinivalue_line(
|
||||
"markers",
|
||||
"shared_yaml(name): load fixtures/<name>.yaml and compile it in a shared, "
|
||||
"hash-keyed incremental build directory",
|
||||
)
|
||||
|
||||
|
||||
FIXTURES_DIR = Path(__file__).parent / "fixtures"
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
|
||||
# CI caches parts of this path; keep in sync with ci.yml integration-tests.
|
||||
INTEGRATION_TESTS_ROOT = Path.home() / ".esphome-integration-tests"
|
||||
|
||||
|
||||
def _get_platformio_env(cache_dir: Path) -> dict[str, str]:
|
||||
"""Get environment variables for PlatformIO with shared cache."""
|
||||
env = os.environ.copy()
|
||||
@@ -78,7 +104,7 @@ def _get_platformio_env(cache_dir: Path) -> dict[str, str]:
|
||||
)
|
||||
# Compile with THIS tree's esphome sources, not wherever the venv's editable
|
||||
# install points (which may be a different git worktree or checkout).
|
||||
repo_root = str(Path(__file__).resolve().parent.parent.parent)
|
||||
repo_root = str(REPO_ROOT)
|
||||
existing = env.get("PYTHONPATH")
|
||||
env["PYTHONPATH"] = f"{repo_root}{os.pathsep}{existing}" if existing else repo_root
|
||||
return env
|
||||
@@ -88,8 +114,7 @@ def _get_platformio_env(cache_dir: Path) -> dict[str, str]:
|
||||
def shared_platformio_cache() -> Generator[Path]:
|
||||
"""Initialize a shared PlatformIO cache for all integration tests."""
|
||||
# Use a dedicated directory for integration tests to avoid conflicts.
|
||||
# CI caches parts of this path; keep in sync with ci.yml integration-tests.
|
||||
test_cache_dir = Path.home() / ".esphome-integration-tests"
|
||||
test_cache_dir = INTEGRATION_TESTS_ROOT
|
||||
cache_dir = test_cache_dir / "platformio"
|
||||
|
||||
# Use a lock file in the home directory to ensure only one process initializes the cache
|
||||
@@ -112,7 +137,9 @@ def shared_platformio_cache() -> Generator[Path]:
|
||||
init_dir = Path(tmpdir)
|
||||
fixture_path = Path(__file__).parent / "fixtures" / "cache_init.yaml"
|
||||
config_path = init_dir / "cache_init.yaml"
|
||||
config_path.write_text(fixture_path.read_text())
|
||||
config_path.write_text(
|
||||
fixture_path.read_text(encoding="utf-8"), encoding="utf-8"
|
||||
)
|
||||
|
||||
# Run compilation to populate the cache
|
||||
# We must succeed here to avoid race conditions where multiple
|
||||
@@ -162,13 +189,6 @@ def integration_test_dir() -> Generator[Path]:
|
||||
yield Path(tmpdir)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def isolated_preferences(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
"""Host preferences persist per device name; give the test its own so a
|
||||
provisioned key never leaks into another run."""
|
||||
monkeypatch.setenv("ESPHOME_PREFDIR", str(tmp_path / "prefs"))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def reserved_tcp_port() -> Generator[tuple[int, socket.socket]]:
|
||||
"""Reserve an unused TCP port by holding the socket open."""
|
||||
@@ -188,21 +208,29 @@ def unused_tcp_port(reserved_tcp_port: tuple[int, socket.socket]) -> int:
|
||||
return reserved_tcp_port[0]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolated_preferences(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path:
|
||||
"""Give every test its own host prefs dir; prefs are keyed only by device
|
||||
name, which tests sharing a fixture also share."""
|
||||
prefdir = tmp_path / "prefs"
|
||||
monkeypatch.setenv("ESPHOME_PREFDIR", str(prefdir))
|
||||
return prefdir
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def yaml_config(request: pytest.FixtureRequest, unused_tcp_port: int) -> str:
|
||||
"""Load YAML configuration based on test name."""
|
||||
# Get the test function name
|
||||
test_name: str = request.node.name
|
||||
# Extract the base test name (remove test_ prefix and any parametrization)
|
||||
base_name = test_name.replace("test_", "").partition("[")[0]
|
||||
shared_name = _shared_yaml_name(request)
|
||||
# Base test name: test_ prefix and any parametrization stripped
|
||||
base_name = shared_name or request.node.name.replace("test_", "").partition("[")[0]
|
||||
|
||||
# Load the fixture file
|
||||
fixture_path = Path(__file__).parent / "fixtures" / f"{base_name}.yaml"
|
||||
fixture_path = FIXTURES_DIR / f"{base_name}.yaml"
|
||||
if not fixture_path.exists():
|
||||
raise FileNotFoundError(f"Fixture file not found: {fixture_path}")
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
content = await loop.run_in_executor(None, fixture_path.read_text)
|
||||
content = await loop.run_in_executor(None, read_file, fixture_path)
|
||||
|
||||
# Replace the port in the config if it contains api section
|
||||
if "api:" in content:
|
||||
@@ -226,11 +254,13 @@ async def yaml_config(request: pytest.FixtureRequest, unused_tcp_port: int) -> s
|
||||
|
||||
# Replace external component path placeholder if present
|
||||
if "EXTERNAL_COMPONENT_PATH" in content:
|
||||
external_components_path = str(
|
||||
Path(__file__).parent / "fixtures" / "external_components"
|
||||
)
|
||||
external_components_path = str(FIXTURES_DIR / "external_components")
|
||||
content = content.replace("EXTERNAL_COMPONENT_PATH", external_components_path)
|
||||
|
||||
if shared_name is not None:
|
||||
# _compile verifies the marked test compiles this content unmodified
|
||||
request.node._shared_yaml_content = content
|
||||
|
||||
return content
|
||||
|
||||
|
||||
@@ -240,24 +270,218 @@ async def write_yaml_config(
|
||||
) -> AsyncGenerator[ConfigWriter]:
|
||||
"""Write YAML configuration to a file."""
|
||||
# Get the test name for default filename
|
||||
test_name = request.node.name
|
||||
base_name = test_name.replace("test_", "").split("[")[0]
|
||||
base_name = request.node.name.replace("test_", "").partition("[")[0]
|
||||
|
||||
async def _write_config(content: str, filename: str | None = None) -> Path:
|
||||
if filename is None:
|
||||
filename = f"{base_name}.yaml"
|
||||
config_path = integration_test_dir / filename
|
||||
loop = asyncio.get_running_loop()
|
||||
await loop.run_in_executor(None, config_path.write_text, content)
|
||||
await loop.run_in_executor(None, write_file, config_path, content)
|
||||
return config_path
|
||||
|
||||
yield _write_config
|
||||
|
||||
|
||||
# Deliberately not CI-cached (ci.yml caches only platformio/ subpaths); stale
|
||||
# dirs for a fixture are pruned when its content hash changes.
|
||||
SHARED_BUILDS_ROOT = INTEGRATION_TESTS_ROOT / "builds"
|
||||
|
||||
# In the dir name (not just the hash) so pruning stays inside this checkout
|
||||
_REPO_KEY = hashlib.sha256(str(REPO_ROOT).encode()).hexdigest()[:8]
|
||||
|
||||
# Give a contended shared build lock time for a full cold compile ahead of us
|
||||
_SHARED_LOCK_TIMEOUT_S = 900
|
||||
_SHARED_LOCK_POLL_S = 0.1
|
||||
_SHARED_LOCK_REPORT_S = 30
|
||||
|
||||
# Reclaims dirs orphaned by fixture renames or deleted checkouts
|
||||
_STALE_BUILD_MAX_AGE_S = 30 * 24 * 3600
|
||||
|
||||
# ELF path per shared build dir; constant once compiled, so resolve it only once
|
||||
_shared_elf_paths: dict[Path, Path] = {}
|
||||
|
||||
# Dirs this process already swept; pruning is session-scoped work
|
||||
_pruned_dirs: set[Path] = set()
|
||||
|
||||
|
||||
def _shared_yaml_name(request: pytest.FixtureRequest) -> str | None:
|
||||
"""Name passed to the shared_yaml marker, or None when unmarked."""
|
||||
marker = request.node.get_closest_marker("shared_yaml")
|
||||
if marker is None:
|
||||
return None
|
||||
# Exactly one \w+ positional arg: the name doubles as a build dir
|
||||
# component, and CI test selection (script/helpers.py) parses the same shape
|
||||
if (
|
||||
len(marker.args) != 1
|
||||
or marker.kwargs
|
||||
or not re.fullmatch(r"\w+", str(marker.args[0]))
|
||||
):
|
||||
raise ValueError(
|
||||
"shared_yaml marker requires exactly one \\w+ fixture name literal"
|
||||
)
|
||||
return marker.args[0]
|
||||
|
||||
|
||||
def _shared_build_prefix(name: str) -> str:
|
||||
return f"{name}-{_REPO_KEY}-"
|
||||
|
||||
|
||||
@cache
|
||||
def _shared_build_dir(name: str) -> Path:
|
||||
"""Dir keyed by checkout and fixture source, before per-test injections."""
|
||||
key = hashlib.sha256((FIXTURES_DIR / f"{name}.yaml").read_bytes()).hexdigest()[:16]
|
||||
return SHARED_BUILDS_ROOT / (_shared_build_prefix(name) + key)
|
||||
|
||||
|
||||
def _read_stamp(stamp: Path, shared_dir: Path) -> Path | None:
|
||||
"""ELF path recorded by the last completed compile, or None."""
|
||||
try:
|
||||
text = stamp.read_text(encoding="utf-8").strip()
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except OSError as err:
|
||||
print(f"Cannot read {stamp}: {err}")
|
||||
return None
|
||||
if not text:
|
||||
print(f"Ignoring empty stamp {stamp}")
|
||||
return None
|
||||
built = Path(text)
|
||||
# Never trust a stamp pointing outside its own build dir as an unlink target
|
||||
if shared_dir.resolve() in built.resolve().parents:
|
||||
return built
|
||||
print(f"Ignoring stamp {stamp} pointing outside {shared_dir}")
|
||||
return None
|
||||
|
||||
|
||||
def _unused_since(stale: Path, cutoff: float) -> bool:
|
||||
"""Whether a build dir looks untouched since cutoff; unknown counts as used."""
|
||||
# Newest of the .built stamp (rewritten by every completed compile) and the
|
||||
# dir itself (freshened by a worker claiming the dir before locking)
|
||||
newest: float | None = None
|
||||
for probe in (stale / ".built", stale):
|
||||
try:
|
||||
mtime = probe.stat().st_mtime
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
except NotADirectoryError:
|
||||
return True # a stray file where a dir should be; reclaimable
|
||||
except OSError as err:
|
||||
print(f"Cannot age-probe {stale}: {err}")
|
||||
return False # unknown never authorizes deletion
|
||||
newest = mtime if newest is None else max(newest, mtime)
|
||||
return newest is not None and newest < cutoff
|
||||
|
||||
|
||||
def _prune_stale_builds(name: str, keep: Path) -> None:
|
||||
"""Remove outdated build dirs (blocking, run in executor): this checkout's
|
||||
other dirs for the fixture, plus anything untouched for 30 days. Tolerates
|
||||
other workers pruning the same dirs concurrently."""
|
||||
cutoff = time.time() - _STALE_BUILD_MAX_AGE_S
|
||||
prefix = _shared_build_prefix(name)
|
||||
for stale in SHARED_BUILDS_ROOT.iterdir():
|
||||
if stale == keep:
|
||||
continue
|
||||
same_fixture = stale.name.startswith(prefix)
|
||||
if not same_fixture and not _unused_since(stale, cutoff):
|
||||
continue
|
||||
# Creating .lock bumps the dir mtime, so remember whether the re-probe
|
||||
# under the lock can trust it
|
||||
lock_preexisting = (stale / ".lock").exists()
|
||||
try:
|
||||
lock_file = (stale / ".lock").open("w")
|
||||
except FileNotFoundError:
|
||||
continue # pruned by another worker meanwhile
|
||||
except NotADirectoryError:
|
||||
print(f"Removing stray file {stale}")
|
||||
stale.unlink(missing_ok=True)
|
||||
continue
|
||||
except OSError as err:
|
||||
print(f"Cannot prune {stale}: {err}")
|
||||
continue
|
||||
with lock_file:
|
||||
try:
|
||||
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError:
|
||||
continue # still in use by another run
|
||||
# Re-probe under the lock: a worker freshens its dir before
|
||||
# locking, so a just-claimed dir no longer looks unused. A dir
|
||||
# whose .lock we just created cannot be held by anyone, and our
|
||||
# own open bumped its mtime, so its pre-open probe stands
|
||||
if (
|
||||
lock_preexisting
|
||||
and not same_fixture
|
||||
and not _unused_since(stale, cutoff)
|
||||
):
|
||||
continue
|
||||
# rmtree tolerates races; a leftover partial tree only costs a
|
||||
# rebuild, since the ELF is deleted before every compile
|
||||
try:
|
||||
rmtree(stale)
|
||||
except OSError as err:
|
||||
print(f"Failed to prune {stale}: {err}")
|
||||
|
||||
|
||||
async def _run_esphome_compile(
|
||||
config_path: Path, cwd: Path, env: dict[str, str]
|
||||
) -> None:
|
||||
"""Run `esphome compile`, retrying up to 3 times on a segfault."""
|
||||
max_retries = 3
|
||||
for attempt in range(max_retries):
|
||||
# Compile using subprocess, inheriting stdout/stderr to show progress
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
sys.executable,
|
||||
"-m",
|
||||
"esphome",
|
||||
"compile",
|
||||
str(config_path),
|
||||
cwd=cwd,
|
||||
stdout=None, # Inherit stdout
|
||||
stderr=None, # Inherit stderr
|
||||
stdin=asyncio.subprocess.DEVNULL,
|
||||
# Start in a new process group to isolate signal handling
|
||||
start_new_session=True,
|
||||
env=env,
|
||||
close_fds=False,
|
||||
)
|
||||
await proc.wait()
|
||||
|
||||
if proc.returncode == 0:
|
||||
break
|
||||
if proc.returncode == -11 and attempt < max_retries - 1:
|
||||
# Segfault (-11 = SIGSEGV), retry
|
||||
print(
|
||||
f"Compilation segfaulted (attempt {attempt + 1}/{max_retries}), retrying..."
|
||||
)
|
||||
await asyncio.sleep(1) # Brief pause before retry
|
||||
continue
|
||||
raise RuntimeError(
|
||||
f"Failed to compile {config_path}, return code: {proc.returncode}. "
|
||||
f"Run with 'pytest -s' to see compilation output."
|
||||
)
|
||||
|
||||
|
||||
def _resolve_compiled_binary(config_path: Path) -> Path:
|
||||
"""Load the config to learn the compiled ELF path (blocking, run in executor)."""
|
||||
CORE.reset() # Reset CORE state between test runs
|
||||
CORE.config_path = config_path
|
||||
config = esphome.config.read_config(
|
||||
{"command": "compile", "config": str(config_path)}
|
||||
)
|
||||
if config is None:
|
||||
raise RuntimeError(f"Failed to read config from {config_path}")
|
||||
idedata = get_idedata(config)
|
||||
binary_path = Path(idedata.firmware_elf_path)
|
||||
if not binary_path.exists():
|
||||
raise RuntimeError(f"Compiled binary not found at {binary_path}")
|
||||
return binary_path
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def compile_esphome(
|
||||
integration_test_dir: Path,
|
||||
shared_platformio_cache: Path,
|
||||
request: pytest.FixtureRequest,
|
||||
) -> AsyncGenerator[CompileFunction]:
|
||||
"""Compile an ESPHome configuration and return the binary path."""
|
||||
|
||||
@@ -265,66 +489,96 @@ async def compile_esphome(
|
||||
# Use the shared PlatformIO cache for faster compilation
|
||||
# This avoids re-downloading dependencies for each test
|
||||
env = _get_platformio_env(shared_platformio_cache)
|
||||
|
||||
# Retry compilation up to 3 times if we get a segfault
|
||||
max_retries = 3
|
||||
for attempt in range(max_retries):
|
||||
# Compile using subprocess, inheriting stdout/stderr to show progress
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
sys.executable,
|
||||
"-m",
|
||||
"esphome",
|
||||
"compile",
|
||||
str(config_path),
|
||||
cwd=integration_test_dir,
|
||||
stdout=None, # Inherit stdout
|
||||
stderr=None, # Inherit stderr
|
||||
stdin=asyncio.subprocess.DEVNULL,
|
||||
# Start in a new process group to isolate signal handling
|
||||
start_new_session=True,
|
||||
env=env,
|
||||
close_fds=False,
|
||||
)
|
||||
await proc.wait()
|
||||
|
||||
if proc.returncode == 0:
|
||||
# Success!
|
||||
break
|
||||
if proc.returncode == -11 and attempt < max_retries - 1:
|
||||
# Segfault (-11 = SIGSEGV), retry
|
||||
print(
|
||||
f"Compilation segfaulted (attempt {attempt + 1}/{max_retries}), retrying..."
|
||||
)
|
||||
await asyncio.sleep(1) # Brief pause before retry
|
||||
continue
|
||||
# Other error or final retry
|
||||
raise RuntimeError(
|
||||
f"Failed to compile {config_path}, return code: {proc.returncode}. "
|
||||
f"Run with 'pytest -s' to see compilation output."
|
||||
)
|
||||
|
||||
# Load the config to get idedata (blocking call, must use executor)
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
def _read_config_and_get_binary():
|
||||
CORE.reset() # Reset CORE state between test runs
|
||||
CORE.config_path = config_path
|
||||
config = esphome.config.read_config(
|
||||
{"command": "compile", "config": str(config_path)}
|
||||
name = _shared_yaml_name(request)
|
||||
if name is None:
|
||||
await _run_esphome_compile(config_path, integration_test_dir, env)
|
||||
return await loop.run_in_executor(
|
||||
None, _resolve_compiled_binary, config_path
|
||||
)
|
||||
if config is None:
|
||||
raise RuntimeError(f"Failed to read config from {config_path}")
|
||||
|
||||
# Get the compiled binary path
|
||||
idedata = get_idedata(config)
|
||||
return Path(idedata.firmware_elf_path)
|
||||
|
||||
binary_path = await loop.run_in_executor(None, _read_config_and_get_binary)
|
||||
|
||||
if not binary_path.exists():
|
||||
raise RuntimeError(f"Compiled binary not found at {binary_path}")
|
||||
|
||||
return binary_path
|
||||
# Shared fixture: build in a hash-keyed dir so tests sharing a config
|
||||
# pay one full compile and later only a main.cpp (port) rebuild + relink
|
||||
shared_dir = _shared_build_dir(name)
|
||||
shared_dir.mkdir(parents=True, exist_ok=True)
|
||||
# Freshen the dir before locking so a concurrent age sweep, which
|
||||
# re-probes under the lock, never reaps a dir a worker just claimed;
|
||||
# if a peer reaped it already, the guarded lock open recreates it
|
||||
with suppress(FileNotFoundError):
|
||||
os.utime(shared_dir)
|
||||
if shared_dir not in _pruned_dirs:
|
||||
_pruned_dirs.add(shared_dir)
|
||||
await loop.run_in_executor(None, _prune_stale_builds, name, shared_dir)
|
||||
shared_config = shared_dir / f"{name}.yaml"
|
||||
private_binary = integration_test_dir / f"{name}.elf"
|
||||
content = await loop.run_in_executor(None, read_file, config_path)
|
||||
if content != getattr(request.node, "_shared_yaml_content", None):
|
||||
# The dir is keyed by the fixture source; a mutated config would be
|
||||
# cached under a hash that does not describe it
|
||||
raise RuntimeError(
|
||||
"shared_yaml tests must compile the yaml_config content unmodified"
|
||||
)
|
||||
# flock serializes concurrent xdist workers; closing the fd releases it.
|
||||
# Hand-rolled rather than filelock.FileLock: non-blocking retries keep
|
||||
# the wait cancellable, while a blocking acquire in an executor thread
|
||||
# would survive test cancellation holding the fd
|
||||
try:
|
||||
lock_file = (shared_dir / ".lock").open("w")
|
||||
except FileNotFoundError:
|
||||
# A peer run pruning divergent hashes reaped the dir between our
|
||||
# mkdir and this open; recreate it and pay a full rebuild
|
||||
shared_dir.mkdir(parents=True, exist_ok=True)
|
||||
lock_file = (shared_dir / ".lock").open("w")
|
||||
with lock_file:
|
||||
start = time.monotonic()
|
||||
last_report = start
|
||||
while True:
|
||||
try:
|
||||
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
break
|
||||
except BlockingIOError:
|
||||
now = time.monotonic()
|
||||
if now - start > _SHARED_LOCK_TIMEOUT_S:
|
||||
raise RuntimeError(
|
||||
f"Timed out waiting for the {shared_dir} lock"
|
||||
) from None
|
||||
if now - last_report >= _SHARED_LOCK_REPORT_S:
|
||||
last_report = now
|
||||
print(
|
||||
f"Waited {now - start:.0f}s for another worker's "
|
||||
f"build of {shared_dir.name}"
|
||||
)
|
||||
await asyncio.sleep(_SHARED_LOCK_POLL_S)
|
||||
# .built carries the ELF path of the last completed compile, so
|
||||
# later workers skip the config re-read in _resolve_compiled_binary
|
||||
stamp = shared_dir / ".built"
|
||||
if (built := _shared_elf_paths.get(shared_dir)) is None:
|
||||
built = await loop.run_in_executor(None, _read_stamp, stamp, shared_dir)
|
||||
# Delete the ELF before compiling: whatever exists afterwards is
|
||||
# this compile's output, so no staleness check is ever needed.
|
||||
# With no usable stamp, sweep any leftover at the known layout
|
||||
if built is not None:
|
||||
built.unlink(missing_ok=True)
|
||||
else:
|
||||
# Layout-agnostic: ESPHOME_BUILD_PATH can move the build tree
|
||||
for leftover in shared_dir.rglob("program"):
|
||||
if leftover.is_file():
|
||||
leftover.unlink()
|
||||
await loop.run_in_executor(
|
||||
None, write_file_if_changed, shared_config, content
|
||||
)
|
||||
await _run_esphome_compile(shared_config, shared_dir, env)
|
||||
if built is None or not built.exists():
|
||||
built = await loop.run_in_executor(
|
||||
None, _resolve_compiled_binary, shared_config
|
||||
)
|
||||
_shared_elf_paths[shared_dir] = built
|
||||
await loop.run_in_executor(None, write_file, stamp, str(built))
|
||||
# Copy out before unlocking: another worker may relink firmware.elf
|
||||
# while this test is still running its private copy
|
||||
await loop.run_in_executor(None, shutil.copy2, built, private_binary)
|
||||
return private_binary
|
||||
|
||||
yield _compile
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
esphome:
|
||||
name: host-ota-test
|
||||
host:
|
||||
api:
|
||||
ota:
|
||||
- platform: esphome
|
||||
port: __OTA_PORT__
|
||||
logger:
|
||||
level: DEBUG
|
||||
@@ -1,58 +0,0 @@
|
||||
esphome:
|
||||
name: test-batch-window-filters
|
||||
|
||||
host:
|
||||
api:
|
||||
batch_delay: 0ms # Disable batching to receive all state updates
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
# Template sensor that we'll use to publish values
|
||||
sensor:
|
||||
- platform: template
|
||||
name: "Source Sensor"
|
||||
id: source_sensor
|
||||
accuracy_decimals: 2
|
||||
|
||||
# Batch window filters (window_size == send_every) - use streaming filters
|
||||
- platform: copy
|
||||
source_id: source_sensor
|
||||
name: "Min Sensor"
|
||||
id: min_sensor
|
||||
filters:
|
||||
- min:
|
||||
window_size: 5
|
||||
send_every: 5
|
||||
send_first_at: 1
|
||||
|
||||
- platform: copy
|
||||
source_id: source_sensor
|
||||
name: "Max Sensor"
|
||||
id: max_sensor
|
||||
filters:
|
||||
- max:
|
||||
window_size: 5
|
||||
send_every: 5
|
||||
send_first_at: 1
|
||||
|
||||
- platform: copy
|
||||
source_id: source_sensor
|
||||
name: "Moving Avg Sensor"
|
||||
id: moving_avg_sensor
|
||||
filters:
|
||||
- sliding_window_moving_average:
|
||||
window_size: 5
|
||||
send_every: 5
|
||||
send_first_at: 1
|
||||
|
||||
# Button to trigger publishing test values
|
||||
button:
|
||||
- platform: template
|
||||
name: "Publish Values Button"
|
||||
id: publish_button
|
||||
on_press:
|
||||
- lambda: |-
|
||||
// Publish 10 values: 1.0, 2.0, ..., 10.0
|
||||
for (int i = 1; i <= 10; i++) {
|
||||
id(source_sensor).publish_state(float(i));
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
esphome:
|
||||
name: uart-mock-modbus-cli-rw
|
||||
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
level: VERBOSE
|
||||
|
||||
external_components:
|
||||
- source:
|
||||
type: local
|
||||
path: EXTERNAL_COMPONENT_PATH
|
||||
|
||||
# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"]
|
||||
# The actual UART bus used is the uart_mock component below
|
||||
uart:
|
||||
baud_rate: 115200
|
||||
port: /dev/null
|
||||
|
||||
# Two virtual buses looped back to each other: the client's transmissions reach the server and the
|
||||
# server's replies reach the client. auto_start so forwarding is active before the button fires.
|
||||
uart_mock:
|
||||
- id: virtual_uart_server
|
||||
baud_rate: 9600
|
||||
auto_start: true
|
||||
debug:
|
||||
on_tx:
|
||||
- then:
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_client
|
||||
data: !lambda return data;
|
||||
- id: virtual_uart_client
|
||||
baud_rate: 9600
|
||||
auto_start: true
|
||||
debug:
|
||||
on_tx:
|
||||
- then:
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_server
|
||||
data: !lambda return data;
|
||||
|
||||
globals:
|
||||
- id: stored_1
|
||||
type: uint16_t
|
||||
initial_value: "0"
|
||||
|
||||
modbus:
|
||||
- uart_id: virtual_uart_server
|
||||
id: virtual_modbus_server
|
||||
role: server
|
||||
- uart_id: virtual_uart_client
|
||||
id: virtual_modbus_client
|
||||
role: client
|
||||
turnaround_time: 10ms
|
||||
|
||||
modbus_server:
|
||||
- address: 1
|
||||
modbus_id: virtual_modbus_server
|
||||
registers:
|
||||
# Writable + readable register: the read publishes what it returns, so the test can confirm the
|
||||
# write half of the 0x17 ran before the read half (Modbus 6.17).
|
||||
- address: 0x01
|
||||
value_type: U_WORD
|
||||
read_lambda: |-
|
||||
id(srv_read_1).publish_state(id(stored_1));
|
||||
return id(stored_1);
|
||||
write_lambda: |-
|
||||
id(stored_1) = x;
|
||||
id(srv_write_1).publish_state(x);
|
||||
return true;
|
||||
# Read-only register, returned together with 0x01 by the 2-register read half.
|
||||
- address: 0x02
|
||||
value_type: U_WORD
|
||||
read_lambda: return 0x00AA;
|
||||
|
||||
sensor:
|
||||
# Server-side observations.
|
||||
- platform: template
|
||||
name: "srv_write_1"
|
||||
id: srv_write_1
|
||||
- platform: template
|
||||
name: "srv_read_1"
|
||||
id: srv_read_1
|
||||
# Client-side read-back: the values the client's on_response received.
|
||||
- platform: template
|
||||
name: "client_read_0"
|
||||
id: client_read_0
|
||||
- platform: template
|
||||
name: "client_read_1"
|
||||
id: client_read_1
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: "Start Scenario"
|
||||
id: start_scenario_btn
|
||||
on_press:
|
||||
# FC 0x17: write reg 0x0001 = 0x1234, then read regs 0x0001..0x0002 back in the same transaction.
|
||||
- modbus_client.read_write_multiple_registers:
|
||||
address: 0x01
|
||||
read_address: 0x0001
|
||||
read_count: 2
|
||||
write_address: 0x0001
|
||||
values: [0x1234]
|
||||
on_response:
|
||||
then:
|
||||
- lambda: |-
|
||||
// values is the read-back block: reg 0x0001 (must be the just-written 0x1234) and reg 0x0002.
|
||||
if (values.size() >= 2) {
|
||||
id(client_read_0).publish_state(values[0]);
|
||||
id(client_read_1).publish_state(values[1]);
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
esphome:
|
||||
name: uart-mock-modbus-custom-pdu
|
||||
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
level: VERBOSE
|
||||
|
||||
external_components:
|
||||
- source:
|
||||
type: local
|
||||
path: EXTERNAL_COMPONENT_PATH
|
||||
|
||||
# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"]
|
||||
# The actual UART bus used is the uart_mock component below
|
||||
uart:
|
||||
baud_rate: 115200
|
||||
port: /dev/null
|
||||
|
||||
uart_mock:
|
||||
- id: virtual_uart_server
|
||||
baud_rate: 9600
|
||||
auto_start: true
|
||||
debug:
|
||||
on_tx:
|
||||
- then:
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_controller
|
||||
data: !lambda return data;
|
||||
- id: virtual_uart_controller
|
||||
baud_rate: 9600
|
||||
auto_start: true
|
||||
debug:
|
||||
on_tx:
|
||||
- then:
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_server
|
||||
data: !lambda return data;
|
||||
|
||||
modbus:
|
||||
- uart_id: virtual_uart_server
|
||||
id: virtual_modbus_server
|
||||
role: server
|
||||
- uart_id: virtual_uart_controller
|
||||
id: virtual_modbus_controller
|
||||
role: client
|
||||
turnaround_time: 10ms
|
||||
|
||||
modbus_controller:
|
||||
- address: 1
|
||||
modbus_id: virtual_modbus_controller
|
||||
id: modbus_controller_1
|
||||
update_interval: 1s
|
||||
|
||||
modbus_server:
|
||||
- address: 1
|
||||
modbus_id: virtual_modbus_server
|
||||
id: modbus_server_1
|
||||
registers:
|
||||
- address: 0x01
|
||||
value_type: U_WORD
|
||||
read_lambda: return 259;
|
||||
|
||||
sensor:
|
||||
# Plain read to confirm the controller <-> server link is up.
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "plain_read"
|
||||
address: 0x01
|
||||
register_type: holding
|
||||
value_type: U_WORD
|
||||
# Custom PDU: read holding register 0x0001, count 1. The PDU is
|
||||
# {function code, address hi, address lo, count hi, count lo}; the device
|
||||
# address and CRC are added by the hub. The lambda parses the response payload
|
||||
# (the register value, big-endian).
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "custom_read"
|
||||
custom_pdu: [0x03, 0x00, 0x01, 0x00, 0x01]
|
||||
lambda: |-
|
||||
if (data.size() < 2) return {};
|
||||
return (float) ((data[0] << 8) | data[1]);
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: "Start Scenario"
|
||||
id: start_scenario_btn
|
||||
# This test does not have anything to start (mock is autostart)
|
||||
@@ -1,106 +0,0 @@
|
||||
esphome:
|
||||
name: uart-mock-modbus-dep-buffer
|
||||
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
level: VERBOSE
|
||||
|
||||
external_components:
|
||||
- source:
|
||||
type: local
|
||||
path: EXTERNAL_COMPONENT_PATH
|
||||
|
||||
# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"]
|
||||
# The actual UART bus used is the uart_mock component below
|
||||
uart:
|
||||
baud_rate: 115200
|
||||
port: /dev/null
|
||||
|
||||
uart_mock:
|
||||
- id: virtual_uart_server
|
||||
baud_rate: 9600
|
||||
auto_start: true
|
||||
debug:
|
||||
on_tx:
|
||||
- then:
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_controller
|
||||
data: !lambda return data;
|
||||
- id: virtual_uart_controller
|
||||
baud_rate: 9600
|
||||
auto_start: true
|
||||
debug:
|
||||
on_tx:
|
||||
- then:
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_server
|
||||
data: !lambda return data;
|
||||
|
||||
globals:
|
||||
- id: reg10
|
||||
type: uint16_t
|
||||
initial_value: "0"
|
||||
|
||||
modbus:
|
||||
- uart_id: virtual_uart_server
|
||||
id: virtual_modbus_server
|
||||
role: server
|
||||
- uart_id: virtual_uart_controller
|
||||
id: virtual_modbus_controller
|
||||
role: client
|
||||
turnaround_time: 10ms
|
||||
|
||||
modbus_controller:
|
||||
- address: 1
|
||||
modbus_id: virtual_modbus_controller
|
||||
id: modbus_controller_1
|
||||
update_interval: 1s
|
||||
|
||||
modbus_server:
|
||||
- address: 1
|
||||
modbus_id: virtual_modbus_server
|
||||
id: modbus_server_1
|
||||
registers:
|
||||
- address: 0x10
|
||||
value_type: U_WORD
|
||||
read_lambda: return id(reg10);
|
||||
write_lambda: |-
|
||||
id(reg10) = x;
|
||||
return true;
|
||||
|
||||
# A number whose write_lambda uses the DEPRECATED buffer parameter (fills `payload` with a legacy raw
|
||||
# frame as words: device address + function code + data) instead of the new item->write_* API. The write
|
||||
# must still land with its legacy semantics, and the one-time deprecation warning must fire only once per
|
||||
# entity no matter how many writes happen.
|
||||
number:
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "buf_number"
|
||||
id: buf_number
|
||||
address: 0x10
|
||||
register_type: holding
|
||||
value_type: U_WORD
|
||||
min_value: 0
|
||||
max_value: 1000
|
||||
step: 1
|
||||
write_lambda: |-
|
||||
// Legacy raw frame as words: [addr 0x01 | fc 0x06], register 0x0010, value.
|
||||
payload.push_back(0x0106);
|
||||
payload.push_back(0x0010);
|
||||
payload.push_back((uint16_t) x);
|
||||
return {};
|
||||
|
||||
# Reports the server-side register so the test can observe that the deprecated buffer write landed.
|
||||
sensor:
|
||||
- platform: template
|
||||
name: "written_value"
|
||||
id: written_value
|
||||
update_interval: 0.5s
|
||||
lambda: "return id(reg10);"
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: "Start Scenario"
|
||||
id: start_scenario_btn
|
||||
# The test drives the writes via number_command; the mock is autostart.
|
||||
@@ -1,95 +0,0 @@
|
||||
esphome:
|
||||
name: uart-mock-modbus-lambda-invert
|
||||
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
level: VERBOSE
|
||||
|
||||
external_components:
|
||||
- source:
|
||||
type: local
|
||||
path: EXTERNAL_COMPONENT_PATH
|
||||
|
||||
# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"]
|
||||
# The actual UART bus used is the uart_mock component below
|
||||
uart:
|
||||
baud_rate: 115200
|
||||
port: /dev/null
|
||||
|
||||
uart_mock:
|
||||
- id: virtual_uart_server
|
||||
baud_rate: 9600
|
||||
auto_start: true
|
||||
debug:
|
||||
on_tx:
|
||||
- then:
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_controller
|
||||
data: !lambda return data;
|
||||
- id: virtual_uart_controller
|
||||
baud_rate: 9600
|
||||
auto_start: true
|
||||
debug:
|
||||
on_tx:
|
||||
- then:
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_server
|
||||
data: !lambda return data;
|
||||
|
||||
globals:
|
||||
- id: reg40
|
||||
type: uint16_t
|
||||
initial_value: "5"
|
||||
|
||||
modbus:
|
||||
- uart_id: virtual_uart_server
|
||||
id: virtual_modbus_server
|
||||
role: server
|
||||
- uart_id: virtual_uart_controller
|
||||
id: virtual_modbus_controller
|
||||
role: client
|
||||
turnaround_time: 10ms
|
||||
|
||||
modbus_controller:
|
||||
- address: 1
|
||||
modbus_id: virtual_modbus_controller
|
||||
id: modbus_controller_1
|
||||
update_interval: 1s
|
||||
|
||||
modbus_server:
|
||||
- address: 1
|
||||
modbus_id: virtual_modbus_server
|
||||
id: modbus_server_1
|
||||
registers:
|
||||
- address: 0x40
|
||||
value_type: U_WORD
|
||||
read_lambda: return id(reg40);
|
||||
write_lambda: id(reg40) = x; return true;
|
||||
|
||||
# An active-low holding switch: the write_lambda inverts the wire value, but the entity must still
|
||||
# report the REQUESTED state. assumed_state keeps the register unpolled, so the published state comes
|
||||
# only from write_state() - turning ON writes 0x0000 yet the switch shows ON.
|
||||
switch:
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "invert_switch"
|
||||
register_type: holding
|
||||
address: 0x40
|
||||
assumed_state: true
|
||||
write_lambda: |-
|
||||
return !x;
|
||||
|
||||
sensor:
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "reg_40"
|
||||
address: 0x40
|
||||
register_type: holding
|
||||
value_type: U_WORD
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: "Start Scenario"
|
||||
id: start_scenario_btn
|
||||
# This test does not have anything to start (mock is autostart)
|
||||
@@ -1,97 +0,0 @@
|
||||
esphome:
|
||||
name: uart-mock-modbus-lambda-write
|
||||
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
level: VERBOSE
|
||||
|
||||
external_components:
|
||||
- source:
|
||||
type: local
|
||||
path: EXTERNAL_COMPONENT_PATH
|
||||
|
||||
# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"]
|
||||
# The actual UART bus used is the uart_mock component below
|
||||
uart:
|
||||
baud_rate: 115200
|
||||
port: /dev/null
|
||||
|
||||
uart_mock:
|
||||
- id: virtual_uart_server
|
||||
baud_rate: 9600
|
||||
auto_start: true
|
||||
debug:
|
||||
on_tx:
|
||||
- then:
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_controller
|
||||
data: !lambda return data;
|
||||
- id: virtual_uart_controller
|
||||
baud_rate: 9600
|
||||
auto_start: true
|
||||
debug:
|
||||
on_tx:
|
||||
- then:
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_server
|
||||
data: !lambda return data;
|
||||
|
||||
globals:
|
||||
- id: reg30
|
||||
type: uint16_t
|
||||
initial_value: "0"
|
||||
|
||||
modbus:
|
||||
- uart_id: virtual_uart_server
|
||||
id: virtual_modbus_server
|
||||
role: server
|
||||
- uart_id: virtual_uart_controller
|
||||
id: virtual_modbus_controller
|
||||
role: client
|
||||
turnaround_time: 10ms
|
||||
|
||||
modbus_controller:
|
||||
- address: 1
|
||||
modbus_id: virtual_modbus_controller
|
||||
id: modbus_controller_1
|
||||
update_interval: 1s
|
||||
|
||||
modbus_server:
|
||||
- address: 1
|
||||
modbus_id: virtual_modbus_server
|
||||
id: modbus_server_1
|
||||
registers:
|
||||
- address: 0x30
|
||||
value_type: U_WORD
|
||||
read_lambda: return id(reg30);
|
||||
write_lambda: id(reg30) = x; return true;
|
||||
|
||||
# A COIL-type switch (assumed_state, write-only) whose write_lambda ignores its own coil type and instead
|
||||
# drives a HOLDING-REGISTER write on the mock server through the entity itself: `item` IS the command, so
|
||||
# item->write_single_register() sends a register write from a coil entity (cross-type). Returning nothing
|
||||
# (an empty optional) tells the write path the lambda already dispatched the frame - no default coil write.
|
||||
switch:
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "cross_switch"
|
||||
register_type: coil
|
||||
address: 0x00
|
||||
assumed_state: true
|
||||
write_lambda: |-
|
||||
item->write_single_register(0x30, x ? 1234 : 0);
|
||||
return {};
|
||||
|
||||
sensor:
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "reg_30"
|
||||
address: 0x30
|
||||
register_type: holding
|
||||
value_type: U_WORD
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: "Start Scenario"
|
||||
id: start_scenario_btn
|
||||
# This test does not have anything to start (mock is autostart)
|
||||
@@ -0,0 +1,233 @@
|
||||
esphome:
|
||||
name: uart-mock-modbus-loopback
|
||||
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
level: VERBOSE
|
||||
|
||||
external_components:
|
||||
- source:
|
||||
type: local
|
||||
path: EXTERNAL_COMPONENT_PATH
|
||||
|
||||
# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"]
|
||||
# The actual UART bus used is the uart_mock component below
|
||||
uart:
|
||||
baud_rate: 115200
|
||||
port: /dev/null
|
||||
|
||||
# Shared loopback fixture (see the shared_yaml markers in the test file);
|
||||
# register spaces are disjoint so each test only observes its own entities.
|
||||
uart_mock:
|
||||
- id: virtual_uart_server
|
||||
baud_rate: 9600
|
||||
auto_start: true
|
||||
debug:
|
||||
on_tx:
|
||||
- then:
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_controller
|
||||
data: !lambda return data;
|
||||
- id: virtual_uart_controller
|
||||
baud_rate: 9600
|
||||
auto_start: true
|
||||
debug:
|
||||
on_tx:
|
||||
- then:
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_server
|
||||
data: !lambda return data;
|
||||
|
||||
globals:
|
||||
- id: reg10
|
||||
type: uint16_t
|
||||
initial_value: "100"
|
||||
- id: reg11
|
||||
type: uint16_t
|
||||
initial_value: "200"
|
||||
- id: reg12
|
||||
type: uint16_t
|
||||
initial_value: "300"
|
||||
- id: reg13
|
||||
type: uint16_t
|
||||
initial_value: "0xABCD"
|
||||
- id: reg30
|
||||
type: uint16_t
|
||||
initial_value: "0"
|
||||
- id: reg40
|
||||
type: uint16_t
|
||||
initial_value: "5"
|
||||
- id: reg50
|
||||
type: uint16_t
|
||||
initial_value: "0"
|
||||
|
||||
modbus:
|
||||
- uart_id: virtual_uart_server
|
||||
id: virtual_modbus_server
|
||||
role: server
|
||||
- uart_id: virtual_uart_controller
|
||||
id: virtual_modbus_controller
|
||||
role: client
|
||||
turnaround_time: 10ms
|
||||
|
||||
modbus_controller:
|
||||
- address: 1
|
||||
modbus_id: virtual_modbus_controller
|
||||
id: modbus_controller_1
|
||||
update_interval: 1s
|
||||
|
||||
modbus_server:
|
||||
- address: 1
|
||||
modbus_id: virtual_modbus_server
|
||||
registers:
|
||||
- address: 0x01
|
||||
value_type: U_WORD
|
||||
read_lambda: return 259;
|
||||
- address: 0x10
|
||||
value_type: U_WORD
|
||||
read_lambda: return id(reg10);
|
||||
write_lambda: id(reg10) = x; return true;
|
||||
- address: 0x11
|
||||
value_type: U_WORD
|
||||
read_lambda: return id(reg11);
|
||||
write_lambda: id(reg11) = x; return true;
|
||||
- address: 0x12
|
||||
value_type: U_WORD
|
||||
read_lambda: return id(reg12);
|
||||
write_lambda: id(reg12) = x; return true;
|
||||
- address: 0x13
|
||||
value_type: U_WORD
|
||||
read_lambda: return id(reg13);
|
||||
- address: 0x30
|
||||
value_type: U_WORD
|
||||
read_lambda: return id(reg30);
|
||||
write_lambda: id(reg30) = x; return true;
|
||||
- address: 0x40
|
||||
value_type: U_WORD
|
||||
read_lambda: return id(reg40);
|
||||
write_lambda: id(reg40) = x; return true;
|
||||
- address: 0x50
|
||||
value_type: U_WORD
|
||||
read_lambda: return id(reg50);
|
||||
write_lambda: id(reg50) = x; return true;
|
||||
|
||||
# Byte-based offset: 2 bytes -> register 0x11 (the old code folded it in as a
|
||||
# register count, hitting 0x12). assumed_state keeps the switch write-only.
|
||||
switch:
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "offset_switch"
|
||||
register_type: holding
|
||||
address: 0x10
|
||||
offset: 2
|
||||
assumed_state: true
|
||||
# Reading switch, byte offset 6 -> register 0x13; the pre-fix resolution (0x16)
|
||||
# would draw ILLEGAL_DATA_ADDRESS and never publish.
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "read_offset_switch"
|
||||
register_type: holding
|
||||
address: 0x10
|
||||
offset: 6
|
||||
bitmask: 0x1
|
||||
# Coil switch whose write_lambda dispatches a holding-register write via `item`;
|
||||
# returning an empty optional suppresses the default coil write.
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "cross_switch"
|
||||
register_type: coil
|
||||
address: 0x00
|
||||
assumed_state: true
|
||||
write_lambda: |-
|
||||
item->write_single_register(0x30, x ? 1234 : 0);
|
||||
return {};
|
||||
# Active-low: the write_lambda inverts the wire value but the entity must still
|
||||
# report the requested state (assumed_state keeps the register unpolled).
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "invert_switch"
|
||||
register_type: holding
|
||||
address: 0x40
|
||||
assumed_state: true
|
||||
write_lambda: |-
|
||||
return !x;
|
||||
|
||||
# Uses the deprecated buffer parameter (legacy raw frame as words); the write
|
||||
# must land and the deprecation warning must fire only once per entity.
|
||||
number:
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "buf_number"
|
||||
id: buf_number
|
||||
address: 0x50
|
||||
register_type: holding
|
||||
value_type: U_WORD
|
||||
min_value: 0
|
||||
max_value: 1000
|
||||
step: 1
|
||||
write_lambda: |-
|
||||
// Legacy raw frame as words: [addr 0x01 | fc 0x06], register 0x0050, value.
|
||||
payload.push_back(0x0106);
|
||||
payload.push_back(0x0050);
|
||||
payload.push_back((uint16_t) x);
|
||||
return {};
|
||||
|
||||
sensor:
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "plain_read"
|
||||
address: 0x01
|
||||
register_type: holding
|
||||
value_type: U_WORD
|
||||
# Custom PDU: read holding register 0x0001; device address and CRC are added
|
||||
# by the hub. The lambda parses the big-endian register value.
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "custom_read"
|
||||
custom_pdu: [0x03, 0x00, 0x01, 0x00, 0x01]
|
||||
lambda: |-
|
||||
if (data.size() < 2) return {};
|
||||
return (float) ((data[0] << 8) | data[1]);
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "reg_10"
|
||||
address: 0x10
|
||||
register_type: holding
|
||||
value_type: U_WORD
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "reg_11"
|
||||
address: 0x11
|
||||
register_type: holding
|
||||
value_type: U_WORD
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "reg_12"
|
||||
address: 0x12
|
||||
register_type: holding
|
||||
value_type: U_WORD
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "reg_30"
|
||||
address: 0x30
|
||||
register_type: holding
|
||||
value_type: U_WORD
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "reg_40"
|
||||
address: 0x40
|
||||
register_type: holding
|
||||
value_type: U_WORD
|
||||
# Reports the server-side register so the test can observe that the deprecated buffer write landed.
|
||||
- platform: template
|
||||
name: "written_value"
|
||||
id: written_value
|
||||
update_interval: 0.5s
|
||||
lambda: "return id(reg50);"
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: "Start Scenario"
|
||||
id: start_scenario_btn
|
||||
# Nothing to start (mock is autostart); tests drive entities directly
|
||||
+110
-11
@@ -1,5 +1,5 @@
|
||||
esphome:
|
||||
name: uart-mock-modbus-server-contro
|
||||
name: uart-mock-modbus-mesh
|
||||
|
||||
host:
|
||||
api:
|
||||
@@ -17,13 +17,14 @@ uart:
|
||||
baud_rate: 115200
|
||||
port: /dev/null
|
||||
|
||||
# Shared 3-bus mesh (see the shared_yaml markers): addr 1 = typed read-only
|
||||
# registers, addr 5 = the read/write 0x17 target, addr 2/3 on the second
|
||||
# server hub. auto_start everywhere: the controller polls at boot, so the
|
||||
# forwarding must already be live or early requests generate warnings.
|
||||
# Every test presses Start Scenario, so all merged actions fire in every test.
|
||||
uart_mock:
|
||||
- id: virtual_uart_server
|
||||
baud_rate: 9600
|
||||
# auto_start must be true for loopback fixtures: the modbus controller
|
||||
# polls on its update_interval immediately at boot, so the uart_mock
|
||||
# forwarding must already be active or early requests are lost and
|
||||
# generate modbus warnings.
|
||||
auto_start: true
|
||||
debug:
|
||||
on_tx:
|
||||
@@ -31,35 +32,68 @@ uart_mock:
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_controller
|
||||
data: !lambda return data;
|
||||
- id: virtual_uart_controller
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_server_2
|
||||
data: !lambda return data;
|
||||
- id: virtual_uart_server_2
|
||||
baud_rate: 9600
|
||||
auto_start: true # See comment on virtual_uart_server above
|
||||
auto_start: true
|
||||
debug:
|
||||
on_tx:
|
||||
- then:
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_server
|
||||
data: !lambda return data;
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_controller
|
||||
data: !lambda return data;
|
||||
- id: virtual_uart_controller
|
||||
baud_rate: 9600
|
||||
auto_start: true
|
||||
debug:
|
||||
on_tx:
|
||||
- then:
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_server
|
||||
data: !lambda return data;
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_server_2
|
||||
data: !lambda return data;
|
||||
|
||||
globals:
|
||||
- id: stored_1
|
||||
type: uint16_t
|
||||
initial_value: "0"
|
||||
|
||||
modbus:
|
||||
- uart_id: virtual_uart_server
|
||||
id: virtual_modbus_server
|
||||
role: server
|
||||
- uart_id: virtual_uart_server_2
|
||||
id: virtual_modbus_server_2
|
||||
role: server
|
||||
- uart_id: virtual_uart_controller
|
||||
id: virtual_modbus_controller
|
||||
id: virtual_modbus_client
|
||||
role: client
|
||||
turnaround_time: 10ms
|
||||
|
||||
modbus_controller:
|
||||
- address: 1
|
||||
modbus_id: virtual_modbus_controller
|
||||
modbus_id: virtual_modbus_client
|
||||
id: modbus_controller_1
|
||||
update_interval: 1s
|
||||
- address: 2
|
||||
modbus_id: virtual_modbus_client
|
||||
id: modbus_controller_2
|
||||
update_interval: 1s
|
||||
- address: 3
|
||||
modbus_id: virtual_modbus_client
|
||||
id: modbus_controller_3
|
||||
update_interval: 1s
|
||||
|
||||
modbus_server:
|
||||
- address: 1
|
||||
modbus_id: virtual_modbus_server
|
||||
id: modbus_server_1
|
||||
registers:
|
||||
- address: 0x01
|
||||
value_type: U_WORD
|
||||
@@ -103,6 +137,34 @@ modbus_server:
|
||||
- address: 0x28
|
||||
value_type: FP32_R
|
||||
read_lambda: return 3.14;
|
||||
- address: 5
|
||||
modbus_id: virtual_modbus_server
|
||||
registers:
|
||||
# Writable + readable register: srv_write_1 plus the client's read-back
|
||||
# confirm the write half of the 0x17 ran before the read half (Modbus 6.17).
|
||||
- address: 0x01
|
||||
value_type: U_WORD
|
||||
read_lambda: return id(stored_1);
|
||||
write_lambda: |-
|
||||
id(stored_1) = x;
|
||||
id(srv_write_1).publish_state(x);
|
||||
return true;
|
||||
# Read-only register, returned together with 0x01 by the 2-register read half.
|
||||
- address: 0x02
|
||||
value_type: U_WORD
|
||||
read_lambda: return 0x00AA;
|
||||
- address: 2
|
||||
modbus_id: virtual_modbus_server_2
|
||||
registers:
|
||||
- address: 0x01
|
||||
value_type: U_WORD
|
||||
read_lambda: return 919;
|
||||
- address: 3
|
||||
modbus_id: virtual_modbus_server_2
|
||||
registers:
|
||||
- address: 0x01
|
||||
value_type: U_WORD
|
||||
read_lambda: return 929;
|
||||
|
||||
sensor:
|
||||
- platform: modbus_controller
|
||||
@@ -195,9 +257,46 @@ sensor:
|
||||
address: 0x28
|
||||
register_type: holding
|
||||
value_type: FP32_R
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_2
|
||||
name: "multi_reg_a"
|
||||
address: 0x01
|
||||
register_type: holding
|
||||
value_type: U_WORD
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_3
|
||||
name: "multi_reg_b"
|
||||
address: 0x01
|
||||
register_type: holding
|
||||
value_type: U_WORD
|
||||
# client_read_write observations, server- and client-side.
|
||||
- platform: template
|
||||
name: "srv_write_1"
|
||||
id: srv_write_1
|
||||
- platform: template
|
||||
name: "client_read_0"
|
||||
id: client_read_0
|
||||
- platform: template
|
||||
name: "client_read_1"
|
||||
id: client_read_1
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: "Start Scenario"
|
||||
id: start_scenario_btn
|
||||
# This test does not have anything to start (mock is autostart)
|
||||
on_press:
|
||||
# FC 0x17: write reg 0x0001 = 0x1234, then read regs 0x0001..0x0002 back in the same transaction.
|
||||
- modbus_client.read_write_multiple_registers:
|
||||
address: 5
|
||||
read_address: 0x0001
|
||||
read_count: 2
|
||||
write_address: 0x0001
|
||||
values: [0x1234]
|
||||
on_response:
|
||||
then:
|
||||
- lambda: |-
|
||||
// values is the read-back block: reg 0x0001 (must be the just-written 0x1234) and reg 0x0002.
|
||||
if (values.size() >= 2) {
|
||||
id(client_read_0).publish_state(values[0]);
|
||||
id(client_read_1).publish_state(values[1]);
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
esphome:
|
||||
name: uart-mock-modbus-reg-offset
|
||||
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
level: VERBOSE
|
||||
|
||||
external_components:
|
||||
- source:
|
||||
type: local
|
||||
path: EXTERNAL_COMPONENT_PATH
|
||||
|
||||
# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"]
|
||||
# The actual UART bus used is the uart_mock component below
|
||||
uart:
|
||||
baud_rate: 115200
|
||||
port: /dev/null
|
||||
|
||||
uart_mock:
|
||||
- id: virtual_uart_server
|
||||
baud_rate: 9600
|
||||
auto_start: true
|
||||
debug:
|
||||
on_tx:
|
||||
- then:
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_controller
|
||||
data: !lambda return data;
|
||||
- id: virtual_uart_controller
|
||||
baud_rate: 9600
|
||||
auto_start: true
|
||||
debug:
|
||||
on_tx:
|
||||
- then:
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_server
|
||||
data: !lambda return data;
|
||||
|
||||
globals:
|
||||
- id: reg10
|
||||
type: uint16_t
|
||||
initial_value: "100"
|
||||
- id: reg11
|
||||
type: uint16_t
|
||||
initial_value: "200"
|
||||
- id: reg12
|
||||
type: uint16_t
|
||||
initial_value: "300"
|
||||
- id: reg13
|
||||
type: uint16_t
|
||||
initial_value: "0xABCD"
|
||||
|
||||
modbus:
|
||||
- uart_id: virtual_uart_server
|
||||
id: virtual_modbus_server
|
||||
role: server
|
||||
- uart_id: virtual_uart_controller
|
||||
id: virtual_modbus_controller
|
||||
role: client
|
||||
turnaround_time: 10ms
|
||||
|
||||
modbus_controller:
|
||||
- address: 1
|
||||
modbus_id: virtual_modbus_controller
|
||||
id: modbus_controller_1
|
||||
update_interval: 1s
|
||||
|
||||
modbus_server:
|
||||
- address: 1
|
||||
modbus_id: virtual_modbus_server
|
||||
id: modbus_server_1
|
||||
registers:
|
||||
- address: 0x10
|
||||
value_type: U_WORD
|
||||
read_lambda: return id(reg10);
|
||||
write_lambda: id(reg10) = x; return true;
|
||||
- address: 0x11
|
||||
value_type: U_WORD
|
||||
read_lambda: return id(reg11);
|
||||
write_lambda: id(reg11) = x; return true;
|
||||
- address: 0x12
|
||||
value_type: U_WORD
|
||||
read_lambda: return id(reg12);
|
||||
write_lambda: id(reg12) = x; return true;
|
||||
- address: 0x13
|
||||
value_type: U_WORD
|
||||
read_lambda: return id(reg13);
|
||||
write_lambda: id(reg13) = x; return true;
|
||||
|
||||
# A holding-register switch at 0x10 with a 2-BYTE offset. offset is byte-based, so the write must target
|
||||
# register 0x10 + 2/2 = 0x11. The old (pre-fix) behavior folded offset into the address as a register
|
||||
# count, hitting 0x12 instead. assumed_state keeps the switch write-only so it does not read any register.
|
||||
switch:
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "offset_switch"
|
||||
register_type: holding
|
||||
address: 0x10
|
||||
offset: 2
|
||||
assumed_state: true
|
||||
# A holding-register switch that READS its state. Byte offset 6 -> register 0x10 + 6/2 = 0x13. Post-fix
|
||||
# the switch itself resolves to 0x13 (the even byte offset folds into the address as whole registers) and
|
||||
# joins the 0x10..0x13 range, so no separate 0x13 sensor is needed. Pre-fix the whole byte offset folds
|
||||
# into the address (0x16), where the server answers ILLEGAL_DATA_ADDRESS and the switch never publishes.
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "read_offset_switch"
|
||||
register_type: holding
|
||||
address: 0x10
|
||||
offset: 6
|
||||
bitmask: 0x1
|
||||
|
||||
sensor:
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "reg_10"
|
||||
address: 0x10
|
||||
register_type: holding
|
||||
value_type: U_WORD
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "reg_11"
|
||||
address: 0x11
|
||||
register_type: holding
|
||||
value_type: U_WORD
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "reg_12"
|
||||
address: 0x12
|
||||
register_type: holding
|
||||
value_type: U_WORD
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: "Start Scenario"
|
||||
id: start_scenario_btn
|
||||
# This test does not have anything to start (mock is autostart)
|
||||
@@ -1,124 +0,0 @@
|
||||
esphome:
|
||||
name: uart-mock-modbus-server-test
|
||||
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
level: VERBOSE
|
||||
|
||||
external_components:
|
||||
- source:
|
||||
type: local
|
||||
path: EXTERNAL_COMPONENT_PATH
|
||||
|
||||
# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"]
|
||||
# The actual UART bus used is the uart_mock component below
|
||||
uart:
|
||||
baud_rate: 115200
|
||||
port: /dev/null
|
||||
|
||||
uart_mock:
|
||||
- id: virtual_uart_dev
|
||||
baud_rate: 9600
|
||||
rx_full_threshold: 120
|
||||
rx_timeout: 2
|
||||
auto_start: false
|
||||
debug:
|
||||
injections:
|
||||
- delay: 100ms
|
||||
inject_rx: [0x01, 0x03, 0x00, 0x03, 0x00, 0x01, 0x74, 0x0A] # Read holding register 3 on device 1 (basic_read)
|
||||
- delay: 100ms
|
||||
# Read holding register 7 on device 2
|
||||
# Reply from device 2
|
||||
# Read holding register 5 on device 1 (read_after_peer_response)
|
||||
inject_rx:
|
||||
[
|
||||
0x02,
|
||||
0x03,
|
||||
0x00,
|
||||
0x07,
|
||||
0x00,
|
||||
0x01,
|
||||
0x35,
|
||||
0xF8,
|
||||
0x02,
|
||||
0x03,
|
||||
0x02,
|
||||
0x00,
|
||||
0xF0,
|
||||
0xFC,
|
||||
0x00,
|
||||
0x01,
|
||||
0x03,
|
||||
0x00,
|
||||
0x05,
|
||||
0x00,
|
||||
0x01,
|
||||
0x94,
|
||||
0x0B,
|
||||
]
|
||||
- delay: 100ms
|
||||
inject_rx: [0x02, 0x03, 0x00, 0x07, 0x00, 0x01, 0x35, 0xF8] # Read holding register 7 on device 2, with no response
|
||||
- delay: 100ms
|
||||
# Read holding register 7 on device 2, with no response
|
||||
# Read holding register A on device 1 (read_after_peer_timeout)
|
||||
inject_rx:
|
||||
[
|
||||
0x02,
|
||||
0x03,
|
||||
0x00,
|
||||
0x07,
|
||||
0x00,
|
||||
0x01,
|
||||
0x35,
|
||||
0xF8,
|
||||
0x01,
|
||||
0x03,
|
||||
0x00,
|
||||
0x0A,
|
||||
0x00,
|
||||
0x01,
|
||||
0xA4,
|
||||
0x08,
|
||||
]
|
||||
|
||||
modbus:
|
||||
uart_id: virtual_uart_dev
|
||||
role: server
|
||||
|
||||
modbus_server:
|
||||
- address: 1
|
||||
registers:
|
||||
- address: 0x03
|
||||
value_type: U_WORD
|
||||
read_lambda: |-
|
||||
id(basic_read).publish_state(1);
|
||||
return 1;
|
||||
- address: 0x05
|
||||
value_type: U_WORD
|
||||
read_lambda: |-
|
||||
id(read_after_peer_response).publish_state(1);
|
||||
return 1;
|
||||
- address: 0x0A
|
||||
value_type: U_WORD
|
||||
read_lambda: |-
|
||||
id(read_after_peer_timeout).publish_state(1);
|
||||
return 1;
|
||||
|
||||
sensor:
|
||||
- platform: template
|
||||
name: "basic_read"
|
||||
id: basic_read
|
||||
- platform: template
|
||||
name: "read_after_peer_response"
|
||||
id: read_after_peer_response
|
||||
- platform: template
|
||||
name: "read_after_peer_timeout"
|
||||
id: read_after_peer_timeout
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: "Start Scenario"
|
||||
id: start_scenario_btn
|
||||
on_press:
|
||||
- lambda: "id(virtual_uart_dev).start_scenario();"
|
||||
@@ -1,116 +0,0 @@
|
||||
esphome:
|
||||
name: uart-mock-modbus-server-mult
|
||||
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
level: VERBOSE
|
||||
|
||||
external_components:
|
||||
- source:
|
||||
type: local
|
||||
path: EXTERNAL_COMPONENT_PATH
|
||||
|
||||
# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"]
|
||||
# The actual UART bus used is the uart_mock component below
|
||||
uart:
|
||||
baud_rate: 115200
|
||||
port: /dev/null
|
||||
|
||||
uart_mock:
|
||||
- id: virtual_uart_server
|
||||
baud_rate: 9600
|
||||
# auto_start must be true for loopback fixtures: the modbus controller
|
||||
# polls on its update_interval immediately at boot, so the uart_mock
|
||||
# forwarding must already be active or early requests are lost and
|
||||
# generate modbus warnings.
|
||||
auto_start: true
|
||||
debug:
|
||||
on_tx:
|
||||
- then:
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_controller
|
||||
data: !lambda return data;
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_server_2
|
||||
data: !lambda return data;
|
||||
- id: virtual_uart_server_2
|
||||
baud_rate: 9600
|
||||
auto_start: true # See comment on virtual_uart_server above
|
||||
debug:
|
||||
on_tx:
|
||||
- then:
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_server
|
||||
data: !lambda return data;
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_controller
|
||||
data: !lambda return data;
|
||||
- id: virtual_uart_controller
|
||||
baud_rate: 9600
|
||||
auto_start: true # See comment on virtual_uart_server above
|
||||
debug:
|
||||
on_tx:
|
||||
- then:
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_server
|
||||
data: !lambda return data;
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_server_2
|
||||
data: !lambda return data;
|
||||
|
||||
modbus:
|
||||
- uart_id: virtual_uart_server
|
||||
id: virtual_modbus_server
|
||||
role: server
|
||||
- uart_id: virtual_uart_server_2
|
||||
id: virtual_modbus_server_2
|
||||
role: server
|
||||
- uart_id: virtual_uart_controller
|
||||
id: virtual_modbus_client
|
||||
role: client
|
||||
turnaround_time: 10ms
|
||||
|
||||
modbus_controller:
|
||||
- address: 1
|
||||
modbus_id: virtual_modbus_client
|
||||
update_interval: 1s
|
||||
id: modbus_controller_1
|
||||
- address: 2
|
||||
modbus_id: virtual_modbus_client
|
||||
update_interval: 1s
|
||||
id: modbus_controller_2
|
||||
|
||||
modbus_server:
|
||||
- address: 1
|
||||
modbus_id: virtual_modbus_server
|
||||
registers:
|
||||
- address: 0x01
|
||||
value_type: U_WORD
|
||||
read_lambda: return 919;
|
||||
- address: 2
|
||||
modbus_id: virtual_modbus_server_2
|
||||
registers:
|
||||
- address: 0x01
|
||||
value_type: U_WORD
|
||||
read_lambda: return 929;
|
||||
|
||||
sensor:
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "reg_u_word"
|
||||
address: 0x01
|
||||
register_type: holding
|
||||
value_type: U_WORD
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_2
|
||||
name: "reg_u_word_2"
|
||||
address: 0x01
|
||||
register_type: holding
|
||||
value_type: U_WORD
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: "Start Scenario"
|
||||
id: start_scenario_btn
|
||||
# This test does not have anything to start (mock is autostart)
|
||||
+47
-8
@@ -1,5 +1,5 @@
|
||||
esphome:
|
||||
name: uart-mock-modbus-srv-rw
|
||||
name: uart-mock-modbus-srv-injected
|
||||
|
||||
host:
|
||||
api:
|
||||
@@ -17,6 +17,8 @@ uart:
|
||||
baud_rate: 115200
|
||||
port: /dev/null
|
||||
|
||||
# Shared server-role fixture (see the shared_yaml markers in the test file);
|
||||
# the injections concatenate and each test waits only on its own sensors.
|
||||
uart_mock:
|
||||
- id: virtual_uart_dev
|
||||
baud_rate: 9600
|
||||
@@ -25,18 +27,31 @@ uart_mock:
|
||||
auto_start: false
|
||||
debug:
|
||||
injections:
|
||||
# FC 0x17 Read/Write Multiple Registers on device 1:
|
||||
# write reg 0x0001 = 0x1234 (qty 1), then read regs 0x0001..0x0002 (qty 2).
|
||||
# Per Modbus 6.17 the write is performed before the read, so reg 0x0001 must
|
||||
# read back the just-written 0x1234 in the same request.
|
||||
- delay: 100ms
|
||||
inject_rx: [0x01, 0x03, 0x00, 0x03, 0x00, 0x01, 0x74, 0x0A] # Read holding register 3 on device 1 (basic_read)
|
||||
- delay: 100ms
|
||||
# Read holding register 7 on device 2, its reply, then read holding
|
||||
# register 5 on device 1 (read_after_peer_response)
|
||||
inject_rx: [0x02, 0x03, 0x00, 0x07, 0x00, 0x01, 0x35, 0xF8,
|
||||
0x02, 0x03, 0x02, 0x00, 0xF0, 0xFC,
|
||||
0x00, 0x01, 0x03, 0x00, 0x05, 0x00, 0x01, 0x94, 0x0B]
|
||||
- delay: 100ms
|
||||
inject_rx: [0x02, 0x03, 0x00, 0x07, 0x00, 0x01, 0x35, 0xF8] # Read holding register 7 on device 2, with no response
|
||||
- delay: 100ms
|
||||
# Read holding register 7 on device 2 with no response, then read
|
||||
# holding register A on device 1 (read_after_peer_timeout)
|
||||
inject_rx: [0x02, 0x03, 0x00, 0x07, 0x00, 0x01, 0x35, 0xF8,
|
||||
0x01, 0x03, 0x00, 0x0A, 0x00, 0x01, 0xA4, 0x08]
|
||||
# FC 0x17 on device 1: write reg 0x0001 = 0x1234 then read 0x0001..0x0002;
|
||||
# per Modbus 6.17 the write runs first, so 0x0001 must read back 0x1234.
|
||||
- delay: 100ms
|
||||
inject_rx:
|
||||
[0x01, 0x17, 0x00, 0x01, 0x00, 0x02, 0x00, 0x01, 0x00, 0x01, 0x02, 0x12, 0x34, 0x49, 0xD8]
|
||||
# FC 0x17: write reg 0x0003 = 0x5678 (qty 1), then read reg 0x0003 (qty 1) -
|
||||
# FC 0x17: write reg 0x0006 = 0x5678 (qty 1), then read reg 0x0006 (qty 1) -
|
||||
# a write and read targeting a different register block.
|
||||
- delay: 100ms
|
||||
inject_rx:
|
||||
[0x01, 0x17, 0x00, 0x03, 0x00, 0x01, 0x00, 0x03, 0x00, 0x01, 0x02, 0x56, 0x78, 0x9B, 0x10]
|
||||
[0x01, 0x17, 0x00, 0x06, 0x00, 0x01, 0x00, 0x06, 0x00, 0x01, 0x02, 0x56, 0x78, 0x8B, 0x55]
|
||||
|
||||
globals:
|
||||
- id: stored_1
|
||||
@@ -70,8 +85,18 @@ modbus_server:
|
||||
read_lambda: |-
|
||||
id(rw_read_2).publish_state(0x00AA);
|
||||
return 0x00AA;
|
||||
# Second writable + readable register, targeted by the second request.
|
||||
- address: 0x03
|
||||
value_type: U_WORD
|
||||
read_lambda: |-
|
||||
id(basic_read).publish_state(1);
|
||||
return 1;
|
||||
- address: 0x05
|
||||
value_type: U_WORD
|
||||
read_lambda: |-
|
||||
id(read_after_peer_response).publish_state(1);
|
||||
return 1;
|
||||
# Second writable + readable register, targeted by the second FC 0x17 request.
|
||||
- address: 0x06
|
||||
value_type: U_WORD
|
||||
read_lambda: |-
|
||||
id(rw_read_3).publish_state(id(stored_3));
|
||||
@@ -80,8 +105,22 @@ modbus_server:
|
||||
id(stored_3) = x;
|
||||
id(rw_write_3).publish_state(x);
|
||||
return true;
|
||||
- address: 0x0A
|
||||
value_type: U_WORD
|
||||
read_lambda: |-
|
||||
id(read_after_peer_timeout).publish_state(1);
|
||||
return 1;
|
||||
|
||||
sensor:
|
||||
- platform: template
|
||||
name: "basic_read"
|
||||
id: basic_read
|
||||
- platform: template
|
||||
name: "read_after_peer_response"
|
||||
id: read_after_peer_response
|
||||
- platform: template
|
||||
name: "read_after_peer_timeout"
|
||||
id: read_after_peer_timeout
|
||||
- platform: template
|
||||
name: "rw_write_1"
|
||||
id: rw_write_1
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Helpers for manipulating the host platform's preferences file.
|
||||
|
||||
ESPHome's host platform stores preferences in
|
||||
``~/.esphome/prefs/<app_name>.prefs`` using a simple binary layout that
|
||||
``$ESPHOME_PREFDIR/<app_name>.prefs`` using a simple binary layout that
|
||||
mirrors ``HostPreferences::sync()``:
|
||||
``[uint32_t key][uint8_t len][uint8_t data[len]]`` per entry.
|
||||
|
||||
@@ -11,13 +11,21 @@ boot (e.g. forcing safe mode) or to clear stale state between runs.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import struct
|
||||
|
||||
|
||||
def host_prefs_path(device_name: str) -> Path:
|
||||
"""Return the on-disk prefs file path for a host-platform device."""
|
||||
return Path.home() / ".esphome" / "prefs" / f"{device_name}.prefs"
|
||||
"""Return the on-disk prefs file path for a host-platform device.
|
||||
|
||||
Requires ESPHOME_PREFDIR, which the autouse isolated_preferences fixture
|
||||
sets; refusing the ~/.esphome/prefs fallback keeps tests off real user
|
||||
data if the fixture is ever bypassed."""
|
||||
prefdir = os.environ.get("ESPHOME_PREFDIR")
|
||||
if not prefdir:
|
||||
raise RuntimeError("ESPHOME_PREFDIR is not set; refusing the real prefs dir")
|
||||
return Path(prefdir) / f"{device_name}.prefs"
|
||||
|
||||
|
||||
def clear_host_prefs(device_name: str) -> None:
|
||||
|
||||
@@ -24,7 +24,6 @@ from .types import (
|
||||
RunCompiledFunction,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.usefixtures("isolated_preferences")
|
||||
NEW_KEY = PROVISIONING_PSK
|
||||
|
||||
|
||||
|
||||
@@ -180,10 +180,14 @@ async def test_host_ota_self_update(
|
||||
)
|
||||
)
|
||||
staged = asyncio.Event()
|
||||
inflated = asyncio.Event()
|
||||
|
||||
def on_log(line: str) -> None:
|
||||
if "OTA staged at" in line:
|
||||
staged.set()
|
||||
# The host backend cannot store gzip, so the upload negotiates deflate
|
||||
if "Inflated " in line and " bytes from " in line:
|
||||
inflated.set()
|
||||
dev.on_log(line)
|
||||
|
||||
async with run_binary(dev.binary_path, line_callback=on_log) as (proc, _lines):
|
||||
@@ -195,6 +199,7 @@ async def test_host_ota_self_update(
|
||||
|
||||
await dev.ota(None, None, "espota2 reported failure")
|
||||
assert staged.is_set()
|
||||
assert inflated.is_set(), "upload was not deflate compressed"
|
||||
|
||||
async with wait_and_connect_api_client(port=dev.api_port) as client:
|
||||
info_after = await client.device_info()
|
||||
@@ -204,6 +209,64 @@ async def test_host_ota_self_update(
|
||||
await dev.ota(None, None, "second OTA failed -- listener leaked across execv")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_host_ota_deflate(
|
||||
yaml_config: str,
|
||||
write_yaml_config: ConfigWriter,
|
||||
compile_esphome: CompileFunction,
|
||||
reserved_tcp_port: tuple[int, socket.socket],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Deflate is negotiated by default, an old client gets an uncompressed
|
||||
upload, and a corrupt stream is rejected without taking the device down."""
|
||||
dev = _Device(
|
||||
*await _build(
|
||||
yaml_config, write_yaml_config, compile_esphome, reserved_tcp_port
|
||||
)
|
||||
)
|
||||
inflated: list[str] = []
|
||||
errors: list[str] = []
|
||||
|
||||
def on_log(line: str) -> None:
|
||||
if "Inflated " in line and " bytes from " in line:
|
||||
inflated.append(line)
|
||||
if "Inflate err" in line or "End update err" in line:
|
||||
errors.append(line)
|
||||
dev.on_log(line)
|
||||
|
||||
real_compress = espota2.zlib.compress
|
||||
|
||||
def corrupt_compress(data: bytes, *args: object, **kwargs: object) -> bytes:
|
||||
out = bytearray(real_compress(data, *args, **kwargs))
|
||||
out[len(out) // 2] ^= 0x55
|
||||
return bytes(out)
|
||||
|
||||
async with run_binary(dev.binary_path, line_callback=on_log) as (proc, _lines):
|
||||
dev.proc = proc
|
||||
await _wait_for_port(LOCALHOST, dev.api_port, PORT_WAIT_TIMEOUT)
|
||||
|
||||
# Default: the host backend cannot store gzip, so the CLI sends deflate
|
||||
await dev.ota(None, None, "deflate upload failed")
|
||||
assert len(inflated) == 1, "device did not inflate the upload"
|
||||
|
||||
# A client that does not offer deflate is served uncompressed
|
||||
monkeypatch.setattr(espota2, "CLIENT_FEATURE_SUPPORTS_DEFLATE", 0)
|
||||
await dev.ota(None, None, "uncompressed upload failed")
|
||||
assert len(inflated) == 1, "device inflated without a client offer"
|
||||
monkeypatch.undo()
|
||||
|
||||
# A corrupt stream fails the upload and leaves the device running
|
||||
monkeypatch.setattr(espota2.zlib, "compress", corrupt_compress)
|
||||
await dev.refused_ota(None, None, "corrupt deflate stream was accepted")
|
||||
monkeypatch.undo()
|
||||
assert errors, "device did not report the corrupt stream"
|
||||
|
||||
# and it still takes a good upload afterwards
|
||||
inflated_before = len(inflated)
|
||||
await dev.ota(None, None, "upload after a rejected stream failed")
|
||||
assert len(inflated) == inflated_before + 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_host_ota_encrypted(
|
||||
yaml_config: str,
|
||||
|
||||
@@ -41,15 +41,6 @@ async def _poll_until_exists(path: Path) -> None:
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolated_preferences(monkeypatch: pytest.MonkeyPatch, tmp_path) -> Path:
|
||||
"""Keep host preferences per-test so this test never touches the real
|
||||
~/.esphome/prefs and never races other tests over ESPHOME_PREFDIR."""
|
||||
prefdir = tmp_path / "prefs"
|
||||
monkeypatch.setenv("ESPHOME_PREFDIR", str(prefdir))
|
||||
return prefdir / f"{DEVICE_NAME}.prefs"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_host_preferences_suspend_resume(
|
||||
yaml_config: str,
|
||||
@@ -58,7 +49,7 @@ async def test_host_preferences_suspend_resume(
|
||||
isolated_preferences: Path,
|
||||
) -> None:
|
||||
"""Test that a running syncer flushes, a suspended one doesn't, and resume restores flushing."""
|
||||
pref_file = isolated_preferences
|
||||
pref_file = isolated_preferences / f"{DEVICE_NAME}.prefs"
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
saved_in_memory = loop.create_future()
|
||||
|
||||
@@ -11,14 +11,6 @@ from .state_utils import InitialStateHelper, require_entity
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolated_preferences(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
|
||||
"""Keep host preferences per-test so RESTORE_AND_ON never loads a stale value left
|
||||
behind by a previous run (host preferences otherwise persist to ~/.esphome/prefs,
|
||||
keyed only by device name)."""
|
||||
monkeypatch.setenv("ESPHOME_PREFDIR", str(tmp_path / "prefs"))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_light_initial_state(
|
||||
yaml_config: str,
|
||||
|
||||
@@ -173,6 +173,7 @@ async def test_uart_mock_modbus_no_threshold(
|
||||
_assert_no_modbus_errors(error_log_lines, warning_log_lines)
|
||||
|
||||
|
||||
@pytest.mark.shared_yaml("uart_mock_modbus_server_injected")
|
||||
@pytest.mark.asyncio
|
||||
async def test_uart_mock_modbus_server(
|
||||
yaml_config: str,
|
||||
@@ -203,6 +204,7 @@ async def test_uart_mock_modbus_server(
|
||||
_assert_no_modbus_errors(error_log_lines, warning_log_lines)
|
||||
|
||||
|
||||
@pytest.mark.shared_yaml("uart_mock_modbus_server_injected")
|
||||
@pytest.mark.asyncio
|
||||
async def test_uart_mock_modbus_server_read_write(
|
||||
yaml_config: str,
|
||||
@@ -231,8 +233,8 @@ async def test_uart_mock_modbus_server_read_write(
|
||||
"rw_write_1": 4660, # 0x1234 written to reg 0x0001
|
||||
"rw_read_1": 4660, # reg 0x0001 reads back the just-written value
|
||||
"rw_read_2": 170, # 0x00AA read from reg 0x0002 in the same request
|
||||
"rw_write_3": 22136, # 0x5678 written to reg 0x0003
|
||||
"rw_read_3": 22136, # reg 0x0003 reads back the just-written value
|
||||
"rw_write_3": 22136, # 0x5678 written to reg 0x0006
|
||||
"rw_read_3": 22136, # reg 0x0006 reads back the just-written value
|
||||
}
|
||||
)
|
||||
|
||||
@@ -241,7 +243,8 @@ async def test_uart_mock_modbus_server_read_write(
|
||||
api_client_connected() as client,
|
||||
):
|
||||
await tracker.setup_and_start_scenario(client)
|
||||
await tracker.await_all(futures)
|
||||
# The FC 0x17 injections fire last, behind four earlier 100ms delays
|
||||
await tracker.await_all(futures, timeout=4.0)
|
||||
_assert_no_modbus_errors(error_log_lines, warning_log_lines)
|
||||
|
||||
|
||||
@@ -296,6 +299,7 @@ async def test_uart_mock_modbus_server_read_write_invalid(
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.shared_yaml("uart_mock_modbus_mesh")
|
||||
@pytest.mark.asyncio
|
||||
async def test_uart_mock_modbus_server_controller(
|
||||
yaml_config: str,
|
||||
@@ -485,6 +489,7 @@ async def test_uart_mock_modbus_server_controller_bits(
|
||||
_assert_no_modbus_errors(error_log_lines, warning_log_lines)
|
||||
|
||||
|
||||
@pytest.mark.shared_yaml("uart_mock_modbus_mesh")
|
||||
@pytest.mark.asyncio
|
||||
async def test_uart_mock_modbus_server_controller_multiple(
|
||||
yaml_config: str,
|
||||
@@ -495,7 +500,7 @@ async def test_uart_mock_modbus_server_controller_multiple(
|
||||
|
||||
line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback()
|
||||
|
||||
expected_values = {"reg_u_word": 919, "reg_u_word_2": 929}
|
||||
expected_values = {"multi_reg_a": 919, "multi_reg_b": 929}
|
||||
tracker = SensorTracker(list(expected_values.keys()))
|
||||
futures = tracker.expect_all(expected_values)
|
||||
|
||||
@@ -706,6 +711,7 @@ async def test_uart_mock_modbus_shared_address(
|
||||
_assert_no_modbus_errors(error_log_lines, warning_log_lines)
|
||||
|
||||
|
||||
@pytest.mark.shared_yaml("uart_mock_modbus_loopback")
|
||||
@pytest.mark.asyncio
|
||||
async def test_uart_mock_modbus_custom_pdu(
|
||||
yaml_config: str,
|
||||
@@ -932,6 +938,7 @@ async def test_uart_mock_modbus_broadcast_write(
|
||||
_assert_no_modbus_errors(error_log_lines, warning_log_lines)
|
||||
|
||||
|
||||
@pytest.mark.shared_yaml("uart_mock_modbus_mesh")
|
||||
@pytest.mark.asyncio
|
||||
async def test_uart_mock_modbus_client_read_write(
|
||||
yaml_config: str,
|
||||
@@ -947,9 +954,7 @@ async def test_uart_mock_modbus_client_read_write(
|
||||
"""
|
||||
line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback()
|
||||
|
||||
tracker = SensorTracker(
|
||||
["srv_write_1", "srv_read_1", "client_read_0", "client_read_1"]
|
||||
)
|
||||
tracker = SensorTracker(["srv_write_1", "client_read_0", "client_read_1"])
|
||||
futures = tracker.expect_all(
|
||||
{
|
||||
"srv_write_1": 4660, # server wrote 0x1234 to reg 0x0001
|
||||
@@ -967,6 +972,7 @@ async def test_uart_mock_modbus_client_read_write(
|
||||
_assert_no_modbus_errors(error_log_lines, warning_log_lines)
|
||||
|
||||
|
||||
@pytest.mark.shared_yaml("uart_mock_modbus_loopback")
|
||||
@pytest.mark.asyncio
|
||||
async def test_uart_mock_modbus_register_offset(
|
||||
yaml_config: str,
|
||||
@@ -1022,6 +1028,7 @@ async def test_uart_mock_modbus_register_offset(
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.shared_yaml("uart_mock_modbus_loopback")
|
||||
@pytest.mark.asyncio
|
||||
async def test_uart_mock_modbus_lambda_write(
|
||||
yaml_config: str,
|
||||
@@ -1058,6 +1065,7 @@ async def test_uart_mock_modbus_lambda_write(
|
||||
await tracker.await_change(wrote_30, "reg_30", timeout=4.0)
|
||||
|
||||
|
||||
@pytest.mark.shared_yaml("uart_mock_modbus_loopback")
|
||||
@pytest.mark.asyncio
|
||||
async def test_uart_mock_modbus_lambda_invert(
|
||||
yaml_config: str,
|
||||
@@ -1113,6 +1121,7 @@ async def test_uart_mock_modbus_lambda_invert(
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.shared_yaml("uart_mock_modbus_loopback")
|
||||
@pytest.mark.asyncio
|
||||
async def test_uart_mock_modbus_deprecated_write_buffer(
|
||||
yaml_config: str,
|
||||
|
||||
@@ -78,17 +78,6 @@ def mock_esp32_platformio_components_to_test() -> Generator[Mock, None, None]:
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_esp8266_native_components_to_test() -> Generator[Mock, None, None]:
|
||||
"""Mock esp8266_native_components_to_test from determine_jobs.
|
||||
|
||||
main() drives both the ``esp8266_native`` boolean output and the
|
||||
``esp8266_native_components`` CSV from this one function.
|
||||
"""
|
||||
with patch.object(determine_jobs, "esp8266_native_components_to_test") as mock:
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_determine_cpp_unit_tests() -> Generator[Mock, None, None]:
|
||||
"""Mock determine_cpp_unit_tests from helpers."""
|
||||
@@ -117,7 +106,6 @@ def clear_determine_jobs_caches() -> None:
|
||||
"""Clear all cached functions before each test."""
|
||||
determine_jobs._is_clang_tidy_full_scan.cache_clear()
|
||||
determine_jobs._component_has_tests.cache_clear()
|
||||
determine_jobs._cached_components_closure.cache_clear()
|
||||
|
||||
|
||||
def test_main_all_tests_should_run(
|
||||
@@ -128,7 +116,6 @@ def test_main_all_tests_should_run(
|
||||
mock_should_run_import_time: Mock,
|
||||
mock_should_run_device_builder: Mock,
|
||||
mock_esp32_platformio_components_to_test: Mock,
|
||||
mock_esp8266_native_components_to_test: Mock,
|
||||
mock_changed_files: Mock,
|
||||
mock_determine_cpp_unit_tests: Mock,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
@@ -145,7 +132,6 @@ def test_main_all_tests_should_run(
|
||||
mock_should_run_import_time.return_value = True
|
||||
mock_should_run_device_builder.return_value = True
|
||||
mock_esp32_platformio_components_to_test.return_value = ["api", "esp32"]
|
||||
mock_esp8266_native_components_to_test.return_value = ["api", "logger"]
|
||||
mock_determine_cpp_unit_tests.return_value = (False, ["wifi", "api", "sensor"])
|
||||
|
||||
# Mock changed_files to return non-component files (to avoid memory impact)
|
||||
@@ -222,8 +208,6 @@ def test_main_all_tests_should_run(
|
||||
assert output["device_builder"] is True
|
||||
assert output["esp32_platformio"] is True
|
||||
assert output["esp32_platformio_components"] == "api,esp32"
|
||||
assert output["esp8266_native"] is True
|
||||
assert output["esp8266_native_components"] == "api,logger"
|
||||
assert output["changed_components"] == ["wifi", "api", "sensor"]
|
||||
# changed_components_with_tests will only include components that actually have test files
|
||||
assert "changed_components_with_tests" in output
|
||||
@@ -260,7 +244,6 @@ def test_main_no_tests_should_run(
|
||||
mock_should_run_import_time: Mock,
|
||||
mock_should_run_device_builder: Mock,
|
||||
mock_esp32_platformio_components_to_test: Mock,
|
||||
mock_esp8266_native_components_to_test: Mock,
|
||||
mock_changed_files: Mock,
|
||||
mock_determine_cpp_unit_tests: Mock,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
@@ -277,7 +260,6 @@ def test_main_no_tests_should_run(
|
||||
mock_should_run_import_time.return_value = False
|
||||
mock_should_run_device_builder.return_value = False
|
||||
mock_esp32_platformio_components_to_test.return_value = []
|
||||
mock_esp8266_native_components_to_test.return_value = []
|
||||
mock_determine_cpp_unit_tests.return_value = (False, [])
|
||||
|
||||
# Mock changed_files to return no component files
|
||||
@@ -320,8 +302,6 @@ def test_main_no_tests_should_run(
|
||||
assert output["device_builder"] is False
|
||||
assert output["esp32_platformio"] is False
|
||||
assert output["esp32_platformio_components"] == ""
|
||||
assert output["esp8266_native"] is False
|
||||
assert output["esp8266_native_components"] == ""
|
||||
assert output["changed_components"] == []
|
||||
assert output["changed_components_with_tests"] == []
|
||||
assert output["component_test_count"] == 0
|
||||
@@ -1013,10 +993,6 @@ _ESP32_PLATFORMIO_FULL_LIST_FILES = [
|
||||
# PlatformIO subsystem (path-prefix trigger) + build generator
|
||||
["esphome/platformio/runner.py"],
|
||||
["esphome/platformio/toolchain.py"],
|
||||
# The pch modules are standalone triggers, not the whole build_helpers/
|
||||
["esphome/build_helpers/pch.py"],
|
||||
["esphome/build_helpers/ccache.py"],
|
||||
["esphome/build_helpers/idedata.py"],
|
||||
["esphome/build_gen/platformio.py"],
|
||||
# Workflow / harness files
|
||||
["script/test_build_components.py"],
|
||||
@@ -1075,9 +1051,6 @@ def test_esp32_platformio_components_to_test_returns_full_list_on_infrastructure
|
||||
# Non-PlatformIO files in esphome/build_gen/ do NOT trigger the
|
||||
# full list -- only esphome/build_gen/platformio.py is a trigger.
|
||||
(["esphome/build_gen/espidf.py"], [], []),
|
||||
# build_helpers modules the pch does not import are not triggers.
|
||||
(["esphome/build_helpers/size_summary.py"], [], []),
|
||||
(["esphome/build_helpers/ninja.py"], [], []),
|
||||
# Docs / unrelated files -> empty.
|
||||
(["README.md"], [], []),
|
||||
([], [], []),
|
||||
@@ -3178,78 +3151,6 @@ def test_memory_impact_elf_layouts_are_found(tmp_path: Path) -> None:
|
||||
assert find_elf_path(build_path) == elf, f"{platform} ELF not found"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"changed",
|
||||
[
|
||||
"esphome/arduino8266/framework.py",
|
||||
"esphome/build_gen/arduino8266.py",
|
||||
"esphome/components/esp8266/build_surgery.py",
|
||||
# Shared modules the native build depends on
|
||||
"esphome/build_helpers/idedata.py",
|
||||
"esphome/platformio/library.py",
|
||||
# Top-level esphome/*.py modules the backend imports directly
|
||||
"esphome/framework_helpers.py",
|
||||
"esphome/writer.py",
|
||||
# esp8266/__init__.py imports copy_ccache_script from it
|
||||
"esphome/platformio/toolchain.py",
|
||||
# The composite cache action must not ship unexercised
|
||||
".github/actions/cache-arduino8266/action.yml",
|
||||
],
|
||||
)
|
||||
def test_esp8266_native_components_full_list_on_infra_change(changed: str) -> None:
|
||||
"""Native-ESP8266 infrastructure changes run the full test list."""
|
||||
with (
|
||||
patch.object(determine_jobs, "changed_files", return_value=[changed]),
|
||||
patch.object(
|
||||
determine_jobs,
|
||||
"get_components_with_dependencies",
|
||||
return_value=["wifi"],
|
||||
),
|
||||
):
|
||||
result = determine_jobs.esp8266_native_components_to_test()
|
||||
assert result == sorted(determine_jobs.ESP8266_NATIVE_TEST_COMPONENTS)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("changed_files", "dependency_closure", "expected"),
|
||||
[
|
||||
# Tested component changed -- narrow to the intersection.
|
||||
(
|
||||
["esphome/components/mqtt/mqtt_client.cpp"],
|
||||
["mqtt", "json"],
|
||||
["mqtt"],
|
||||
),
|
||||
# Components outside the test set return an empty list (job skipped).
|
||||
(
|
||||
["esphome/components/wifi/wifi_component.cpp"],
|
||||
["wifi", "network"],
|
||||
[],
|
||||
),
|
||||
# espidf infrastructure is not an esp8266-native trigger; the
|
||||
# native backend depends on esphome/build_helpers/ instead.
|
||||
(["esphome/build_gen/espidf.py"], [], []),
|
||||
(["esphome/espidf/toolchain.py"], [], []),
|
||||
(["README.md"], [], []),
|
||||
],
|
||||
)
|
||||
def test_esp8266_native_components_to_test_narrowing(
|
||||
changed_files: list[str],
|
||||
dependency_closure: list[str],
|
||||
expected: list[str],
|
||||
) -> None:
|
||||
"""Component changes narrow the native-ESP8266 test list."""
|
||||
with (
|
||||
patch.object(determine_jobs, "changed_files", return_value=changed_files),
|
||||
patch.object(
|
||||
determine_jobs,
|
||||
"get_components_with_dependencies",
|
||||
return_value=dependency_closure,
|
||||
),
|
||||
):
|
||||
result = determine_jobs.esp8266_native_components_to_test()
|
||||
assert result == expected
|
||||
|
||||
|
||||
def test_compute_integration_test_buckets_no_durations_full_fanout() -> None:
|
||||
"""Without recorded durations the fan-out stays at the maximum."""
|
||||
files = [f"tests/integration/test_{i:03d}.py" for i in range(15)]
|
||||
|
||||
@@ -2122,6 +2122,34 @@ def test_get_cpp_changed_components_independent_of_cwd(
|
||||
) == ["time"]
|
||||
|
||||
|
||||
def test_fixture_map_includes_shared_yaml_markers() -> None:
|
||||
"""Fixtures named only by shared_yaml markers must map to their test file."""
|
||||
helpers.get_fixture_to_test_files.cache_clear()
|
||||
mapping = helpers.get_fixture_to_test_files()
|
||||
for fixture in (
|
||||
"uart_mock_modbus_loopback",
|
||||
"uart_mock_modbus_mesh",
|
||||
"uart_mock_modbus_server_injected",
|
||||
):
|
||||
assert mapping[fixture] == frozenset(
|
||||
{"tests/integration/test_uart_mock_modbus.py"}
|
||||
)
|
||||
|
||||
|
||||
def test_no_orphan_integration_fixtures() -> None:
|
||||
"""Every fixture must reach CI test selection; an orphan selects nothing."""
|
||||
helpers.get_fixture_to_test_files.cache_clear()
|
||||
mapping = helpers.get_fixture_to_test_files()
|
||||
fixtures_dir = (Path(__file__).parent.parent / "integration" / "fixtures").resolve()
|
||||
fixtures = list(fixtures_dir.glob("*.yaml"))
|
||||
assert fixtures, f"no fixtures found under {fixtures_dir}"
|
||||
# cache_init is covered via INTEGRATION_TESTS_TRIGGER_FILES instead
|
||||
orphans = [
|
||||
f.stem for f in fixtures if f.stem != "cache_init" and f.stem not in mapping
|
||||
]
|
||||
assert not orphans, f"fixtures invisible to CI test selection: {orphans}"
|
||||
|
||||
|
||||
def test_lpt_partition_balances_skewed_weights() -> None:
|
||||
"""Heavy items spread across groups instead of clustering."""
|
||||
items = [f"i{n}" for n in range(6)]
|
||||
|
||||
@@ -35,8 +35,8 @@ def _load_script():
|
||||
def test_spec_key_collapses_destinations() -> None:
|
||||
"""Two specs delivering one package share a directory and one key."""
|
||||
mod = _load_script()
|
||||
assert mod.spec_key("esphome/noise-c @ 0.1.24") == "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("esphome/noise-c@0.1.26") == "noise-c"
|
||||
assert mod.spec_key("ESP32Async/AsyncTCP @ ^3.4.10") == mod.spec_key(
|
||||
"esp32async/asynctcp @ 3.5.0"
|
||||
)
|
||||
@@ -54,23 +54,23 @@ def test_parse_specs_and_cli_args(tmp_path: Path) -> None:
|
||||
"[env:a]\n"
|
||||
"platform = fake/platform@1\n"
|
||||
"lib_deps =\n"
|
||||
" esphome/noise-c @ 0.1.24\n"
|
||||
" esphome/noise-c @ 0.1.26\n"
|
||||
" ${common.lib_deps}\n"
|
||||
" internal_lib\n"
|
||||
"[env:b]\n"
|
||||
"lib_deps =\n"
|
||||
" esphome/noise-c @ 0.1.24\n"
|
||||
" esphome/noise-c @ 0.1.26\n"
|
||||
)
|
||||
mod = _load_script()
|
||||
args = Namespace(libraries=True, platforms=True, tools=False)
|
||||
libs, platforms, tools = mod.parse_specs(str(ini), args)
|
||||
# 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 tools == []
|
||||
assert mod.build_cli_args(libs, platforms, tools) == [
|
||||
"-l",
|
||||
"esphome/noise-c @ 0.1.24",
|
||||
"esphome/noise-c @ 0.1.26",
|
||||
"-p",
|
||||
"fake/platform@1",
|
||||
]
|
||||
@@ -162,13 +162,13 @@ def test_parallel_install_behavior(tmp_path: Path) -> None:
|
||||
mod.parallel_install(
|
||||
cls,
|
||||
[
|
||||
"esphome/noise-c @ 0.1.24",
|
||||
"esphome/noise-c @ 0.1.24",
|
||||
"esphome/noise-c @ 0.1.26",
|
||||
"esphome/noise-c @ 0.1.26",
|
||||
"esphome/already @ 1.0",
|
||||
"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"]
|
||||
|
||||
|
||||
@@ -205,7 +205,7 @@ def test_parallel_install_runs_dependency_waves(tmp_path: Path) -> None:
|
||||
mod = _load_script()
|
||||
cls = _reset_fake(str(tmp_path))
|
||||
cls.deps = {
|
||||
"esphome/noise-c @ 0.1.24": [
|
||||
"esphome/noise-c @ 0.1.26": [
|
||||
{"owner": "esphome", "name": "libsodium", "version": "^1.0"},
|
||||
{"name": "SPI"},
|
||||
],
|
||||
@@ -213,12 +213,12 @@ def test_parallel_install_runs_dependency_waves(tmp_path: Path) -> None:
|
||||
{"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 {mod.spec_key(c) for c in cls.calls} == {"noise-c", "wg", "libsodium"}
|
||||
# Wave-1 strings carry no compatibility; the dependency wave does
|
||||
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)
|
||||
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()
|
||||
cls = _reset_fake(str(tmp_path))
|
||||
cls.deps = {
|
||||
"esphome/noise-c @ 0.1.24": [
|
||||
"esphome/noise-c @ 0.1.26": [
|
||||
{"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"}
|
||||
|
||||
|
||||
@@ -348,13 +348,13 @@ def test_warm_store_still_walks_dependencies(tmp_path: Path) -> None:
|
||||
"""Already-installed top-level packages still feed the dependency
|
||||
wave; a warm store can be missing a transitive dep."""
|
||||
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 = {
|
||||
"esphome/noise-c @ 0.1.24": [
|
||||
"esphome/noise-c @ 0.1.26": [
|
||||
{"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"]
|
||||
|
||||
|
||||
|
||||
@@ -236,98 +236,3 @@ def test_run_grouped_test_closes_group_when_subprocess_raises(
|
||||
)
|
||||
|
||||
assert "::endgroup::" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_components_empty_match_fails_with_flag(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""Under --fail-on-no-tests, a real component filtered to a platform
|
||||
with no matching test file must not pass CI as a green zero-component
|
||||
compile."""
|
||||
rc = tbc.test_components(
|
||||
["logger"],
|
||||
"zz-none",
|
||||
"compile",
|
||||
False,
|
||||
enable_grouping=False,
|
||||
fail_on_no_tests=True,
|
||||
)
|
||||
assert rc == 1
|
||||
assert "No tests ran for requested pattern(s): logger" in (capsys.readouterr().out)
|
||||
|
||||
|
||||
def test_components_component_with_no_base_file_fails_with_flag(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A component whose fixture matches the platform but whose platform has
|
||||
no base file builds nothing; under the flag that silent zero fails by
|
||||
component name instead of hiding behind other components."""
|
||||
monkeypatch.setattr(tbc, "get_platform_base_files", lambda base_dir: {})
|
||||
rc = tbc.test_components(
|
||||
["logger"],
|
||||
"esp8266-ard",
|
||||
"compile",
|
||||
False,
|
||||
enable_grouping=False,
|
||||
fail_on_no_tests=True,
|
||||
)
|
||||
assert rc == 1
|
||||
assert "No tests ran for requested pattern(s): logger" in (capsys.readouterr().out)
|
||||
|
||||
|
||||
def test_components_blank_list_fails_with_flag(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""A fully blank component list must not slide into the baseline
|
||||
fallback and exit green under the flag."""
|
||||
rc = tbc.test_components(
|
||||
[""], "esp8266-ard", "compile", False, fail_on_no_tests=True
|
||||
)
|
||||
assert rc == 1
|
||||
assert "blank component list" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_components_wildcard_no_match_fails_with_flag(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""A wildcard matching nothing must not degrade to the synthetic
|
||||
baseline build and exit green under the flag."""
|
||||
rc = tbc.test_components(
|
||||
["zz_no_such*"],
|
||||
"esp8266-ard",
|
||||
"compile",
|
||||
False,
|
||||
enable_grouping=False,
|
||||
fail_on_no_tests=True,
|
||||
)
|
||||
assert rc == 1
|
||||
assert "No components found matching" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_components_empty_match_tolerated_without_flag() -> None:
|
||||
"""The esp32-ard smoke leg deliberately builds only the subset with a
|
||||
matching fixture; without the flag an empty match stays green."""
|
||||
assert (
|
||||
tbc.test_components(
|
||||
["logger"], "zz-none", "compile", False, enable_grouping=False
|
||||
)
|
||||
== 0
|
||||
)
|
||||
|
||||
|
||||
def test_components_unknown_component_fails_with_flag(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""A renamed smoke-test component must shrink coverage loudly, not fall
|
||||
into the reference-baseline build."""
|
||||
rc = tbc.test_components(
|
||||
["no_such_component_xyz"],
|
||||
"esp8266-ard",
|
||||
"compile",
|
||||
False,
|
||||
enable_grouping=False,
|
||||
fail_on_no_tests=True,
|
||||
)
|
||||
assert rc == 1
|
||||
assert "No components found matching" in capsys.readouterr().out
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -245,20 +244,3 @@ def test_copy_failure_leaves_no_partial_output(tmp_path: Path) -> None:
|
||||
):
|
||||
assert build_tool.main() == 1
|
||||
assert not dst.exists()
|
||||
|
||||
|
||||
def test_touch_creates_and_updates_stamp(tmp_path: Path) -> None:
|
||||
stamp = tmp_path / "esphome_pch.probe"
|
||||
assert build_tool._run_touch(str(stamp)) == 0
|
||||
assert stamp.is_file()
|
||||
os.utime(stamp, (1, 1))
|
||||
assert build_tool._run_touch(str(stamp)) == 0
|
||||
assert stamp.stat().st_mtime > 1
|
||||
|
||||
|
||||
def test_touch_reports_failure(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
missing_dir = tmp_path / "gone" / "stamp"
|
||||
assert build_tool._run_touch(str(missing_dir)) == 1
|
||||
assert "touch:" in capsys.readouterr().err
|
||||
|
||||
@@ -4,9 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
@@ -490,922 +488,3 @@ def test_get_component_cmakelists_no_compile_features() -> None:
|
||||
content = get_component_cmakelists()
|
||||
|
||||
assert "target_compile_features" not in content
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _pch_default_on(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Pin the knob so a developer's ESPHOME_PCH_ENABLE=0 cannot fail these."""
|
||||
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "1")
|
||||
|
||||
|
||||
def _make_pch_device(tmp_path: Path, name: str) -> Path:
|
||||
"""A device dir with the pch source headers and a stub compile_commands."""
|
||||
from esphome.build_helpers.pch import PCH_DEFAULT_HEADERS
|
||||
|
||||
dev = tmp_path / name
|
||||
for header in PCH_DEFAULT_HEADERS:
|
||||
path = dev / "src" / header
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text("")
|
||||
# A real quoted include chain and a per-device-named sdkconfig with
|
||||
# identical content: the closure and sdkconfig inputs must be exercised
|
||||
(dev / "src" / "esphome" / "core" / "defines.h").write_text(
|
||||
'#include "esphome/core/macros.h"\n'
|
||||
)
|
||||
(dev / "src" / "esphome" / "core" / "macros.h").write_text("#define M 1\n")
|
||||
# Both spellings: tests patch CORE.name to "test" or to the device name
|
||||
(dev / f"sdkconfig.{name}").write_text("CONFIG_X=y\n")
|
||||
(dev / "sdkconfig.test").write_text("CONFIG_X=y\n")
|
||||
build = dev / "build"
|
||||
build.mkdir(exist_ok=True)
|
||||
from esphome.build_helpers.pch import pch_header_text
|
||||
|
||||
(build / "esphome_pch.h").write_text(pch_header_text(PCH_DEFAULT_HEADERS))
|
||||
# Native separators: mixed f-string paths break the src-prefix match
|
||||
# on Windows
|
||||
src_file = str(dev / "src" / "a.cpp")
|
||||
(build / "compile_commands.json").write_text(
|
||||
json.dumps(
|
||||
[
|
||||
{
|
||||
"directory": str(build),
|
||||
"command": (
|
||||
"g++ -DX=1 -include esphome_pch.h "
|
||||
"-o esp-idf/src/CMakeFiles/__idf_src.dir/a.cpp.obj "
|
||||
f'-c "{src_file}"'
|
||||
),
|
||||
"file": src_file,
|
||||
}
|
||||
]
|
||||
)
|
||||
)
|
||||
return dev
|
||||
|
||||
|
||||
def test_prepare_pch_writes_header_and_sum(tmp_path: Path) -> None:
|
||||
from esphome.build_gen.espidf import prepare_pch
|
||||
|
||||
dev = _make_pch_device(tmp_path, "dev_a")
|
||||
CORE.build_path = dev
|
||||
gch = dev / "build" / "esphome_pch.h.gch"
|
||||
|
||||
def fake_compile(cmd, **kwargs):
|
||||
if "-fsyntax-only" in cmd:
|
||||
# The load probe follows a successful .gch build
|
||||
return subprocess.CompletedProcess(cmd, 0, "", "")
|
||||
# The compile must target the header, not the stub TU
|
||||
assert cmd[-5:-3] == ["c++-header", "-c"]
|
||||
gch.write_bytes(b"gch")
|
||||
return subprocess.CompletedProcess(cmd, 0, "", "")
|
||||
|
||||
with (
|
||||
patch.object(CORE, "name", "test"),
|
||||
patch("esphome.build_helpers.pch.subprocess.run", side_effect=fake_compile),
|
||||
):
|
||||
prepare_pch()
|
||||
checksum = (dev / "build" / "esphome_pch.h.gch.sum").read_text().strip()
|
||||
assert len(checksum) == 64
|
||||
# Unchanged inputs: the second call must not recompile
|
||||
with (
|
||||
patch.object(CORE, "name", "test"),
|
||||
patch("esphome.build_helpers.pch.subprocess.run", side_effect=AssertionError),
|
||||
):
|
||||
prepare_pch()
|
||||
|
||||
|
||||
def test_pch_no_device_path_poison(tmp_path: Path) -> None:
|
||||
"""Regression: neither the injected -include nor the .sum may carry the
|
||||
per-device build path, or cross-device ccache sharing breaks."""
|
||||
from esphome.build_gen.espidf import get_component_cmakelists, prepare_pch
|
||||
|
||||
sums = []
|
||||
for name in ("dev_a", "dev_b"):
|
||||
dev = _make_pch_device(tmp_path, name)
|
||||
CORE.build_path = dev
|
||||
gch = dev / "build" / "esphome_pch.h.gch"
|
||||
|
||||
def fake_compile(cmd, _gch=gch, **kwargs):
|
||||
_gch.write_bytes(b"gch")
|
||||
return subprocess.CompletedProcess(cmd, 0, "", "")
|
||||
|
||||
with (
|
||||
patch.object(CORE, "name", name),
|
||||
patch("esphome.build_helpers.pch.subprocess.run", side_effect=fake_compile),
|
||||
):
|
||||
prepare_pch()
|
||||
content = get_component_cmakelists()
|
||||
assert str(dev) not in content
|
||||
sums.append((dev / "build" / "esphome_pch.h.gch.sum").read_text())
|
||||
assert sums[0] == sums[1]
|
||||
|
||||
|
||||
def test_component_cmakelists_pch_block(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from esphome.build_gen.espidf import get_component_cmakelists
|
||||
|
||||
content = get_component_cmakelists()
|
||||
assert '"$<$<COMPILE_LANGUAGE:CXX>:-include>"' in content
|
||||
assert "-Wno-error=invalid-pch" in content
|
||||
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
|
||||
strict_content = get_component_cmakelists()
|
||||
assert "-Werror=invalid-pch" in strict_content
|
||||
monkeypatch.delenv("ESPHOME_PCH_STRICT")
|
||||
assert '"$<$<COMPILE_LANGUAGE:CXX>:esphome_pch.h>"' in content
|
||||
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0")
|
||||
assert "-include" not in get_component_cmakelists()
|
||||
|
||||
|
||||
def test_pch_compile_command_variants(tmp_path: Path) -> None:
|
||||
"""Missing DB, no matching entry, and launcher-prefixed commands."""
|
||||
from esphome.build_helpers.pch import pch_compile_command
|
||||
|
||||
build = tmp_path / "build"
|
||||
build.mkdir()
|
||||
header = build / "esphome_pch.h"
|
||||
gch = build / "esphome_pch.h.gch"
|
||||
assert pch_compile_command(build, header, gch) is None
|
||||
|
||||
(build / "compile_commands.json").write_text(
|
||||
json.dumps(
|
||||
[
|
||||
{"command": "gcc -c other.c", "file": "other.c"},
|
||||
]
|
||||
)
|
||||
)
|
||||
assert pch_compile_command(build, header, gch) is None
|
||||
|
||||
src_file = str(tmp_path / "src" / "esphome" / "a.cpp")
|
||||
(build / "compile_commands.json").write_text(
|
||||
json.dumps(
|
||||
[
|
||||
{
|
||||
"command": (
|
||||
"/usr/bin/ccache g++ -DX=1 -include esphome_pch.h -MMD "
|
||||
"-MT a.cpp.obj -MF a.cpp.obj.d "
|
||||
"-o esp-idf/src/CMakeFiles/__idf_src.dir/a.cpp.obj "
|
||||
f"-c {src_file}"
|
||||
),
|
||||
"file": src_file,
|
||||
},
|
||||
]
|
||||
)
|
||||
)
|
||||
# Launcher stripped; -include/-o/-c and depfile flags removed
|
||||
cmd, cmd_dir = pch_compile_command(build, header, gch)
|
||||
assert cmd == [
|
||||
"g++",
|
||||
"-DX=1",
|
||||
"-x",
|
||||
"c++-header",
|
||||
"-c",
|
||||
str(header),
|
||||
"-o",
|
||||
str(gch),
|
||||
]
|
||||
# The compile must run where the flags were resolved
|
||||
assert cmd_dir == build
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="symlinks need privileges on Windows")
|
||||
def test_pch_compile_command_matches_src_through_symlink(tmp_path: Path) -> None:
|
||||
"""Find the src TU when CMake spells paths through a different symlink (macOS /tmp)."""
|
||||
from esphome.build_helpers.pch import pch_compile_command
|
||||
|
||||
real = tmp_path / "real"
|
||||
(real / "src" / "esphome").mkdir(parents=True)
|
||||
link = tmp_path / "link"
|
||||
link.symlink_to(real, target_is_directory=True)
|
||||
CORE.build_path = str(real)
|
||||
build = real / "build"
|
||||
build.mkdir()
|
||||
header = build / "esphome_pch.h"
|
||||
gch = build / "esphome_pch.h.gch"
|
||||
src_file = str(link / "src" / "esphome" / "a.cpp")
|
||||
(build / "compile_commands.json").write_text(
|
||||
json.dumps([{"command": f"g++ -DX=1 -o a.obj -c {src_file}", "file": src_file}])
|
||||
)
|
||||
|
||||
cmd, cmd_dir = pch_compile_command(build, header, gch)
|
||||
|
||||
assert cmd[:2] == ["g++", "-DX=1"]
|
||||
assert cmd_dir == build
|
||||
|
||||
|
||||
def test_pch_compile_command_rejects_unusable_entries(tmp_path: Path) -> None:
|
||||
"""Malformed DB shapes and command-less entries skip cleanly instead of
|
||||
producing a compiler-less argv retried every build."""
|
||||
from esphome.build_helpers.pch import pch_compile_command
|
||||
|
||||
build = tmp_path / "build"
|
||||
build.mkdir()
|
||||
header = build / "esphome_pch.h"
|
||||
gch = build / "esphome_pch.h.gch"
|
||||
db = build / "compile_commands.json"
|
||||
src_file = str(tmp_path / "src" / "esphome" / "a.cpp")
|
||||
|
||||
db.write_text(json.dumps({"not": "a list"}))
|
||||
assert pch_compile_command(build, header, gch) is None
|
||||
|
||||
db.write_text(json.dumps(["just a string"]))
|
||||
assert pch_compile_command(build, header, gch) is None
|
||||
|
||||
# An empty-string directory must fall back to the build dir, not cwd
|
||||
db.write_text(
|
||||
json.dumps(
|
||||
[
|
||||
{
|
||||
"directory": "",
|
||||
"command": f"g++ -DX=1 -o a.obj -c {src_file}",
|
||||
"file": src_file,
|
||||
}
|
||||
]
|
||||
)
|
||||
)
|
||||
_, cmd_dir = pch_compile_command(build, header, gch)
|
||||
assert cmd_dir == build
|
||||
|
||||
# Corrupted entries with null fields must skip, not raise
|
||||
db.write_text(
|
||||
json.dumps(
|
||||
[
|
||||
{"file": None, "command": "g++ -c x.cpp", "directory": None},
|
||||
{"file": src_file, "command": None, "directory": None},
|
||||
]
|
||||
)
|
||||
)
|
||||
assert pch_compile_command(build, header, gch) is None
|
||||
|
||||
# arguments-style entry (allowed by the spec, unused by CMake)
|
||||
db.write_text(
|
||||
json.dumps([{"arguments": ["g++", "-c", src_file], "file": src_file}])
|
||||
)
|
||||
assert pch_compile_command(build, header, gch) is None
|
||||
|
||||
|
||||
def test_pch_header_list_order_is_in_checksum(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Reordering PCH_DEFAULT_HEADERS keeps the include closure identical, but the
|
||||
generated header text differs, so the .gch must rebuild."""
|
||||
import esphome.build_gen.espidf as espidf_mod
|
||||
|
||||
dev = _make_pch_device(tmp_path, "dev_r")
|
||||
CORE.build_path = dev
|
||||
gch = dev / "build" / "esphome_pch.h.gch"
|
||||
|
||||
def fake_compile(cmd, **kwargs):
|
||||
gch.write_bytes(b"gch")
|
||||
return subprocess.CompletedProcess(cmd, 0, "", "")
|
||||
|
||||
with (
|
||||
patch.object(CORE, "name", "test"),
|
||||
patch("esphome.build_helpers.pch.subprocess.run", side_effect=fake_compile),
|
||||
):
|
||||
espidf_mod.prepare_pch()
|
||||
first = (dev / "build" / "esphome_pch.h.gch.sum").read_text()
|
||||
monkeypatch.setattr(
|
||||
espidf_mod,
|
||||
"PCH_DEFAULT_HEADERS",
|
||||
tuple(reversed(espidf_mod.PCH_DEFAULT_HEADERS)),
|
||||
)
|
||||
espidf_mod.prepare_pch()
|
||||
assert (dev / "build" / "esphome_pch.h.gch.sum").read_text() != first
|
||||
|
||||
|
||||
def test_prepare_pch_failure_writes_marker_and_skips_retry(tmp_path: Path) -> None:
|
||||
from esphome.build_gen.espidf import prepare_pch
|
||||
|
||||
dev = _make_pch_device(tmp_path, "dev_f")
|
||||
CORE.build_path = dev
|
||||
calls = []
|
||||
|
||||
def failing_compile(cmd, **kwargs):
|
||||
calls.append(cmd)
|
||||
return subprocess.CompletedProcess(cmd, 1, "", "boom")
|
||||
|
||||
with (
|
||||
patch.object(CORE, "name", "test"),
|
||||
patch("esphome.build_helpers.pch.subprocess.run", side_effect=failing_compile),
|
||||
):
|
||||
prepare_pch()
|
||||
prepare_pch()
|
||||
assert len(calls) == 1
|
||||
assert not (dev / "build" / "esphome_pch.h.gch.sum").exists()
|
||||
assert (dev / "build" / "esphome_pch.h.gch.failed").exists()
|
||||
|
||||
|
||||
def test_prepare_pch_spawn_oserror_is_transient(tmp_path: Path) -> None:
|
||||
"""Spawn/IO failures retry on the next build instead of latching."""
|
||||
from esphome.build_gen.espidf import prepare_pch
|
||||
|
||||
dev = _make_pch_device(tmp_path, "dev_o")
|
||||
CORE.build_path = dev
|
||||
calls = []
|
||||
|
||||
def raising(cmd, **kwargs):
|
||||
calls.append(cmd)
|
||||
raise OSError("no such compiler")
|
||||
|
||||
header = dev / "build" / "esphome_pch.h"
|
||||
before = header.stat().st_mtime_ns
|
||||
with (
|
||||
patch.object(CORE, "name", "test"),
|
||||
patch("esphome.build_helpers.pch.subprocess.run", side_effect=raising),
|
||||
):
|
||||
prepare_pch()
|
||||
prepare_pch()
|
||||
assert not (dev / "build" / "esphome_pch.h.gch.failed").exists()
|
||||
assert not (dev / "build" / "esphome_pch.h.gch.sum").exists()
|
||||
assert len(calls) == 2
|
||||
# No .gch was ever in play, so the header must not be re-touched into
|
||||
# forcing a full rebuild on every failing build
|
||||
assert header.stat().st_mtime_ns == before
|
||||
|
||||
|
||||
def test_prepare_pch_transient_with_stale_gch_bumps_header(tmp_path: Path) -> None:
|
||||
"""A stale .gch removed on a transient failure must dirty its consumers."""
|
||||
from esphome.build_gen.espidf import prepare_pch
|
||||
|
||||
dev = _make_pch_device(tmp_path, "dev_s")
|
||||
CORE.build_path = dev
|
||||
gch = dev / "build" / "esphome_pch.h.gch"
|
||||
gch.write_bytes(b"stale")
|
||||
header = dev / "build" / "esphome_pch.h"
|
||||
os.utime(header, (1, 1))
|
||||
with (
|
||||
patch.object(CORE, "name", "test"),
|
||||
patch(
|
||||
"esphome.build_helpers.pch.subprocess.run",
|
||||
side_effect=OSError("no such compiler"),
|
||||
),
|
||||
):
|
||||
prepare_pch()
|
||||
assert not gch.exists()
|
||||
assert header.stat().st_mtime_ns > 1_000_000_000
|
||||
|
||||
|
||||
def test_prepare_pch_disabled_discards_and_skips_compile(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""The escape hatch is self-cleaning: a leftover .gch is removed."""
|
||||
from esphome.build_gen.espidf import prepare_pch
|
||||
|
||||
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0")
|
||||
dev = _make_pch_device(tmp_path, "dev_d")
|
||||
CORE.build_path = dev
|
||||
stale = dev / "build" / "esphome_pch.h.gch"
|
||||
stale.write_bytes(b"stale")
|
||||
with patch("esphome.build_helpers.pch.subprocess.run", side_effect=AssertionError):
|
||||
prepare_pch()
|
||||
assert not stale.exists()
|
||||
|
||||
|
||||
def test_prepare_pch_missing_sdkconfig_fails_closed(tmp_path: Path) -> None:
|
||||
"""No sdkconfig means no config identity for the .sum: no pch at all."""
|
||||
from esphome.build_gen.espidf import prepare_pch
|
||||
|
||||
dev = _make_pch_device(tmp_path, "dev_m")
|
||||
(dev / "sdkconfig.test").unlink()
|
||||
CORE.build_path = dev
|
||||
stale = dev / "build" / "esphome_pch.h.gch"
|
||||
stale.write_bytes(b"stale")
|
||||
with (
|
||||
patch.object(CORE, "name", "test"),
|
||||
patch("esphome.build_helpers.pch.subprocess.run", side_effect=AssertionError),
|
||||
):
|
||||
prepare_pch()
|
||||
assert not stale.exists()
|
||||
assert not (dev / "build" / "esphome_pch.h.gch.sum").exists()
|
||||
|
||||
|
||||
def test_prepare_pch_signal_kill_is_transient(tmp_path: Path) -> None:
|
||||
"""A signal-killed compile (OOM) must not latch the .failed marker."""
|
||||
from esphome.build_gen.espidf import prepare_pch
|
||||
|
||||
dev = _make_pch_device(tmp_path, "dev_k")
|
||||
CORE.build_path = dev
|
||||
calls = []
|
||||
|
||||
def killed(cmd, **kwargs):
|
||||
calls.append(cmd)
|
||||
return subprocess.CompletedProcess(cmd, -9, "", "")
|
||||
|
||||
with (
|
||||
patch.object(CORE, "name", "test"),
|
||||
patch("esphome.build_helpers.pch.subprocess.run", side_effect=killed),
|
||||
):
|
||||
prepare_pch()
|
||||
prepare_pch()
|
||||
assert not (dev / "build" / "esphome_pch.h.gch.failed").exists()
|
||||
assert len(calls) == 2
|
||||
|
||||
|
||||
def test_prepare_pch_probe_spawn_failure_degrades(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A probe that cannot run discards the pch; strict raises."""
|
||||
from esphome.build_gen.espidf import prepare_pch
|
||||
from esphome.core import EsphomeError
|
||||
|
||||
dev = _make_pch_device(tmp_path, "dev_pf")
|
||||
CORE.build_path = dev
|
||||
gch = dev / "build" / "esphome_pch.h.gch"
|
||||
|
||||
def probe_dies(cmd, **kwargs):
|
||||
if "-fsyntax-only" in cmd:
|
||||
raise OSError("probe spawn failed")
|
||||
gch.write_bytes(b"gch")
|
||||
return subprocess.CompletedProcess(cmd, 0, "", "")
|
||||
|
||||
with (
|
||||
patch.object(CORE, "name", "test"),
|
||||
patch("esphome.build_helpers.pch.subprocess.run", side_effect=probe_dies),
|
||||
):
|
||||
prepare_pch()
|
||||
assert not gch.exists()
|
||||
assert not (dev / "build" / "esphome_pch.h.gch.sum").exists()
|
||||
|
||||
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
|
||||
with (
|
||||
patch.object(CORE, "name", "test"),
|
||||
patch("esphome.build_helpers.pch.subprocess.run", side_effect=probe_dies),
|
||||
pytest.raises(EsphomeError, match="probe did not run"),
|
||||
):
|
||||
prepare_pch()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("stderr", "code"),
|
||||
[("", -9), ("fatal: No space left on device", 1)],
|
||||
ids=("signal-kill", "enospc"),
|
||||
)
|
||||
def test_prepare_pch_probe_environmental_failures_do_not_latch(
|
||||
tmp_path: Path, stderr: str, code: int
|
||||
) -> None:
|
||||
"""A signal-killed or ENOSPC probe retries next build, no marker."""
|
||||
from esphome.build_gen.espidf import prepare_pch
|
||||
|
||||
dev = _make_pch_device(tmp_path, "dev_pe")
|
||||
CORE.build_path = dev
|
||||
gch = dev / "build" / "esphome_pch.h.gch"
|
||||
|
||||
def env_probe(cmd, **kwargs):
|
||||
if "-fsyntax-only" in cmd:
|
||||
return subprocess.CompletedProcess(cmd, code, "", stderr)
|
||||
gch.write_bytes(b"gch")
|
||||
return subprocess.CompletedProcess(cmd, 0, "", "")
|
||||
|
||||
with (
|
||||
patch.object(CORE, "name", "test"),
|
||||
patch("esphome.build_helpers.pch.subprocess.run", side_effect=env_probe),
|
||||
):
|
||||
prepare_pch()
|
||||
assert not gch.exists()
|
||||
assert not (dev / "build" / "esphome_pch.h.gch.failed").exists()
|
||||
|
||||
|
||||
def test_prepare_pch_signal_kill_strict_raises(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
from esphome.build_gen.espidf import prepare_pch
|
||||
from esphome.core import EsphomeError
|
||||
|
||||
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
|
||||
dev = _make_pch_device(tmp_path, "dev_ks")
|
||||
CORE.build_path = dev
|
||||
with (
|
||||
patch.object(CORE, "name", "test"),
|
||||
patch(
|
||||
"esphome.build_helpers.pch.subprocess.run",
|
||||
side_effect=lambda cmd, **kw: subprocess.CompletedProcess(cmd, -9, "", ""),
|
||||
),
|
||||
pytest.raises(EsphomeError, match="killed by signal"),
|
||||
):
|
||||
prepare_pch()
|
||||
|
||||
|
||||
def test_prepare_pch_strict_raises_when_disabled(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Strict must not read a disabled pch as success."""
|
||||
from esphome.build_gen.espidf import prepare_pch
|
||||
from esphome.core import EsphomeError
|
||||
|
||||
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0")
|
||||
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
|
||||
dev = _make_pch_device(tmp_path, "dev_ds")
|
||||
CORE.build_path = dev
|
||||
with (
|
||||
patch.object(CORE, "name", "test"),
|
||||
pytest.raises(EsphomeError, match="disabled"),
|
||||
):
|
||||
prepare_pch()
|
||||
|
||||
|
||||
def test_prepare_pch_missing_sdkconfig_strict_raises(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
from esphome.build_gen.espidf import prepare_pch
|
||||
from esphome.core import EsphomeError
|
||||
|
||||
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
|
||||
dev = _make_pch_device(tmp_path, "dev_ss")
|
||||
(dev / "sdkconfig.test").unlink()
|
||||
CORE.build_path = dev
|
||||
with (
|
||||
patch.object(CORE, "name", "test"),
|
||||
pytest.raises(EsphomeError, match="sdkconfig unreadable"),
|
||||
):
|
||||
prepare_pch()
|
||||
|
||||
|
||||
def test_prepare_pch_without_compile_commands(tmp_path: Path) -> None:
|
||||
"""Stale checksum but no configured TU yet: no compile, no sidecars."""
|
||||
from esphome.build_gen.espidf import prepare_pch
|
||||
|
||||
dev = _make_pch_device(tmp_path, "dev_n")
|
||||
(dev / "build" / "compile_commands.json").unlink()
|
||||
CORE.build_path = dev
|
||||
with (
|
||||
patch.object(CORE, "name", "test"),
|
||||
patch("esphome.build_helpers.pch.subprocess.run", side_effect=AssertionError),
|
||||
):
|
||||
prepare_pch()
|
||||
assert not (dev / "build" / "esphome_pch.h.gch.sum").exists()
|
||||
assert not (dev / "build" / "esphome_pch.h.gch.failed").exists()
|
||||
|
||||
|
||||
def test_write_project_pch_disabled_writes_no_header(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
from esphome.build_gen.espidf import write_project
|
||||
|
||||
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0")
|
||||
_write_project_description(tmp_path, {})
|
||||
CORE.build_path = tmp_path
|
||||
with (
|
||||
patch("esphome.build_gen.espidf.get_esp32_variant", return_value="ESP32"),
|
||||
patch.object(CORE, "name", "test"),
|
||||
):
|
||||
write_project()
|
||||
assert not (tmp_path / "build" / "esphome_pch.h").exists()
|
||||
|
||||
|
||||
def test_write_project_writes_pch_header(tmp_path: Path) -> None:
|
||||
"""The header write_project emits is what _pch_cmake() force-includes;
|
||||
this pairing is the one non-fail-safe path in the design."""
|
||||
from esphome.build_gen.espidf import write_project
|
||||
from esphome.build_helpers.pch import PCH_DEFAULT_HEADERS, pch_header_text
|
||||
|
||||
_write_project_description(tmp_path, {})
|
||||
CORE.build_path = tmp_path
|
||||
with (
|
||||
patch("esphome.build_gen.espidf.get_esp32_variant", return_value="ESP32"),
|
||||
patch.object(CORE, "name", "test"),
|
||||
):
|
||||
write_project()
|
||||
assert (tmp_path / "build" / "esphome_pch.h").read_text() == pch_header_text(
|
||||
PCH_DEFAULT_HEADERS
|
||||
)
|
||||
|
||||
|
||||
def test_prepare_pch_stale_bailout_removes_gch(tmp_path: Path) -> None:
|
||||
"""A stale .gch must not survive when no compile command is available."""
|
||||
from esphome.build_gen.espidf import prepare_pch
|
||||
|
||||
dev = _make_pch_device(tmp_path, "dev_s")
|
||||
CORE.build_path = dev
|
||||
gch = dev / "build" / "esphome_pch.h.gch"
|
||||
gch.write_bytes(b"stale")
|
||||
(dev / "build" / "esphome_pch.h.gch.sum").write_text("stale-sum\n")
|
||||
(dev / "build" / "compile_commands.json").unlink()
|
||||
with patch.object(CORE, "name", "test"):
|
||||
prepare_pch()
|
||||
assert not gch.exists()
|
||||
assert not (dev / "build" / "esphome_pch.h.gch.sum").exists()
|
||||
|
||||
|
||||
def test_prepare_pch_zero_exit_without_gch_is_failure(tmp_path: Path) -> None:
|
||||
from esphome.build_gen.espidf import prepare_pch
|
||||
|
||||
dev = _make_pch_device(tmp_path, "dev_z")
|
||||
CORE.build_path = dev
|
||||
|
||||
def no_output(cmd, **kwargs):
|
||||
return subprocess.CompletedProcess(cmd, 0, "", "")
|
||||
|
||||
with (
|
||||
patch.object(CORE, "name", "test"),
|
||||
patch("esphome.build_helpers.pch.subprocess.run", side_effect=no_output),
|
||||
):
|
||||
prepare_pch()
|
||||
assert not (dev / "build" / "esphome_pch.h.gch.sum").exists()
|
||||
assert (dev / "build" / "esphome_pch.h.gch.failed").exists()
|
||||
|
||||
|
||||
def test_prepare_pch_bumps_header_for_object_depends(tmp_path: Path) -> None:
|
||||
"""The OBJECT_DEPENDS edge watches the header; a rebuilt .gch must bump
|
||||
it so pch-consuming TUs recompile."""
|
||||
import os as _os
|
||||
|
||||
from esphome.build_gen.espidf import prepare_pch
|
||||
|
||||
dev = _make_pch_device(tmp_path, "dev_t")
|
||||
CORE.build_path = dev
|
||||
header = dev / "build" / "esphome_pch.h"
|
||||
gch = dev / "build" / "esphome_pch.h.gch"
|
||||
_os.utime(header, (0, 0))
|
||||
before = header.stat().st_mtime
|
||||
|
||||
def fake_compile(cmd, **kwargs):
|
||||
gch.write_bytes(b"gch")
|
||||
return subprocess.CompletedProcess(cmd, 0, "", "")
|
||||
|
||||
with (
|
||||
patch.object(CORE, "name", "test"),
|
||||
patch("esphome.build_helpers.pch.subprocess.run", side_effect=fake_compile),
|
||||
):
|
||||
prepare_pch()
|
||||
assert header.stat().st_mtime > before
|
||||
|
||||
|
||||
def test_component_cmakelists_pch_object_depends() -> None:
|
||||
from esphome.build_gen.espidf import get_component_cmakelists
|
||||
|
||||
content = get_component_cmakelists()
|
||||
assert 'OBJECT_DEPENDS "${CMAKE_BINARY_DIR}/esphome_pch.h"' in content
|
||||
|
||||
|
||||
def test_prepare_pch_command_change_invalidates_sum(tmp_path: Path) -> None:
|
||||
"""A flag-only change in the compile DB must rebuild the .gch."""
|
||||
from esphome.build_gen.espidf import prepare_pch
|
||||
|
||||
dev = _make_pch_device(tmp_path, "dev_c")
|
||||
CORE.build_path = dev
|
||||
gch = dev / "build" / "esphome_pch.h.gch"
|
||||
|
||||
def fake_compile(cmd, **kwargs):
|
||||
gch.write_bytes(b"gch")
|
||||
return subprocess.CompletedProcess(cmd, 0, "", "")
|
||||
|
||||
with (
|
||||
patch.object(CORE, "name", "test"),
|
||||
patch("esphome.build_helpers.pch.subprocess.run", side_effect=fake_compile),
|
||||
):
|
||||
prepare_pch()
|
||||
first = (dev / "build" / "esphome_pch.h.gch.sum").read_text()
|
||||
db = dev / "build" / "compile_commands.json"
|
||||
db.write_text(db.read_text().replace("-DX=1", "-DX=2"))
|
||||
prepare_pch()
|
||||
assert (dev / "build" / "esphome_pch.h.gch.sum").read_text() != first
|
||||
|
||||
|
||||
def test_prepare_pch_keeps_user_force_includes(tmp_path: Path) -> None:
|
||||
from esphome.build_helpers.pch import pch_compile_command
|
||||
|
||||
dev = _make_pch_device(tmp_path, "dev_u")
|
||||
CORE.build_path = dev
|
||||
build = dev / "build"
|
||||
src_file = str(dev / "src" / "esphome" / "a.cpp")
|
||||
build.joinpath("compile_commands.json").write_text(
|
||||
json.dumps(
|
||||
[
|
||||
{
|
||||
"directory": str(build),
|
||||
"command": (
|
||||
"g++ -include user.h -include esphome_pch.h "
|
||||
f"-o a.obj -c {src_file}"
|
||||
),
|
||||
"file": src_file,
|
||||
}
|
||||
]
|
||||
)
|
||||
)
|
||||
cmd, _ = pch_compile_command(build, build / "esphome_pch.h", build / "x.gch")
|
||||
assert "user.h" in cmd
|
||||
assert "esphome_pch.h" not in " ".join(cmd[:-3])
|
||||
|
||||
|
||||
def test_prepare_pch_identity_unknown_discards(tmp_path: Path) -> None:
|
||||
"""An OSError from the checksum discards artifacts and skips the pch."""
|
||||
from esphome.build_gen.espidf import prepare_pch
|
||||
|
||||
dev = _make_pch_device(tmp_path, "dev_i")
|
||||
CORE.build_path = dev
|
||||
stale = dev / "build" / "esphome_pch.h.gch"
|
||||
stale.write_bytes(b"stale")
|
||||
with (
|
||||
patch.object(CORE, "name", "test"),
|
||||
patch(
|
||||
"esphome.build_helpers.pch.pch_checksum",
|
||||
side_effect=OSError("stat failed"),
|
||||
),
|
||||
patch("esphome.build_helpers.pch.subprocess.run", side_effect=AssertionError),
|
||||
):
|
||||
prepare_pch()
|
||||
assert not stale.exists()
|
||||
assert not (dev / "build" / "esphome_pch.h.gch.sum").exists()
|
||||
|
||||
|
||||
def test_prepare_pch_transient_compiler_failure_does_not_latch(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""ENOSPC-style failures clear on their own; no .failed marker."""
|
||||
from esphome.build_gen.espidf import prepare_pch
|
||||
|
||||
dev = _make_pch_device(tmp_path, "dev_e")
|
||||
CORE.build_path = dev
|
||||
calls = []
|
||||
|
||||
def enospc(cmd, **kwargs):
|
||||
calls.append(cmd)
|
||||
return subprocess.CompletedProcess(
|
||||
cmd, 1, "", "fatal error: No space left on device"
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(CORE, "name", "test"),
|
||||
patch("esphome.build_helpers.pch.subprocess.run", side_effect=enospc),
|
||||
):
|
||||
prepare_pch()
|
||||
prepare_pch()
|
||||
assert not (dev / "build" / "esphome_pch.h.gch.failed").exists()
|
||||
assert len(calls) == 2
|
||||
|
||||
|
||||
def test_prepare_pch_strict_raises_on_missing_db(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""ESPHOME_PCH_STRICT turns the silent skip into a failure."""
|
||||
from esphome.build_gen.espidf import prepare_pch
|
||||
from esphome.core import EsphomeError
|
||||
|
||||
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
|
||||
dev = _make_pch_device(tmp_path, "dev_st")
|
||||
(dev / "build" / "compile_commands.json").unlink()
|
||||
CORE.build_path = dev
|
||||
with (
|
||||
patch.object(CORE, "name", "test"),
|
||||
patch("esphome.build_helpers.pch.subprocess.run", side_effect=AssertionError),
|
||||
pytest.raises(EsphomeError, match="no usable compile command"),
|
||||
):
|
||||
prepare_pch()
|
||||
|
||||
|
||||
def test_prepare_pch_probe_rejection_latches_and_degrades(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A toolchain that cannot load its own .gch discards it, latches the
|
||||
marker, and fails strict mode."""
|
||||
from esphome.build_gen.espidf import prepare_pch
|
||||
from esphome.core import EsphomeError
|
||||
|
||||
dev = _make_pch_device(tmp_path, "dev_p")
|
||||
CORE.build_path = dev
|
||||
gch = dev / "build" / "esphome_pch.h.gch"
|
||||
|
||||
def rejecting(cmd, **kwargs):
|
||||
if "-fsyntax-only" in cmd:
|
||||
if "-include" not in cmd:
|
||||
# Baseline without the pch passes: the pch is to blame
|
||||
return subprocess.CompletedProcess(cmd, 0, "", "")
|
||||
return subprocess.CompletedProcess(
|
||||
cmd, 1, "", "error: esphome_pch.h.gch: had text segment "
|
||||
)
|
||||
gch.write_bytes(b"gch")
|
||||
return subprocess.CompletedProcess(cmd, 0, "", "")
|
||||
|
||||
with (
|
||||
patch.object(CORE, "name", "test"),
|
||||
patch("esphome.build_helpers.pch.subprocess.run", side_effect=rejecting),
|
||||
):
|
||||
prepare_pch()
|
||||
assert not gch.exists()
|
||||
assert (dev / "build" / "esphome_pch.h.gch.failed").exists()
|
||||
|
||||
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
|
||||
(dev / "build" / "esphome_pch.h.gch.failed").unlink()
|
||||
with (
|
||||
patch.object(CORE, "name", "test"),
|
||||
patch("esphome.build_helpers.pch.subprocess.run", side_effect=rejecting),
|
||||
pytest.raises(EsphomeError, match="cannot load the pch"),
|
||||
):
|
||||
prepare_pch()
|
||||
|
||||
|
||||
def test_prepare_pch_strict_reprobes_cached_gch(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Rejection is per-process: strict must re-prove a cached .gch loads."""
|
||||
from esphome.build_gen.espidf import prepare_pch
|
||||
from esphome.core import EsphomeError
|
||||
|
||||
dev = _make_pch_device(tmp_path, "dev_rc")
|
||||
CORE.build_path = dev
|
||||
gch = dev / "build" / "esphome_pch.h.gch"
|
||||
|
||||
def ok(cmd, **kwargs):
|
||||
if "-fsyntax-only" not in cmd:
|
||||
gch.write_bytes(b"gch")
|
||||
return subprocess.CompletedProcess(cmd, 0, "", "")
|
||||
|
||||
with (
|
||||
patch.object(CORE, "name", "test"),
|
||||
patch("esphome.build_helpers.pch.subprocess.run", side_effect=ok),
|
||||
):
|
||||
prepare_pch()
|
||||
assert gch.exists()
|
||||
|
||||
def reject(cmd, **kwargs):
|
||||
assert "-fsyntax-only" in cmd, "cached path must not recompile"
|
||||
if "-include" not in cmd:
|
||||
return subprocess.CompletedProcess(cmd, 0, "", "")
|
||||
return subprocess.CompletedProcess(
|
||||
cmd, 1, "", "error: esphome_pch.h.gch: had text segment "
|
||||
)
|
||||
|
||||
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
|
||||
with (
|
||||
patch.object(CORE, "name", "test"),
|
||||
patch("esphome.build_helpers.pch.subprocess.run", side_effect=reject),
|
||||
pytest.raises(EsphomeError, match="cannot load the pch"),
|
||||
):
|
||||
prepare_pch()
|
||||
assert not gch.exists()
|
||||
# Per-process rejection may not reproduce: the cached path must not
|
||||
# latch the pch off for later non-strict builds
|
||||
assert not (dev / "build" / "esphome_pch.h.gch.failed").exists()
|
||||
|
||||
|
||||
def test_prepare_pch_unexpected_command_shape_degrades(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A tail the probe slice cannot trust discards and degrades."""
|
||||
from esphome.build_gen.espidf import prepare_pch
|
||||
from esphome.core import EsphomeError
|
||||
|
||||
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
|
||||
dev = _make_pch_device(tmp_path, "dev_sh")
|
||||
CORE.build_path = dev
|
||||
gch = dev / "build" / "esphome_pch.h.gch"
|
||||
|
||||
def ok(cmd, **kwargs):
|
||||
gch.write_bytes(b"gch")
|
||||
return subprocess.CompletedProcess(cmd, 0, "", "")
|
||||
|
||||
with (
|
||||
patch.object(CORE, "name", "test"),
|
||||
patch("esphome.build_helpers.pch.subprocess.run", side_effect=ok),
|
||||
patch(
|
||||
"esphome.build_helpers.pch.pch_compile_command",
|
||||
return_value=(
|
||||
["g++", "-DX=1", "-c", "x", "-o", "y", "extra"],
|
||||
dev / "build",
|
||||
),
|
||||
),
|
||||
pytest.raises(EsphomeError, match="command shape"),
|
||||
):
|
||||
prepare_pch()
|
||||
assert not gch.exists()
|
||||
|
||||
# Non-strict: same shape problem degrades without raising
|
||||
monkeypatch.delenv("ESPHOME_PCH_STRICT")
|
||||
with (
|
||||
patch.object(CORE, "name", "test"),
|
||||
patch("esphome.build_helpers.pch.subprocess.run", side_effect=ok),
|
||||
patch(
|
||||
"esphome.build_helpers.pch.pch_compile_command",
|
||||
return_value=(
|
||||
["g++", "-DX=1", "-c", "x", "-o", "y", "extra"],
|
||||
dev / "build",
|
||||
),
|
||||
),
|
||||
):
|
||||
prepare_pch()
|
||||
assert not gch.exists()
|
||||
|
||||
|
||||
def test_prepare_pch_probe_baseline_spawn_failure_is_transient(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""A baseline that cannot spawn is environmental: no marker."""
|
||||
from esphome.build_gen.espidf import prepare_pch
|
||||
|
||||
dev = _make_pch_device(tmp_path, "dev_bs")
|
||||
CORE.build_path = dev
|
||||
gch = dev / "build" / "esphome_pch.h.gch"
|
||||
|
||||
def flaky(cmd, **kwargs):
|
||||
if "-fsyntax-only" in cmd:
|
||||
if "-include" in cmd:
|
||||
return subprocess.CompletedProcess(cmd, 1, "", "boom")
|
||||
raise OSError("baseline spawn failed")
|
||||
gch.write_bytes(b"gch")
|
||||
return subprocess.CompletedProcess(cmd, 0, "", "")
|
||||
|
||||
with (
|
||||
patch.object(CORE, "name", "test"),
|
||||
patch("esphome.build_helpers.pch.subprocess.run", side_effect=flaky),
|
||||
):
|
||||
prepare_pch()
|
||||
assert not gch.exists()
|
||||
assert not (dev / "build" / "esphome_pch.h.gch.failed").exists()
|
||||
|
||||
@@ -10,7 +10,6 @@ from unittest.mock import patch
|
||||
import pytest
|
||||
|
||||
from esphome.build_helpers import ccache
|
||||
from esphome.core import CORE
|
||||
|
||||
|
||||
def test_resolve_opt_out() -> None:
|
||||
@@ -121,17 +120,3 @@ def test_parse_enable_env_spelling_tables(
|
||||
"""cv.boolean's spelling tables plus the 1/0 env convention."""
|
||||
monkeypatch.setenv("ESPHOME_CCACHE_ENABLE", raw)
|
||||
assert ccache.parse_enable_env("ESPHOME_CCACHE_ENABLE") is expected
|
||||
|
||||
|
||||
def test_effective_ccache_basedir_prefers_user_value(tmp_path: Path) -> None:
|
||||
CORE.build_path = tmp_path
|
||||
# Drive-qualified on Windows: "/custom/base" is not absolute there
|
||||
base = "C:\\custom\\base" if os.name == "nt" else "/custom/base"
|
||||
with patch.dict(os.environ, {"CCACHE_BASEDIR": base}, clear=True):
|
||||
assert ccache.effective_ccache_basedir() == base
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
assert ccache.effective_ccache_basedir() == str(tmp_path.resolve())
|
||||
# Degenerate values would strip substrings ccache never rewrites
|
||||
for bad in ("", "/", "a/b"):
|
||||
with patch.dict(os.environ, {"CCACHE_BASEDIR": bad}, clear=True):
|
||||
assert ccache.effective_ccache_basedir() == str(tmp_path.resolve())
|
||||
|
||||
@@ -85,87 +85,6 @@ def test_parse_entry_resolves_relative_includes() -> None:
|
||||
assert all(Path(inc).is_absolute() for inc in includes)
|
||||
|
||||
|
||||
def test_parse_entry_resolves_force_include_path(tmp_path: Path) -> None:
|
||||
"""The pch -include is emitted relative to the build dir; idedata must
|
||||
resolve it so cached flags work from any cwd."""
|
||||
(tmp_path / "esphome_pch.h").write_text("")
|
||||
entry = _entry(
|
||||
str(tmp_path),
|
||||
f"{tmp_path}/src/esphome/x.cpp",
|
||||
"g++ -include esphome_pch.h -c x.cpp",
|
||||
)
|
||||
|
||||
_, _, _, cxx_flags = idedata.parse_entry(entry)
|
||||
|
||||
idx = cxx_flags.index("-include")
|
||||
resolved = cxx_flags[idx + 1]
|
||||
assert Path(resolved).is_absolute()
|
||||
assert resolved == str(tmp_path / "esphome_pch.h").replace("\\", "/")
|
||||
|
||||
|
||||
def test_parse_entry_resolves_joined_force_include(tmp_path: Path) -> None:
|
||||
"""The joined -includefoo.h spelling takes the same resolve path."""
|
||||
(tmp_path / "esphome_pch.h").write_text("")
|
||||
entry = _entry(
|
||||
str(tmp_path),
|
||||
f"{tmp_path}/src/esphome/x.cpp",
|
||||
"g++ -includeesphome_pch.h -c x.cpp",
|
||||
)
|
||||
|
||||
_, _, _, cxx_flags = idedata.parse_entry(entry)
|
||||
|
||||
resolved = cxx_flags[cxx_flags.index("-include") + 1]
|
||||
assert resolved == str(tmp_path / "esphome_pch.h").replace("\\", "/")
|
||||
|
||||
|
||||
def test_parse_entry_keeps_search_chain_force_include(tmp_path: Path) -> None:
|
||||
"""-include names resolved via the -I chain (libretiny's Arduino.h) must
|
||||
not be re-anchored to a nonexistent build-dir path."""
|
||||
entry = _entry(
|
||||
str(tmp_path),
|
||||
f"{tmp_path}/src/esphome/x.cpp",
|
||||
"g++ -include Arduino.h -c x.cpp",
|
||||
)
|
||||
|
||||
_, _, _, cxx_flags = idedata.parse_entry(entry)
|
||||
|
||||
assert cxx_flags[cxx_flags.index("-include") + 1] == "Arduino.h"
|
||||
|
||||
|
||||
def test_parse_entry_warns_on_vanished_force_include(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A build-dir force-include deleted by clean_build must leave a trail;
|
||||
a name resolvable via the -I chain must not warn."""
|
||||
inc = tmp_path / "inc"
|
||||
inc.mkdir()
|
||||
(inc / "Arduino.h").write_text("")
|
||||
entry = _entry(
|
||||
str(tmp_path),
|
||||
f"{tmp_path}/src/esphome/x.cpp",
|
||||
f"g++ -I{inc} -include Arduino.h -include esphome_pch.h -c x.cpp",
|
||||
)
|
||||
|
||||
_, _, _, cxx_flags = idedata.parse_entry(entry)
|
||||
|
||||
assert "Arduino.h" in cxx_flags
|
||||
assert "esphome_pch.h" in caplog.text
|
||||
assert "Arduino.h" not in caplog.text
|
||||
|
||||
|
||||
def test_parse_entry_drops_trailing_force_include(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
entry = _entry(
|
||||
str(tmp_path), f"{tmp_path}/src/esphome/x.cpp", "g++ -c x.cpp -include"
|
||||
)
|
||||
|
||||
_, _, _, cxx_flags = idedata.parse_entry(entry)
|
||||
|
||||
assert "-include" not in cxx_flags
|
||||
assert "no argument" in caplog.text
|
||||
|
||||
|
||||
def test_parse_entry_skips_dependency_flags() -> None:
|
||||
"""Dependency-generation flags (and their args) are dropped."""
|
||||
entry = _entry(
|
||||
@@ -185,7 +104,7 @@ def test_expand_response_files(tmp_path: Path) -> None:
|
||||
rsp = tmp_path / "flags.rsp"
|
||||
rsp.write_text("-DFROM_RSP -I/rsp/inc")
|
||||
|
||||
tokens = idedata.expand_response_files(
|
||||
tokens = idedata._expand_response_files(
|
||||
["g++", f"@{rsp.name}", "-c", "x.cpp"], tmp_path
|
||||
)
|
||||
|
||||
@@ -196,7 +115,7 @@ def test_expand_response_files(tmp_path: Path) -> None:
|
||||
|
||||
def test_expand_response_files_keeps_literal_when_missing(tmp_path: Path) -> None:
|
||||
"""An unreadable ``@file`` token is kept verbatim rather than dropped."""
|
||||
tokens = idedata.expand_response_files(["g++", "@nope.rsp"], tmp_path)
|
||||
tokens = idedata._expand_response_files(["g++", "@nope.rsp"], tmp_path)
|
||||
assert "@nope.rsp" in tokens
|
||||
|
||||
|
||||
@@ -408,7 +327,7 @@ def test_split_command_preserves_paths_and_unescapes_quotes() -> None:
|
||||
r"""Backslash paths survive while ``\"`` define-quoting is unescaped."""
|
||||
command = r"C:\esp\bin\riscv32-esp-elf-g++.exe -DVER=\"1.2.3\" -IC:/inc/a -c x.cpp"
|
||||
|
||||
tokens = idedata.split_command(command)
|
||||
tokens = idedata._split_command(command)
|
||||
|
||||
assert tokens[0] == r"C:\esp\bin\riscv32-esp-elf-g++.exe"
|
||||
assert '-DVER="1.2.3"' in tokens
|
||||
@@ -422,8 +341,8 @@ def test_split_command_empty_returns_empty() -> None:
|
||||
Guards against ``CommandLineToArgvW("")`` returning the current process name
|
||||
instead of an empty list.
|
||||
"""
|
||||
assert idedata.split_command("") == []
|
||||
assert idedata.split_command(" ") == []
|
||||
assert idedata._split_command("") == []
|
||||
assert idedata._split_command(" ") == []
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name != "nt", reason="Windows argv tokenization")
|
||||
@@ -584,9 +503,9 @@ def test_load_or_build_idedata_rebuilds_non_dict_cache(tmp_path: Path) -> None:
|
||||
def test_is_launcher_matches_only_known_launchers() -> None:
|
||||
"""Compilers of any shape pass; only the closed launcher set matches."""
|
||||
for token in ("/t/g++-13", "gcc-8.4.0", "clang++-17", "armcc", "icx", "cc"):
|
||||
assert not idedata.is_launcher(token)
|
||||
assert not idedata._is_launcher(token)
|
||||
for token in ("/opt/homebrew/bin/ccache", "CCACHE.EXE", "distcc", "sccache"):
|
||||
assert idedata.is_launcher(token)
|
||||
assert idedata._is_launcher(token)
|
||||
|
||||
|
||||
def test_load_or_build_idedata_corrupted_cache_is_logged(
|
||||
|
||||
@@ -1,381 +0,0 @@
|
||||
"""Tests for esphome.build_helpers.pch."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.build_helpers import pch
|
||||
|
||||
|
||||
def _write(src_dir: Path, name: str, content: str) -> None:
|
||||
path = src_dir / name
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[
|
||||
(None, True),
|
||||
("1", True),
|
||||
("0", False),
|
||||
("false", False),
|
||||
("", False),
|
||||
],
|
||||
)
|
||||
def test_pch_enabled(value: str | None, expected: bool) -> None:
|
||||
env = {} if value is None else {"ESPHOME_PCH_ENABLE": value}
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
assert pch.pch_enabled() is expected
|
||||
|
||||
|
||||
def test_ccache_pch_env_empty_until_emitted() -> None:
|
||||
"""No sloppiness relaxation for a build that skipped the pch."""
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
assert pch.ccache_pch_env() == {}
|
||||
|
||||
|
||||
def test_ccache_pch_env_enabled() -> None:
|
||||
pch.mark_pch_emitted()
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
env = pch.ccache_pch_env()
|
||||
assert env["CCACHE_SLOPPINESS"] == "pch_defines,time_macros"
|
||||
assert env["CCACHE_PCH_EXTSUM"] == "true"
|
||||
|
||||
|
||||
def test_ccache_pch_env_disabled() -> None:
|
||||
with patch.dict(os.environ, {"ESPHOME_PCH_ENABLE": "0"}, clear=True):
|
||||
assert pch.ccache_pch_env() == {}
|
||||
|
||||
|
||||
def test_ccache_pch_env_token_check_is_membership_not_substring(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A token merely containing ours must not suppress the union."""
|
||||
pch.mark_pch_emitted()
|
||||
with patch.dict(os.environ, {"CCACHE_SLOPPINESS": "pch_defines_extra"}, clear=True):
|
||||
env = pch.ccache_pch_env()
|
||||
assert env["CCACHE_SLOPPINESS"] == "pch_defines_extra,pch_defines,time_macros"
|
||||
|
||||
|
||||
def test_ccache_pch_env_unions_user_sloppiness(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Without pch_defines/time_macros ccache declines every pch-consuming
|
||||
compile, so missing tokens are unioned onto the user's value."""
|
||||
pch.mark_pch_emitted()
|
||||
with patch.dict(os.environ, {"CCACHE_SLOPPINESS": "locale"}, clear=True):
|
||||
env = pch.ccache_pch_env()
|
||||
assert env["CCACHE_SLOPPINESS"] == "locale,pch_defines,time_macros"
|
||||
assert env["CCACHE_PCH_EXTSUM"] == "true"
|
||||
assert "Adding pch_defines,time_macros" in caplog.text
|
||||
caplog.clear()
|
||||
with patch.dict(
|
||||
os.environ, {"CCACHE_SLOPPINESS": "pch_defines,time_macros"}, clear=True
|
||||
):
|
||||
env = pch.ccache_pch_env()
|
||||
assert "CCACHE_SLOPPINESS" not in env
|
||||
assert not caplog.records
|
||||
|
||||
|
||||
def test_pch_header_text_preserves_order() -> None:
|
||||
text = pch.pch_header_text(["b.h", "a.h"])
|
||||
assert text == '#include "b.h"\n#include "a.h"\n'
|
||||
|
||||
|
||||
def test_include_closure_resolves_relative_and_root(tmp_path: Path) -> None:
|
||||
"""Sibling includes resolve against the includer's directory first,
|
||||
full paths against the src root; unresolvable names end the walk."""
|
||||
_write(tmp_path, "esphome/components/x/a.h", '#include "b.h"\n')
|
||||
_write(
|
||||
tmp_path,
|
||||
"esphome/components/x/b.h",
|
||||
'#include "esphome/core/deep.h"\n#include <system.h>\n#include "missing.h"\n',
|
||||
)
|
||||
_write(tmp_path, "esphome/core/deep.h", "")
|
||||
closure = pch._include_closure(tmp_path, ["esphome/components/x/a.h"])
|
||||
assert sorted(closure) == [
|
||||
"esphome/components/x/a.h",
|
||||
"esphome/components/x/b.h",
|
||||
"esphome/core/deep.h",
|
||||
]
|
||||
|
||||
|
||||
def test_include_closure_handles_cycles(tmp_path: Path) -> None:
|
||||
_write(tmp_path, "a.h", '#include "b.h"\n')
|
||||
_write(tmp_path, "b.h", '#include "a.h"\n')
|
||||
assert sorted(pch._include_closure(tmp_path, ["a.h"])) == ["a.h", "b.h"]
|
||||
|
||||
|
||||
def test_include_closure_blocks_parent_escape(tmp_path: Path) -> None:
|
||||
_write(tmp_path / "src", "a.h", '#include "../outside.h"\n')
|
||||
(tmp_path / "outside.h").write_text("")
|
||||
assert sorted(pch._include_closure(tmp_path / "src", ["a.h"])) == ["a.h"]
|
||||
|
||||
|
||||
def test_pch_checksum_tracks_closure_content(tmp_path: Path) -> None:
|
||||
"""A transitive header edit or an extra-identity change must change the
|
||||
digest; unrelated files must not."""
|
||||
_write(tmp_path, "root.h", '#include "nested.h"\n')
|
||||
_write(tmp_path, "nested.h", "int a;\n")
|
||||
_write(tmp_path, "unrelated.h", "int u;\n")
|
||||
base = pch.pch_checksum(tmp_path, ["root.h"], ["id"])
|
||||
assert base == pch.pch_checksum(tmp_path, ["root.h"], ["id"])
|
||||
assert base != pch.pch_checksum(tmp_path, ["root.h"], ["other-id"])
|
||||
_write(tmp_path, "unrelated.h", "int changed;\n")
|
||||
assert base == pch.pch_checksum(tmp_path, ["root.h"], ["id"])
|
||||
_write(tmp_path, "nested.h", "int b;\n")
|
||||
assert base != pch.pch_checksum(tmp_path, ["root.h"], ["id"])
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
os.name == "nt" or os.geteuid() == 0, reason="chmod is ineffective here"
|
||||
)
|
||||
def test_include_closure_fails_closed_on_unreadable(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A marker would truncate the transitive walk; the OSError propagates
|
||||
so callers compile without a pch."""
|
||||
_write(tmp_path, "a.h", '#include "locked.h"\n')
|
||||
locked = tmp_path / "locked.h"
|
||||
locked.write_text("")
|
||||
locked.chmod(0)
|
||||
try:
|
||||
with pytest.raises(OSError):
|
||||
pch._include_closure(tmp_path, ["a.h"])
|
||||
finally:
|
||||
locked.chmod(0o644)
|
||||
assert "Could not read locked.h" in caplog.text
|
||||
|
||||
|
||||
def test_pch_extra_scripts_gated(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
assert pch.pch_extra_scripts() == ["post:pch.py"]
|
||||
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0")
|
||||
assert pch.pch_extra_scripts() == []
|
||||
|
||||
|
||||
def test_include_closure_raises_when_identity_unknown(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""An unreadable header propagates; callers compile without a pch."""
|
||||
|
||||
class _BadFile:
|
||||
def stat(self): # noqa: ANN202 -- regular-file mode only
|
||||
import os
|
||||
import stat as stat_mod
|
||||
|
||||
return os.stat_result((stat_mod.S_IFREG | 0o644,) + (0,) * 9)
|
||||
|
||||
def read_bytes(self) -> bytes:
|
||||
raise OSError("read failed")
|
||||
|
||||
class _FakeSrcDir:
|
||||
def __truediv__(self, rel: str) -> _BadFile:
|
||||
return _BadFile()
|
||||
|
||||
with pytest.raises(OSError, match="read failed"):
|
||||
pch._include_closure(_FakeSrcDir(), ["a.h"])
|
||||
assert "Could not read a.h" in caplog.text
|
||||
|
||||
|
||||
def test_include_closure_survives_non_utf8_include_name(tmp_path: Path) -> None:
|
||||
"""A non-UTF-8 quoted include must not abort the build; it simply does
|
||||
not resolve and ends the walk."""
|
||||
(tmp_path / "a.h").write_bytes(b'#include "bad\xff.h"\n#include "b.h"\n')
|
||||
(tmp_path / "b.h").write_text("")
|
||||
closure = pch._include_closure(tmp_path, ["a.h"])
|
||||
assert set(closure) == {"a.h", "b.h"}
|
||||
|
||||
|
||||
def test_pch_checksum_survives_surrogate_extra(tmp_path: Path) -> None:
|
||||
"""Install paths from non-UTF-8 filesystems carry surrogates; hashing
|
||||
them must not raise past the caller's identity-unknown guard."""
|
||||
assert pch.pch_checksum(tmp_path, [], ["/opt/bad\udcff/framework"])
|
||||
|
||||
|
||||
def test_include_closure_walks_angle_includes_under_src(tmp_path: Path) -> None:
|
||||
"""An angle include resolving under src/ must enter the digest; one
|
||||
that does not simply ends the walk."""
|
||||
_write(tmp_path, "a.h", "#include <local.h>\n#include <Arduino.h>\n")
|
||||
(tmp_path / "local.h").write_text("")
|
||||
closure = pch._include_closure(tmp_path, ["a.h"])
|
||||
assert set(closure) == {"a.h", "local.h"}
|
||||
|
||||
|
||||
def test_ccache_pch_env_warns_on_falsy_extsum(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A user CCACHE_PCH_EXTSUM=false makes ccache hash the .gch bytes."""
|
||||
pch.mark_pch_emitted()
|
||||
with patch.dict(os.environ, {"CCACHE_PCH_EXTSUM": "false"}, clear=True):
|
||||
env = pch.ccache_pch_env()
|
||||
assert "CCACHE_PCH_EXTSUM" not in env
|
||||
assert "disables pch caching" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[(None, False), ("0", False), ("1", True), ("true", True)],
|
||||
)
|
||||
def test_pch_strict(
|
||||
value: str | None, expected: bool, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
if value is None:
|
||||
monkeypatch.delenv("ESPHOME_PCH_STRICT", raising=False)
|
||||
else:
|
||||
monkeypatch.setenv("ESPHOME_PCH_STRICT", value)
|
||||
assert pch.pch_strict() is expected
|
||||
|
||||
|
||||
def test_pch_cmake_consumer_substitutes_target_and_sources(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.delenv("ESPHOME_PCH_ENABLE", raising=False)
|
||||
monkeypatch.delenv("ESPHOME_PCH_STRICT", raising=False)
|
||||
block = pch.pch_cmake_consumer("app", "${APP_SOURCES}")
|
||||
assert "target_compile_options(app PRIVATE" in block
|
||||
assert '"$<$<COMPILE_LANGUAGE:CXX>:-Winvalid-pch>"' in block
|
||||
assert "-Wno-error=invalid-pch" in block
|
||||
assert '"$<$<COMPILE_LANGUAGE:CXX>:esphome_pch.h>"' in block
|
||||
assert "set_source_files_properties(${APP_SOURCES} PROPERTIES" in block
|
||||
assert 'OBJECT_DEPENDS "${CMAKE_BINARY_DIR}/esphome_pch.h"' in block
|
||||
# Placeholder guard: survives a build-system-side pristine wipe
|
||||
assert 'file(TOUCH "${CMAKE_BINARY_DIR}/esphome_pch.h")' in block
|
||||
|
||||
|
||||
def test_pch_cmake_consumer_strict_escalates(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.delenv("ESPHOME_PCH_ENABLE", raising=False)
|
||||
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
|
||||
assert "-Werror=invalid-pch" in pch.pch_cmake_consumer("app", "${APP_SOURCES}")
|
||||
|
||||
|
||||
def test_pch_cmake_consumer_empty_when_disabled(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0")
|
||||
assert pch.pch_cmake_consumer("app", "${APP_SOURCES}") == ""
|
||||
|
||||
|
||||
def test_guarded_prepare_logs_placeholder_failure(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A failed placeholder touch must be traceable, not silent."""
|
||||
monkeypatch.delenv("ESPHOME_PCH_STRICT", raising=False)
|
||||
|
||||
def boom() -> None:
|
||||
raise RuntimeError("boom")
|
||||
|
||||
# Missing build dir: the touch raises and only warns
|
||||
pch.guarded_prepare(tmp_path / "missing", boom)
|
||||
assert "Could not create the pch placeholder" in caplog.text
|
||||
|
||||
|
||||
def test_pch_degraded_raises_only_in_strict(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from esphome.core import EsphomeError
|
||||
|
||||
monkeypatch.delenv("ESPHOME_PCH_STRICT", raising=False)
|
||||
pch.pch_degraded("reason")
|
||||
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
|
||||
with pytest.raises(EsphomeError, match="reason"):
|
||||
pch.pch_degraded("reason")
|
||||
|
||||
|
||||
def test_pch_extra_scripts_strict_raises_when_disabled(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from esphome.core import EsphomeError
|
||||
|
||||
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0")
|
||||
monkeypatch.setenv("ESPHOME_PCH_STRICT", "1")
|
||||
with pytest.raises(EsphomeError, match="disabled"):
|
||||
pch.pch_extra_scripts()
|
||||
|
||||
|
||||
def test_pch_strict_rejects_unrecognized_values(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A typo must not silently disable the gate."""
|
||||
from esphome.core import EsphomeError
|
||||
|
||||
monkeypatch.setenv("ESPHOME_PCH_STRICT", "yolo")
|
||||
with pytest.raises(EsphomeError, match="Unrecognized ESPHOME_PCH_STRICT"):
|
||||
pch.pch_strict()
|
||||
|
||||
|
||||
def test_discard_pch_raises_when_gch_survives(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A .gch an unlink failure leaves behind would be consumed silently."""
|
||||
from pathlib import Path as _P
|
||||
|
||||
from esphome.core import EsphomeError
|
||||
|
||||
(tmp_path / "esphome_pch.h").write_text("")
|
||||
gch = tmp_path / "esphome_pch.h.gch"
|
||||
gch.write_bytes(b"gch")
|
||||
real_unlink = _P.unlink
|
||||
|
||||
def failing_unlink(self, missing_ok=False):
|
||||
if self.name.endswith(".gch"):
|
||||
raise OSError("readonly")
|
||||
return real_unlink(self, missing_ok=missing_ok)
|
||||
|
||||
monkeypatch.setattr(_P, "unlink", failing_unlink)
|
||||
with pytest.raises(EsphomeError, match="Could not discard"):
|
||||
pch.discard_pch(tmp_path)
|
||||
|
||||
|
||||
def test_discard_pch_raises_when_sum_survives(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""The .sum is ccache's pch identity; one that survives is as unsafe
|
||||
as a surviving .gch."""
|
||||
from pathlib import Path as _P
|
||||
|
||||
from esphome.core import EsphomeError
|
||||
|
||||
(tmp_path / "esphome_pch.h").write_text("")
|
||||
(tmp_path / "esphome_pch.h.gch").write_bytes(b"gch")
|
||||
(tmp_path / "esphome_pch.h.gch.sum").write_text("x")
|
||||
real_unlink = _P.unlink
|
||||
|
||||
def failing_unlink(self, missing_ok=False):
|
||||
if self.name.endswith(".sum"):
|
||||
raise OSError("readonly")
|
||||
return real_unlink(self, missing_ok=missing_ok)
|
||||
|
||||
monkeypatch.setattr(_P, "unlink", failing_unlink)
|
||||
with pytest.raises(EsphomeError, match="Could not discard"):
|
||||
pch.discard_pch(tmp_path)
|
||||
|
||||
|
||||
def test_discard_pch_warns_when_file_vanished_concurrently(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""An unlink error on a file that is nonetheless gone only warns."""
|
||||
from pathlib import Path as _P
|
||||
|
||||
(tmp_path / "esphome_pch.h").write_text("")
|
||||
(tmp_path / "esphome_pch.h.gch").write_bytes(b"gch")
|
||||
real_unlink = _P.unlink
|
||||
|
||||
def racing_unlink(self, missing_ok=False):
|
||||
if self.name.endswith(".sum"):
|
||||
# Racer removed it, then our unlink errored
|
||||
raise OSError("stale handle")
|
||||
return real_unlink(self, missing_ok=missing_ok)
|
||||
|
||||
monkeypatch.setattr(_P, "unlink", racing_unlink)
|
||||
pch.discard_pch(tmp_path)
|
||||
assert "Could not discard the pch sidecars" in caplog.text
|
||||
@@ -1,43 +0,0 @@
|
||||
"""The pch script must reach PlatformIO builds only; the native ESP-IDF
|
||||
toolchain has its own pch flow in build_gen/espidf.py."""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components import esp32
|
||||
from esphome.const import Toolchain
|
||||
from esphome.core import CORE
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("toolchain", "copied"),
|
||||
[(Toolchain.PLATFORMIO, True), (Toolchain.ESP_IDF, False)],
|
||||
)
|
||||
def test_copy_files_gates_pch_script_on_toolchain(
|
||||
toolchain: Toolchain, copied: bool, tmp_path: Path
|
||||
) -> None:
|
||||
CORE.toolchain = toolchain
|
||||
CORE.build_path = tmp_path
|
||||
from esphome.components.esp32 import (
|
||||
KEY_ESP32,
|
||||
KEY_EXTRA_BUILD_FILES,
|
||||
KEY_FLASH_SIZE,
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(esp32, "_write_sdkconfig") as write_sdkconfig,
|
||||
patch.object(esp32, "_write_idf_component_yml"),
|
||||
patch.object(esp32, "copy_pch_script") as copy_script,
|
||||
patch.object(esp32, "write_file_if_changed"),
|
||||
patch.object(esp32, "get_partition_csv"),
|
||||
patch.dict(
|
||||
CORE.data,
|
||||
{KEY_ESP32: {KEY_EXTRA_BUILD_FILES: {}, KEY_FLASH_SIZE: "4MB"}},
|
||||
),
|
||||
):
|
||||
esp32.copy_files()
|
||||
# Proves execution reached (and passed) the gate on both rows
|
||||
assert write_sdkconfig.called
|
||||
assert copy_script.called is copied
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user