mirror of
https://github.com/esphome/esphome.git
synced 2026-09-04 12:06:01 +00:00
Compare commits
52
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
590a4a6268 | ||
|
|
6a9791609f | ||
|
|
bd94a6858f | ||
|
|
c432ab146f | ||
|
|
82675a2c78 | ||
|
|
29b5935a22 | ||
|
|
0224929624 | ||
|
|
4437a0bd7f | ||
|
|
fa5e784cbe | ||
|
|
14f6d44ac2 | ||
|
|
88402743d5 | ||
|
|
6ae5f070c8 | ||
|
|
89d00c6d93 | ||
|
|
e723d404e2 | ||
|
|
dde216d8c8 | ||
|
|
ab8e180ff5 | ||
|
|
99a82222ab | ||
|
|
3162a16b05 | ||
|
|
feeacda4ba | ||
|
|
f14d69ff36 | ||
|
|
5e79c617e8 | ||
|
|
1cc48bfee2 | ||
|
|
890f0408a5 | ||
|
|
3e0f81bf1e | ||
|
|
6111791706 | ||
|
|
7a05749231 | ||
|
|
997e218376 | ||
|
|
973da47da6 | ||
|
|
e80aa9579b | ||
|
|
a060db1251 | ||
|
|
9c4016a871 | ||
|
|
5846977cf6 | ||
|
|
ca42862742 | ||
|
|
e0a054dbcb | ||
|
|
5e822b828e | ||
|
|
ef646a9303 | ||
|
|
f8bec0813d | ||
|
|
84762e6ae0 | ||
|
|
2edf313ee3 | ||
|
|
ae9c999052 | ||
|
|
7d2f6fbf55 | ||
|
|
608bef86cc | ||
|
|
6514dc2fe1 | ||
|
|
240afd23b3 | ||
|
|
156c2a8cb0 | ||
|
|
908c47bb5e | ||
|
|
6df3a30740 | ||
|
|
0aaf59dbed | ||
|
|
249c5bb724 | ||
|
|
54ea8dd207 | ||
|
|
4cfb794b62 | ||
|
|
917af8ff31 |
@@ -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 }}
|
||||
@@ -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
|
||||
@@ -1258,7 +1228,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..."
|
||||
@@ -1267,40 +1237,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.
|
||||
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
|
||||
@@ -1663,7 +1599,6 @@ jobs:
|
||||
needs:
|
||||
- common
|
||||
- seed-apt-cache
|
||||
- seed-esp8266-native-cache
|
||||
- determine-jobs
|
||||
- ci-custom
|
||||
- pylint
|
||||
@@ -1679,7 +1614,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
|
||||
|
||||
+31
-83
@@ -817,9 +817,7 @@ def write_cpp_file() -> int:
|
||||
from esphome.build_gen import espidf
|
||||
|
||||
espidf.write_project()
|
||||
elif not CORE.using_native_toolchain:
|
||||
# Other native builds generate their project at compile time;
|
||||
# never write a platformio.ini for them
|
||||
else:
|
||||
from esphome.build_gen import platformio
|
||||
|
||||
platformio.write_project()
|
||||
@@ -861,14 +859,20 @@ def compile_program(args: ArgsProtocol, config: ConfigType) -> int:
|
||||
toolchain.create_factory_bin()
|
||||
toolchain.create_ota_bin()
|
||||
toolchain.create_elf_copy()
|
||||
from esphome.build_helpers.idedata import warn_if_idedata_missing
|
||||
from esphome.build_helpers.idedata import IDEDATA_BEST_EFFORT_ERRORS
|
||||
|
||||
warn_if_idedata_missing(toolchain.get_idedata)
|
||||
elif CORE.using_native_toolchain:
|
||||
raise EsphomeError(
|
||||
f"Toolchain '{CORE.toolchain.value}' resolved but no platform "
|
||||
"backend claimed the build"
|
||||
)
|
||||
try:
|
||||
if toolchain.get_idedata() is None:
|
||||
_LOGGER.warning("No idedata was generated for this build")
|
||||
except IDEDATA_BEST_EFFORT_ERRORS as err:
|
||||
# The firmware already built; an idedata failure must not fail
|
||||
# a successful build.
|
||||
_LOGGER.warning(
|
||||
"Could not generate idedata: %s (IDE, clang-tidy, and "
|
||||
"memory-analysis data will be unavailable for this build)",
|
||||
err,
|
||||
)
|
||||
_LOGGER.debug("Idedata failure detail", exc_info=True)
|
||||
else:
|
||||
from esphome.platformio import toolchain
|
||||
|
||||
@@ -971,15 +975,12 @@ def upload_using_esptool(
|
||||
|
||||
if file is not None:
|
||||
flash_images = [FlashImage(path=file, offset="0x0")]
|
||||
elif (native := _native_toolchain_module()) is not None:
|
||||
# Every native backend supplies its own 0x0 flash image (bootloader
|
||||
# and partitions included where the target needs them)
|
||||
image = native.get_factory_firmware_path()
|
||||
if not image.is_file():
|
||||
raise EsphomeError(
|
||||
f"{image} does not exist; compile the configuration first"
|
||||
)
|
||||
flash_images = [FlashImage(path=image, offset="0x0")]
|
||||
elif CORE.using_toolchain_esp_idf:
|
||||
from esphome.espidf import toolchain
|
||||
|
||||
flash_images = [
|
||||
FlashImage(path=toolchain.get_factory_firmware_path(), offset="0x0")
|
||||
]
|
||||
else:
|
||||
from esphome.platformio import toolchain
|
||||
|
||||
@@ -1949,39 +1950,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",
|
||||
@@ -2020,17 +1997,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:
|
||||
@@ -2042,31 +2008,13 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int:
|
||||
|
||||
# Get idedata for analysis
|
||||
idedata = None
|
||||
if native_toolchain is not None:
|
||||
objdump = native_toolchain.get_objdump_path()
|
||||
readelf = native_toolchain.get_readelf_path()
|
||||
for tool in (objdump, readelf):
|
||||
if not tool.is_file():
|
||||
# The analyzer would silently fall back to host binutils,
|
||||
# which cannot read the target ELF. clean-all is heavy for
|
||||
# ESP-IDF, so suggest a recompile first.
|
||||
_LOGGER.error(
|
||||
"%s is missing; the toolchain install may be incomplete "
|
||||
"(recompile, or run 'esphome clean-all' if it persists)",
|
||||
tool,
|
||||
)
|
||||
return 1
|
||||
objdump_path = str(objdump)
|
||||
readelf_path = str(readelf)
|
||||
if CORE.using_toolchain_esp_idf:
|
||||
from esphome.espidf import toolchain
|
||||
|
||||
firmware_elf = native_toolchain.get_elf_path()
|
||||
if not firmware_elf.is_file():
|
||||
# The analyzer swallows tool failures, so a missing ELF would
|
||||
# produce an exit-0 zeroed report
|
||||
_LOGGER.error(
|
||||
"%s is missing; compile the configuration first", firmware_elf
|
||||
)
|
||||
return 1
|
||||
objdump_path = str(toolchain.get_objdump_path())
|
||||
readelf_path = str(toolchain.get_readelf_path())
|
||||
|
||||
firmware_elf = toolchain.get_elf_path()
|
||||
else:
|
||||
from esphome.platformio import toolchain
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ from typing import NamedTuple
|
||||
|
||||
from esphome.build_helpers.ccache import ccache_defaults_env
|
||||
from esphome.build_helpers.ninja import find_ninja
|
||||
from esphome.build_helpers.pch import ccache_pch_env
|
||||
from esphome.build_helpers.tools_cache import ARDUINO8266_TOOLS_CACHE, tools_cache_path
|
||||
from esphome.core import EsphomeError, Version
|
||||
from esphome.framework_helpers import str_to_lst_of_str
|
||||
@@ -157,6 +156,4 @@ def ccache_env(ccache: str | None) -> dict[str, str]:
|
||||
"""
|
||||
if ccache is None:
|
||||
return {}
|
||||
env = ccache_defaults_env(get_arduino8266_tools_path() / "ccache")
|
||||
env.update(ccache_pch_env())
|
||||
return env
|
||||
return ccache_defaults_env(get_arduino8266_tools_path() / "ccache")
|
||||
|
||||
@@ -1,329 +0,0 @@
|
||||
"""Native Arduino ESP8266 build driver (the PlatformIO ``run`` equivalent)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
from typing import Any
|
||||
|
||||
from esphome.arduino8266 import framework
|
||||
from esphome.build_helpers.ccache import resolve_ccache_path
|
||||
from esphome.const import (
|
||||
CONF_COMPILE_PROCESS_LIMIT,
|
||||
CONF_ESPHOME,
|
||||
KEY_CORE,
|
||||
KEY_FRAMEWORK_VERSION,
|
||||
)
|
||||
from esphome.core import CORE, EsphomeError
|
||||
from esphome.helpers import write_file_if_changed
|
||||
from esphome.types import ConfigType
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# ESP8266 user RAM (matches upload.maximum_ram_size in every board manifest)
|
||||
_MAX_RAM_SIZE = 81920
|
||||
|
||||
|
||||
def _warn_ignored_platformio_options() -> None:
|
||||
"""Warn for component-added platformio options the native build drops.
|
||||
|
||||
The consumed set is exported by core/config.py next to the routing that
|
||||
stores these options, so the two cannot drift; YAML upload_speed never
|
||||
reaches CORE.platformio_options here.
|
||||
"""
|
||||
from esphome.core.config import NATIVE_ARDUINO_CONSUMED_PIO_OPTIONS
|
||||
|
||||
consumed = NATIVE_ARDUINO_CONSUMED_PIO_OPTIONS
|
||||
for key in sorted(CORE.platformio_options or {}):
|
||||
if key not in consumed:
|
||||
_LOGGER.warning(
|
||||
"platformio_options->%s is ignored when building with the "
|
||||
"native 'arduino' toolchain",
|
||||
key,
|
||||
)
|
||||
|
||||
|
||||
_RAM_SECTIONS = (".data", ".rodata", ".bss")
|
||||
_FLASH_SECTIONS = (".irom0.text", ".text", ".text1", ".data", ".rodata")
|
||||
|
||||
|
||||
def get_build_dir() -> Path:
|
||||
return CORE.relative_pioenvs_path(CORE.name)
|
||||
|
||||
|
||||
def get_elf_path() -> Path:
|
||||
return get_build_dir() / "firmware.elf"
|
||||
|
||||
|
||||
def _toolchain_tool(name: str) -> Path:
|
||||
return framework.toolchain_tool(framework.get_toolchain_path(), name)
|
||||
|
||||
|
||||
def get_factory_firmware_path() -> Path:
|
||||
"""The image to serial-flash at 0x0 (same bytes as firmware.bin: the
|
||||
8266 factory copy exists for artifact-contract parity, not content)."""
|
||||
return get_build_dir() / "firmware.factory.bin"
|
||||
|
||||
|
||||
def get_addr2line_path() -> Path:
|
||||
return _toolchain_tool("addr2line")
|
||||
|
||||
|
||||
def get_objdump_path() -> Path:
|
||||
return _toolchain_tool("objdump")
|
||||
|
||||
|
||||
def get_readelf_path() -> Path:
|
||||
return _toolchain_tool("readelf")
|
||||
|
||||
|
||||
def run_compile(config: ConfigType, verbose: bool) -> int:
|
||||
from esphome.build_gen import arduino8266 as build_gen
|
||||
|
||||
_warn_ignored_platformio_options()
|
||||
paths = framework.check_and_install(CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION])
|
||||
# Resolved once per build: the resolution probes PATH and spawns the
|
||||
# runnability check, and three consumers need the same answer
|
||||
ccache = resolve_ccache_path()
|
||||
ninja_changed = build_gen.write_project(paths, ccache)
|
||||
|
||||
build_dir = get_build_dir()
|
||||
env = framework.get_build_env(paths.toolchain, ccache)
|
||||
|
||||
# Regenerate the compile DB before the build (a pure function of
|
||||
# build.ninja); skip only when it is at least as fresh as build.ninja
|
||||
# (an interrupted previous run may have rewritten the manifest without
|
||||
# regenerating the DB).
|
||||
compdb = build_dir / "compile_commands.json"
|
||||
compdb_stamp = build_dir / ".compile_commands.stamp"
|
||||
ninja_file = build_dir / "build.ninja"
|
||||
# Freshness rides a stamp: the DB itself is written through
|
||||
# write_file_if_changed (its mtime feeds get_idedata's cache), so a
|
||||
# regeneration with identical content would stay "stale" forever
|
||||
if (
|
||||
ninja_changed
|
||||
or not compdb.is_file()
|
||||
or not compdb_stamp.is_file()
|
||||
or compdb_stamp.stat().st_mtime < ninja_file.stat().st_mtime
|
||||
):
|
||||
_write_compile_commands(paths.ninja, build_dir, env)
|
||||
compdb_stamp.touch()
|
||||
|
||||
cmd = [str(paths.ninja)]
|
||||
if verbose:
|
||||
cmd.append("-v")
|
||||
if jobs := config[CONF_ESPHOME].get(CONF_COMPILE_PROCESS_LIMIT):
|
||||
cmd += ["-j", str(jobs)]
|
||||
# Explicit targets, not the default statement: a generator defect that
|
||||
# drops them fails loudly with "unknown target" instead of a green
|
||||
# no-op run that leaves stale artifacts in place
|
||||
targets = ["firmware.factory.bin", "firmware.ota.bin"]
|
||||
cmd += targets
|
||||
|
||||
# A dry-run probe keeps a no-op rebuild quiet: ninja would only print
|
||||
# "no work to do". A freshly rewritten manifest all but guarantees work,
|
||||
# so skip the probe (and its full stat pass) on that path. cwd instead
|
||||
# of -C also drops the "Entering directory" banner on real builds.
|
||||
skip_build = False
|
||||
if not ninja_changed:
|
||||
probe = subprocess.run(
|
||||
[str(paths.ninja), "-n", *targets],
|
||||
cwd=build_dir,
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
close_fds=False,
|
||||
)
|
||||
if probe.stderr.strip():
|
||||
# A load-time diagnostic (e.g. "multiple rules generate X")
|
||||
# flags a generator bug; the skip branch would otherwise
|
||||
# swallow it forever
|
||||
_LOGGER.warning("ninja: %s", probe.stderr.strip())
|
||||
if probe.returncode != 0:
|
||||
# An unknown target here is the defective-manifest case; fall
|
||||
# through to the real build so the error prints attributably
|
||||
_LOGGER.debug("ninja probe failed; running the full build")
|
||||
skip_build = probe.returncode == 0 and "no work to do" in probe.stdout
|
||||
if skip_build:
|
||||
_LOGGER.debug("ninja: nothing to rebuild")
|
||||
else:
|
||||
_LOGGER.debug("Running: %s", " ".join(cmd))
|
||||
rc = subprocess.run(
|
||||
cmd, cwd=build_dir, env=env, check=False, close_fds=False
|
||||
).returncode
|
||||
if rc != 0:
|
||||
return rc
|
||||
|
||||
# ninja already refused a manifest missing the explicit targets above;
|
||||
# existence covers the remaining hole (a rule that ran but wrote
|
||||
# elsewhere). The factory/ota copies are what upload and OTA consume.
|
||||
build_dir_artifacts = (
|
||||
get_elf_path(),
|
||||
build_dir / "firmware.bin",
|
||||
get_factory_firmware_path(),
|
||||
build_dir / "firmware.ota.bin",
|
||||
)
|
||||
for artifact in build_dir_artifacts:
|
||||
if not artifact.is_file():
|
||||
_LOGGER.error("Build produced no %s", artifact)
|
||||
return 1
|
||||
|
||||
if not _print_size_summary(build_dir, paths):
|
||||
# The cause was already warned; name the consequence so a build
|
||||
# contributing no RAM/Flash metric is visible to CI harnesses
|
||||
_LOGGER.warning("Firmware size summary unavailable for this build")
|
||||
from esphome.build_helpers.idedata import warn_if_idedata_missing
|
||||
|
||||
warn_if_idedata_missing(lambda: get_idedata(ccache))
|
||||
return 0
|
||||
|
||||
|
||||
def _write_compile_commands(
|
||||
ninja_path: Path, build_dir: Path, env: dict[str, str]
|
||||
) -> None:
|
||||
compdb = build_dir / "compile_commands.json"
|
||||
result = subprocess.run(
|
||||
[str(ninja_path), "-C", str(build_dir), "-t", "compdb", "c", "cxx", "asm"],
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
close_fds=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
# Drop any stale database so consumers (IDE integration, clang-tidy,
|
||||
# the memory analyzer) can't silently read outdated data.
|
||||
compdb.unlink(missing_ok=True)
|
||||
raise EsphomeError(f"Could not generate compile_commands.json: {result.stderr}")
|
||||
try:
|
||||
entries = json.loads(result.stdout)
|
||||
except ValueError as err:
|
||||
compdb.unlink(missing_ok=True)
|
||||
raise EsphomeError(
|
||||
f"ninja produced an unparsable compile database: {err} "
|
||||
f"(output starts {result.stdout[:120]!r})"
|
||||
) from err
|
||||
if not entries:
|
||||
# compdb exits 0 with [] for unknown rule names; a renamed compile
|
||||
# rule must fail the build, not silently strand every consumer
|
||||
compdb.unlink(missing_ok=True)
|
||||
raise EsphomeError(
|
||||
"ninja produced an empty compile database; the generator's rule "
|
||||
"names no longer match"
|
||||
)
|
||||
# write_file_if_changed keeps the mtime stable on no-op builds so the
|
||||
# idedata cache in get_idedata() stays valid.
|
||||
write_file_if_changed(compdb, result.stdout)
|
||||
|
||||
|
||||
def _parse_app_size(build_dir: Path, paths: framework.InstalledPaths) -> int | None:
|
||||
"""Read the app flash budget (irom0_0_seg length) from the linker script."""
|
||||
from esphome.build_gen.arduino8266 import get_flash_ld_path
|
||||
from esphome.components.esp8266.build_surgery import segment_length
|
||||
|
||||
# Warnings, not debug: without the app size the Flash summary line is
|
||||
# dropped and CI's memory-impact extraction loses its flash metric.
|
||||
ld_path = get_flash_ld_path(build_dir, paths)
|
||||
try:
|
||||
ld_text = ld_path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError) as err:
|
||||
# UnicodeDecodeError: a truncated/corrupt script must degrade to
|
||||
# the same warning, never abort an already-linked build
|
||||
_LOGGER.warning("Cannot read linker script for the Flash summary: %s", err)
|
||||
return None
|
||||
app_size = segment_length(ld_text, "irom0_0_seg")
|
||||
if app_size is None:
|
||||
_LOGGER.warning("irom0_0_seg not found in %s; skipping Flash summary", ld_path)
|
||||
return None
|
||||
if app_size == 0:
|
||||
_LOGGER.warning(
|
||||
"irom0_0_seg has zero length in %s; skipping Flash summary", ld_path
|
||||
)
|
||||
return None
|
||||
return app_size
|
||||
|
||||
|
||||
def _print_size_summary(build_dir: Path, paths: framework.InstalledPaths) -> bool:
|
||||
"""Print the PlatformIO-shaped RAM/Flash lines; False when skipped.
|
||||
|
||||
The exact shape (including the bar) is parsed by
|
||||
``script/ci_memory_impact_extract.py``; ``print_size_line`` matches it.
|
||||
"""
|
||||
from esphome.build_helpers.size_summary import print_size_line
|
||||
|
||||
size_tool = _toolchain_tool("size")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[str(size_tool), "-A", "-d", str(get_elf_path())],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
close_fds=False,
|
||||
)
|
||||
except OSError as err:
|
||||
# The summary is a bonus artifact like idedata; a truncated
|
||||
# toolchain extraction must not discard an already-linked build
|
||||
_LOGGER.warning("Could not summarize firmware size: %s", err)
|
||||
return False
|
||||
if result.returncode != 0:
|
||||
_LOGGER.warning("Could not summarize firmware size: %s", result.stderr)
|
||||
return False
|
||||
sections: dict[str, int] = {}
|
||||
for line in result.stdout.splitlines():
|
||||
parts = line.split()
|
||||
if len(parts) >= 2 and parts[0].startswith("."):
|
||||
try:
|
||||
sections[parts[0]] = int(parts[1])
|
||||
except ValueError:
|
||||
# An unparsed RAM/Flash section trips the missing-sections
|
||||
# guard below, so no total is built on a dropped value
|
||||
_LOGGER.warning("Unparsable size output for section %s", parts[0])
|
||||
if missing := set(_RAM_SECTIONS + _FLASH_SECTIONS) - set(sections):
|
||||
# A defaulted 0 would print a confidently wrong total for CI's metric
|
||||
_LOGGER.warning(
|
||||
"Size output is missing section(s) %s; skipping the size summary",
|
||||
", ".join(sorted(missing)),
|
||||
)
|
||||
return False
|
||||
# Resolve the flash budget before printing anything: a RAM line without
|
||||
# its Flash line would let CI's memory-impact extraction sum the two
|
||||
# metrics over different build counts (_parse_app_size already warned).
|
||||
app_size = _parse_app_size(build_dir, paths)
|
||||
if not app_size:
|
||||
return False
|
||||
ram = sum(sections[s] for s in _RAM_SECTIONS)
|
||||
flash = sum(sections[s] for s in _FLASH_SECTIONS)
|
||||
print_size_line("RAM", ram, _MAX_RAM_SIZE)
|
||||
print_size_line("Flash", flash, app_size)
|
||||
return True
|
||||
|
||||
|
||||
# Sentinel: "resolve for me"; None is a real value meaning disabled.
|
||||
_CCACHE_UNRESOLVED: Any = object()
|
||||
|
||||
|
||||
def get_idedata(ccache: str | None = _CCACHE_UNRESOLVED) -> dict | None:
|
||||
"""Derive idedata from the build's compile_commands.json.
|
||||
|
||||
Same contract as ``espidf.toolchain.get_idedata``: the fields IDE
|
||||
integrations, clang-tidy, and the memory analyzer expect.
|
||||
"""
|
||||
from esphome.build_helpers.idedata import load_or_build_idedata
|
||||
|
||||
if ccache is _CCACHE_UNRESOLVED:
|
||||
# Deliberately uncached: env/PATH can change between builds in a
|
||||
# long-lived host process
|
||||
ccache = resolve_ccache_path()
|
||||
return load_or_build_idedata(
|
||||
get_build_dir() / "compile_commands.json",
|
||||
get_elf_path(),
|
||||
# Suffixed so a platformio->arduino->platformio round trip on one
|
||||
# config never serves the other toolchain's cache shape
|
||||
CORE.relative_internal_path("idedata", f"{CORE.name}.arduino.json"),
|
||||
# The compile DB's commands carry the same ccache prefix the ninja
|
||||
# rules were generated with
|
||||
launcher=str(ccache) if ccache else None,
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,67 +279,9 @@ idf_component_register(
|
||||
target_link_options(${{COMPONENT_LIB}} PUBLIC
|
||||
{link_opts_str}
|
||||
)
|
||||
{_pch_cmake()}"""
|
||||
|
||||
|
||||
def _pch_cmake() -> str:
|
||||
"""The src component's precompiled-header block (C++ TUs only).
|
||||
|
||||
The -include stays relative (resolved from the compiler cwd, the build
|
||||
dir); an absolute path would poison ccache keys.
|
||||
"""
|
||||
if not pch_enabled():
|
||||
return ""
|
||||
return f"""
|
||||
# ESPHome precompiled header (see esphome/build_helpers/pch.py).
|
||||
# OBJECT_DEPENDS is on the header, not the .gch: pch-baked headers drop
|
||||
# out of TU depfiles, and prepare_pch() touches the header on rebuild.
|
||||
target_compile_options(${{COMPONENT_LIB}} PRIVATE
|
||||
"$<$<COMPILE_LANGUAGE:CXX>:-Winvalid-pch>"
|
||||
"$<$<COMPILE_LANGUAGE:CXX>:-include>"
|
||||
"$<$<COMPILE_LANGUAGE:CXX>:{PCH_HEADER_NAME}>"
|
||||
)
|
||||
set_source_files_properties(${{app_sources}} PROPERTIES
|
||||
OBJECT_DEPENDS "${{CMAKE_BINARY_DIR}}/{PCH_HEADER_NAME}")
|
||||
"""
|
||||
|
||||
|
||||
def discard_pch() -> None:
|
||||
"""Drop the pch sidecars in the IDF build dir."""
|
||||
pch.discard_pch(CORE.relative_build_path("build"))
|
||||
|
||||
|
||||
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"))
|
||||
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"))
|
||||
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(
|
||||
minimal: bool = False, builtin_components: list[str] | None = None
|
||||
) -> None:
|
||||
@@ -367,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
|
||||
|
||||
@@ -87,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,423 +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 Iterable
|
||||
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"
|
||||
|
||||
# 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 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 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)."""
|
||||
return ["post:pch.py"] if pch_enabled() else []
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
header = build_dir / PCH_HEADER_NAME
|
||||
gch = Path(f"{header}.gch")
|
||||
had_gch = gch.is_file()
|
||||
gch.unlink(missing_ok=True)
|
||||
Path(f"{gch}.sum").unlink(missing_ok=True)
|
||||
if had_gch and header.is_file():
|
||||
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)
|
||||
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)
|
||||
return
|
||||
if gch.is_file() and _read_stamp(sum_path) == checksum:
|
||||
_log_pch_in_use()
|
||||
return
|
||||
failed_marker = Path(f"{gch}.failed")
|
||||
if _read_stamp(failed_marker) == checksum:
|
||||
_LOGGER.info(
|
||||
"Precompiled header disabled after an earlier failure; delete %s to retry",
|
||||
failed_marker,
|
||||
)
|
||||
return
|
||||
_log_pch_in_use()
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
cwd=cmd_dir,
|
||||
# C locale keeps diagnostics matchable by _TRANSIENT_ERRORS
|
||||
env={**os.environ, "LC_ALL": "C"},
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
timeout=300,
|
||||
)
|
||||
error = None
|
||||
if result.returncode < 0:
|
||||
# Killed by a signal (OOM, ^C): environmental, do not latch
|
||||
_LOGGER.warning(
|
||||
"Precompiled header compile was killed (signal %d); retrying "
|
||||
"next build",
|
||||
-result.returncode,
|
||||
)
|
||||
discard_pch(build_dir)
|
||||
return
|
||||
if result.returncode != 0:
|
||||
error = result.stderr.strip() or f"exit code {result.returncode}"
|
||||
elif not gch.is_file():
|
||||
error = "compiler produced no .gch"
|
||||
except (OSError, subprocess.SubprocessError) as err:
|
||||
# Transient (timeout, spawn/IO): warn and retry next build, no marker
|
||||
_LOGGER.warning("Precompiled header compile did not run: %s", err)
|
||||
discard_pch(build_dir)
|
||||
return
|
||||
if error is not None:
|
||||
_LOGGER.warning(
|
||||
"Precompiled header failed; compiling without it: %s", error[:400]
|
||||
)
|
||||
# This path latches, so keep the full compiler output recoverable
|
||||
_LOGGER.debug("Full pch compile output: %s", error)
|
||||
discard_pch(build_dir)
|
||||
if any(m in error for m in _TRANSIENT_ERRORS):
|
||||
# Resource exhaustion clears on its own; retry next build
|
||||
return
|
||||
# Skip retries until a header/flag/backend-identity/command change
|
||||
failed_marker.write_text(checksum + "\n", encoding="utf-8")
|
||||
os.utime(header)
|
||||
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)
|
||||
@@ -70,13 +70,17 @@ service APIConnection {
|
||||
rpc zwave_proxy_frame(ZWaveProxyFrame) returns (void) {}
|
||||
rpc zwave_proxy_request(ZWaveProxyRequest) returns (void) {}
|
||||
|
||||
rpc zigbee_proxy_request(ZigbeeProxyRequest) returns (void) {}
|
||||
|
||||
rpc infrared_rf_transmit_raw_timings(InfraredRFTransmitRawTimingsRequest) returns (void) {}
|
||||
|
||||
rpc serial_proxy_configure(SerialProxyConfigureRequest) returns (void) {}
|
||||
rpc serial_proxy_write(SerialProxyWriteRequest) returns (void) {}
|
||||
rpc serial_proxy_set_modem_pins(SerialProxySetModemPinsRequest) returns (void) {}
|
||||
rpc serial_proxy_get_modem_pins(SerialProxyGetModemPinsRequest) returns (void) {}
|
||||
rpc serial_proxy_get_usb_info(SerialProxyGetUsbInfoRequest) returns (void) {}
|
||||
rpc serial_proxy_request(SerialProxyRequest) returns (void) {}
|
||||
rpc serial_proxy_set_mode(SerialProxySetModeRequest) returns (void) {}
|
||||
}
|
||||
|
||||
|
||||
@@ -227,6 +231,11 @@ enum SerialProxyPortType {
|
||||
SERIAL_PROXY_PORT_TYPE_TTL = 0;
|
||||
SERIAL_PROXY_PORT_TYPE_RS232 = 1;
|
||||
SERIAL_PROXY_PORT_TYPE_RS485 = 2;
|
||||
// A serial device attached through a USB bridge. Set by the device configuration, never
|
||||
// by the user; identifies ports whose USB identity can be read with
|
||||
// SerialProxyGetUsbInfoRequest. Deliberately not a USB endpoint type: serial_proxy
|
||||
// carries serial devices only, whatever bridge chip connects them.
|
||||
SERIAL_PROXY_PORT_TYPE_USB_SERIAL = 3;
|
||||
}
|
||||
|
||||
message SerialProxyInfo {
|
||||
@@ -331,6 +340,10 @@ message DeviceInfoResponse {
|
||||
// all-zeros PSK, so the api encryption key can be provisioned without being
|
||||
// sent in plaintext (protects against passive sniffing, not active MITM)
|
||||
bool api_encryption_provisionable = 26 [(field_ifdef) = "USE_API_NOISE"];
|
||||
|
||||
// Indicates if Zigbee proxy support is available and features supported
|
||||
uint32 zigbee_proxy_feature_flags = 27 [(field_ifdef) = "USE_ZIGBEE_PROXY"];
|
||||
uint64 zigbee_ieee_address = 28 [(field_ifdef) = "USE_ZIGBEE_PROXY"];
|
||||
}
|
||||
|
||||
// ==================== DEVICE CAPABILITIES ====================
|
||||
@@ -2726,7 +2739,8 @@ enum SerialProxyParity {
|
||||
SERIAL_PROXY_PARITY_ODD = 2;
|
||||
}
|
||||
|
||||
// Configure UART parameters for a serial proxy instance
|
||||
// Configure UART parameters for a serial proxy instance. Only the subscribed client may
|
||||
// configure the port; others are refused with PORT_IN_USE (since API 1.17).
|
||||
message SerialProxyConfigureRequest {
|
||||
option (id) = 138;
|
||||
option (source) = SOURCE_CLIENT;
|
||||
@@ -2752,7 +2766,8 @@ message SerialProxyDataReceived {
|
||||
bytes data = 2; // Raw data received from the serial device
|
||||
}
|
||||
|
||||
// Write data to a serial device
|
||||
// Write data to a serial device. Only the subscribed client may write; writes from
|
||||
// others are ignored (since API 1.17).
|
||||
message SerialProxyWriteRequest {
|
||||
option (id) = 140;
|
||||
option (source) = SOURCE_CLIENT;
|
||||
@@ -2763,7 +2778,8 @@ message SerialProxyWriteRequest {
|
||||
bytes data = 2; // Raw data to write to the serial device
|
||||
}
|
||||
|
||||
// Set modem control pin states (RTS and DTR)
|
||||
// Set modem control pin states (RTS and DTR). Only the subscribed client may set them;
|
||||
// others are refused with PORT_IN_USE (since API 1.17).
|
||||
message SerialProxySetModemPinsRequest {
|
||||
option (id) = 141;
|
||||
option (source) = SOURCE_CLIENT;
|
||||
@@ -2802,6 +2818,7 @@ enum SerialProxyRequestType {
|
||||
// error the device answers with INVALID_ARGUMENT.
|
||||
SERIAL_PROXY_REQUEST_TYPE_CONFIGURE = 3; // Acknowledges a SerialProxyConfigureRequest
|
||||
SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS = 4; // Acknowledges a SerialProxySetModemPinsRequest
|
||||
SERIAL_PROXY_REQUEST_TYPE_SET_MODE = 5; // Acknowledges a SerialProxySetModeRequest (since API 1.17)
|
||||
}
|
||||
|
||||
enum SerialProxyStatus {
|
||||
@@ -2814,7 +2831,8 @@ enum SerialProxyStatus {
|
||||
SERIAL_PROXY_STATUS_INVALID_ARGUMENT = 6; // Invalid instance index or parameter value
|
||||
}
|
||||
|
||||
// Generic request message for simple serial proxy operations
|
||||
// Generic request message for simple serial proxy operations. FLUSH requires an active
|
||||
// subscription; it is refused with PORT_IN_USE otherwise (since API 1.17).
|
||||
message SerialProxyRequest {
|
||||
option (id) = 144;
|
||||
option (source) = SOURCE_CLIENT;
|
||||
@@ -2838,6 +2856,59 @@ message SerialProxyRequestResponse {
|
||||
string error_message = 4; // Additional detail on failure (optional)
|
||||
}
|
||||
|
||||
// How a port treats the bytes passing through it. RAW is a plain byte pipe; PROTOCOL
|
||||
// activates the port's protocol-aware tap (if one is configured), letting it observe
|
||||
// traffic and inject protocol bytes such as acknowledgements. Which protocol the tap
|
||||
// speaks is a property of the device configuration, discoverable from the tap
|
||||
// component's own API surface. A client that is about to flash firmware selects RAW
|
||||
// first, which definitively disables that injection.
|
||||
enum SerialProxyMode {
|
||||
SERIAL_PROXY_MODE_RAW = 0;
|
||||
SERIAL_PROXY_MODE_PROTOCOL = 1;
|
||||
}
|
||||
|
||||
// Only the subscribed client may change the mode; any other caller -- including one that
|
||||
// never subscribed -- is refused with PORT_IN_USE. PROTOCOL is refused with NOT_SUPPORTED
|
||||
// when the port has no protocol-aware tap configured.
|
||||
message SerialProxySetModeRequest {
|
||||
option (id) = 152;
|
||||
option (source) = SOURCE_CLIENT;
|
||||
option (ifdef) = "USE_SERIAL_PROXY";
|
||||
|
||||
uint32 instance = 1;
|
||||
SerialProxyMode mode = 2;
|
||||
}
|
||||
|
||||
// Ask for the USB identity of the device behind a USB_SERIAL port. Read-only, so no
|
||||
// subscription is required -- a client typically uses this to decide which port to
|
||||
// subscribe to. Answered with NOT_SUPPORTED on ports that are not USB_SERIAL.
|
||||
message SerialProxyGetUsbInfoRequest {
|
||||
option (id) = 153;
|
||||
option (source) = SOURCE_CLIENT;
|
||||
option (ifdef) = "USE_SERIAL_PROXY";
|
||||
|
||||
uint32 instance = 1;
|
||||
}
|
||||
|
||||
// The USB identity of the device currently behind a port, read live from the cached
|
||||
// USB descriptors. Fields are zero/empty while no device is connected.
|
||||
message SerialProxyGetUsbInfoResponse {
|
||||
option (id) = 154;
|
||||
option (source) = SOURCE_SERVER;
|
||||
option (ifdef) = "USE_SERIAL_PROXY";
|
||||
|
||||
uint32 instance = 1;
|
||||
SerialProxyStatus status = 2; // NOT_SUPPORTED when the port is not USB_SERIAL
|
||||
bool connected = 3; // True when a USB device is currently attached
|
||||
uint32 vendor_id = 4;
|
||||
uint32 product_id = 5;
|
||||
uint32 bcd_device = 6;
|
||||
uint32 interface_number = 7; // Channel index on multi-port bridges
|
||||
string manufacturer = 8;
|
||||
string product = 9;
|
||||
string serial_number = 10;
|
||||
}
|
||||
|
||||
// ==================== BLUETOOTH CONNECTION PARAMS ====================
|
||||
message BluetoothSetConnectionParamsRequest {
|
||||
option (id) = 145;
|
||||
@@ -2859,3 +2930,18 @@ message BluetoothSetConnectionParamsResponse {
|
||||
uint64 address = 1;
|
||||
int32 error = 2;
|
||||
}
|
||||
|
||||
// ==================== ZIGBEE ====================
|
||||
|
||||
enum ZigbeeProxyRequestType {
|
||||
ZIGBEE_PROXY_REQUEST_TYPE_NETWORK_INFO = 0;
|
||||
}
|
||||
|
||||
message ZigbeeProxyRequest {
|
||||
option (id) = 155;
|
||||
option (source) = SOURCE_BOTH;
|
||||
option (ifdef) = "USE_ZIGBEE_PROXY";
|
||||
|
||||
ZigbeeProxyRequestType type = 1;
|
||||
bytes data = 2;
|
||||
}
|
||||
|
||||
@@ -48,6 +48,12 @@
|
||||
#ifdef USE_ZWAVE_PROXY
|
||||
#include "esphome/components/zwave_proxy/zwave_proxy.h"
|
||||
#endif
|
||||
#ifdef USE_ZIGBEE_PROXY
|
||||
#include "esphome/components/zigbee_proxy/zigbee_proxy.h"
|
||||
#endif
|
||||
#ifdef USE_SERIAL_PROXY_USB_INFO
|
||||
#include "esphome/components/usb_host/usb_host.h"
|
||||
#endif
|
||||
#ifdef USE_WATER_HEATER
|
||||
#include "esphome/components/water_heater/water_heater.h"
|
||||
#endif
|
||||
@@ -1389,6 +1395,12 @@ void APIConnection::on_z_wave_proxy_request(const ZWaveProxyRequest &msg) {
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_ZIGBEE_PROXY
|
||||
void APIConnection::on_zigbee_proxy_request(const ZigbeeProxyRequest &msg) {
|
||||
zigbee_proxy::global_zigbee_proxy->zigbee_proxy_request(this, msg);
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_ALARM_CONTROL_PANEL
|
||||
bool APIConnection::send_alarm_control_panel_state(alarm_control_panel::AlarmControlPanel *a_alarm_control_panel) {
|
||||
return this->send_message_smart_(a_alarm_control_panel, AlarmControlPanelStateResponse::MESSAGE_TYPE,
|
||||
@@ -1642,6 +1654,27 @@ void APIConnection::on_serial_proxy_get_modem_pins_request(const SerialProxyGetM
|
||||
}
|
||||
}
|
||||
|
||||
void APIConnection::on_serial_proxy_get_usb_info_request(const SerialProxyGetUsbInfoRequest &msg) {
|
||||
auto &proxies = App.get_serial_proxies();
|
||||
SerialProxyGetUsbInfoResponse resp{};
|
||||
resp.instance = msg.instance;
|
||||
if (msg.instance >= proxies.size()) {
|
||||
ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range", msg.instance);
|
||||
resp.status = enums::SERIAL_PROXY_STATUS_INVALID_ARGUMENT;
|
||||
} else {
|
||||
#ifdef USE_SERIAL_PROXY_USB_INFO
|
||||
// The response's strings are views into this buffer, which outlives the send below
|
||||
usb_host::UsbDeviceInfo info;
|
||||
proxies[msg.instance]->get_usb_info(info, resp);
|
||||
#else
|
||||
resp.status = enums::SERIAL_PROXY_STATUS_NOT_SUPPORTED;
|
||||
#endif
|
||||
}
|
||||
if (!this->send_message(resp)) {
|
||||
API_LOG_MSG_DROPPED(TAG, "Serial proxy response");
|
||||
}
|
||||
}
|
||||
|
||||
void APIConnection::on_serial_proxy_request(const SerialProxyRequest &msg) {
|
||||
auto &proxies = App.get_serial_proxies();
|
||||
if (msg.instance >= proxies.size()) {
|
||||
@@ -1661,6 +1694,7 @@ void APIConnection::on_serial_proxy_request(const SerialProxyRequest &msg) {
|
||||
break;
|
||||
case enums::SERIAL_PROXY_REQUEST_TYPE_CONFIGURE:
|
||||
case enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS:
|
||||
case enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODE:
|
||||
// Response-only discriminators; never valid in a request
|
||||
ESP_LOGW(TAG, "Response-only serial proxy request type: %" PRIu32, static_cast<uint32_t>(msg.type));
|
||||
status = enums::SERIAL_PROXY_STATUS_INVALID_ARGUMENT;
|
||||
@@ -1673,6 +1707,19 @@ void APIConnection::on_serial_proxy_request(const SerialProxyRequest &msg) {
|
||||
send_serial_proxy_ack(this, msg.instance, msg.type, status);
|
||||
}
|
||||
|
||||
void APIConnection::on_serial_proxy_set_mode_request(const SerialProxySetModeRequest &msg) {
|
||||
auto &proxies = App.get_serial_proxies();
|
||||
if (msg.instance >= proxies.size()) {
|
||||
ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range", msg.instance);
|
||||
send_serial_proxy_ack(this, msg.instance, enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODE,
|
||||
enums::SERIAL_PROXY_STATUS_INVALID_ARGUMENT);
|
||||
return;
|
||||
}
|
||||
serial_proxy::SerialProxyResult result = proxies[msg.instance]->set_mode_from_client(this, msg.mode);
|
||||
send_serial_proxy_ack(this, msg.instance, enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODE,
|
||||
serial_proxy_result_to_status(result));
|
||||
}
|
||||
|
||||
void APIConnection::send_serial_proxy_data(const SerialProxyDataReceived &msg) {
|
||||
if (!this->send_message(msg)) {
|
||||
ESP_LOGV(TAG, "Serial proxy data dropped, TCP buffer full");
|
||||
@@ -1784,6 +1831,11 @@ void APIConnection::complete_authentication_() {
|
||||
zwave_proxy::global_zwave_proxy->api_connection_authenticated(this);
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_ZIGBEE_PROXY
|
||||
if (zigbee_proxy::global_zigbee_proxy != nullptr) {
|
||||
zigbee_proxy::global_zigbee_proxy->api_connection_authenticated(this);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
bool APIConnection::send_hello_response_(const HelloRequest &msg) {
|
||||
@@ -1799,7 +1851,7 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) {
|
||||
|
||||
HelloResponse resp;
|
||||
resp.api_version_major = 1;
|
||||
resp.api_version_minor = 16;
|
||||
resp.api_version_minor = 17;
|
||||
// Send only the version string - the client only logs this for debugging and doesn't use it otherwise
|
||||
resp.server_info = ESPHOME_VERSION_REF;
|
||||
resp.name = StringRef(App.get_name());
|
||||
@@ -1936,6 +1988,10 @@ bool APIConnection::send_device_info_response_() {
|
||||
info.configured_line_states = proxy->get_configured_modem_pins();
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_ZIGBEE_PROXY
|
||||
resp.zigbee_proxy_feature_flags = zigbee_proxy::global_zigbee_proxy->get_feature_flags();
|
||||
resp.zigbee_ieee_address = zigbee_proxy::global_zigbee_proxy->get_ieee_address();
|
||||
#endif
|
||||
#ifdef USE_API_NOISE
|
||||
resp.api_encryption_supported = true;
|
||||
#ifndef USE_API_NOISE_PSK_FROM_YAML
|
||||
|
||||
@@ -223,6 +223,10 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
void on_z_wave_proxy_request(const ZWaveProxyRequest &msg);
|
||||
#endif
|
||||
|
||||
#ifdef USE_ZIGBEE_PROXY
|
||||
void on_zigbee_proxy_request(const ZigbeeProxyRequest &msg);
|
||||
#endif
|
||||
|
||||
#ifdef USE_ALARM_CONTROL_PANEL
|
||||
bool send_alarm_control_panel_state(alarm_control_panel::AlarmControlPanel *a_alarm_control_panel);
|
||||
void on_alarm_control_panel_command_request(const AlarmControlPanelCommandRequest &msg);
|
||||
@@ -243,7 +247,9 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
void on_serial_proxy_write_request(const SerialProxyWriteRequest &msg);
|
||||
void on_serial_proxy_set_modem_pins_request(const SerialProxySetModemPinsRequest &msg);
|
||||
void on_serial_proxy_get_modem_pins_request(const SerialProxyGetModemPinsRequest &msg);
|
||||
void on_serial_proxy_get_usb_info_request(const SerialProxyGetUsbInfoRequest &msg);
|
||||
void on_serial_proxy_request(const SerialProxyRequest &msg);
|
||||
void on_serial_proxy_set_mode_request(const SerialProxySetModeRequest &msg);
|
||||
void send_serial_proxy_data(const SerialProxyDataReceived &msg);
|
||||
#endif
|
||||
|
||||
|
||||
@@ -175,6 +175,12 @@ uint8_t *DeviceInfoResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_
|
||||
#endif
|
||||
#ifdef USE_API_NOISE
|
||||
ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 26, this->api_encryption_provisionable);
|
||||
#endif
|
||||
#ifdef USE_ZIGBEE_PROXY
|
||||
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 27, this->zigbee_proxy_feature_flags);
|
||||
#endif
|
||||
#ifdef USE_ZIGBEE_PROXY
|
||||
ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 28, this->zigbee_ieee_address);
|
||||
#endif
|
||||
return pos;
|
||||
}
|
||||
@@ -240,6 +246,12 @@ uint32_t DeviceInfoResponse::calculate_size() const {
|
||||
#endif
|
||||
#ifdef USE_API_NOISE
|
||||
size += ProtoSize::calc_bool(2, this->api_encryption_provisionable);
|
||||
#endif
|
||||
#ifdef USE_ZIGBEE_PROXY
|
||||
size += ProtoSize::calc_uint32(2, this->zigbee_proxy_feature_flags);
|
||||
#endif
|
||||
#ifdef USE_ZIGBEE_PROXY
|
||||
size += ProtoSize::calc_uint64(2, this->zigbee_ieee_address);
|
||||
#endif
|
||||
return size;
|
||||
}
|
||||
@@ -4253,6 +4265,57 @@ uint32_t SerialProxyRequestResponse::calculate_size() const {
|
||||
size += ProtoSize::calc_length(1, this->error_message.size());
|
||||
return size;
|
||||
}
|
||||
bool SerialProxySetModeRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) {
|
||||
switch (field_id) {
|
||||
case 1:
|
||||
this->instance = value;
|
||||
break;
|
||||
case 2:
|
||||
this->mode = static_cast<enums::SerialProxyMode>(value);
|
||||
break;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
bool SerialProxyGetUsbInfoRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) {
|
||||
switch (field_id) {
|
||||
case 1:
|
||||
this->instance = value;
|
||||
break;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
uint8_t *SerialProxyGetUsbInfoResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
|
||||
uint8_t *__restrict__ pos = buffer.get_pos();
|
||||
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->instance);
|
||||
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast<uint32_t>(this->status));
|
||||
ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, this->connected);
|
||||
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, this->vendor_id);
|
||||
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, this->product_id);
|
||||
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, this->bcd_device);
|
||||
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, this->interface_number);
|
||||
ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, this->manufacturer);
|
||||
ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 9, this->product);
|
||||
ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 10, this->serial_number);
|
||||
return pos;
|
||||
}
|
||||
uint32_t SerialProxyGetUsbInfoResponse::calculate_size() const {
|
||||
uint32_t size = 0;
|
||||
size += ProtoSize::calc_uint32(1, this->instance);
|
||||
size += this->status ? 2 : 0;
|
||||
size += ProtoSize::calc_bool(1, this->connected);
|
||||
size += ProtoSize::calc_uint32(1, this->vendor_id);
|
||||
size += ProtoSize::calc_uint32(1, this->product_id);
|
||||
size += ProtoSize::calc_uint32(1, this->bcd_device);
|
||||
size += ProtoSize::calc_uint32(1, this->interface_number);
|
||||
size += ProtoSize::calc_length(1, this->manufacturer.size());
|
||||
size += ProtoSize::calc_length(1, this->product.size());
|
||||
size += ProtoSize::calc_length(1, this->serial_number.size());
|
||||
return size;
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
bool BluetoothSetConnectionParamsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) {
|
||||
@@ -4290,5 +4353,41 @@ uint32_t BluetoothSetConnectionParamsResponse::calculate_size() const {
|
||||
return size;
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_ZIGBEE_PROXY
|
||||
bool ZigbeeProxyRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) {
|
||||
switch (field_id) {
|
||||
case 1:
|
||||
this->type = static_cast<enums::ZigbeeProxyRequestType>(value);
|
||||
break;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
bool ZigbeeProxyRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) {
|
||||
switch (field_id) {
|
||||
case 2: {
|
||||
this->data = value.data();
|
||||
this->data_len = value.size();
|
||||
break;
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
uint8_t *ZigbeeProxyRequest::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
|
||||
uint8_t *__restrict__ pos = buffer.get_pos();
|
||||
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast<uint32_t>(this->type));
|
||||
ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 2, this->data, this->data_len);
|
||||
return pos;
|
||||
}
|
||||
uint32_t ZigbeeProxyRequest::calculate_size() const {
|
||||
uint32_t size = 0;
|
||||
size += this->type ? 2 : 0;
|
||||
size += ProtoSize::calc_length(1, this->data_len);
|
||||
return size;
|
||||
}
|
||||
#endif
|
||||
|
||||
} // namespace esphome::api
|
||||
|
||||
@@ -23,6 +23,7 @@ enum SerialProxyPortType : uint32_t {
|
||||
SERIAL_PROXY_PORT_TYPE_TTL = 0,
|
||||
SERIAL_PROXY_PORT_TYPE_RS232 = 1,
|
||||
SERIAL_PROXY_PORT_TYPE_RS485 = 2,
|
||||
SERIAL_PROXY_PORT_TYPE_USB_SERIAL = 3,
|
||||
};
|
||||
enum EntityCategory : uint32_t {
|
||||
ENTITY_CATEGORY_NONE = 0,
|
||||
@@ -356,6 +357,7 @@ enum SerialProxyRequestType : uint32_t {
|
||||
SERIAL_PROXY_REQUEST_TYPE_FLUSH = 2,
|
||||
SERIAL_PROXY_REQUEST_TYPE_CONFIGURE = 3,
|
||||
SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS = 4,
|
||||
SERIAL_PROXY_REQUEST_TYPE_SET_MODE = 5,
|
||||
};
|
||||
enum SerialProxyStatus : uint32_t {
|
||||
SERIAL_PROXY_STATUS_OK = 0,
|
||||
@@ -366,6 +368,15 @@ enum SerialProxyStatus : uint32_t {
|
||||
SERIAL_PROXY_STATUS_PORT_IN_USE = 5,
|
||||
SERIAL_PROXY_STATUS_INVALID_ARGUMENT = 6,
|
||||
};
|
||||
enum SerialProxyMode : uint32_t {
|
||||
SERIAL_PROXY_MODE_RAW = 0,
|
||||
SERIAL_PROXY_MODE_PROTOCOL = 1,
|
||||
};
|
||||
#endif
|
||||
#ifdef USE_ZIGBEE_PROXY
|
||||
enum ZigbeeProxyRequestType : uint32_t {
|
||||
ZIGBEE_PROXY_REQUEST_TYPE_NETWORK_INFO = 0,
|
||||
};
|
||||
#endif
|
||||
|
||||
} // namespace enums
|
||||
@@ -549,7 +560,7 @@ class SerialProxyInfo final : public ProtoMessage {
|
||||
class DeviceInfoResponse final : public ProtoMessage {
|
||||
public:
|
||||
static constexpr uint16_t MESSAGE_TYPE = 10;
|
||||
static constexpr uint16_t ESTIMATED_SIZE = 312;
|
||||
static constexpr uint16_t ESTIMATED_SIZE = 322;
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
const LogString *message_name() const override { return LOG_STR("device_info_response"); }
|
||||
#endif
|
||||
@@ -607,6 +618,12 @@ class DeviceInfoResponse final : public ProtoMessage {
|
||||
#endif
|
||||
#ifdef USE_API_NOISE
|
||||
bool api_encryption_provisionable{false};
|
||||
#endif
|
||||
#ifdef USE_ZIGBEE_PROXY
|
||||
uint32_t zigbee_proxy_feature_flags{0};
|
||||
#endif
|
||||
#ifdef USE_ZIGBEE_PROXY
|
||||
uint64_t zigbee_ieee_address{0};
|
||||
#endif
|
||||
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
|
||||
uint32_t calculate_size() const;
|
||||
@@ -3403,6 +3420,62 @@ class SerialProxyRequestResponse final : public ProtoMessage {
|
||||
|
||||
protected:
|
||||
};
|
||||
class SerialProxySetModeRequest final : public ProtoDecodableMessage {
|
||||
public:
|
||||
static constexpr uint16_t MESSAGE_TYPE = 152;
|
||||
static constexpr uint8_t ESTIMATED_SIZE = 6;
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
const LogString *message_name() const override { return LOG_STR("serial_proxy_set_mode_request"); }
|
||||
#endif
|
||||
uint32_t instance{0};
|
||||
enums::SerialProxyMode mode{};
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
const char *dump_to(DumpBuffer &out) const override;
|
||||
#endif
|
||||
|
||||
protected:
|
||||
bool decode_varint(uint32_t field_id, proto_varint_value_t value) override;
|
||||
};
|
||||
class SerialProxyGetUsbInfoRequest final : public ProtoDecodableMessage {
|
||||
public:
|
||||
static constexpr uint16_t MESSAGE_TYPE = 153;
|
||||
static constexpr uint8_t ESTIMATED_SIZE = 4;
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
const LogString *message_name() const override { return LOG_STR("serial_proxy_get_usb_info_request"); }
|
||||
#endif
|
||||
uint32_t instance{0};
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
const char *dump_to(DumpBuffer &out) const override;
|
||||
#endif
|
||||
|
||||
protected:
|
||||
bool decode_varint(uint32_t field_id, proto_varint_value_t value) override;
|
||||
};
|
||||
class SerialProxyGetUsbInfoResponse final : public ProtoMessage {
|
||||
public:
|
||||
static constexpr uint16_t MESSAGE_TYPE = 154;
|
||||
static constexpr uint8_t ESTIMATED_SIZE = 51;
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
const LogString *message_name() const override { return LOG_STR("serial_proxy_get_usb_info_response"); }
|
||||
#endif
|
||||
uint32_t instance{0};
|
||||
enums::SerialProxyStatus status{};
|
||||
bool connected{false};
|
||||
uint32_t vendor_id{0};
|
||||
uint32_t product_id{0};
|
||||
uint32_t bcd_device{0};
|
||||
uint32_t interface_number{0};
|
||||
StringRef manufacturer{};
|
||||
StringRef product{};
|
||||
StringRef serial_number{};
|
||||
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
|
||||
uint32_t calculate_size() const;
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
const char *dump_to(DumpBuffer &out) const override;
|
||||
#endif
|
||||
|
||||
protected:
|
||||
};
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
class BluetoothSetConnectionParamsRequest final : public ProtoDecodableMessage {
|
||||
@@ -3442,5 +3515,27 @@ class BluetoothSetConnectionParamsResponse final : public ProtoMessage {
|
||||
protected:
|
||||
};
|
||||
#endif
|
||||
#ifdef USE_ZIGBEE_PROXY
|
||||
class ZigbeeProxyRequest final : public ProtoDecodableMessage {
|
||||
public:
|
||||
static constexpr uint16_t MESSAGE_TYPE = 155;
|
||||
static constexpr uint8_t ESTIMATED_SIZE = 21;
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
const LogString *message_name() const override { return LOG_STR("zigbee_proxy_request"); }
|
||||
#endif
|
||||
enums::ZigbeeProxyRequestType type{};
|
||||
const uint8_t *data{nullptr};
|
||||
uint16_t data_len{0};
|
||||
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
|
||||
uint32_t calculate_size() const;
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
const char *dump_to(DumpBuffer &out) const override;
|
||||
#endif
|
||||
|
||||
protected:
|
||||
bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override;
|
||||
bool decode_varint(uint32_t field_id, proto_varint_value_t value) override;
|
||||
};
|
||||
#endif
|
||||
|
||||
} // namespace esphome::api
|
||||
|
||||
@@ -3,10 +3,8 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
#if defined(USE_BLUETOOTH_PROXY) || defined(USE_BLUETOOTH_PROXY_CONNECTIONS)
|
||||
#ifndef USE_API_VARINT64
|
||||
#define USE_API_VARINT64
|
||||
#endif
|
||||
#endif
|
||||
|
||||
namespace esphome::api {} // namespace esphome::api
|
||||
|
||||
@@ -143,6 +143,8 @@ template<> const char *proto_enum_to_string<enums::SerialProxyPortType>(enums::S
|
||||
return ESPHOME_PSTR("SERIAL_PROXY_PORT_TYPE_RS232");
|
||||
case enums::SERIAL_PROXY_PORT_TYPE_RS485:
|
||||
return ESPHOME_PSTR("SERIAL_PROXY_PORT_TYPE_RS485");
|
||||
case enums::SERIAL_PROXY_PORT_TYPE_USB_SERIAL:
|
||||
return ESPHOME_PSTR("SERIAL_PROXY_PORT_TYPE_USB_SERIAL");
|
||||
default:
|
||||
return ESPHOME_PSTR("UNKNOWN");
|
||||
}
|
||||
@@ -854,6 +856,8 @@ template<> const char *proto_enum_to_string<enums::SerialProxyRequestType>(enums
|
||||
return ESPHOME_PSTR("SERIAL_PROXY_REQUEST_TYPE_CONFIGURE");
|
||||
case enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS:
|
||||
return ESPHOME_PSTR("SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS");
|
||||
case enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODE:
|
||||
return ESPHOME_PSTR("SERIAL_PROXY_REQUEST_TYPE_SET_MODE");
|
||||
default:
|
||||
return ESPHOME_PSTR("UNKNOWN");
|
||||
}
|
||||
@@ -878,6 +882,26 @@ template<> const char *proto_enum_to_string<enums::SerialProxyStatus>(enums::Ser
|
||||
return ESPHOME_PSTR("UNKNOWN");
|
||||
}
|
||||
}
|
||||
template<> const char *proto_enum_to_string<enums::SerialProxyMode>(enums::SerialProxyMode value) {
|
||||
switch (value) {
|
||||
case enums::SERIAL_PROXY_MODE_RAW:
|
||||
return ESPHOME_PSTR("SERIAL_PROXY_MODE_RAW");
|
||||
case enums::SERIAL_PROXY_MODE_PROTOCOL:
|
||||
return ESPHOME_PSTR("SERIAL_PROXY_MODE_PROTOCOL");
|
||||
default:
|
||||
return ESPHOME_PSTR("UNKNOWN");
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_ZIGBEE_PROXY
|
||||
template<> const char *proto_enum_to_string<enums::ZigbeeProxyRequestType>(enums::ZigbeeProxyRequestType value) {
|
||||
switch (value) {
|
||||
case enums::ZIGBEE_PROXY_REQUEST_TYPE_NETWORK_INFO:
|
||||
return ESPHOME_PSTR("ZIGBEE_PROXY_REQUEST_TYPE_NETWORK_INFO");
|
||||
default:
|
||||
return ESPHOME_PSTR("UNKNOWN");
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
const char *HelloRequest::dump_to(DumpBuffer &out) const {
|
||||
@@ -1008,6 +1032,12 @@ const char *DeviceInfoResponse::dump_to(DumpBuffer &out) const {
|
||||
#endif
|
||||
#ifdef USE_API_NOISE
|
||||
dump_field(out, ESPHOME_PSTR("api_encryption_provisionable"), this->api_encryption_provisionable);
|
||||
#endif
|
||||
#ifdef USE_ZIGBEE_PROXY
|
||||
dump_field(out, ESPHOME_PSTR("zigbee_proxy_feature_flags"), this->zigbee_proxy_feature_flags);
|
||||
#endif
|
||||
#ifdef USE_ZIGBEE_PROXY
|
||||
dump_field(out, ESPHOME_PSTR("zigbee_ieee_address"), this->zigbee_ieee_address);
|
||||
#endif
|
||||
return out.c_str();
|
||||
}
|
||||
@@ -2805,6 +2835,31 @@ const char *SerialProxyRequestResponse::dump_to(DumpBuffer &out) const {
|
||||
dump_field(out, ESPHOME_PSTR("error_message"), this->error_message);
|
||||
return out.c_str();
|
||||
}
|
||||
const char *SerialProxySetModeRequest::dump_to(DumpBuffer &out) const {
|
||||
MessageDumpHelper helper(out, ESPHOME_PSTR("SerialProxySetModeRequest"));
|
||||
dump_field(out, ESPHOME_PSTR("instance"), this->instance);
|
||||
dump_field(out, ESPHOME_PSTR("mode"), static_cast<enums::SerialProxyMode>(this->mode));
|
||||
return out.c_str();
|
||||
}
|
||||
const char *SerialProxyGetUsbInfoRequest::dump_to(DumpBuffer &out) const {
|
||||
MessageDumpHelper helper(out, ESPHOME_PSTR("SerialProxyGetUsbInfoRequest"));
|
||||
dump_field(out, ESPHOME_PSTR("instance"), this->instance);
|
||||
return out.c_str();
|
||||
}
|
||||
const char *SerialProxyGetUsbInfoResponse::dump_to(DumpBuffer &out) const {
|
||||
MessageDumpHelper helper(out, ESPHOME_PSTR("SerialProxyGetUsbInfoResponse"));
|
||||
dump_field(out, ESPHOME_PSTR("instance"), this->instance);
|
||||
dump_field(out, ESPHOME_PSTR("status"), static_cast<enums::SerialProxyStatus>(this->status));
|
||||
dump_field(out, ESPHOME_PSTR("connected"), this->connected);
|
||||
dump_field(out, ESPHOME_PSTR("vendor_id"), this->vendor_id);
|
||||
dump_field(out, ESPHOME_PSTR("product_id"), this->product_id);
|
||||
dump_field(out, ESPHOME_PSTR("bcd_device"), this->bcd_device);
|
||||
dump_field(out, ESPHOME_PSTR("interface_number"), this->interface_number);
|
||||
dump_field(out, ESPHOME_PSTR("manufacturer"), this->manufacturer);
|
||||
dump_field(out, ESPHOME_PSTR("product"), this->product);
|
||||
dump_field(out, ESPHOME_PSTR("serial_number"), this->serial_number);
|
||||
return out.c_str();
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
const char *BluetoothSetConnectionParamsRequest::dump_to(DumpBuffer &out) const {
|
||||
@@ -2823,6 +2878,14 @@ const char *BluetoothSetConnectionParamsResponse::dump_to(DumpBuffer &out) const
|
||||
return out.c_str();
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_ZIGBEE_PROXY
|
||||
const char *ZigbeeProxyRequest::dump_to(DumpBuffer &out) const {
|
||||
MessageDumpHelper helper(out, ESPHOME_PSTR("ZigbeeProxyRequest"));
|
||||
dump_field(out, ESPHOME_PSTR("type"), static_cast<enums::ZigbeeProxyRequestType>(this->type));
|
||||
dump_bytes_field(out, ESPHOME_PSTR("data"), this->data, this->data_len);
|
||||
return out.c_str();
|
||||
}
|
||||
#endif
|
||||
|
||||
} // namespace esphome::api
|
||||
|
||||
|
||||
@@ -712,6 +712,39 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
|
||||
this->on_device_capabilities_request();
|
||||
break;
|
||||
}
|
||||
#ifdef USE_SERIAL_PROXY
|
||||
case SerialProxySetModeRequest::MESSAGE_TYPE: {
|
||||
SerialProxySetModeRequest msg;
|
||||
msg.decode(msg_data, msg_size);
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
this->log_receive_message_(LOG_STR("on_serial_proxy_set_mode_request"), msg);
|
||||
#endif
|
||||
this->on_serial_proxy_set_mode_request(msg);
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_SERIAL_PROXY
|
||||
case SerialProxyGetUsbInfoRequest::MESSAGE_TYPE: {
|
||||
SerialProxyGetUsbInfoRequest msg;
|
||||
msg.decode(msg_data, msg_size);
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
this->log_receive_message_(LOG_STR("on_serial_proxy_get_usb_info_request"), msg);
|
||||
#endif
|
||||
this->on_serial_proxy_get_usb_info_request(msg);
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_ZIGBEE_PROXY
|
||||
case ZigbeeProxyRequest::MESSAGE_TYPE: {
|
||||
ZigbeeProxyRequest msg;
|
||||
msg.decode(msg_data, msg_size);
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
this->log_receive_message_(LOG_STR("on_zigbee_proxy_request"), msg);
|
||||
#endif
|
||||
this->on_zigbee_proxy_request(msg);
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -235,9 +235,20 @@ class APIServerConnectionBase {
|
||||
void on_serial_proxy_request(const SerialProxyRequest &value){};
|
||||
#endif
|
||||
|
||||
#ifdef USE_SERIAL_PROXY
|
||||
void on_serial_proxy_set_mode_request(const SerialProxySetModeRequest &value){};
|
||||
#endif
|
||||
#ifdef USE_SERIAL_PROXY
|
||||
void on_serial_proxy_get_usb_info_request(const SerialProxyGetUsbInfoRequest &value){};
|
||||
#endif
|
||||
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
void on_bluetooth_set_connection_params_request(const BluetoothSetConnectionParamsRequest &value){};
|
||||
#endif
|
||||
|
||||
#ifdef USE_ZIGBEE_PROXY
|
||||
void on_zigbee_proxy_request(const ZigbeeProxyRequest &value){};
|
||||
#endif
|
||||
};
|
||||
|
||||
} // namespace esphome::api
|
||||
|
||||
@@ -404,6 +404,14 @@ void APIServer::on_zwave_proxy_request(const ZWaveProxyRequest &msg) {
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_ZIGBEE_PROXY
|
||||
void APIServer::on_zigbee_proxy_request(const ZigbeeProxyRequest &msg) {
|
||||
// Very infrequent and small - send to all clients rather than tracking a subscription
|
||||
for (auto &c : this->active_clients())
|
||||
c->send_message(msg);
|
||||
}
|
||||
#endif
|
||||
|
||||
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
|
||||
void APIServer::send_infrared_rf_receive_event([[maybe_unused]] uint32_t device_id, uint32_t key,
|
||||
const std::vector<int32_t> *timings) {
|
||||
|
||||
@@ -189,6 +189,9 @@ class APIServer final : public Component,
|
||||
#ifdef USE_ZWAVE_PROXY
|
||||
void on_zwave_proxy_request(const ZWaveProxyRequest &msg);
|
||||
#endif
|
||||
#ifdef USE_ZIGBEE_PROXY
|
||||
void on_zigbee_proxy_request(const ZigbeeProxyRequest &msg);
|
||||
#endif
|
||||
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
|
||||
void send_infrared_rf_receive_event(uint32_t device_id, uint32_t key, const std::vector<int32_t> *timings);
|
||||
#endif
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -18,9 +18,10 @@ from esphome import pins
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import uart
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID, CONF_NAME
|
||||
from esphome.const import CONF_ID, CONF_NAME, CONF_UART_ID
|
||||
from esphome.core import CORE, coroutine_with_priority
|
||||
from esphome.coroutine import CoroPriority
|
||||
import esphome.final_validate as fv
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@kbx81"]
|
||||
@@ -30,14 +31,18 @@ MULTI_CONF = True
|
||||
|
||||
serial_proxy_ns = cg.esphome_ns.namespace("serial_proxy")
|
||||
SerialProxy = serial_proxy_ns.class_("SerialProxy", cg.Component, uart.UARTDevice)
|
||||
SerialProxyTap = serial_proxy_ns.class_("SerialProxyTap")
|
||||
|
||||
api_enums_ns = cg.esphome_ns.namespace("api").namespace("enums")
|
||||
SerialProxyPortType = api_enums_ns.enum("SerialProxyPortType")
|
||||
# User-selectable electrical types. USB_SERIAL is deliberately absent: it is derived
|
||||
# from the uart_id pointing at a usb_uart channel, never set by the user.
|
||||
SERIAL_PROXY_PORT_TYPES = {
|
||||
"TTL": SerialProxyPortType.SERIAL_PROXY_PORT_TYPE_TTL,
|
||||
"RS232": SerialProxyPortType.SERIAL_PROXY_PORT_TYPE_RS232,
|
||||
"RS485": SerialProxyPortType.SERIAL_PROXY_PORT_TYPE_RS485,
|
||||
}
|
||||
PORT_TYPE_USB_SERIAL = SerialProxyPortType.SERIAL_PROXY_PORT_TYPE_USB_SERIAL
|
||||
|
||||
CONF_DTR_PIN = "dtr_pin"
|
||||
CONF_PORT_TYPE = "port_type"
|
||||
@@ -62,7 +67,7 @@ CONFIG_SCHEMA = (
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(SerialProxy),
|
||||
cv.Required(CONF_NAME): cv.string_strict,
|
||||
cv.Required(CONF_PORT_TYPE): cv.enum(SERIAL_PROXY_PORT_TYPES, upper=True),
|
||||
cv.Optional(CONF_PORT_TYPE): cv.enum(SERIAL_PROXY_PORT_TYPES, upper=True),
|
||||
cv.Optional(CONF_RTS_PIN): pins.gpio_output_pin_schema,
|
||||
cv.Optional(CONF_DTR_PIN): pins.gpio_output_pin_schema,
|
||||
}
|
||||
@@ -72,6 +77,26 @@ CONFIG_SCHEMA = (
|
||||
)
|
||||
|
||||
|
||||
def _uses_usb_uart(config: ConfigType, full_config: ConfigType) -> bool:
|
||||
from esphome.components.usb_uart import is_usb_uart_channel
|
||||
|
||||
return is_usb_uart_channel(config[CONF_UART_ID], full_config)
|
||||
|
||||
|
||||
def _final_validate(config: ConfigType) -> ConfigType:
|
||||
if _uses_usb_uart(config, fv.full_config.get()):
|
||||
if CONF_PORT_TYPE in config:
|
||||
raise cv.Invalid(
|
||||
f"{CONF_PORT_TYPE} is set automatically for USB serial ports"
|
||||
)
|
||||
elif CONF_PORT_TYPE not in config:
|
||||
raise cv.Invalid(f"{CONF_PORT_TYPE} is required")
|
||||
return config
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = _final_validate
|
||||
|
||||
|
||||
@coroutine_with_priority(CoroPriority.FINAL)
|
||||
async def _add_serial_proxy_count_define() -> None:
|
||||
"""Emit the SERIAL_PROXY_COUNT define once with the final instance count."""
|
||||
@@ -86,7 +111,13 @@ async def to_code(config: ConfigType) -> None:
|
||||
await uart.register_uart_device(var, config)
|
||||
cg.add(cg.App.register_serial_proxy(var))
|
||||
cg.add(var.set_name(config[CONF_NAME]))
|
||||
cg.add(var.set_port_type(config[CONF_PORT_TYPE]))
|
||||
if _uses_usb_uart(config, CORE.config):
|
||||
cg.add(var.set_port_type(PORT_TYPE_USB_SERIAL))
|
||||
channel = await cg.get_variable(config[CONF_UART_ID])
|
||||
cg.add(var.set_usb_channel(channel))
|
||||
cg.add_define("USE_SERIAL_PROXY_USB_INFO")
|
||||
else:
|
||||
cg.add(var.set_port_type(config[CONF_PORT_TYPE]))
|
||||
cg.add_define("USE_SERIAL_PROXY")
|
||||
|
||||
# Track instance count for the FINAL priority define
|
||||
|
||||
@@ -12,6 +12,10 @@
|
||||
#include "esphome/components/api/api_server.h"
|
||||
#endif
|
||||
|
||||
#ifdef USE_SERIAL_PROXY_USB_INFO
|
||||
#include "esphome/components/usb_uart/usb_uart.h"
|
||||
#endif
|
||||
|
||||
namespace esphome::serial_proxy {
|
||||
|
||||
static const char *const TAG = "serial_proxy";
|
||||
@@ -29,26 +33,57 @@ void SerialProxy::setup() {
|
||||
#ifdef USE_API
|
||||
// instance_index_ is fixed at registration time; pre-set it so loop() only needs to update data
|
||||
this->outgoing_msg_.instance = this->instance_index_;
|
||||
#endif
|
||||
#ifdef USE_SERIAL_PROXY_TAP
|
||||
// A tap sets itself up before this runs (its setup priority is higher), so it may
|
||||
// already be waiting on the port -- a boot-time handshake with the device, say. Leaving
|
||||
// the loop enabled is what lets that finish; without it the tap would stall until a
|
||||
// client happened to subscribe.
|
||||
if (this->tap_ != nullptr && this->tap_->tap_needs_port()) {
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
// No subscriber at startup; disable loop until a client subscribes
|
||||
this->disable_loop();
|
||||
}
|
||||
|
||||
void SerialProxy::loop() {
|
||||
#ifdef USE_API
|
||||
// Safety check — loop should only run when subscribed, but guard against races
|
||||
if (this->api_connection_ == nullptr) [[unlikely]] {
|
||||
this->disable_loop();
|
||||
#ifdef USE_SERIAL_PROXY_TAP
|
||||
void SerialProxy::reset_mode_() {
|
||||
// The mode belongs to a session, not to the port. Carrying a departed client's choice
|
||||
// over to the next one would inject protocol bytes into a stream that never asked for
|
||||
// them -- a firmware upload, or any client built before this request existed and so
|
||||
// unable to turn it off. Guessing RAW is the safe direction: a client that wanted
|
||||
// protocol handling and did not ask for it merely sends its own acknowledgements.
|
||||
if (this->mode_ == api::enums::SERIAL_PROXY_MODE_RAW) {
|
||||
return;
|
||||
}
|
||||
ESP_LOGD(TAG, "Session ended, returning serial proxy [%" PRIu32 "] to RAW mode", this->instance_index_);
|
||||
this->mode_ = api::enums::SERIAL_PROXY_MODE_RAW;
|
||||
}
|
||||
#endif
|
||||
|
||||
void SerialProxy::loop() {
|
||||
#ifdef USE_API
|
||||
// Detect subscriber disconnect
|
||||
if (this->api_connection_->is_marked_for_removal() || !this->api_connection_->is_connection_setup() ||
|
||||
!api_is_connected()) {
|
||||
if (this->api_connection_ != nullptr && (this->api_connection_->is_marked_for_removal() ||
|
||||
!this->api_connection_->is_connection_setup() || !api_is_connected())) {
|
||||
ESP_LOGW(TAG, "Subscriber disconnected");
|
||||
this->api_connection_ = nullptr;
|
||||
this->reset_mode_();
|
||||
}
|
||||
|
||||
// With no subscriber there is normally nothing to do, but a tap may still need the port
|
||||
// read -- it does its protocol work precisely while nobody else is listening.
|
||||
if (this->api_connection_ == nullptr) [[unlikely]] {
|
||||
#ifdef USE_SERIAL_PROXY_TAP
|
||||
if (this->tap_ == nullptr || !this->tap_->tap_needs_port()) {
|
||||
this->disable_loop();
|
||||
return;
|
||||
}
|
||||
#else
|
||||
this->disable_loop();
|
||||
return;
|
||||
#endif
|
||||
}
|
||||
|
||||
// Read available data from UART and forward to subscribed client
|
||||
@@ -69,11 +104,54 @@ void __attribute__((noinline)) SerialProxy::read_and_send_(size_t available) {
|
||||
if (!this->read_array(buffer, to_read))
|
||||
return;
|
||||
|
||||
#ifdef USE_SERIAL_PROXY_TAP
|
||||
// Before forwarding, so a tap that answers the device (an acknowledgement, say) is not
|
||||
// waiting on the network round trip to a subscriber that may not even exist.
|
||||
if (this->tap_observing_()) {
|
||||
this->tap_->on_device_rx(buffer, to_read);
|
||||
}
|
||||
#endif
|
||||
|
||||
if (this->api_connection_ == nullptr) {
|
||||
return;
|
||||
}
|
||||
this->outgoing_msg_.set_data(buffer, to_read);
|
||||
this->api_connection_->send_serial_proxy_data(this->outgoing_msg_);
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_SERIAL_PROXY_TAP
|
||||
|
||||
bool SerialProxy::tap_observing_() const {
|
||||
if (this->tap_ == nullptr) {
|
||||
return false;
|
||||
}
|
||||
// With no subscriber, a tap doing its own protocol work (the boot-time handshake with
|
||||
// the device, say) is served regardless of mode -- nobody has chosen one yet. Once a
|
||||
// subscriber holds the port, the mode alone decides, so RAW stays inert.
|
||||
if (this->api_connection_ == nullptr && this->tap_->tap_needs_port()) {
|
||||
return true;
|
||||
}
|
||||
// Otherwise the mode decides. RAW must be inert: a client that flips to RAW before
|
||||
// flashing firmware is entitled to a byte pipe with nothing injecting protocol bytes
|
||||
// into it, and "the tap turned out not to recognise the stream" is not good enough.
|
||||
return this->mode_ == api::enums::SERIAL_PROXY_MODE_PROTOCOL;
|
||||
}
|
||||
|
||||
void SerialProxy::tap_pump() {
|
||||
#ifdef USE_API
|
||||
// Nothing would consume the bytes; leave them in the FIFO
|
||||
if (!this->tap_observing_() && this->api_connection_ == nullptr) {
|
||||
return;
|
||||
}
|
||||
const size_t available = this->available();
|
||||
if (available > 0) {
|
||||
this->read_and_send_(available);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
|
||||
void SerialProxy::dump_config() {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"Serial Proxy [%" PRIu32 "]:\n"
|
||||
@@ -82,9 +160,10 @@ void SerialProxy::dump_config() {
|
||||
" RTS Pin: %s\n"
|
||||
" DTR Pin: %s",
|
||||
this->instance_index_, this->name_ != nullptr ? this->name_ : "",
|
||||
this->port_type_ == api::enums::SERIAL_PROXY_PORT_TYPE_RS485 ? LOG_STR_LITERAL("RS485")
|
||||
: this->port_type_ == api::enums::SERIAL_PROXY_PORT_TYPE_RS232 ? LOG_STR_LITERAL("RS232")
|
||||
: LOG_STR_LITERAL("TTL"),
|
||||
this->port_type_ == api::enums::SERIAL_PROXY_PORT_TYPE_RS485 ? LOG_STR_LITERAL("RS485")
|
||||
: this->port_type_ == api::enums::SERIAL_PROXY_PORT_TYPE_RS232 ? LOG_STR_LITERAL("RS232")
|
||||
: this->port_type_ == api::enums::SERIAL_PROXY_PORT_TYPE_USB_SERIAL ? LOG_STR_LITERAL("USB_SERIAL")
|
||||
: LOG_STR_LITERAL("TTL"),
|
||||
this->rts_pin_ != nullptr ? LOG_STR_LITERAL("configured") : LOG_STR_LITERAL("not configured"),
|
||||
this->dtr_pin_ != nullptr ? LOG_STR_LITERAL("configured") : LOG_STR_LITERAL("not configured"));
|
||||
}
|
||||
@@ -92,8 +171,9 @@ void SerialProxy::dump_config() {
|
||||
SerialProxyResult SerialProxy::configure(api::APIConnection *api_connection, uint32_t baudrate, bool flow_control,
|
||||
uint8_t parity, uint8_t stop_bits, uint8_t data_size) {
|
||||
#ifdef USE_API
|
||||
if (this->port_claimed_by_other_(api_connection)) {
|
||||
ESP_LOGW(TAG, "Ignoring configure request from client without port access [%" PRIu32 "]", this->instance_index_);
|
||||
if (!this->is_subscriber_(api_connection)) {
|
||||
ESP_LOGW(TAG, "Ignoring configure request from client without port subscription [%" PRIu32 "]",
|
||||
this->instance_index_);
|
||||
return SerialProxyResult::SERIAL_PROXY_RESULT_PORT_IN_USE;
|
||||
}
|
||||
#endif
|
||||
@@ -159,24 +239,80 @@ SerialProxyResult SerialProxy::configure(api::APIConnection *api_connection, uin
|
||||
return SerialProxyResult::SERIAL_PROXY_RESULT_OK;
|
||||
}
|
||||
|
||||
SerialProxyResult SerialProxy::set_mode_from_client(api::APIConnection *api_connection,
|
||||
api::enums::SerialProxyMode mode) {
|
||||
#ifdef USE_API
|
||||
// Only the live subscriber may change the mode, so the mode cannot outlive a session
|
||||
if (!this->is_subscriber_(api_connection)) {
|
||||
ESP_LOGW(TAG, "Ignoring mode request from client without port subscription [%" PRIu32 "]", this->instance_index_);
|
||||
return SerialProxyResult::SERIAL_PROXY_RESULT_PORT_IN_USE;
|
||||
}
|
||||
#endif
|
||||
// Values come from a remote client
|
||||
if (mode != api::enums::SERIAL_PROXY_MODE_RAW && mode != api::enums::SERIAL_PROXY_MODE_PROTOCOL) {
|
||||
ESP_LOGW(TAG, "Invalid mode: %" PRIu32, static_cast<uint32_t>(mode));
|
||||
return SerialProxyResult::SERIAL_PROXY_RESULT_INVALID_ARGUMENT;
|
||||
}
|
||||
// PROTOCOL on a port with no tap would be a silent no-op; refuse so the client knows
|
||||
#ifdef USE_SERIAL_PROXY_TAP
|
||||
const bool has_tap = this->tap_ != nullptr;
|
||||
#else
|
||||
const bool has_tap = false;
|
||||
#endif
|
||||
if (mode == api::enums::SERIAL_PROXY_MODE_PROTOCOL && !has_tap) {
|
||||
ESP_LOGW(TAG, "No tap on serial proxy [%" PRIu32 "]; PROTOCOL mode unavailable", this->instance_index_);
|
||||
return SerialProxyResult::SERIAL_PROXY_RESULT_NOT_SUPPORTED;
|
||||
}
|
||||
ESP_LOGD(TAG, "Serial proxy [%" PRIu32 "] mode set to %s", this->instance_index_,
|
||||
mode == api::enums::SERIAL_PROXY_MODE_PROTOCOL ? LOG_STR_LITERAL("PROTOCOL") : LOG_STR_LITERAL("RAW"));
|
||||
#ifdef USE_SERIAL_PROXY_TAP
|
||||
const bool leaving_protocol_mode =
|
||||
this->mode_ != api::enums::SERIAL_PROXY_MODE_RAW && mode == api::enums::SERIAL_PROXY_MODE_RAW;
|
||||
this->mode_ = mode;
|
||||
|
||||
// Only for an explicit client request, not for reset_mode_() at the end of a session:
|
||||
// an ordinary disconnect says nothing about the device, whereas a client deliberately
|
||||
// asking for raw bytes usually precedes changing what the device is.
|
||||
if (leaving_protocol_mode && this->tap_ != nullptr) {
|
||||
this->tap_->on_protocol_disabled();
|
||||
}
|
||||
#endif
|
||||
return SerialProxyResult::SERIAL_PROXY_RESULT_OK;
|
||||
}
|
||||
|
||||
void SerialProxy::write_from_client(api::APIConnection *api_connection, const uint8_t *data, size_t len) {
|
||||
#ifdef USE_API
|
||||
// Bytes from a client other than the live subscriber would interleave with the
|
||||
// subscriber's traffic on the wire
|
||||
if (this->port_claimed_by_other_(api_connection)) {
|
||||
ESP_LOGW(TAG, "Ignoring write from client without port access [%" PRIu32 "]", this->instance_index_);
|
||||
// Bytes from anyone but the live subscriber would interleave with the subscriber's
|
||||
// traffic -- or with an active tap's -- on the wire
|
||||
if (!this->is_subscriber_(api_connection)) {
|
||||
if (this->api_connection_ != nullptr) {
|
||||
ESP_LOGW(TAG, "Ignoring write from client that does not hold serial proxy [%" PRIu32 "]", this->instance_index_);
|
||||
} else {
|
||||
// A legacy client streaming writes without subscribing would flood WARN, one per
|
||||
// request; writes are the only high-rate, unacknowledged operation, so keep this
|
||||
// visible without drowning the log
|
||||
ESP_LOGV(TAG, "Ignoring write from client without port subscription [%" PRIu32 "]", this->instance_index_);
|
||||
}
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
if (data == nullptr || len == 0)
|
||||
return;
|
||||
this->write_array(data, len);
|
||||
|
||||
#ifdef USE_SERIAL_PROXY_TAP
|
||||
// After the write, so the tap observes the same ordering the device does
|
||||
if (this->tap_observing_()) {
|
||||
this->tap_->on_client_tx(data, len);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
SerialProxyResult SerialProxy::set_modem_pins(api::APIConnection *api_connection, uint32_t line_states) {
|
||||
#ifdef USE_API
|
||||
if (this->port_claimed_by_other_(api_connection)) {
|
||||
ESP_LOGW(TAG, "Ignoring modem pin request from client without port access [%" PRIu32 "]", this->instance_index_);
|
||||
if (!this->is_subscriber_(api_connection)) {
|
||||
ESP_LOGW(TAG, "Ignoring modem pin request from client without port subscription [%" PRIu32 "]",
|
||||
this->instance_index_);
|
||||
return SerialProxyResult::SERIAL_PROXY_RESULT_PORT_IN_USE;
|
||||
}
|
||||
#endif
|
||||
@@ -202,6 +338,27 @@ SerialProxyResult SerialProxy::set_modem_pins(api::APIConnection *api_connection
|
||||
return SerialProxyResult::SERIAL_PROXY_RESULT_OK;
|
||||
}
|
||||
|
||||
#if defined(USE_SERIAL_PROXY_USB_INFO) && defined(USE_API)
|
||||
void SerialProxy::get_usb_info(usb_host::UsbDeviceInfo &info, api::SerialProxyGetUsbInfoResponse &resp) const {
|
||||
if (this->usb_channel_ == nullptr) {
|
||||
resp.status = api::enums::SERIAL_PROXY_STATUS_NOT_SUPPORTED;
|
||||
return;
|
||||
}
|
||||
resp.interface_number = this->usb_channel_->get_index();
|
||||
if (!this->usb_channel_->get_parent()->get_device_info(info)) {
|
||||
// No device attached right now; not an error
|
||||
return;
|
||||
}
|
||||
resp.connected = true;
|
||||
resp.vendor_id = info.vendor_id;
|
||||
resp.product_id = info.product_id;
|
||||
resp.bcd_device = info.bcd_device;
|
||||
resp.manufacturer = StringRef(info.manufacturer);
|
||||
resp.product = StringRef(info.product);
|
||||
resp.serial_number = StringRef(info.serial_number);
|
||||
}
|
||||
#endif
|
||||
|
||||
uint32_t SerialProxy::get_modem_pins() const {
|
||||
return (this->rts_state_ ? static_cast<uint32_t>(SERIAL_PROXY_LINE_STATE_FLAG_RTS) : 0u) |
|
||||
(this->dtr_state_ ? static_cast<uint32_t>(SERIAL_PROXY_LINE_STATE_FLAG_DTR) : 0u);
|
||||
@@ -210,8 +367,8 @@ uint32_t SerialProxy::get_modem_pins() const {
|
||||
SerialProxyResult SerialProxy::flush_port(api::APIConnection *api_connection) {
|
||||
#ifdef USE_API
|
||||
// Flushing stalls the port, so it gets the same ownership check as writes
|
||||
if (this->port_claimed_by_other_(api_connection)) {
|
||||
ESP_LOGW(TAG, "Ignoring flush from client without port access [%" PRIu32 "]", this->instance_index_);
|
||||
if (!this->is_subscriber_(api_connection)) {
|
||||
ESP_LOGW(TAG, "Ignoring flush from client without port subscription [%" PRIu32 "]", this->instance_index_);
|
||||
return SerialProxyResult::SERIAL_PROXY_RESULT_PORT_IN_USE;
|
||||
}
|
||||
#endif
|
||||
@@ -230,11 +387,6 @@ SerialProxyResult SerialProxy::flush_port(api::APIConnection *api_connection) {
|
||||
}
|
||||
|
||||
#ifdef USE_API
|
||||
bool SerialProxy::port_claimed_by_other_(api::APIConnection *api_connection) const {
|
||||
return this->api_connection_ != nullptr && this->api_connection_ != api_connection &&
|
||||
this->api_connection_->is_connection_setup();
|
||||
}
|
||||
|
||||
SerialProxyResult SerialProxy::serial_proxy_request(api::APIConnection *api_connection,
|
||||
api::enums::SerialProxyRequestType type) {
|
||||
switch (type) {
|
||||
@@ -252,6 +404,10 @@ SerialProxyResult SerialProxy::serial_proxy_request(api::APIConnection *api_conn
|
||||
return SerialProxyResult::SERIAL_PROXY_RESULT_PORT_IN_USE;
|
||||
}
|
||||
ESP_LOGW(TAG, "Previous subscriber disconnected; taking over subscription");
|
||||
// End the dead client's session before starting the new one, so its mode
|
||||
// cannot leak into a session that never asked for it
|
||||
this->api_connection_ = nullptr;
|
||||
this->reset_mode_();
|
||||
}
|
||||
this->api_connection_ = api_connection;
|
||||
this->enable_loop();
|
||||
@@ -264,7 +420,15 @@ SerialProxyResult SerialProxy::serial_proxy_request(api::APIConnection *api_conn
|
||||
return SerialProxyResult::SERIAL_PROXY_RESULT_OK;
|
||||
}
|
||||
this->api_connection_ = nullptr;
|
||||
this->reset_mode_();
|
||||
#ifdef USE_SERIAL_PROXY_TAP
|
||||
// Keep the loop alive for a tap that still needs the port (mirrors loop())
|
||||
if (this->tap_ == nullptr || !this->tap_->tap_needs_port()) {
|
||||
this->disable_loop();
|
||||
}
|
||||
#else
|
||||
this->disable_loop();
|
||||
#endif
|
||||
ESP_LOGV(TAG, "API connection unsubscribed from serial proxy [%" PRIu32 "]", this->instance_index_);
|
||||
return SerialProxyResult::SERIAL_PROXY_RESULT_OK;
|
||||
default:
|
||||
|
||||
@@ -20,12 +20,22 @@
|
||||
#include "esphome/components/api/api_pb2.h"
|
||||
#endif
|
||||
|
||||
#ifdef USE_SERIAL_PROXY_USB_INFO
|
||||
namespace esphome::usb_uart {
|
||||
class USBUartChannel;
|
||||
} // namespace esphome::usb_uart
|
||||
namespace esphome::usb_host {
|
||||
struct UsbDeviceInfo;
|
||||
} // namespace esphome::usb_host
|
||||
#endif
|
||||
|
||||
// Forward-declare types needed outside the USE_API guard.
|
||||
namespace esphome::api {
|
||||
class APIConnection;
|
||||
namespace enums {
|
||||
enum SerialProxyPortType : uint32_t;
|
||||
enum SerialProxyRequestType : uint32_t;
|
||||
enum SerialProxyMode : uint32_t;
|
||||
} // namespace enums
|
||||
} // namespace esphome::api
|
||||
|
||||
@@ -52,6 +62,36 @@ enum class SerialProxyResult : uint8_t {
|
||||
/// Maximum bytes to read from UART in a single loop iteration
|
||||
inline constexpr size_t SERIAL_PROXY_MAX_READ_SIZE = 256;
|
||||
|
||||
#ifdef USE_SERIAL_PROXY_TAP
|
||||
/// Observes a port's traffic without owning it, and may inject bytes of its own.
|
||||
///
|
||||
/// This exists so protocol-aware behaviour can be layered onto a plain byte pipe without
|
||||
/// the pipe knowing anything about the protocol: the tap is compiled in only when some
|
||||
/// component asks for one, so a proxy carrying an RS485 meter pays nothing for it.
|
||||
///
|
||||
/// A tap is an observer, never a gatekeeper -- it cannot suppress or alter the bytes
|
||||
/// flowing in either direction, so a misbehaving tap cannot corrupt the stream.
|
||||
class SerialProxyTap {
|
||||
public:
|
||||
/// Bytes read from the device, before they are forwarded to any subscriber.
|
||||
virtual void on_device_rx(const uint8_t *data, size_t len) = 0;
|
||||
|
||||
/// Bytes a subscriber sent towards the device, after they have been written.
|
||||
virtual void on_client_tx(const uint8_t *data, size_t len) = 0;
|
||||
|
||||
/// True when the port must keep reading even with no subscriber attached, so a tap can
|
||||
/// do its own protocol work while nobody is listening. Honoured only while no
|
||||
/// subscriber holds the port; with one attached, the port mode alone decides.
|
||||
virtual bool tap_needs_port() const = 0;
|
||||
|
||||
/// A client explicitly turned protocol handling off for this port. Distinct from the
|
||||
/// automatic reset when a session ends: this one means a client intends to do something
|
||||
/// else with the device -- reflash it, most likely -- so anything the tap believes about
|
||||
/// it should be treated as suspect.
|
||||
virtual void on_protocol_disabled() = 0;
|
||||
};
|
||||
#endif
|
||||
|
||||
class SerialProxy final : public uart::UARTDevice, public Component {
|
||||
public:
|
||||
void setup() override;
|
||||
@@ -77,6 +117,9 @@ class SerialProxy final : public uart::UARTDevice, public Component {
|
||||
/// Get the port type
|
||||
api::enums::SerialProxyPortType get_port_type() const { return this->port_type_; }
|
||||
|
||||
/// Handle a mode change requested by an API client
|
||||
SerialProxyResult set_mode_from_client(api::APIConnection *api_connection, api::enums::SerialProxyMode mode);
|
||||
|
||||
/// Configure UART parameters and apply them
|
||||
/// @param api_connection The API connection requesting the change
|
||||
/// @param baudrate Baud rate in bits per second
|
||||
@@ -121,13 +164,78 @@ class SerialProxy final : public uart::UARTDevice, public Component {
|
||||
/// Set the DTR GPIO pin (from YAML configuration)
|
||||
void set_dtr_pin(GPIOPin *pin) { this->dtr_pin_ = pin; }
|
||||
|
||||
#ifdef USE_SERIAL_PROXY_USB_INFO
|
||||
/// Attach the USB UART channel behind this port (from code generation)
|
||||
void set_usb_channel(usb_uart::USBUartChannel *channel) { this->usb_channel_ = channel; }
|
||||
|
||||
#ifdef USE_API
|
||||
/// Fill a USB info response for this port. The response's strings are views into
|
||||
/// info, so info must outlive the send.
|
||||
void get_usb_info(usb_host::UsbDeviceInfo &info, api::SerialProxyGetUsbInfoResponse &resp) const;
|
||||
#endif
|
||||
#endif
|
||||
|
||||
#ifdef USE_SERIAL_PROXY_TAP
|
||||
/// Attach a traffic observer. At most one, set once at setup time.
|
||||
void set_tap(SerialProxyTap *tap) { this->tap_ = tap; }
|
||||
|
||||
/// Write bytes originating from the tap rather than from a client. Bypasses the
|
||||
/// subscriber ownership check, but only while the tap is being served bytes -- so a
|
||||
/// port in RAW mode with a subscriber attached stays inert. Returns false when the
|
||||
/// bytes were dropped for that reason.
|
||||
bool write_from_tap(const uint8_t *data, size_t len) {
|
||||
if (!this->tap_observing_()) {
|
||||
return false;
|
||||
}
|
||||
this->write_array(data, len);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Whether the tap is currently being served bytes. Can flip false with no callback
|
||||
/// (a subscriber attaching in RAW mode, say), so a tap should check before starting
|
||||
/// protocol work and when a reply seems overdue.
|
||||
bool tap_is_observed() const { return this->tap_observing_(); }
|
||||
|
||||
/// Resume reading after a tap's needs change. loop() disables itself when there is
|
||||
/// neither a subscriber nor a tap that wants the port, so a tap starting fresh work
|
||||
/// must ask for it back. Must be called from the main loop.
|
||||
void tap_request_port() { this->enable_loop(); }
|
||||
|
||||
/// Whether the underlying device is present. On a USB UART this tracks enumeration, so
|
||||
/// a tap can notice the device being unplugged and plugged back in.
|
||||
bool is_device_connected() const { return this->parent_->is_connected(); }
|
||||
|
||||
/// Run one read-and-dispatch cycle immediately. Lets a tap make progress before the
|
||||
/// main loop is running -- during setup, for instance, while a component is still
|
||||
/// blocking on can_proceed(). Must not be called from on_device_rx() or
|
||||
/// on_client_tx(): each nested cycle costs a 256-byte stack frame.
|
||||
void tap_pump();
|
||||
#endif
|
||||
|
||||
protected:
|
||||
#ifdef USE_API
|
||||
/// Read from UART and send to API client (slow path with 256-byte stack buffer)
|
||||
/// Read from UART, hand the bytes to any tap, and forward them to a subscriber
|
||||
/// (slow path with a 256-byte stack buffer)
|
||||
void read_and_send_(size_t available);
|
||||
|
||||
/// True when a live subscriber other than the given connection holds the port
|
||||
bool port_claimed_by_other_(api::APIConnection *api_connection) const;
|
||||
/// True when the given connection is the live subscriber. Every port operation
|
||||
/// (write, configure, modem pins, flush, mode) requires this, so an unsubscribed
|
||||
/// client can never share the wire with the subscriber or an active tap.
|
||||
bool is_subscriber_(api::APIConnection *api_connection) const { return this->api_connection_ == api_connection; }
|
||||
#endif
|
||||
|
||||
#ifdef USE_SERIAL_PROXY_TAP
|
||||
/// Return the port to RAW when a subscriber goes away, so the mode never outlives it
|
||||
void reset_mode_();
|
||||
#else
|
||||
/// Without a tap, PROTOCOL is refused, so the mode is fixed at RAW and there is
|
||||
/// nothing to reset
|
||||
void reset_mode_() {}
|
||||
#endif
|
||||
|
||||
#ifdef USE_SERIAL_PROXY_TAP
|
||||
/// True when the tap should be shown the traffic passing through this port
|
||||
bool tap_observing_() const;
|
||||
#endif
|
||||
|
||||
/// Instance index for identifying this proxy in API messages
|
||||
@@ -147,6 +255,11 @@ class SerialProxy final : public uart::UARTDevice, public Component {
|
||||
/// Port type
|
||||
api::enums::SerialProxyPortType port_type_{};
|
||||
|
||||
#ifdef USE_SERIAL_PROXY_TAP
|
||||
/// How the bytes passing through are treated; zero is SERIAL_PROXY_MODE_RAW
|
||||
api::enums::SerialProxyMode mode_{};
|
||||
#endif
|
||||
|
||||
/// Optional GPIO pins for modem control
|
||||
GPIOPin *rts_pin_{nullptr};
|
||||
GPIOPin *dtr_pin_{nullptr};
|
||||
@@ -154,6 +267,15 @@ class SerialProxy final : public uart::UARTDevice, public Component {
|
||||
/// Current modem pin states
|
||||
bool rts_state_{false};
|
||||
bool dtr_state_{false};
|
||||
|
||||
#ifdef USE_SERIAL_PROXY_TAP
|
||||
SerialProxyTap *tap_{nullptr};
|
||||
#endif
|
||||
|
||||
#ifdef USE_SERIAL_PROXY_USB_INFO
|
||||
/// The USB UART channel behind this port; nullptr on non-USB ports
|
||||
usb_uart::USBUartChannel *usb_channel_{nullptr};
|
||||
#endif
|
||||
};
|
||||
|
||||
} // namespace esphome::serial_proxy
|
||||
|
||||
@@ -117,6 +117,20 @@ struct UsbEvent {
|
||||
|
||||
// callback function type.
|
||||
|
||||
// USB string descriptors hold at most 126 characters; one more for the terminator
|
||||
static constexpr size_t DESC_STRING_BUF_SIZE = 128;
|
||||
|
||||
/// Identity of a connected USB device, copied out of the descriptors the USB host
|
||||
/// stack caches for the lifetime of the connection
|
||||
struct UsbDeviceInfo {
|
||||
uint16_t vendor_id;
|
||||
uint16_t product_id;
|
||||
uint16_t bcd_device;
|
||||
char manufacturer[DESC_STRING_BUF_SIZE];
|
||||
char product[DESC_STRING_BUF_SIZE];
|
||||
char serial_number[DESC_STRING_BUF_SIZE];
|
||||
};
|
||||
|
||||
enum ClientState {
|
||||
USB_CLIENT_INIT = 0,
|
||||
USB_CLIENT_OPEN,
|
||||
@@ -144,6 +158,10 @@ class USBClient : public Component {
|
||||
bool control_transfer(uint8_t type, uint8_t request, uint16_t value, uint16_t index, const transfer_cb_t &callback,
|
||||
const std::vector<uint8_t> &data = {});
|
||||
|
||||
/// Copy the connected device's identity out of the cached USB descriptors.
|
||||
/// Returns false when no device is connected.
|
||||
bool get_device_info(UsbDeviceInfo &info) const;
|
||||
|
||||
// Lock-free event queue and pool for USB task to main loop communication
|
||||
// Must be public for access from static callbacks
|
||||
LockFreeQueue<UsbEvent, USB_EVENT_QUEUE_SIZE> event_queue;
|
||||
|
||||
@@ -143,10 +143,8 @@ static void usb_client_print_config_descriptor(const usb_config_desc_t *cfg_desc
|
||||
} while (next_desc != NULL);
|
||||
}
|
||||
#endif
|
||||
// USB string descriptors: bLength (uint8_t, max 255) includes the 2-byte header (bLength and bDescriptorType).
|
||||
// Character count = (bLength - 2) / 2, max 126 chars + null terminator.
|
||||
static constexpr size_t DESC_STRING_BUF_SIZE = 128;
|
||||
|
||||
// bLength (uint8_t, max 255) includes the 2-byte header (bLength and bDescriptorType),
|
||||
// so character count = (bLength - 2) / 2.
|
||||
static const char *get_descriptor_string(const usb_str_desc_t *desc, std::span<char, DESC_STRING_BUF_SIZE> buffer) {
|
||||
if (desc == nullptr || desc->bLength < 2)
|
||||
return "(unspecified)";
|
||||
@@ -162,6 +160,41 @@ static const char *get_descriptor_string(const usb_str_desc_t *desc, std::span<c
|
||||
return buffer.data();
|
||||
}
|
||||
|
||||
// A missing descriptor copies as an empty string, unlike the "(unspecified)"
|
||||
// placeholder the logging helper above uses
|
||||
static void copy_descriptor_string(const usb_str_desc_t *desc, std::span<char, DESC_STRING_BUF_SIZE> buffer) {
|
||||
buffer[0] = '\0';
|
||||
if (desc == nullptr || desc->bLength < 2)
|
||||
return;
|
||||
int char_count = (desc->bLength - 2) / 2;
|
||||
char *p = buffer.data();
|
||||
char *end = p + buffer.size() - 1;
|
||||
for (int i = 0; i != char_count && p < end; i++) {
|
||||
auto c = desc->wData[i];
|
||||
if (c < 0x100)
|
||||
*p++ = static_cast<char>(c);
|
||||
}
|
||||
*p = '\0';
|
||||
}
|
||||
|
||||
bool USBClient::get_device_info(UsbDeviceInfo &info) const {
|
||||
if (this->state_ != USB_CLIENT_CONNECTED)
|
||||
return false;
|
||||
const usb_device_desc_t *desc;
|
||||
if (usb_host_get_device_descriptor(this->device_handle_, &desc) != ESP_OK)
|
||||
return false;
|
||||
info.vendor_id = desc->idVendor;
|
||||
info.product_id = desc->idProduct;
|
||||
info.bcd_device = desc->bcdDevice;
|
||||
usb_device_info_t dev_info;
|
||||
if (usb_host_device_info(this->device_handle_, &dev_info) != ESP_OK)
|
||||
return false;
|
||||
copy_descriptor_string(dev_info.str_desc_manufacturer, info.manufacturer);
|
||||
copy_descriptor_string(dev_info.str_desc_product, info.product);
|
||||
copy_descriptor_string(dev_info.str_desc_serial_num, info.serial_number);
|
||||
return true;
|
||||
}
|
||||
|
||||
// CALLBACK CONTEXT: USB task (called from usb_host_client_handle_events in USB task)
|
||||
static void client_event_cb(const usb_host_client_event_msg_t *event_msg, void *ptr) {
|
||||
auto *client = static_cast<USBClient *>(ptr);
|
||||
|
||||
@@ -16,7 +16,7 @@ from esphome.const import (
|
||||
CONF_DUMMY_RECEIVER,
|
||||
CONF_ID,
|
||||
)
|
||||
from esphome.core import CORE
|
||||
from esphome.core import CORE, ID
|
||||
from esphome.cpp_types import Component
|
||||
from esphome.types import ConfigType
|
||||
|
||||
@@ -27,6 +27,16 @@ usb_uart_ns = cg.esphome_ns.namespace("usb_uart")
|
||||
USBUartComponent = usb_uart_ns.class_("USBUartComponent", Component)
|
||||
USBUartChannel = usb_uart_ns.class_("USBUartChannel", UARTComponent)
|
||||
|
||||
|
||||
def is_usb_uart_channel(uart_id: ID, full_config: ConfigType) -> bool:
|
||||
"""Return True if the given ID refers to a channel of a configured usb_uart device."""
|
||||
return any(
|
||||
channel[CONF_ID] == uart_id
|
||||
for device in full_config.get("usb_uart") or []
|
||||
for channel in device[CONF_CHANNELS]
|
||||
)
|
||||
|
||||
|
||||
UARTParityOptions = usb_uart_ns.enum("UARTParityOptions")
|
||||
UART_PARITY_OPTIONS = {
|
||||
"NONE": UARTParityOptions.UART_CONFIG_PARITY_NONE,
|
||||
|
||||
@@ -164,6 +164,9 @@ class USBUartChannelBase : public uart::UARTComponent, public Parented<USBUartCo
|
||||
/// they arrive, eliminating one full main-loop-wakeup cycle of latency.
|
||||
void set_rx_callback(std::function<void()> cb) { this->rx_callback_ = std::move(cb); }
|
||||
|
||||
/// Channel index on the bridge (interface number on multi-port bridges)
|
||||
uint8_t get_index() const { return this->index_; }
|
||||
|
||||
protected:
|
||||
// Not directly instantiable; construct a concrete channel type instead.
|
||||
USBUartChannelBase(uint8_t index, uint16_t buffer_size) : input_buffer_(RingBuffer(buffer_size)), index_(index) {}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import serial_proxy
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_BUFFER_SIZE, CONF_ID, CONF_POWER_SAVE_MODE, CONF_WIFI
|
||||
import esphome.final_validate as fv
|
||||
|
||||
CODEOWNERS = ["@kbx81"]
|
||||
DEPENDENCIES = ["api", "serial_proxy"]
|
||||
|
||||
CONF_INITIAL_TIMEOUT = "initial_timeout"
|
||||
CONF_MIN_TIMEOUT = "min_timeout"
|
||||
CONF_MAX_TIMEOUT = "max_timeout"
|
||||
CONF_SERIAL_PROXY_ID = "serial_proxy_id"
|
||||
|
||||
# Default ACK timeout values for the boot-time metadata harvest
|
||||
_DEFAULT_INITIAL_TIMEOUT = 1600
|
||||
_DEFAULT_MIN_TIMEOUT = 400
|
||||
_DEFAULT_MAX_TIMEOUT = 3200
|
||||
|
||||
zigbee_proxy_ns = cg.esphome_ns.namespace("zigbee_proxy")
|
||||
ZigbeeProxy = zigbee_proxy_ns.class_(
|
||||
"ZigbeeProxy", cg.Component, serial_proxy.SerialProxyTap
|
||||
)
|
||||
|
||||
|
||||
def final_validate(config):
|
||||
full_config = fv.full_config.get()
|
||||
if (wifi_conf := full_config.get(CONF_WIFI)) and (
|
||||
wifi_conf.get(CONF_POWER_SAVE_MODE, "").lower() != "none"
|
||||
):
|
||||
raise cv.Invalid(
|
||||
f"{CONF_WIFI} {CONF_POWER_SAVE_MODE} must be set to 'none' when using Zigbee proxy"
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(ZigbeeProxy),
|
||||
cv.Required(CONF_SERIAL_PROXY_ID): cv.use_id(serial_proxy.SerialProxy),
|
||||
cv.Optional(CONF_BUFFER_SIZE): cv.SplitDefault(
|
||||
cv.int_range(min=256, max=2048),
|
||||
esp8266=512,
|
||||
default=1024,
|
||||
),
|
||||
cv.Optional(
|
||||
CONF_INITIAL_TIMEOUT, default=_DEFAULT_INITIAL_TIMEOUT
|
||||
): cv.int_range(min=10, max=10000),
|
||||
cv.Optional(CONF_MIN_TIMEOUT, default=_DEFAULT_MIN_TIMEOUT): cv.int_range(
|
||||
min=10, max=5000
|
||||
),
|
||||
cv.Optional(CONF_MAX_TIMEOUT, default=_DEFAULT_MAX_TIMEOUT): cv.int_range(
|
||||
min=50, max=10000
|
||||
),
|
||||
}
|
||||
).extend(cv.COMPONENT_SCHEMA),
|
||||
)
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = final_validate
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
|
||||
sp = await cg.get_variable(config[CONF_SERIAL_PROXY_ID])
|
||||
cg.add(var.set_serial_proxy(sp))
|
||||
|
||||
cg.add_define("USE_ZIGBEE_PROXY")
|
||||
# Compiles the tap interface into serial_proxy; without it the port is a plain byte pipe
|
||||
cg.add_define("USE_SERIAL_PROXY_TAP")
|
||||
|
||||
# Set buffer size via define for compile-time allocation
|
||||
if CONF_BUFFER_SIZE in config:
|
||||
cg.add_define("ZIGBEE_PROXY_BUFFER_SIZE", config[CONF_BUFFER_SIZE])
|
||||
|
||||
cg.add(var.set_initial_timeout(config[CONF_INITIAL_TIMEOUT]))
|
||||
cg.add(var.set_min_timeout(config[CONF_MIN_TIMEOUT]))
|
||||
cg.add(var.set_max_timeout(config[CONF_MAX_TIMEOUT]))
|
||||
@@ -0,0 +1,256 @@
|
||||
#include "ash_detector.h"
|
||||
|
||||
#ifdef USE_ZIGBEE_PROXY
|
||||
|
||||
namespace esphome::zigbee_proxy {
|
||||
|
||||
// Control byte of an RSTACK, and the only ASH version byte that can follow it
|
||||
static constexpr uint8_t ASH_RSTACK_CONTROL = 0xC1;
|
||||
static constexpr uint8_t ASH_PROTOCOL_VERSION = 0x02;
|
||||
static constexpr size_t ASH_RSTACK_BODY_SIZE = 3; // control, version, reset code
|
||||
static constexpr size_t ASH_CRC_SIZE = 2;
|
||||
// Smallest legal frame on the wire: a bare control byte plus its CRC
|
||||
static constexpr size_t ASH_MIN_FRAME_SIZE = 1 + ASH_CRC_SIZE;
|
||||
|
||||
// The opening EZSP version command is a constant: control 0x00 (frmNum 0, ackNum 0)
|
||||
// followed by [seq=0][frameControl=0][frameId=0] randomized by 0x42 0x21 0xA8. Only the
|
||||
// requested version varies, as version ^ 0x54, so it can be recovered for free.
|
||||
static constexpr uint8_t EZSP_VERSION_CMD_PREFIX[] = {0x00, 0x42, 0x21, 0xA8};
|
||||
static constexpr size_t EZSP_VERSION_CMD_SIZE = 5;
|
||||
static constexpr uint8_t EZSP_VERSION_RANDOM_MASK = 0x54;
|
||||
|
||||
// Consecutive frames we could not accept, with neither a good frame nor a retransmission
|
||||
// in between, before concluding the peer is no longer speaking ASH. A real ASH peer must
|
||||
// retransmit an unacknowledged frame, so the absence of one is the positive evidence
|
||||
// here -- garbage on the line is not, since noise proves nothing either way.
|
||||
static constexpr uint8_t MAX_UNCONFIRMED_REJECTS = 4;
|
||||
|
||||
bool ash_reset_code_is_known(uint8_t code) {
|
||||
switch (code) {
|
||||
case 0x00: // RESET_UNKNOWN
|
||||
case 0x01: // RESET_EXTERNAL
|
||||
case 0x02: // RESET_POWER_ON
|
||||
case 0x03: // RESET_WATCHDOG
|
||||
case 0x06: // RESET_ASSERT
|
||||
case 0x09: // RESET_BOOTLOADER
|
||||
case 0x0B: // RESET_SOFTWARE
|
||||
case 0x51: // ERROR_EXCEEDED_MAXIMUM_ACK_TIMEOUT_COUNT
|
||||
case 0x80: // ERROR_CHIP_SPECIFIC
|
||||
case 0x81: // RESET_CHIP_SPECIFIC
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
void AshFrameScanner::begin_frame_() {
|
||||
this->index_ = 0;
|
||||
this->crc_ = ASH_CRC_INIT;
|
||||
this->escaped_ = false;
|
||||
this->poisoned_ = false;
|
||||
}
|
||||
|
||||
void AshFrameScanner::reset() {
|
||||
this->begin_frame_();
|
||||
this->frame_length_ = 0;
|
||||
this->discarding_ = false;
|
||||
}
|
||||
|
||||
ScanResult AshFrameScanner::feed(uint8_t byte) {
|
||||
if (byte == ASH_FLAG_BYTE) {
|
||||
// Snapshot everything the verdict depends on: begin_frame_() clears all of it.
|
||||
const bool discarding = this->discarding_;
|
||||
const bool poisoned = this->poisoned_;
|
||||
const bool escaped = this->escaped_;
|
||||
const size_t index = this->index_;
|
||||
const uint16_t crc = this->crc_;
|
||||
// A FLAG always starts the next frame afresh, whatever preceded it
|
||||
this->begin_frame_();
|
||||
this->discarding_ = false;
|
||||
|
||||
if (discarding || index == 0) {
|
||||
// Consecutive delimiters carry no frame at all, so there is nothing to judge
|
||||
this->frame_length_ = 0;
|
||||
return ScanResult::NONE;
|
||||
}
|
||||
// Running the CRC over the body *and* its trailing CRC bytes leaves zero when
|
||||
// correct, so validity needs no second pass over the frame.
|
||||
if (poisoned || escaped || index < ASH_MIN_FRAME_SIZE || crc != 0) {
|
||||
this->frame_length_ = 0;
|
||||
return ScanResult::INVALID;
|
||||
}
|
||||
this->frame_length_ = index - ASH_CRC_SIZE;
|
||||
return ScanResult::FRAME;
|
||||
}
|
||||
|
||||
if (this->discarding_) {
|
||||
return ScanResult::NONE;
|
||||
}
|
||||
|
||||
switch (byte) {
|
||||
case ASH_CANCEL_BYTE:
|
||||
// Everything received since the last FLAG is to be ignored
|
||||
this->begin_frame_();
|
||||
return ScanResult::NONE;
|
||||
|
||||
case ASH_SUBSTITUTE_BYTE:
|
||||
// A low-level error was flagged; ignore everything up to the next FLAG
|
||||
this->discarding_ = true;
|
||||
return ScanResult::NONE;
|
||||
|
||||
case ASH_XON_BYTE:
|
||||
case ASH_XOFF_BYTE:
|
||||
// Transport flow control, not frame content: skip it without disturbing the frame
|
||||
return ScanResult::NONE;
|
||||
|
||||
case ASH_ESCAPE_BYTE:
|
||||
this->escaped_ = true;
|
||||
return ScanResult::NONE;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
uint8_t value = byte;
|
||||
if (this->escaped_) {
|
||||
this->escaped_ = false;
|
||||
value = byte ^ ASH_XOR_BYTE;
|
||||
// An escape must decode to a reserved byte; anything else is not ASH framing at all
|
||||
if (!ash_is_reserved(value)) {
|
||||
this->poisoned_ = true;
|
||||
return ScanResult::NONE;
|
||||
}
|
||||
}
|
||||
|
||||
if (this->index_ >= sizeof(this->buffer_)) {
|
||||
this->poisoned_ = true;
|
||||
return ScanResult::NONE;
|
||||
}
|
||||
|
||||
this->buffer_[this->index_++] = value;
|
||||
this->crc_ = ash_crc16(&value, 1, this->crc_);
|
||||
return ScanResult::NONE;
|
||||
}
|
||||
|
||||
void AshDetector::reset() {
|
||||
this->ncp_scanner_.reset();
|
||||
this->host_scanner_.reset();
|
||||
this->state_ = AshDetectState::IDLE;
|
||||
this->rx_sequence_ = 0;
|
||||
this->ack_owed_ = false;
|
||||
this->data_frame_ready_ = false;
|
||||
this->unconfirmed_rejects_ = 0;
|
||||
this->negotiated_version_ = 0;
|
||||
}
|
||||
|
||||
void AshDetector::from_ncp(uint8_t byte) {
|
||||
this->data_frame_ready_ = false;
|
||||
switch (this->ncp_scanner_.feed(byte)) {
|
||||
case ScanResult::FRAME:
|
||||
this->handle_ncp_frame_();
|
||||
break;
|
||||
case ScanResult::INVALID:
|
||||
// A delimited chunk that is not a frame. While armed this may be a corrupted ASH
|
||||
// frame, which the peer will retransmit, or a sign the peer stopped speaking ASH.
|
||||
// reject_() distinguishes the two by whether a retransmission ever arrives.
|
||||
this->reject_();
|
||||
break;
|
||||
case ScanResult::NONE:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void AshDetector::handle_ncp_frame_() {
|
||||
const uint8_t *body = this->ncp_scanner_.frame();
|
||||
const size_t length = this->ncp_scanner_.length();
|
||||
const uint8_t control = body[0];
|
||||
|
||||
// RSTACK is the only way into the handshake, and the only way back after a firmware
|
||||
// swap: a Spinel or bootloader NCP never emits one, so those stay unarmed forever.
|
||||
if (control == ASH_RSTACK_CONTROL) {
|
||||
if (length == ASH_RSTACK_BODY_SIZE && body[1] == ASH_PROTOCOL_VERSION && ash_reset_code_is_known(body[2])) {
|
||||
this->state_ = AshDetectState::SAW_RSTACK;
|
||||
this->rx_sequence_ = 0;
|
||||
this->ack_owed_ = false;
|
||||
this->unconfirmed_rejects_ = 0;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (this->state_ != AshDetectState::ARMED) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ((control & 0x80) != 0) {
|
||||
return; // ACK/NAK/RST/ERROR: nothing is owed for these
|
||||
}
|
||||
|
||||
const uint8_t frame_num = (control >> 4) & ASH_MAX_SEQUENCE;
|
||||
const bool re_tx = (control & 0x08) != 0;
|
||||
|
||||
if (frame_num != this->rx_sequence_) {
|
||||
// A retransmission still proves the peer is speaking ASH even though we cannot use
|
||||
// this copy, so it clears the suspicion without being acknowledged.
|
||||
if (re_tx) {
|
||||
this->unconfirmed_rejects_ = 0;
|
||||
} else {
|
||||
this->reject_();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this->rx_sequence_ = (this->rx_sequence_ + 1) & ASH_MAX_SEQUENCE;
|
||||
this->pending_ack_ = this->rx_sequence_;
|
||||
this->ack_owed_ = true;
|
||||
this->data_frame_ready_ = true;
|
||||
this->unconfirmed_rejects_ = 0;
|
||||
}
|
||||
|
||||
void AshDetector::reject_() {
|
||||
if (this->state_ != AshDetectState::ARMED) {
|
||||
return;
|
||||
}
|
||||
if (++this->unconfirmed_rejects_ >= MAX_UNCONFIRMED_REJECTS) {
|
||||
this->state_ = AshDetectState::IDLE;
|
||||
this->unconfirmed_rejects_ = 0;
|
||||
}
|
||||
}
|
||||
|
||||
void AshDetector::from_host(uint8_t byte) {
|
||||
if (this->host_scanner_.feed(byte) != ScanResult::FRAME) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this->state_ != AshDetectState::SAW_RSTACK) {
|
||||
return;
|
||||
}
|
||||
|
||||
const uint8_t *body = this->host_scanner_.frame();
|
||||
if (this->host_scanner_.length() != EZSP_VERSION_CMD_SIZE) {
|
||||
return;
|
||||
}
|
||||
for (size_t i = 0; i < sizeof(EZSP_VERSION_CMD_PREFIX); i++) {
|
||||
if (body[i] != EZSP_VERSION_CMD_PREFIX[i]) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this->negotiated_version_ = body[4] ^ EZSP_VERSION_RANDOM_MASK;
|
||||
this->state_ = AshDetectState::ARMED;
|
||||
this->rx_sequence_ = 0;
|
||||
this->ack_owed_ = false;
|
||||
this->unconfirmed_rejects_ = 0;
|
||||
}
|
||||
|
||||
bool AshDetector::take_pending_ack(uint8_t &ack_num) {
|
||||
if (!this->ack_owed_) {
|
||||
return false;
|
||||
}
|
||||
this->ack_owed_ = false;
|
||||
ack_num = this->pending_ack_;
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace esphome::zigbee_proxy
|
||||
|
||||
#endif // USE_ZIGBEE_PROXY
|
||||
@@ -0,0 +1,118 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
#ifdef USE_ZIGBEE_PROXY
|
||||
|
||||
#include "ash_protocol.h"
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
namespace esphome::zigbee_proxy {
|
||||
|
||||
// Decides when it is safe to acknowledge NCP frames on a client's behalf.
|
||||
//
|
||||
// The client suppresses its own ACKs, so nobody else will send them, and injecting ASH
|
||||
// bytes into a stream that is not ASH would corrupt it. Detection is therefore one-sided:
|
||||
// arm only on the session handshake, which is a fixed byte string, and never on frame
|
||||
// validity, which non-ASH traffic can satisfy by luck.
|
||||
//
|
||||
// RSTACK (NCP -> host) c1 02 <reset_code> <crc> 7e
|
||||
// version (host -> NCP) 00 42 21 a8 <version^0x54> <crc> 7e
|
||||
//
|
||||
// Requiring both, in that order, in opposite directions cannot be satisfied by a
|
||||
// unidirectional byte stream whatever it contains -- which is exactly the situation
|
||||
// during a firmware upload. Verified against real .gbl images and real Spinel traffic:
|
||||
// zero false arms, and neither pattern occurs even as a substring.
|
||||
//
|
||||
// Getting it wrong in the other direction is cheap: a frame we decline to acknowledge is
|
||||
// retransmitted by the NCP, so we see a clean copy and lose only the ack timeout. That
|
||||
// asymmetry is why this errs towards silence everywhere.
|
||||
|
||||
enum class AshDetectState : uint8_t {
|
||||
IDLE, // Not ASH, or not yet proven to be
|
||||
SAW_RSTACK, // Handshake half-complete; watching for the version command
|
||||
ARMED, // Session confirmed; acknowledging on the client's behalf
|
||||
};
|
||||
|
||||
enum class ScanResult : uint8_t {
|
||||
NONE, // Mid-frame, or a delimiter that carried nothing
|
||||
FRAME, // frame()/length() hold a complete body with a verified CRC
|
||||
INVALID, // A delimited chunk arrived but was not a well-formed ASH frame
|
||||
};
|
||||
|
||||
// Reassembles one direction of the byte stream into unstuffed, CRC-checked frames.
|
||||
// Mirrors bellows' AshProtocol.data_received: FLAG ends a frame, CANCEL discards what
|
||||
// precedes it, SUBSTITUTE poisons everything up to the next FLAG, and XON/XOFF are
|
||||
// transport flow control removed without disturbing the frame around them.
|
||||
class AshFrameScanner {
|
||||
public:
|
||||
ScanResult feed(uint8_t byte);
|
||||
void reset();
|
||||
|
||||
// Valid only until the next feed() call, which begins overwriting the buffer.
|
||||
const uint8_t *frame() const { return this->buffer_; }
|
||||
size_t length() const { return this->frame_length_; }
|
||||
|
||||
private:
|
||||
void begin_frame_();
|
||||
|
||||
// Frames are bounded by the ASH maximum, so a stream carrying no delimiters cannot
|
||||
// grow the buffer without limit; it just keeps failing.
|
||||
uint8_t buffer_[MAX_ASH_FRAME_SIZE];
|
||||
size_t index_{0}; // accumulation position for the frame being read
|
||||
size_t frame_length_{0}; // body length of the last completed frame
|
||||
uint16_t crc_{ASH_CRC_INIT};
|
||||
bool escaped_{false};
|
||||
bool discarding_{false};
|
||||
bool poisoned_{false};
|
||||
};
|
||||
|
||||
class AshDetector {
|
||||
public:
|
||||
void reset();
|
||||
|
||||
// Feed observed traffic. Neither call gates forwarding: the detector only watches.
|
||||
void from_ncp(uint8_t byte);
|
||||
void from_host(uint8_t byte);
|
||||
|
||||
bool armed() const { return this->state_ == AshDetectState::ARMED; }
|
||||
|
||||
// True only while the host direction can affect the state machine, i.e. while waiting
|
||||
// for the version command. Lets the caller skip scanning that direction entirely the
|
||||
// rest of the time -- it is the one carrying firmware uploads.
|
||||
bool needs_host_scan() const { return this->state_ == AshDetectState::SAW_RSTACK; }
|
||||
|
||||
// An acknowledgement became owed after the last from_ncp() call. Clears the flag.
|
||||
bool take_pending_ack(uint8_t &ack_num);
|
||||
|
||||
// The EZSP frame carried by the DATA frame just accepted, for metadata sniffing. The
|
||||
// ASH control byte is skipped, so offset 0 is the EZSP sequence number. Still
|
||||
// randomized, and valid only until the next from_ncp() call.
|
||||
const uint8_t *last_ezsp_frame() const { return this->ncp_scanner_.frame() + 1; }
|
||||
size_t last_ezsp_frame_length() const {
|
||||
const size_t length = this->ncp_scanner_.length();
|
||||
return length > 0 ? length - 1 : 0;
|
||||
}
|
||||
|
||||
AshDetectState state() const { return this->state_; }
|
||||
uint8_t negotiated_version() const { return this->negotiated_version_; }
|
||||
|
||||
protected:
|
||||
void handle_ncp_frame_();
|
||||
void reject_();
|
||||
|
||||
AshFrameScanner ncp_scanner_;
|
||||
AshFrameScanner host_scanner_;
|
||||
AshDetectState state_{AshDetectState::IDLE};
|
||||
uint8_t rx_sequence_{0};
|
||||
uint8_t pending_ack_{0};
|
||||
bool ack_owed_{false};
|
||||
bool data_frame_ready_{false};
|
||||
uint8_t unconfirmed_rejects_{0};
|
||||
uint8_t negotiated_version_{0};
|
||||
};
|
||||
|
||||
} // namespace esphome::zigbee_proxy
|
||||
|
||||
#endif // USE_ZIGBEE_PROXY
|
||||
@@ -0,0 +1,444 @@
|
||||
#include "zigbee_proxy.h"
|
||||
|
||||
#ifdef USE_ZIGBEE_PROXY
|
||||
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
namespace esphome::zigbee_proxy {
|
||||
|
||||
static const char *const TAG = "zigbee_proxy";
|
||||
|
||||
static constexpr size_t ASH_MAX_LOG_BYTES = 168; // Cap verbose hex dumps (168 * 3 = 504 byte buffer)
|
||||
|
||||
// CRC-CCITT lookup table for polynomial 0x1021 (x^16 + x^12 + x^5 + 1)
|
||||
static const uint16_t CRC_TABLE[256] = {
|
||||
0x0000, 0x1021, 0x2042, 0x3063, 0x4084, 0x50A5, 0x60C6, 0x70E7, 0x8108, 0x9129, 0xA14A, 0xB16B, 0xC18C, 0xD1AD,
|
||||
0xE1CE, 0xF1EF, 0x1231, 0x0210, 0x3273, 0x2252, 0x52B5, 0x4294, 0x72F7, 0x62D6, 0x9339, 0x8318, 0xB37B, 0xA35A,
|
||||
0xD3BD, 0xC39C, 0xF3FF, 0xE3DE, 0x2462, 0x3443, 0x0420, 0x1401, 0x64E6, 0x74C7, 0x44A4, 0x5485, 0xA56A, 0xB54B,
|
||||
0x8528, 0x9509, 0xE5EE, 0xF5CF, 0xC5AC, 0xD58D, 0x3653, 0x2672, 0x1611, 0x0630, 0x76D7, 0x66F6, 0x5695, 0x46B4,
|
||||
0xB75B, 0xA77A, 0x9719, 0x8738, 0xF7DF, 0xE7FE, 0xD79D, 0xC7BC, 0x48C4, 0x58E5, 0x6886, 0x78A7, 0x0840, 0x1861,
|
||||
0x2802, 0x3823, 0xC9CC, 0xD9ED, 0xE98E, 0xF9AF, 0x8948, 0x9969, 0xA90A, 0xB92B, 0x5AF5, 0x4AD4, 0x7AB7, 0x6A96,
|
||||
0x1A71, 0x0A50, 0x3A33, 0x2A12, 0xDBFD, 0xCBDC, 0xFBBF, 0xEB9E, 0x9B79, 0x8B58, 0xBB3B, 0xAB1A, 0x6CA6, 0x7C87,
|
||||
0x4CE4, 0x5CC5, 0x2C22, 0x3C03, 0x0C60, 0x1C41, 0xEDAE, 0xFD8F, 0xCDEC, 0xDDCD, 0xAD2A, 0xBD0B, 0x8D68, 0x9D49,
|
||||
0x7E97, 0x6EB6, 0x5ED5, 0x4EF4, 0x3E13, 0x2E32, 0x1E51, 0x0E70, 0xFF9F, 0xEFBE, 0xDFDD, 0xCFFC, 0xBF1B, 0xAF3A,
|
||||
0x9F59, 0x8F78, 0x9188, 0x81A9, 0xB1CA, 0xA1EB, 0xD10C, 0xC12D, 0xF14E, 0xE16F, 0x1080, 0x00A1, 0x30C2, 0x20E3,
|
||||
0x5004, 0x4025, 0x7046, 0x6067, 0x83B9, 0x9398, 0xA3FB, 0xB3DA, 0xC33D, 0xD31C, 0xE37F, 0xF35E, 0x02B1, 0x1290,
|
||||
0x22F3, 0x32D2, 0x4235, 0x5214, 0x6277, 0x7256, 0xB5EA, 0xA5CB, 0x95A8, 0x8589, 0xF56E, 0xE54F, 0xD52C, 0xC50D,
|
||||
0x34E2, 0x24C3, 0x14A0, 0x0481, 0x7466, 0x6447, 0x5424, 0x4405, 0xA7DB, 0xB7FA, 0x8799, 0x97B8, 0xE75F, 0xF77E,
|
||||
0xC71D, 0xD73C, 0x26D3, 0x36F2, 0x0691, 0x16B0, 0x6657, 0x7676, 0x4615, 0x5634, 0xD94C, 0xC96D, 0xF90E, 0xE92F,
|
||||
0x99C8, 0x89E9, 0xB98A, 0xA9AB, 0x5844, 0x4865, 0x7806, 0x6827, 0x18C0, 0x08E1, 0x3882, 0x28A3, 0xCB7D, 0xDB5C,
|
||||
0xEB3F, 0xFB1E, 0x8BF9, 0x9BD8, 0xABBB, 0xBB9A, 0x4A75, 0x5A54, 0x6A37, 0x7A16, 0x0AF1, 0x1AD0, 0x2AB3, 0x3A92,
|
||||
0xFD2E, 0xED0F, 0xDD6C, 0xCD4D, 0xBDAA, 0xAD8B, 0x9DE8, 0x8DC9, 0x7C26, 0x6C07, 0x5C64, 0x4C45, 0x3CA2, 0x2C83,
|
||||
0x1CE0, 0x0CC1, 0xEF1F, 0xFF3E, 0xCF5D, 0xDF7C, 0xAF9B, 0xBFBA, 0x8FD9, 0x9FF8, 0x6E17, 0x7E36, 0x4E55, 0x5E74,
|
||||
0x2E93, 0x3EB2, 0x0ED1, 0x1EF0};
|
||||
|
||||
void ash_randomize(uint8_t *data, size_t length) {
|
||||
uint8_t rand = 0x42;
|
||||
for (size_t i = 0; i < length; i++) {
|
||||
data[i] ^= rand;
|
||||
rand = (rand & 0x01) ? static_cast<uint8_t>((rand >> 1) ^ 0xB8) : static_cast<uint8_t>(rand >> 1);
|
||||
}
|
||||
}
|
||||
|
||||
uint16_t ash_crc16(const uint8_t *data, size_t length, uint16_t init) {
|
||||
uint16_t crc = init;
|
||||
for (size_t i = 0; i < length; i++) {
|
||||
crc = (crc << 8) ^ CRC_TABLE[(crc >> 8) ^ data[i]];
|
||||
}
|
||||
return crc;
|
||||
}
|
||||
|
||||
uint16_t ZigbeeProxy::calculate_crc_(const uint8_t *data, size_t length, uint16_t init) {
|
||||
return ash_crc16(data, length, init);
|
||||
}
|
||||
|
||||
bool ZigbeeProxy::validate_frame_crc_() {
|
||||
// CRC is calculated over control byte + data
|
||||
// rx_buffer_[0] contains control byte, rx_buffer_[1..rx_buffer_index_-3] contains data
|
||||
// rx_buffer_[rx_buffer_index_-2] and rx_buffer_[rx_buffer_index_-1] contain CRC
|
||||
if (this->rx_buffer_index_ < 3) {
|
||||
// Frame too short to contain CRC
|
||||
return false;
|
||||
}
|
||||
|
||||
// Calculate CRC over control + data (exclude CRC bytes)
|
||||
uint16_t calculated = this->calculate_crc_(this->rx_buffer_.data(), this->rx_buffer_index_ - 2);
|
||||
|
||||
// Extract received CRC (big-endian)
|
||||
uint16_t received = (static_cast<uint16_t>(this->rx_buffer_[this->rx_buffer_index_ - 2]) << 8) |
|
||||
this->rx_buffer_[this->rx_buffer_index_ - 1];
|
||||
|
||||
if (calculated != received) {
|
||||
ESP_LOGW(TAG, "CRC validation failed: calculated=0x%04X, received=0x%04X", calculated, received);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ZigbeeProxy::handle_ack_num_(uint8_t ack_num) {
|
||||
// ackNum means "I expect frame N next", i.e. everything up to N-1 arrived, so a
|
||||
// pending frame numbered ack_num-1 has been acknowledged. Carried by DATA, ACK
|
||||
// and NAK alike.
|
||||
if (!this->tx_buffer_pending_ || ack_num != ((this->tx_pending_frame_num_ + 1) & ASH_MAX_SEQUENCE)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
uint32_t rtt = millis() - this->ack_timer_start_;
|
||||
this->update_adaptive_timeout_(rtt);
|
||||
ESP_LOGV(TAG, "Frame %d acknowledged, RTT: %u ms", this->tx_pending_frame_num_, rtt);
|
||||
this->clear_tx_buffer_();
|
||||
return true;
|
||||
}
|
||||
|
||||
void ZigbeeProxy::parse_control_byte_(uint8_t control) {
|
||||
// Decode frame type based on bit patterns:
|
||||
// DATA: 0xxxxxxx (bit 7 = 0)
|
||||
// ACK: 10x0xxxx (bits 7-6 = 10, bit 5 = 0)
|
||||
// NAK: 10x1xxxx (bits 7-6 = 10, bit 5 = 1)
|
||||
// RST: 11000000 (0xC0)
|
||||
// RSTACK: 11000001 (0xC1)
|
||||
// ERROR: 11000010 (0xC2)
|
||||
|
||||
AshFrameType frame_type;
|
||||
if ((control & 0x80) == 0) {
|
||||
// Bit 7 = 0: DATA frame
|
||||
frame_type = AshFrameType::DATA;
|
||||
} else if ((control & 0xC0) == 0x80) {
|
||||
// Bits 7-6 = 10: ACK or NAK
|
||||
// ACK format: 100nrPPP (bit 5 = 0)
|
||||
// NAK format: 101nrPPP (bit 5 = 1)
|
||||
if ((control & 0x20) == 0) {
|
||||
frame_type = AshFrameType::ACK;
|
||||
} else {
|
||||
frame_type = AshFrameType::NAK;
|
||||
}
|
||||
} else {
|
||||
// Bits 7-6 = 11: control frames (RST, RSTACK, ERROR)
|
||||
uint8_t control_bits = control & 0x07;
|
||||
if (control_bits == 0x00) {
|
||||
frame_type = AshFrameType::RST;
|
||||
} else if (control_bits == 0x01) {
|
||||
frame_type = AshFrameType::RSTACK;
|
||||
} else if (control_bits == 0x02) {
|
||||
frame_type = AshFrameType::ERROR;
|
||||
} else {
|
||||
ESP_LOGW(TAG, "Unknown control frame type: 0x%02X", control);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract sequence numbers from DATA frame format: 0ffrPPPP
|
||||
// Bits 6-4 = frmNum, bit 3 = reTx, bits 2-0 = ackNum
|
||||
uint8_t frame_num = (control >> 4) & 0x07; // Bits 6-4
|
||||
uint8_t ack_num = control & 0x07; // Bits 2-0
|
||||
bool retx = (control & 0x08) != 0; // Bit 3 (for DATA frames)
|
||||
|
||||
ESP_LOGV(TAG, "Parsed control byte: type=%d, frmNum=%d, ackNum=%d, reTx=%d", static_cast<int>(frame_type), frame_num,
|
||||
ack_num, retx);
|
||||
|
||||
// Handle frame based on type
|
||||
switch (frame_type) {
|
||||
case AshFrameType::DATA: {
|
||||
// Process the piggybacked ACK first: ackNum is valid regardless of the DATA
|
||||
// frame's own sequence ordering
|
||||
if (this->handle_ack_num_(ack_num)) {
|
||||
ESP_LOGV(TAG, "ACK received (piggybacked in DATA)");
|
||||
}
|
||||
|
||||
// Check sequence number
|
||||
if (frame_num != this->rx_sequence_) {
|
||||
if (retx && frame_num == ((this->rx_sequence_ - 1) & ASH_MAX_SEQUENCE)) {
|
||||
// Retransmission of a frame we already ACKed (our ACK was lost) - re-ACK and discard
|
||||
ESP_LOGV(TAG, "Duplicate DATA frame %d, re-sending ACK", frame_num);
|
||||
this->send_ack_frame_(this->rx_sequence_);
|
||||
} else {
|
||||
ESP_LOGW(TAG, "Out of sequence DATA frame: expected %d, got %d", this->rx_sequence_, frame_num);
|
||||
this->send_nak_frame_(this->rx_sequence_);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Increment RX sequence and send ACK (ack_num = next expected frame)
|
||||
this->increment_rx_sequence_();
|
||||
this->send_ack_frame_(this->rx_sequence_);
|
||||
|
||||
// Extract payload (skip control byte, exclude CRC)
|
||||
size_t payload_length = this->rx_buffer_index_ > 3 ? this->rx_buffer_index_ - 3 : 0;
|
||||
const uint8_t *payload = this->rx_buffer_.data() + 1;
|
||||
|
||||
// This path only runs during the boot harvest, where this component is the ASH
|
||||
// endpoint and consumes frames itself, so they must be derandomized. A subscribed
|
||||
// client is served by the transparent relay instead, which never reaches here.
|
||||
if (payload_length > 0) {
|
||||
ash_randomize(this->rx_buffer_.data() + 1, payload_length);
|
||||
this->handle_boot_data_frame_(payload, payload_length);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case AshFrameType::ACK:
|
||||
this->handle_ack_num_(ack_num);
|
||||
break;
|
||||
|
||||
case AshFrameType::NAK:
|
||||
// A NAK carries valid ACK information like any other frame: ackNum is the
|
||||
// next frame the NCP expects, so everything before it did arrive. Honour
|
||||
// that first -- retransmitting an already-acknowledged frame otherwise
|
||||
// burns all ASH_MAX_RETRIES and drops the link. bellows applies the same
|
||||
// ACK handling to DATA, ACK and NAK alike.
|
||||
if (this->handle_ack_num_(ack_num)) {
|
||||
ESP_LOGW(TAG, "NAK received for frame %d (already acknowledged, not retransmitting)", ack_num);
|
||||
break;
|
||||
}
|
||||
ESP_LOGW(TAG, "NAK received for frame %d, retransmitting", ack_num);
|
||||
if (this->tx_buffer_pending_) {
|
||||
this->handle_retransmission_();
|
||||
}
|
||||
break;
|
||||
|
||||
case AshFrameType::RST: {
|
||||
// An NCP never sends RST in normal operation; treat it as a reset indication
|
||||
// and run the RSTACK handling to resynchronize state (nothing is transmitted here)
|
||||
ESP_LOGW(TAG, "Received unexpected RST frame from NCP, resynchronizing");
|
||||
uint8_t rstack_data[] = {0x02, 0x01, 0x00}; // Synthesized RSTACK payload
|
||||
this->handle_rstack_frame_(rstack_data, sizeof(rstack_data));
|
||||
break;
|
||||
}
|
||||
|
||||
case AshFrameType::RSTACK:
|
||||
this->handle_rstack_frame_(this->rx_buffer_.data() + 1, this->rx_buffer_index_ - 3);
|
||||
break;
|
||||
|
||||
case AshFrameType::ERROR:
|
||||
this->handle_error_frame_(this->rx_buffer_.data() + 1, this->rx_buffer_index_ - 3);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
bool ZigbeeProxy::parse_byte_(uint8_t byte) {
|
||||
static constexpr uint8_t ASH_CAN_BYTE = 0x1A;
|
||||
static constexpr uint8_t ASH_XON_BYTE = 0x11;
|
||||
static constexpr uint8_t ASH_XOFF_BYTE = 0x13;
|
||||
|
||||
// Reserved bytes are only meaningful when they appear *bare* in the stream, so
|
||||
// they must be filtered here, before unescaping, and never afterwards. A frame
|
||||
// whose control or data byte happens to equal one of them arrives stuffed (0x11
|
||||
// is sent as 7D 31), and unescaping yields the real value -- so filtering after
|
||||
// unescaping silently eats a valid control byte, shifting the whole frame by one
|
||||
// and failing CRC on every retransmission. This mirrors bellows, which strips
|
||||
// flow control from the raw buffer and only then unstuffs.
|
||||
if (!this->escape_next_byte_) {
|
||||
if (byte == ASH_CAN_BYTE) {
|
||||
// Cancel: discard any partial frame
|
||||
this->rx_buffer_index_ = 0;
|
||||
this->parsing_state_ = ParsingState::WAIT_FLAG_START;
|
||||
return false;
|
||||
}
|
||||
if (byte == ASH_XON_BYTE || byte == ASH_XOFF_BYTE) {
|
||||
// Flow control: not part of any frame, may appear anywhere
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
switch (this->parsing_state_) {
|
||||
case ParsingState::WAIT_FLAG_START:
|
||||
// Handle escape sequences - NCP may send escaped control byte at frame start
|
||||
if (byte == ASH_ESCAPE_BYTE) {
|
||||
this->escape_next_byte_ = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this->escape_next_byte_) {
|
||||
byte ^= ASH_XOR_BYTE;
|
||||
this->escape_next_byte_ = false;
|
||||
}
|
||||
|
||||
if (byte == ASH_FLAG_BYTE) {
|
||||
// Start of frame with FLAG delimiter
|
||||
this->rx_buffer_index_ = 0;
|
||||
this->escape_next_byte_ = false;
|
||||
this->parsing_state_ = ParsingState::WAIT_CONTROL;
|
||||
ESP_LOGV(TAG, "Frame start detected (FLAG)");
|
||||
} else if (this->ash_state_ == AshState::CONNECTED) {
|
||||
// When connected, NCP often omits leading FLAG on responses
|
||||
// Any byte could be a control byte:
|
||||
// - DATA frames: 0x00-0x7F (bit 7 = 0)
|
||||
// - ACK frames: 0x80-0x9F (bits 7-6 = 10, bit 5 = 0)
|
||||
// - NAK frames: 0xA0-0xBF (bits 7-6 = 10, bit 5 = 1)
|
||||
// - RST/RSTACK/ERROR: 0xC0-0xC2 (bits 7-6 = 11)
|
||||
// Bare flow-control bytes were already filtered above, so anything
|
||||
// reaching here is genuine frame content.
|
||||
this->rx_buffer_index_ = 0;
|
||||
this->rx_buffer_[this->rx_buffer_index_++] = byte;
|
||||
this->parsing_state_ = ParsingState::WAIT_DATA;
|
||||
ESP_LOGV(TAG, "Frame start detected (control byte 0x%02X)", byte);
|
||||
} else if ((byte & 0x80) != 0) {
|
||||
// Before connected, only accept control/management frames (bit 7 set)
|
||||
// This handles RSTACK (0xC1), ACK (0x8X), NAK (0xAX), ERROR (0xC2)
|
||||
this->rx_buffer_index_ = 0;
|
||||
this->rx_buffer_[this->rx_buffer_index_++] = byte;
|
||||
this->parsing_state_ = ParsingState::WAIT_DATA;
|
||||
ESP_LOGV(TAG, "Frame start detected (control byte 0x%02X)", byte);
|
||||
}
|
||||
break;
|
||||
|
||||
case ParsingState::WAIT_CONTROL:
|
||||
if (byte == ASH_FLAG_BYTE) {
|
||||
// Empty frame or repeated FLAG
|
||||
ESP_LOGV(TAG, "Empty frame or repeated FLAG, restarting");
|
||||
this->rx_buffer_index_ = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (byte == ASH_ESCAPE_BYTE) {
|
||||
this->escape_next_byte_ = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this->escape_next_byte_) {
|
||||
byte ^= ASH_XOR_BYTE;
|
||||
this->escape_next_byte_ = false;
|
||||
}
|
||||
|
||||
// Store control byte
|
||||
this->rx_buffer_[this->rx_buffer_index_++] = byte;
|
||||
this->parsing_state_ = ParsingState::WAIT_DATA;
|
||||
break;
|
||||
|
||||
case ParsingState::WAIT_DATA:
|
||||
if (byte == ASH_FLAG_BYTE) {
|
||||
// End of frame - validate and process
|
||||
ESP_LOGV(TAG, "Frame complete, %u bytes in buffer", this->rx_buffer_index_);
|
||||
if (this->validate_frame_crc_()) {
|
||||
this->parse_control_byte_(this->rx_buffer_[0]);
|
||||
} else {
|
||||
// CRC failed - WARN logs byte count only; hex dump at VERBOSE (truncated to ASH_MAX_LOG_BYTES)
|
||||
ESP_LOGW(TAG, "CRC failed (%u bytes)", this->rx_buffer_index_);
|
||||
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
|
||||
char hex_buf[format_hex_pretty_size(ASH_MAX_LOG_BYTES)];
|
||||
#endif
|
||||
ESP_LOGV(TAG, "CRC failed frame: %s",
|
||||
format_hex_pretty_to(hex_buf, this->rx_buffer_.data(), this->rx_buffer_index_));
|
||||
this->send_nak_frame_(this->rx_sequence_);
|
||||
}
|
||||
this->parsing_state_ = ParsingState::WAIT_FLAG_START;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (byte == ASH_ESCAPE_BYTE) {
|
||||
this->escape_next_byte_ = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this->escape_next_byte_) {
|
||||
byte ^= ASH_XOR_BYTE;
|
||||
this->escape_next_byte_ = false;
|
||||
}
|
||||
|
||||
// Check buffer overflow
|
||||
if (this->rx_buffer_index_ >= MAX_ASH_FRAME_SIZE) {
|
||||
ESP_LOGE(TAG, "RX buffer overflow, frame too large");
|
||||
this->parsing_state_ = ParsingState::WAIT_FLAG_START;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Store data byte
|
||||
this->rx_buffer_[this->rx_buffer_index_++] = byte;
|
||||
break;
|
||||
|
||||
default:
|
||||
this->parsing_state_ = ParsingState::WAIT_FLAG_START;
|
||||
break;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Appends a byte with ASH stuffing (reserved: FLAG, ESCAPE, XON, XOFF, SUB, CAN);
|
||||
// returns false if it would exceed capacity
|
||||
static bool append_byte_stuffed(uint8_t *output, size_t capacity, size_t &pos, uint8_t byte) {
|
||||
const bool reserved = byte == ASH_FLAG_BYTE || byte == ASH_ESCAPE_BYTE || byte == 0x11 || byte == 0x13 ||
|
||||
byte == ASH_SUBSTITUTE_BYTE || byte == 0x1A;
|
||||
if (pos + (reserved ? 2 : 1) > capacity) {
|
||||
return false;
|
||||
}
|
||||
if (reserved) {
|
||||
output[pos++] = ASH_ESCAPE_BYTE;
|
||||
output[pos++] = byte ^ ASH_XOR_BYTE;
|
||||
} else {
|
||||
output[pos++] = byte;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
size_t ZigbeeProxy::build_frame_(uint8_t *output, size_t capacity, const uint8_t *data, size_t length,
|
||||
AshFrameType type, uint8_t frame_num, uint8_t ack_num, bool retx) {
|
||||
size_t pos = 0;
|
||||
|
||||
// Start with FLAG
|
||||
if (capacity < 1) {
|
||||
return 0;
|
||||
}
|
||||
output[pos++] = ASH_FLAG_BYTE;
|
||||
|
||||
// Build control byte
|
||||
uint8_t control = 0;
|
||||
switch (type) {
|
||||
case AshFrameType::DATA:
|
||||
// DATA frame format: 0ffrPPPP
|
||||
// Bit 7 = 0 (DATA indicator), bits 6-4 = frmNum, bit 3 = reTx, bits 2-0 = ackNum
|
||||
control = (frame_num << 4) | (retx ? 0x08 : 0x00) | ack_num;
|
||||
break;
|
||||
case AshFrameType::ACK:
|
||||
control = 0x80 | ack_num;
|
||||
break;
|
||||
case AshFrameType::NAK:
|
||||
control = 0xA0 | ack_num;
|
||||
break;
|
||||
case AshFrameType::RST:
|
||||
control = 0xC0;
|
||||
break;
|
||||
case AshFrameType::RSTACK:
|
||||
control = 0xC1;
|
||||
break;
|
||||
case AshFrameType::ERROR:
|
||||
control = 0xC2;
|
||||
break;
|
||||
}
|
||||
|
||||
// Add control byte with stuffing
|
||||
if (!append_byte_stuffed(output, capacity, pos, control)) {
|
||||
ESP_LOGE(TAG, "Frame too large for buffer (%u byte payload, %u byte buffer)", length, capacity);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Add data payload with stuffing
|
||||
for (size_t i = 0; i < length; i++) {
|
||||
if (!append_byte_stuffed(output, capacity, pos, data[i])) {
|
||||
ESP_LOGE(TAG, "Frame too large for buffer (%u byte payload, %u byte buffer)", length, capacity);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate CRC incrementally over control byte then data (avoids a MAX_ASH_FRAME_SIZE stack copy)
|
||||
uint16_t crc = this->calculate_crc_(&control, 1);
|
||||
if (length > 0) {
|
||||
crc = this->calculate_crc_(data, length, crc);
|
||||
}
|
||||
|
||||
// Add CRC with stuffing (big-endian), then the end FLAG
|
||||
if (!append_byte_stuffed(output, capacity, pos, (crc >> 8) & 0xFF) ||
|
||||
!append_byte_stuffed(output, capacity, pos, crc & 0xFF) || pos + 1 > capacity) {
|
||||
ESP_LOGE(TAG, "Frame too large for buffer (%u byte payload, %u byte buffer)", length, capacity);
|
||||
return 0;
|
||||
}
|
||||
output[pos++] = ASH_FLAG_BYTE;
|
||||
|
||||
return pos;
|
||||
}
|
||||
|
||||
} // namespace esphome::zigbee_proxy
|
||||
|
||||
#endif // USE_ZIGBEE_PROXY
|
||||
@@ -0,0 +1,115 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstddef>
|
||||
|
||||
namespace esphome::zigbee_proxy {
|
||||
|
||||
// ASH Protocol Constants
|
||||
static constexpr uint8_t ASH_FLAG_BYTE = 0x7E; // Frame delimiter
|
||||
static constexpr uint8_t ASH_ESCAPE_BYTE = 0x7D; // Escape/substitution byte
|
||||
static constexpr uint8_t ASH_XOR_BYTE = 0x20; // XOR mask for escaped bytes
|
||||
static constexpr uint8_t ASH_SUBSTITUTE_BYTE = 0x18; // Substitution for invalid bytes
|
||||
|
||||
static constexpr uint8_t ASH_XON_BYTE = 0x11; // Resume transmission
|
||||
static constexpr uint8_t ASH_XOFF_BYTE = 0x13; // Pause transmission
|
||||
static constexpr uint8_t ASH_CANCEL_BYTE = 0x1A; // Discards the partial frame before it
|
||||
|
||||
// A reserved byte can never appear literally inside a frame; it is escaped as
|
||||
// ESCAPE followed by the byte XOR 0x20. Rejecting frames that contain one is what
|
||||
// eliminates most non-ASH traffic before its CRC is ever computed: real firmware
|
||||
// images and Spinel payloads are dense in 0x11/0x13/0x18/0x1A.
|
||||
inline bool ash_is_reserved(uint8_t byte) {
|
||||
return byte == ASH_FLAG_BYTE || byte == ASH_ESCAPE_BYTE || byte == ASH_XON_BYTE || byte == ASH_XOFF_BYTE ||
|
||||
byte == ASH_SUBSTITUTE_BYTE || byte == ASH_CANCEL_BYTE;
|
||||
}
|
||||
|
||||
// CRC-CCITT (init 0xFFFF, polynomial 0x1021, transmitted big-endian). Note this is a
|
||||
// different variant from the Kermit FCS that Spinel/HDLC-lite uses over the same
|
||||
// 0x7E framing, so Spinel frames systematically fail this check.
|
||||
uint16_t ash_crc16(const uint8_t *data, size_t length, uint16_t init = 0xFFFF);
|
||||
|
||||
// Buffer size configuration
|
||||
#ifdef ZIGBEE_PROXY_BUFFER_SIZE
|
||||
static constexpr size_t MAX_ASH_FRAME_SIZE = ZIGBEE_PROXY_BUFFER_SIZE;
|
||||
#else
|
||||
#ifdef USE_ESP8266
|
||||
static constexpr size_t MAX_ASH_FRAME_SIZE = 512; // Limited RAM on ESP8266
|
||||
#else
|
||||
static constexpr size_t MAX_ASH_FRAME_SIZE = 1024; // Full buffer on ESP32/RP2040
|
||||
#endif
|
||||
#endif
|
||||
|
||||
// Protocol limits
|
||||
static constexpr uint8_t ASH_MAX_SEQUENCE = 7; // 3-bit sequence number (0-7)
|
||||
static constexpr uint8_t ASH_TX_WINDOW_SIZE = 1; // Only 1 unacknowledged frame allowed
|
||||
static constexpr uint8_t ASH_MAX_RETRIES = 5; // Maximum retransmission attempts
|
||||
static constexpr uint16_t ASH_CRC_INIT = 0xFFFF; // CRC-CCITT initial value
|
||||
static constexpr uint32_t ASH_RESET_TIMEOUT = 3000; // RST/RSTACK timeout in milliseconds
|
||||
|
||||
// IEEE address size
|
||||
static constexpr size_t ZIGBEE_IEEE_ADDR_SIZE = 8; // 64-bit IEEE address
|
||||
|
||||
// ASH data randomization. The Data Field of every DATA frame is XORed with a
|
||||
// pseudo-random sequence (LFSR seeded at 0x42, polynomial 0xB8) before
|
||||
// transmission and again after reception; the operation is its own inverse.
|
||||
//
|
||||
// Proxied client traffic must NOT be passed through this: the client randomizes
|
||||
// and the NCP derandomizes, so payloads travel end to end untouched and the
|
||||
// proxy stays transparent. Apply it only to frames this component originates or
|
||||
// consumes itself, i.e. the boot-harvest EZSP commands and their responses.
|
||||
// Sending an unrandomized command makes the NCP derandomize it into garbage and
|
||||
// answer with an error frame that decodes as a plausible-looking wrong value.
|
||||
void ash_randomize(uint8_t *data, size_t length);
|
||||
|
||||
// ASH Frame Types (encoded in control byte)
|
||||
// DATA format: 0ffrPPPP - bit 7=0, bits 6-4=frmNum, bit 3=reTx, bits 2-0=ackNum
|
||||
// ACK/NAK format: 10XnrPPP - bit 5 distinguishes ACK(0) from NAK(1)
|
||||
enum class AshFrameType : uint8_t {
|
||||
DATA = 0x00, // Data frame (bit 7 = 0)
|
||||
ACK = 0x80, // Acknowledge frame (100nrPPP, bit 5 = 0)
|
||||
NAK = 0xA0, // Negative acknowledge (101nrPPP, bit 5 = 1)
|
||||
RST = 0xC0, // Reset request (bits 7-6 = 11, bits 2-0 = 000)
|
||||
RSTACK = 0xC1, // Reset acknowledgment (bits 7-6 = 11, bits 2-0 = 001)
|
||||
ERROR = 0xC2, // Error indication (bits 7-6 = 11, bits 2-0 = 010)
|
||||
};
|
||||
|
||||
// ASH Connection State
|
||||
enum class AshState : uint8_t {
|
||||
DISCONNECTED, // Initial state, no connection
|
||||
CONNECTING, // Sent RST, waiting for RSTACK
|
||||
CONNECTED, // Normal operation
|
||||
FAILED, // Too many errors/timeouts, requires reset
|
||||
};
|
||||
|
||||
// Frame Parsing State Machine
|
||||
enum class ParsingState : uint8_t {
|
||||
WAIT_FLAG_START, // Looking for frame start FLAG (0x7E)
|
||||
WAIT_CONTROL, // Reading control byte
|
||||
WAIT_DATA, // Reading data payload
|
||||
WAIT_CRC_HIGH, // Reading CRC high byte
|
||||
WAIT_CRC_LOW, // Reading CRC low byte
|
||||
WAIT_FLAG_END, // Expecting end FLAG (0x7E)
|
||||
};
|
||||
|
||||
// Bootloader detection states
|
||||
enum class BootloaderState : uint8_t {
|
||||
NORMAL, // Normal operation
|
||||
DETECTED, // Bootloader mode detected
|
||||
MENU, // In bootloader menu
|
||||
};
|
||||
|
||||
// EZSP Error Codes (from ERROR frame)
|
||||
enum class EzspError : uint8_t {
|
||||
VERSION_NOT_SET = 0x00,
|
||||
RESET_UNKNOWN = 0x01,
|
||||
RESET_EXTERNAL = 0x02,
|
||||
RESET_POWER_ON = 0x03,
|
||||
RESET_WATCHDOG = 0x04,
|
||||
RESET_ASSERT = 0x05,
|
||||
RESET_BOOTLOADER = 0x06,
|
||||
RESET_SOFTWARE = 0x07,
|
||||
EXCEEDED_MAXIMUM_ACK_TIMEOUT_COUNT = 0x51,
|
||||
};
|
||||
|
||||
} // namespace esphome::zigbee_proxy
|
||||
@@ -0,0 +1,88 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
namespace esphome::zigbee_proxy {
|
||||
|
||||
// EZSP Protocol Versions
|
||||
static constexpr uint8_t EZSP_MIN_VERSION = 13; // Minimum supported version
|
||||
static constexpr uint8_t EZSP_MAX_VERSION = 13; // Maximum version we request
|
||||
|
||||
// EZSP Frame Control bits
|
||||
static constexpr uint8_t EZSP_FRAME_CONTROL_COMMAND = 0x00; // Host to NCP
|
||||
static constexpr uint8_t EZSP_FRAME_CONTROL_RESPONSE = 0x80; // NCP to Host
|
||||
static constexpr uint8_t EZSP_FRAME_CONTROL_CALLBACK = 0x90; // Async callback from NCP
|
||||
|
||||
// High byte of the 16-bit frame control, carrying frameFormatVersion = 1. Every
|
||||
// command after version negotiation must set this: omitting it leaves the NCP
|
||||
// reading the frame ID's low byte as frame_control_high, so the command is
|
||||
// discarded and the reply is an error frame rather than the expected response.
|
||||
static constexpr uint8_t EZSP_FRAME_CONTROL_EXTENDED = 0x01;
|
||||
|
||||
// Legacy EZSP frame format (v4-v7): [sequence] [frame_control] [frame_id]
|
||||
// Extended EZSP frame format (v8+): [sequence] [frame_control_low] [frame_control_high] [frame_id_low] [frame_id_high]
|
||||
//
|
||||
// Only the `version` command and its response use the legacy format, because the
|
||||
// NCP starts in legacy mode and has not yet learned the negotiated version.
|
||||
// Everything after that is extended, with no per-NCP exceptions.
|
||||
|
||||
// EZSP Frame IDs - Callbacks (NCP to host, async)
|
||||
static constexpr uint16_t EZSP_STACK_STATUS_HANDLER = 0x0019; // Stack up/down notification
|
||||
|
||||
// EZSP Frame IDs - Commands (host to NCP)
|
||||
static constexpr uint16_t EZSP_VERSION = 0x0000; // Version negotiation
|
||||
static constexpr uint16_t EZSP_GET_EUI64 = 0x0026; // Get IEEE address
|
||||
static constexpr uint16_t EZSP_GET_NETWORK_PARAMETERS = 0x0028; // Get network parameters
|
||||
static constexpr uint16_t EZSP_GET_TOKEN_DATA = 0x0102; // Read an NVM3 token
|
||||
|
||||
// Extended EZSP header: [sequence] [frame_control_lo] [frame_control_hi] [id_lo] [id_hi]
|
||||
static constexpr size_t EZSP_EXTENDED_HEADER_SIZE = 5;
|
||||
|
||||
// Network metadata comes straight out of NVM3 instead of from a running stack.
|
||||
// NVM3KEY_STACK_NODE_DATA holds the PAN ID, channel, extended PAN ID and node type of
|
||||
// the network this radio is commissioned onto, and reading it requires nothing beyond a
|
||||
// completed version negotiation: no stack configuration, no networkInit, no waiting on
|
||||
// stackStatusHandler, and above all no joining the network -- so simply plugging the
|
||||
// device in never brings the radio up.
|
||||
//
|
||||
// Note the 0x0001 domain prefix on the NVM3 object key. The bare creator ID
|
||||
// 0x0000EE64 is a different thing and getTokenData answers FAIL for it.
|
||||
static constexpr uint32_t NVM3KEY_STACK_NODE_DATA = 0x0001EE64;
|
||||
|
||||
// getTokenData response: [status (4)] [length (4)] [value (length)]
|
||||
static constexpr size_t TOKEN_DATA_VALUE_OFFSET = 8;
|
||||
|
||||
// NV3StackNodeData value layout (16 bytes, little-endian):
|
||||
// [panId (2)] [radioTxPower (1)] [radioFreqChannel (1)] [stackProfile (1)]
|
||||
// [nodeType (1)] [zigbeeNodeId (2)] [extendedPanId (8)]
|
||||
static constexpr size_t NV3_NODE_DATA_SIZE = 16;
|
||||
static constexpr size_t NV3_NODE_DATA_PAN_ID_OFFSET = 0;
|
||||
static constexpr size_t NV3_NODE_DATA_CHANNEL_OFFSET = 3;
|
||||
static constexpr size_t NV3_NODE_DATA_NODE_TYPE_OFFSET = 5;
|
||||
static constexpr size_t NV3_NODE_DATA_EXT_PAN_ID_OFFSET = 8;
|
||||
|
||||
// A radio with no network still has the token, holding a sentinel rather than being
|
||||
// absent: panId reads 0xFFFF and nodeType reads UNKNOWN_DEVICE. Detecting "no network"
|
||||
// therefore means inspecting nodeType, not treating the read as failed.
|
||||
static constexpr uint8_t NV3_NODE_TYPE_UNKNOWN_DEVICE = 0x00;
|
||||
|
||||
// Status codes (subset). EZSP v13+ / EmberZNet 8.x report sl_status_t, not the
|
||||
// legacy 8-bit EmberStatus.
|
||||
enum class SlStatus : uint8_t {
|
||||
OK = 0x00,
|
||||
NETWORK_UP = 0x15,
|
||||
NETWORK_DOWN = 0x16,
|
||||
};
|
||||
|
||||
// getNetworkParameters response layout, used when sniffing a client's own traffic. This
|
||||
// is a different shape from the NV3 token the boot harvest reads: 25 bytes of
|
||||
// [status (4)] [nodeType (1)] [extendedPanId (8)] [panId (2)] [radioTxPower (1)]
|
||||
// [radioChannel (1)] [joinMethod (1)] [nwkManagerId (2)] [nwkUpdateId (1)] [channels (4)]
|
||||
static constexpr size_t NETWORK_PARAMS_RESPONSE_SIZE = 25;
|
||||
static constexpr size_t NETWORK_PARAMS_STATUS_OFFSET = 0;
|
||||
static constexpr size_t NETWORK_PARAMS_EXT_PAN_ID_OFFSET = 5;
|
||||
static constexpr size_t NETWORK_PARAMS_PAN_ID_OFFSET = 13;
|
||||
static constexpr size_t NETWORK_PARAMS_CHANNEL_OFFSET = 16;
|
||||
|
||||
} // namespace esphome::zigbee_proxy
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,250 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
#ifdef USE_ZIGBEE_PROXY
|
||||
|
||||
#include "esphome/components/api/api_connection.h"
|
||||
#include "esphome/components/api/api_pb2.h"
|
||||
#include "esphome/components/serial_proxy/serial_proxy.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "ash_protocol.h"
|
||||
#include "ash_detector.h"
|
||||
|
||||
#include <array>
|
||||
|
||||
namespace esphome::zigbee_proxy {
|
||||
|
||||
// Timeout configuration structure
|
||||
struct TimeoutConfig {
|
||||
uint32_t initial_timeout_ms{1600}; // Initial ACK timeout
|
||||
uint32_t min_timeout_ms{400}; // Minimum adaptive timeout
|
||||
uint32_t max_timeout_ms{3200}; // Maximum adaptive timeout
|
||||
uint32_t current_timeout_ms{1600}; // Current adaptive timeout
|
||||
};
|
||||
|
||||
// Network information structure
|
||||
struct NetworkInfo {
|
||||
std::array<uint8_t, ZIGBEE_IEEE_ADDR_SIZE> ieee_address{};
|
||||
uint16_t pan_id{0};
|
||||
std::array<uint8_t, 8> extended_pan_id{};
|
||||
uint8_t channel{0};
|
||||
bool valid{false};
|
||||
};
|
||||
|
||||
enum ZigbeeProxyFeature : uint32_t {
|
||||
FEATURE_ZIGBEE_PROXY_ENABLED = 1 << 0,
|
||||
// Set only when the harvest actually read a network off the radio. Without it a client
|
||||
// cannot tell "a Zigbee radio with no network formed" from "not a Zigbee radio at all"
|
||||
// -- both otherwise present as ENABLED with an all-zero payload, and the second happens
|
||||
// whenever the NCP has been reflashed to Thread or is simply not responding.
|
||||
FEATURE_ZIGBEE_NETWORK_INFO_VALID = 1 << 1,
|
||||
};
|
||||
|
||||
// Boot-time initialization state machine
|
||||
enum class BootState : uint8_t {
|
||||
IDLE, // Not initializing
|
||||
WAIT_RSTACK, // Sent RST, waiting for RSTACK
|
||||
SEND_VERSION, // Send EZSP version command
|
||||
WAIT_VERSION, // Waiting for version response
|
||||
SEND_TOKEN_DATA, // Send getTokenData(NVM3KEY_STACK_NODE_DATA)
|
||||
WAIT_TOKEN_DATA, // Waiting for token data response
|
||||
SEND_GET_EUI64, // Send getEui64 command
|
||||
WAIT_EUI64, // Waiting for EUI64 response
|
||||
SEND_FINAL_RST, // Send final RST to reset NCP
|
||||
WAIT_FINAL_RSTACK, // Waiting for final RSTACK
|
||||
COMPLETE, // Boot sequence complete
|
||||
FAILED, // Boot sequence failed
|
||||
};
|
||||
|
||||
// Watches a `serial_proxy` port carrying an EZSP NCP and reports what it learns about the
|
||||
// Zigbee network. It never carries client traffic: the serial proxy owns the port and the
|
||||
// bytes, and this component only observes them, plus two exceptions where it writes to the
|
||||
// port itself -- the boot-time metadata harvest, which runs before any client connects, and
|
||||
// the ASH acknowledgements a client asks it to send on its behalf.
|
||||
class ZigbeeProxy : public serial_proxy::SerialProxyTap, public Component {
|
||||
public:
|
||||
ZigbeeProxy();
|
||||
|
||||
void setup() override;
|
||||
void loop() override;
|
||||
void dump_config() override;
|
||||
float get_setup_priority() const override;
|
||||
bool can_proceed() override;
|
||||
|
||||
void set_serial_proxy(serial_proxy::SerialProxy *parent) { this->parent_ = parent; }
|
||||
|
||||
// SerialProxyTap
|
||||
void on_device_rx(const uint8_t *data, size_t len) override;
|
||||
void on_client_tx(const uint8_t *data, size_t len) override;
|
||||
bool tap_needs_port() const override {
|
||||
if (this->boot_sequence_active_) {
|
||||
return true;
|
||||
}
|
||||
// A pending re-harvest waits for the port to go idle. Starting one under a subscriber
|
||||
// would inject our own ASH frames into whatever it is doing -- most likely the very
|
||||
// firmware upload that invalidated the metadata.
|
||||
return this->reharvest_pending_ && this->parent_->get_api_connection() == nullptr;
|
||||
}
|
||||
|
||||
/// The port stopped handling our protocol, so whatever we know about the radio may no
|
||||
/// longer be true -- a client asking for raw bytes is usually about to reflash it.
|
||||
void on_protocol_disabled() override;
|
||||
|
||||
/// The radio was unplugged or a new one appeared; metadata describes neither.
|
||||
void on_device_presence_changed_(bool connected);
|
||||
|
||||
// API integration
|
||||
void api_connection_authenticated(api::APIConnection *conn);
|
||||
void zigbee_proxy_request(api::APIConnection *api_connection, const api::ZigbeeProxyRequest &msg);
|
||||
|
||||
// Feature flags
|
||||
uint32_t get_feature_flags() const {
|
||||
uint32_t flags = ZigbeeProxyFeature::FEATURE_ZIGBEE_PROXY_ENABLED;
|
||||
if (this->network_info_.valid) {
|
||||
flags |= ZigbeeProxyFeature::FEATURE_ZIGBEE_NETWORK_INFO_VALID;
|
||||
}
|
||||
return flags;
|
||||
}
|
||||
|
||||
// Network information accessors
|
||||
const NetworkInfo &get_network_info() const { return this->network_info_; }
|
||||
uint64_t get_ieee_address() const;
|
||||
|
||||
// Timeout configuration (callable from Python/API)
|
||||
void set_timeout_config(uint32_t initial_ms, uint32_t min_ms, uint32_t max_ms);
|
||||
void set_initial_timeout(uint32_t timeout_ms) { this->timeout_config_.initial_timeout_ms = timeout_ms; }
|
||||
void set_min_timeout(uint32_t timeout_ms) { this->timeout_config_.min_timeout_ms = timeout_ms; }
|
||||
void set_max_timeout(uint32_t timeout_ms) { this->timeout_config_.max_timeout_ms = timeout_ms; }
|
||||
|
||||
protected:
|
||||
// ASH Protocol State Machine
|
||||
void reset_ash_protocol_();
|
||||
void send_rst_frame_();
|
||||
void handle_rstack_frame_(const uint8_t *data, size_t length);
|
||||
void handle_error_frame_(const uint8_t *data, size_t length);
|
||||
// Applies a frame's ackNum to the pending TX frame. Returns true if it
|
||||
// acknowledged one. Valid on DATA, ACK and NAK frames alike.
|
||||
bool handle_ack_num_(uint8_t ack_num);
|
||||
bool send_ack_frame_(uint8_t ack_num);
|
||||
bool send_nak_frame_(uint8_t ack_num);
|
||||
bool send_data_frame_(const uint8_t *data, size_t length, bool retransmit = false);
|
||||
|
||||
// Frame parsing and building (implemented in ash_protocol.cpp)
|
||||
bool parse_byte_(uint8_t byte);
|
||||
void parse_control_byte_(uint8_t control);
|
||||
bool validate_frame_crc_();
|
||||
// Builds a stuffed frame into output; returns 0 if the frame (worst case 2*length + 8
|
||||
// bytes after byte stuffing) would exceed capacity.
|
||||
size_t build_frame_(uint8_t *output, size_t capacity, const uint8_t *data, size_t length, AshFrameType type,
|
||||
uint8_t frame_num = 0, uint8_t ack_num = 0, bool retx = false);
|
||||
uint16_t calculate_crc_(const uint8_t *data, size_t length, uint16_t init = ASH_CRC_INIT);
|
||||
|
||||
// Sequence number management
|
||||
void increment_tx_sequence_() { this->tx_sequence_ = (this->tx_sequence_ + 1) & ASH_MAX_SEQUENCE; }
|
||||
void increment_rx_sequence_() { this->rx_sequence_ = (this->rx_sequence_ + 1) & ASH_MAX_SEQUENCE; }
|
||||
|
||||
// Timeout management
|
||||
void update_adaptive_timeout_(uint32_t measured_rtt_ms);
|
||||
void start_ack_timer_() { this->ack_timer_start_ = millis(); }
|
||||
bool check_ack_timeout_();
|
||||
|
||||
// Retransmission
|
||||
void handle_retransmission_();
|
||||
void clear_tx_buffer_() {
|
||||
this->tx_buffer_pending_ = false;
|
||||
this->tx_retry_count_ = 0;
|
||||
}
|
||||
|
||||
// Boot-time NCP initialization
|
||||
void advance_boot_state_();
|
||||
void check_boot_timeouts_();
|
||||
void handle_boot_data_frame_(const uint8_t *data, size_t length);
|
||||
void send_ezsp_version_();
|
||||
void send_get_eui64_();
|
||||
void send_get_token_data_();
|
||||
void handle_version_response_(const uint8_t *data, size_t length);
|
||||
void handle_eui64_response_(const uint8_t *data, size_t length);
|
||||
void handle_token_data_response_(const uint8_t *data, size_t length);
|
||||
|
||||
// IEEE address and network info
|
||||
bool set_ieee_address_(const uint8_t *new_address);
|
||||
void send_network_info_changed_msg_(api::APIConnection *conn = nullptr);
|
||||
|
||||
// WiFi/Zigbee channel conflict detection
|
||||
void check_wifi_zigbee_conflict_();
|
||||
|
||||
// Bootloader detection (fed consecutive raw byte pairs while not CONNECTED)
|
||||
void check_bootloader_mode_(uint8_t prev_byte, uint8_t byte);
|
||||
|
||||
// Reads network metadata out of a proxied getNetworkParameters response. Read-only, so a
|
||||
// misparse costs a missed update rather than corrupting anything.
|
||||
void sniff_network_info_(const uint8_t *frame, size_t length);
|
||||
// Invalidates network metadata when the stack reports it has left the network.
|
||||
void sniff_stack_status_(const uint8_t *frame, size_t length);
|
||||
|
||||
// NCP-side ASH buffers
|
||||
std::array<uint8_t, MAX_ASH_FRAME_SIZE> rx_buffer_;
|
||||
std::array<uint8_t, MAX_ASH_FRAME_SIZE> tx_buffer_;
|
||||
std::array<uint8_t, MAX_ASH_FRAME_SIZE> tx_pending_buffer_; // For retransmission
|
||||
|
||||
// Network information
|
||||
NetworkInfo network_info_;
|
||||
|
||||
// Timeout configuration
|
||||
TimeoutConfig timeout_config_;
|
||||
|
||||
// The port this component observes. Owns the UART and the bytes; every write we make
|
||||
// goes through it.
|
||||
serial_proxy::SerialProxy *parent_{nullptr};
|
||||
|
||||
uint32_t setup_time_{0}; // Time when last RST frame was sent
|
||||
uint32_t boot_start_time_{0}; // Time when the boot sequence began (for overall timeout)
|
||||
uint32_t ack_timer_start_{0}; // Time when ACK timer started
|
||||
uint32_t last_rtt_ms_{0}; // Last measured round-trip time
|
||||
|
||||
uint16_t rx_buffer_index_{0}; // Index for populating rx_buffer_
|
||||
uint16_t tx_pending_length_{0}; // Length of pending TX frame for retransmission
|
||||
uint16_t calculated_crc_{0}; // CRC calculated during frame reception
|
||||
|
||||
uint8_t tx_sequence_{0}; // TX sequence number (0-7)
|
||||
uint8_t rx_sequence_{0}; // RX sequence number (0-7)
|
||||
uint8_t tx_retry_count_{0}; // Number of retransmission attempts
|
||||
uint8_t tx_pending_frame_num_{0}; // Frame number of pending TX frame
|
||||
uint8_t last_ack_sent_{0}; // Last ACK number sent
|
||||
uint8_t last_rx_byte_{0}; // Previous raw RX byte (bootloader detection)
|
||||
|
||||
AshState ash_state_{AshState::DISCONNECTED};
|
||||
ParsingState parsing_state_{ParsingState::WAIT_FLAG_START};
|
||||
BootloaderState bootloader_state_{BootloaderState::NORMAL};
|
||||
BootState boot_state_{BootState::IDLE};
|
||||
|
||||
uint8_t ezsp_version_{0}; // NCP's EZSP protocol version
|
||||
uint8_t ezsp_sequence_{0}; // EZSP frame sequence number
|
||||
uint8_t ezsp_requested_version_{0}; // Version we last requested (for re-negotiation)
|
||||
// The NCP keeps using legacy framing until `version` is repeated in the
|
||||
// negotiated (extended) format; until then it rejects every extended command
|
||||
// with frame ID 0x0058. Tracks whether that second handshake has happened.
|
||||
bool ezsp_version_confirmed_{false};
|
||||
|
||||
bool tx_buffer_pending_{false}; // True if waiting for ACK from NCP
|
||||
bool escape_next_byte_{false}; // True if next NCP byte should be unescaped
|
||||
|
||||
bool boot_sequence_active_{false}; // True during boot-time init
|
||||
// Set when the metadata was discarded and a fresh harvest is owed once the port frees up
|
||||
bool reharvest_pending_{false};
|
||||
// Last observed device presence, for spotting a hot-plug
|
||||
bool was_connected_{false};
|
||||
// Earliest millis() at which a pending re-harvest may start
|
||||
uint32_t reharvest_after_{0};
|
||||
|
||||
// Decides when acknowledging on the client's behalf is safe. Armed only by the ASH
|
||||
// session handshake, so a bootloader or Thread NCP never triggers it.
|
||||
AshDetector detector_;
|
||||
};
|
||||
|
||||
extern ZigbeeProxy *global_zigbee_proxy; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
|
||||
} // namespace esphome::zigbee_proxy
|
||||
|
||||
#endif // USE_ZIGBEE_PROXY
|
||||
@@ -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)
|
||||
|
||||
@@ -180,6 +180,7 @@
|
||||
#define USE_SENSOR
|
||||
#define USE_SENSOR_FILTER
|
||||
#define USE_SERIAL_PROXY
|
||||
#define USE_SERIAL_PROXY_TAP
|
||||
#define USE_SETUP_PRIORITY_OVERRIDE
|
||||
#define USE_STATUS_LED
|
||||
#define USE_STATUS_SENSOR
|
||||
@@ -197,6 +198,7 @@
|
||||
#define USE_VALVE
|
||||
#define USE_WATER_HEATER
|
||||
#define USE_WATER_HEATER_VISUAL_OVERRIDES
|
||||
#define USE_ZIGBEE_PROXY
|
||||
#define USE_ZWAVE_PROXY
|
||||
|
||||
// Feature flags which do not work for zephyr
|
||||
@@ -393,6 +395,10 @@
|
||||
#define USB_HOST_MAX_REQUESTS 16
|
||||
#define USB_HOST_MAX_PACKET_SIZE 64
|
||||
#define USB_UART_OUTPUT_CHUNK_COUNT 5
|
||||
// USB identity on serial proxy ports needs the usb_host stack
|
||||
#ifdef USE_ESP32
|
||||
#define USE_SERIAL_PROXY_USB_INFO
|
||||
#endif
|
||||
|
||||
#ifdef USE_ARDUINO
|
||||
#define USE_ARDUINO_VERSION_CODE VERSION_CODE(3, 3, 7)
|
||||
|
||||
+17
-27
@@ -16,7 +16,6 @@ from esphome.build_helpers.ccache import (
|
||||
parse_enable_env,
|
||||
resolve_ccache_path,
|
||||
)
|
||||
from esphome.build_helpers.pch import ccache_pch_env
|
||||
from esphome.build_helpers.tools_cache import IDF_TOOLS_CACHE, tools_cache_path
|
||||
from esphome.core import Version
|
||||
from esphome.framework_helpers import (
|
||||
@@ -1206,33 +1205,15 @@ def _ccache_env() -> dict[str, str]:
|
||||
|
||||
Only values the user has not already set in the environment are returned, so
|
||||
a custom ``CCACHE_DIR`` / ``CCACHE_MAXSIZE`` / etc. is respected.
|
||||
The pch settings add ``time_macros`` sloppiness process-wide; the visible
|
||||
effect is a cached TU can keep an older ``esp_app_desc`` build timestamp.
|
||||
"""
|
||||
if not _ccache_enabled():
|
||||
# The raw knob value (e.g. "disable") is still inherited by idf.py
|
||||
# via os.environ, where a non-false-constant string reads as
|
||||
# truthy; export the canonical off spelling instead
|
||||
return {"IDF_CCACHE_ENABLE": "0"}
|
||||
|
||||
env = ccache_defaults_env(get_idf_tools_path() / "ccache")
|
||||
env.update(ccache_pch_env())
|
||||
# Exactly one canonical spelling ever reaches idf.py, whatever the
|
||||
# accepted input spelling was ("enable", "yes", ...)
|
||||
env["IDF_CCACHE_ENABLE"] = "1"
|
||||
return env
|
||||
|
||||
|
||||
def _ccache_enabled() -> bool:
|
||||
"""Whether ESP-IDF compiles run under ccache.
|
||||
|
||||
IDF_CCACHE_ENABLE (the backend-native knob) wins over the shared
|
||||
ESPHOME_CCACHE_ENABLE; when unset, enabled iff a runnable binary is
|
||||
on PATH.
|
||||
"""
|
||||
# IDF_CCACHE_ENABLE (the backend-native knob) wins over the shared
|
||||
# ESPHOME_CCACHE_ENABLE.
|
||||
idf_knob = parse_enable_env("IDF_CCACHE_ENABLE")
|
||||
if idf_knob is False:
|
||||
return False
|
||||
# The raw value (e.g. "disable") is still inherited by idf.py via
|
||||
# os.environ, where a non-false-constant string reads as truthy;
|
||||
# export the canonical off spelling instead
|
||||
return {"IDF_CCACHE_ENABLE": "0"}
|
||||
if idf_knob is True:
|
||||
# Forced on ignores the runnability verdict, but the outcome is
|
||||
# worth saying out loud. Probed directly (not via the resolver,
|
||||
@@ -1252,8 +1233,17 @@ def _ccache_enabled() -> bool:
|
||||
"IDF_CCACHE_ENABLE=1 forces on the ccache at %s even though "
|
||||
"it failed to run; idf.py will use it anyway",
|
||||
)
|
||||
return True
|
||||
return resolve_ccache_path() is not None
|
||||
elif resolve_ccache_path() is None:
|
||||
# ESP-IDF silently skips ccache without the binary; export the
|
||||
# canonical off spelling so an unparsable inherited value (or a
|
||||
# probe-rejected ccache idf.py would still find) cannot enable it
|
||||
return {"IDF_CCACHE_ENABLE": "0"}
|
||||
|
||||
env = ccache_defaults_env(get_idf_tools_path() / "ccache")
|
||||
# Exactly one canonical spelling ever reaches idf.py, whatever the
|
||||
# accepted input spelling was ("enable", "yes", ...)
|
||||
env["IDF_CCACHE_ENABLE"] = "1"
|
||||
return env
|
||||
|
||||
|
||||
def get_framework_env(
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""ESP-IDF direct build API for ESPHome."""
|
||||
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass, field
|
||||
import hashlib
|
||||
import json
|
||||
@@ -529,20 +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.
|
||||
# An optional speedup must never abort the build
|
||||
from esphome.build_gen.espidf import discard_pch, prepare_pch
|
||||
|
||||
try:
|
||||
prepare_pch()
|
||||
except Exception: # noqa: BLE001 # pylint: disable=broad-exception-caught
|
||||
# Discard so a stale .gch can never be consumed
|
||||
with suppress(OSError):
|
||||
discard_pch()
|
||||
_LOGGER.warning(
|
||||
"Precompiled header setup failed; compiling without it", exc_info=True
|
||||
)
|
||||
|
||||
# Build
|
||||
args = []
|
||||
|
||||
|
||||
@@ -1,364 +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, and stamp flow 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")
|
||||
|
||||
_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
|
||||
# -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 = subprocess.run( # noqa: PLW1510
|
||||
[
|
||||
cxx,
|
||||
*flags,
|
||||
*dep_redirect,
|
||||
"-Winvalid-pch",
|
||||
"-include",
|
||||
str(header),
|
||||
"-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}")
|
||||
if probe.returncode != 0 or ".gch" in probe.stderr:
|
||||
return f"toolchain cannot load the pch: {probe.stderr.strip()}"
|
||||
return None
|
||||
|
||||
|
||||
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() -> None:
|
||||
if projenv is None:
|
||||
# Expected under -t nobuild; anything else must leave a trail
|
||||
print(f"ESPHome: projenv unavailable ({_projenv_error}); skipping pch")
|
||||
return
|
||||
# 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
|
||||
if (
|
||||
not header.is_file()
|
||||
or not gch.is_file()
|
||||
or not sum_path.is_file()
|
||||
or (_read_stamp(sum_path) != checksum)
|
||||
):
|
||||
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", "-Wno-error=invalid-pch", "-include", header.name]
|
||||
)
|
||||
projenv["ENV"].update(ccache_updates) # noqa: F821
|
||||
print("ESPHome: Compiling with precompiled header")
|
||||
|
||||
|
||||
try:
|
||||
_setup_pch()
|
||||
except Exception: # noqa: BLE001 -- a speedup must never break the build
|
||||
print("ESPHome: pch internal error; compiling without it")
|
||||
traceback.print_exc()
|
||||
@@ -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
-14
@@ -7,7 +7,6 @@ import re
|
||||
import time
|
||||
|
||||
from esphome import loader
|
||||
from esphome.build_helpers.pch import PCH_ARTIFACT_NAMES
|
||||
from esphome.compiled_config import save_compiled_config
|
||||
from esphome.config import iter_component_configs, iter_components
|
||||
from esphome.const import (
|
||||
@@ -610,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()
|
||||
|
||||
|
||||
+24
-132
@@ -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,53 +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",
|
||||
}
|
||||
|
||||
|
||||
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]:
|
||||
@@ -616,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:
|
||||
@@ -653,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]]:
|
||||
@@ -1328,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
|
||||
@@ -1341,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
|
||||
)
|
||||
@@ -1538,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),
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -40,6 +40,9 @@ class SerialProxy {
|
||||
return SerialProxyResult::SERIAL_PROXY_RESULT_OK;
|
||||
}
|
||||
void write_from_client(api::APIConnection *api_connection, const uint8_t *data, size_t len) {}
|
||||
SerialProxyResult set_mode_from_client(api::APIConnection *api_connection, api::enums::SerialProxyMode mode) {
|
||||
return SerialProxyResult::SERIAL_PROXY_RESULT_OK;
|
||||
}
|
||||
SerialProxyResult set_modem_pins(api::APIConnection *api_connection, uint32_t line_states) {
|
||||
return SerialProxyResult::SERIAL_PROXY_RESULT_OK;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
substitutions:
|
||||
tx_pin: GPIO4
|
||||
rx_pin: GPIO5
|
||||
|
||||
# Compile the tap code paths; no tap is attached, so this exercises the
|
||||
# null-tap branches that a normal build never defines.
|
||||
esphome:
|
||||
platformio_options:
|
||||
build_flags:
|
||||
- "-DUSE_SERIAL_PROXY_TAP"
|
||||
|
||||
packages:
|
||||
uart: !include ../../test_build_components/common/uart/esp32-idf.yaml
|
||||
serial_proxy: !include common.yaml
|
||||
@@ -0,0 +1,21 @@
|
||||
wifi:
|
||||
ssid: MySSID
|
||||
password: password1
|
||||
|
||||
api:
|
||||
|
||||
usb_host:
|
||||
|
||||
# port_type is omitted deliberately: a port on a USB UART channel derives USB_SERIAL
|
||||
usb_uart:
|
||||
- type: CDC_ACM
|
||||
vid: 0x303A
|
||||
pid: 0x831A
|
||||
channels:
|
||||
- id: usb_serial_channel
|
||||
baud_rate: 460800
|
||||
|
||||
serial_proxy:
|
||||
- id: serial_proxy_usb
|
||||
uart_id: usb_serial_channel
|
||||
name: USB Serial Port
|
||||
@@ -0,0 +1,5 @@
|
||||
# Gitignore settings for ESPHome
|
||||
# This is an example and may include too much for your use-case.
|
||||
# You can modify this file to suit your needs.
|
||||
/.esphome/
|
||||
/secrets.yaml
|
||||
@@ -0,0 +1,22 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
wifi:
|
||||
ssid: test
|
||||
password: password
|
||||
power_save_mode: none
|
||||
|
||||
api:
|
||||
|
||||
uart:
|
||||
- id: zigbee_uart
|
||||
tx_pin: ${tx_pin}
|
||||
rx_pin: ${rx_pin}
|
||||
baud_rate: 115200
|
||||
|
||||
# The port owns the UART and carries every byte; zigbee_proxy only taps it
|
||||
serial_proxy:
|
||||
- id: zigbee_serial
|
||||
uart_id: zigbee_uart
|
||||
name: Zigbee
|
||||
port_type: TTL
|
||||
@@ -0,0 +1,26 @@
|
||||
wifi:
|
||||
ssid: test
|
||||
password: password
|
||||
power_save_mode: none
|
||||
|
||||
api:
|
||||
|
||||
usb_host:
|
||||
|
||||
usb_uart:
|
||||
- type: CDC_ACM
|
||||
vid: 0x303A
|
||||
pid: 0x831A
|
||||
channels:
|
||||
- id: zigbee_usb_channel
|
||||
baud_rate: 460800
|
||||
|
||||
# The tapped port may be a USB CDC ACM channel just as well as a hardware UART:
|
||||
# zigbee_proxy never touches the UART itself, so it does not care which it is.
|
||||
serial_proxy:
|
||||
- id: zigbee_usb_serial
|
||||
uart_id: zigbee_usb_channel
|
||||
name: Zigbee
|
||||
|
||||
zigbee_proxy:
|
||||
serial_proxy_id: zigbee_usb_serial
|
||||
@@ -0,0 +1,15 @@
|
||||
substitutions:
|
||||
tx_pin: GPIO17
|
||||
rx_pin: GPIO16
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
|
||||
<<: !include common.yaml
|
||||
|
||||
zigbee_proxy:
|
||||
serial_proxy_id: zigbee_serial
|
||||
buffer_size: 1024
|
||||
initial_timeout: 1600
|
||||
min_timeout: 400
|
||||
max_timeout: 3200
|
||||
@@ -0,0 +1,12 @@
|
||||
substitutions:
|
||||
tx_pin: GPIO1
|
||||
rx_pin: GPIO3
|
||||
|
||||
esp8266:
|
||||
board: nodemcuv2
|
||||
|
||||
<<: !include common.yaml
|
||||
|
||||
zigbee_proxy:
|
||||
serial_proxy_id: zigbee_serial
|
||||
buffer_size: 512
|
||||
@@ -0,0 +1,8 @@
|
||||
substitutions:
|
||||
tx_pin: GPIO0
|
||||
rx_pin: GPIO1
|
||||
|
||||
<<: !include common.yaml
|
||||
|
||||
zigbee_proxy:
|
||||
serial_proxy_id: zigbee_serial
|
||||
@@ -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
|
||||
@@ -3171,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)]
|
||||
|
||||
@@ -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
@@ -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,616 +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):
|
||||
# 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 '"$<$<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_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
|
||||
|
||||
@@ -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,218 +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
|
||||
@@ -1,193 +0,0 @@
|
||||
"""Tests for the native (non-PlatformIO) toolchain config validation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components import esp8266
|
||||
from esphome.components.esp8266 import (
|
||||
ARDUINO_FRAMEWORK_SCHEMA,
|
||||
_resolve_toolchain,
|
||||
_validate_native_toolchain,
|
||||
)
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_BOARD,
|
||||
CONF_FRAMEWORK,
|
||||
CONF_PLATFORM_VERSION,
|
||||
CONF_SOURCE,
|
||||
CONF_TOOLCHAIN,
|
||||
CONF_VERSION,
|
||||
Toolchain,
|
||||
)
|
||||
from esphome.core import CORE, EsphomeError
|
||||
from esphome.types import ConfigType
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _arduino_toolchain() -> Generator[None]:
|
||||
# The suite-wide reset_core fixture clears CORE.toolchain after each test
|
||||
CORE.toolchain = Toolchain.ARDUINO
|
||||
esp8266._DECODE_WARNED_AT.clear()
|
||||
yield
|
||||
esp8266._DECODE_WARNED_AT.clear()
|
||||
|
||||
|
||||
def _config(
|
||||
board: str = "nodemcuv2",
|
||||
version: str = "3.1.2",
|
||||
source: str | None = None,
|
||||
platform_version: str | None = None,
|
||||
) -> ConfigType:
|
||||
framework: dict[str, str] = {CONF_VERSION: version}
|
||||
if source is not None:
|
||||
framework[CONF_SOURCE] = source
|
||||
if platform_version is not None:
|
||||
framework[CONF_PLATFORM_VERSION] = platform_version
|
||||
# The real schema fills the source/platform_version defaults, so these
|
||||
# tests validate against what config validation actually emits
|
||||
return {
|
||||
CONF_FRAMEWORK: ARDUINO_FRAMEWORK_SCHEMA(framework),
|
||||
CONF_BOARD: board,
|
||||
}
|
||||
|
||||
|
||||
def test_valid_config_passes() -> None:
|
||||
config = _config()
|
||||
assert _validate_native_toolchain(config) is config
|
||||
|
||||
|
||||
def test_platformio_toolchain_skips_checks() -> None:
|
||||
# 3.0.2 is pio-legal (>= the global 3.0.0 floor) but below the native
|
||||
# toolchain's own 3.1.1 floor; the bogus board only the native path checks
|
||||
CORE.toolchain = Toolchain.PLATFORMIO
|
||||
config = _config(board="not_a_board", version="3.0.2")
|
||||
assert _validate_native_toolchain(config) is config
|
||||
|
||||
|
||||
def test_version_below_floor_rejected() -> None:
|
||||
# 3.1.0 has no registry package, so the native floor is 3.1.1
|
||||
with pytest.raises(cv.Invalid, match="3.1.1 or newer"):
|
||||
_validate_native_toolchain(_config(version="3.1.0"))
|
||||
|
||||
|
||||
def test_version_at_floor_accepted() -> None:
|
||||
_validate_native_toolchain(_config(version="3.1.1"))
|
||||
|
||||
|
||||
def test_custom_platform_version_warns_and_is_dropped(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
config = _config(platform_version="platformio/espressif8266@4.0.1")
|
||||
_validate_native_toolchain(config)
|
||||
assert "'platform_version' is ignored" in caplog.text
|
||||
assert CONF_PLATFORM_VERSION not in config[CONF_FRAMEWORK]
|
||||
|
||||
|
||||
def test_default_platform_version_does_not_warn(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
config = _config()
|
||||
_validate_native_toolchain(config)
|
||||
assert "'platform_version' is ignored" not in caplog.text
|
||||
assert CONF_PLATFORM_VERSION not in config[CONF_FRAMEWORK]
|
||||
|
||||
|
||||
def test_custom_source_rejected() -> None:
|
||||
with pytest.raises(cv.Invalid, match="custom framework source"):
|
||||
_validate_native_toolchain(
|
||||
_config(source="https://github.com/esp8266/Arduino.git")
|
||||
)
|
||||
|
||||
|
||||
def test_unsupported_board_rejected() -> None:
|
||||
with pytest.raises(cv.Invalid, match="not supported by"):
|
||||
_validate_native_toolchain(_config(board="not_a_board"))
|
||||
|
||||
|
||||
def test_yaml_toolchain_key_resolves() -> None:
|
||||
"""The documented `toolchain: arduino` YAML key selects the native path."""
|
||||
CORE.toolchain = None
|
||||
_resolve_toolchain({CONF_TOOLCHAIN: Toolchain.ARDUINO})
|
||||
assert CORE.toolchain == Toolchain.ARDUINO
|
||||
assert CORE.using_toolchain_arduino
|
||||
|
||||
|
||||
def test_yaml_toolchain_key_defaults_to_platformio() -> None:
|
||||
CORE.toolchain = None
|
||||
_resolve_toolchain({})
|
||||
assert CORE.toolchain == Toolchain.PLATFORMIO
|
||||
|
||||
|
||||
def test_decode_pc_native_missing_tools_warns_once(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A stack dump of many addresses produces one missing-tool warning."""
|
||||
|
||||
with (
|
||||
patch(
|
||||
"esphome.arduino8266.toolchain.get_addr2line_path",
|
||||
return_value=tmp_path / "missing-addr2line",
|
||||
),
|
||||
patch(
|
||||
"esphome.arduino8266.toolchain.get_elf_path",
|
||||
return_value=tmp_path / "missing.elf",
|
||||
),
|
||||
):
|
||||
esp8266._decode_pc({}, "40201234")
|
||||
esp8266._decode_pc({}, "40201238")
|
||||
assert caplog.text.count("Cannot decode crash addresses") == 1
|
||||
|
||||
|
||||
def test_decode_pc_platformio_missing_tools_warns_once(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""The PlatformIO branch reports a missing addr2line/ELF at the same
|
||||
warning level as the native one; raw undecoded addresses with no
|
||||
stated reason are undiagnosable at default log level."""
|
||||
|
||||
CORE.toolchain = Toolchain.PLATFORMIO
|
||||
idedata = SimpleNamespace(addr2line_path=None, firmware_elf_path=None)
|
||||
with patch("esphome.platformio.toolchain.get_idedata", return_value=idedata):
|
||||
esp8266._decode_pc({}, "40201234")
|
||||
esp8266._decode_pc({}, "40201238")
|
||||
assert caplog.text.count("Cannot decode crash addresses") == 1
|
||||
|
||||
|
||||
def test_resolve_toolchain_rejects_unsupported() -> None:
|
||||
"""ESP8266 rejects a CLI toolchain it cannot serve, like every platform."""
|
||||
|
||||
CORE.toolchain = Toolchain.SDK_NRF
|
||||
with pytest.raises(cv.Invalid, match="Unsupported toolchain 'sdk-nrf'"):
|
||||
_resolve_toolchain({})
|
||||
|
||||
|
||||
def test_run_compile_platformio_falls_through() -> None:
|
||||
"""Under toolchain: platformio the hook returns False without touching
|
||||
the native backend; this is what keeps existing users on PlatformIO."""
|
||||
CORE.toolchain = Toolchain.PLATFORMIO
|
||||
with patch("esphome.arduino8266.toolchain.run_compile") as mock_native:
|
||||
assert esp8266.run_compile(SimpleNamespace(), {}) is False
|
||||
mock_native.assert_not_called()
|
||||
|
||||
|
||||
def test_run_compile_arduino_failure_raises() -> None:
|
||||
"""A non-zero native build fails by name instead of returning success."""
|
||||
CORE.verbose = False
|
||||
with (
|
||||
patch("esphome.arduino8266.toolchain.run_compile", return_value=1),
|
||||
pytest.raises(EsphomeError, match="native build failed"),
|
||||
):
|
||||
esp8266.run_compile(SimpleNamespace(), {})
|
||||
|
||||
|
||||
def test_copy_files_native_skips_platformio_scripts(tmp_path: Path) -> None:
|
||||
"""The native build writes no PlatformIO extra scripts."""
|
||||
CORE.build_path = tmp_path
|
||||
esp8266.copy_files()
|
||||
assert list(tmp_path.iterdir()) == []
|
||||
@@ -1429,3 +1429,12 @@ async def test_add_platformio_options_native_arduino(
|
||||
assert "board_build.ldscript is ignored" in caplog.text
|
||||
assert "'arduino' toolchain" in caplog.text
|
||||
assert "upload_speed" not in caplog.text
|
||||
|
||||
|
||||
def test_esp8266_rejects_unsupported_cli_toolchain() -> None:
|
||||
"""Until the native backend lands, ESP8266 serves only PlatformIO."""
|
||||
from esphome.components.esp8266 import CONFIG_SCHEMA
|
||||
|
||||
CORE.toolchain = Toolchain.ARDUINO
|
||||
with pytest.raises(cv.Invalid, match="Unsupported toolchain 'arduino'"):
|
||||
CONFIG_SCHEMA({"board": "nodemcuv2"})
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
"""Run the native-toolchain serial-upload path and report heavy modules.
|
||||
|
||||
Executed as a subprocess by test_lazy_imports.py: heavy module names come
|
||||
in on argv, the ones found in sys.modules afterwards go out on stdout.
|
||||
``upload_using_esptool`` dispatches native toolchains through a
|
||||
toolchain-keyed table; if someone routes it back through the platform
|
||||
component packages (esp32 or esp8266), this reports the leak.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
from unittest.mock import patch
|
||||
|
||||
from _leak_report import print_leaked_modules
|
||||
|
||||
from esphome.__main__ import upload_using_esptool
|
||||
from esphome.const import (
|
||||
CONF_ESPHOME,
|
||||
KEY_CORE,
|
||||
KEY_ESP32,
|
||||
KEY_TARGET_PLATFORM,
|
||||
KEY_VARIANT,
|
||||
Toolchain,
|
||||
)
|
||||
from esphome.core import CORE
|
||||
|
||||
# An ambient ESPHOME_USE_SUBPROCESS would route past the patched
|
||||
# run_external_command into run_external_process and confuse the checks.
|
||||
os.environ.pop("ESPHOME_USE_SUBPROCESS", None)
|
||||
|
||||
config = {CONF_ESPHOME: {"platformio_options": {}}}
|
||||
|
||||
with tempfile.TemporaryDirectory() as build_dir:
|
||||
CORE.name = "leaktest"
|
||||
CORE.build_path = build_dir
|
||||
|
||||
for platform, toolchain, backend in (
|
||||
("esp8266", Toolchain.ARDUINO, "esphome.arduino8266.toolchain"),
|
||||
("esp32", Toolchain.ESP_IDF, "esphome.espidf.toolchain"),
|
||||
):
|
||||
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: platform}
|
||||
if platform == "esp32":
|
||||
CORE.data[KEY_ESP32] = {KEY_VARIANT: "ESP32S3"}
|
||||
CORE.toolchain = toolchain
|
||||
|
||||
import importlib
|
||||
|
||||
image = importlib.import_module(backend).get_factory_firmware_path()
|
||||
image.parent.mkdir(parents=True, exist_ok=True)
|
||||
image.write_bytes(b"\x00")
|
||||
|
||||
with patch("esphome.__main__.run_external_command", return_value=0) as mock_run:
|
||||
rc = upload_using_esptool(config, "/dev/ttyUSB0", None, None)
|
||||
|
||||
# Fail loudly if the upload path stopped doing its work; otherwise
|
||||
# an empty leak list could just mean nothing ran.
|
||||
if rc != 0:
|
||||
sys.exit(f"upload_using_esptool({platform}) returned {rc}")
|
||||
cmd = list(mock_run.call_args[0][1:])
|
||||
if str(image) not in [str(Path(c)) for c in cmd]:
|
||||
sys.exit(f"native factory image did not reach esptool: {cmd}")
|
||||
|
||||
print_leaked_modules()
|
||||
@@ -1,657 +0,0 @@
|
||||
"""Tests for esphome.arduino8266.toolchain (the ninja build driver)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.arduino8266 import framework, toolchain
|
||||
from esphome.build_helpers.pch import mark_pch_emitted
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_COMPILE_PROCESS_LIMIT,
|
||||
CONF_ESPHOME,
|
||||
KEY_CORE,
|
||||
KEY_FRAMEWORK_VERSION,
|
||||
)
|
||||
from esphome.core import CORE, EsphomeError
|
||||
|
||||
_SIZE_OUTPUT = """\
|
||||
firmware.elf :
|
||||
section size addr
|
||||
.data 1924 1073643520
|
||||
.noinit 56 1073645444
|
||||
.text 496 1074790400
|
||||
.irom0.text 342804 1075843088
|
||||
.text1 27489 1074790896
|
||||
.rodata 2588 1073645504
|
||||
.bss 26504 1073648096
|
||||
Total 401861
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup_core(tmp_path: Path) -> None:
|
||||
CORE.name = "test8266"
|
||||
CORE.config_path = tmp_path / "test8266.yaml"
|
||||
CORE.build_path = tmp_path
|
||||
CORE.data[KEY_CORE] = {KEY_FRAMEWORK_VERSION: cv.Version(3, 1, 2)}
|
||||
# run_compile verifies the produced artifacts; give every test a build
|
||||
# that "produced" them (tests for the guard delete them again). The
|
||||
# manifest comes first: artifacts must not be older than build.ninja.
|
||||
build_dir = CORE.relative_pioenvs_path("test8266")
|
||||
build_dir.mkdir(parents=True, exist_ok=True)
|
||||
(build_dir / "build.ninja").write_text("# manifest")
|
||||
for artifact in (
|
||||
"firmware.elf",
|
||||
"firmware.bin",
|
||||
"firmware.factory.bin",
|
||||
"firmware.ota.bin",
|
||||
):
|
||||
(build_dir / artifact).write_bytes(b"")
|
||||
|
||||
|
||||
def _paths(tmp_path: Path) -> framework.InstalledPaths:
|
||||
return framework.InstalledPaths(
|
||||
framework=tmp_path / "framework",
|
||||
toolchain=tmp_path / "toolchain",
|
||||
ninja=tmp_path / "ninja",
|
||||
)
|
||||
|
||||
|
||||
def test_path_getters(tmp_path: Path) -> None:
|
||||
assert toolchain.get_build_dir() == CORE.relative_pioenvs_path("test8266")
|
||||
assert toolchain.get_elf_path().name == "firmware.elf"
|
||||
# The framework accessor owns the layout and the Windows suffix
|
||||
suffix = ".exe" if os.name == "nt" else ""
|
||||
assert toolchain.get_addr2line_path().name == f"xtensa-lx106-elf-addr2line{suffix}"
|
||||
assert toolchain.get_objdump_path().name == f"xtensa-lx106-elf-objdump{suffix}"
|
||||
assert toolchain.get_readelf_path().name == f"xtensa-lx106-elf-readelf{suffix}"
|
||||
|
||||
|
||||
def test_run_compile_build_failure(tmp_path: Path) -> None:
|
||||
with (
|
||||
patch.object(framework, "check_and_install", return_value=_paths(tmp_path)),
|
||||
patch.object(framework, "get_build_env", return_value={}),
|
||||
patch("esphome.build_gen.arduino8266.write_project"),
|
||||
patch.object(
|
||||
toolchain.subprocess, "run", return_value=MagicMock(returncode=2)
|
||||
) as mock_run,
|
||||
patch.object(toolchain, "_write_compile_commands") as mock_compdb,
|
||||
):
|
||||
assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=True) == 2
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "-v" in cmd
|
||||
# The compile database is generated before the build runs, so a failed
|
||||
# build cannot leave a stale database behind.
|
||||
mock_compdb.assert_called_once()
|
||||
|
||||
|
||||
def test_run_compile_success(tmp_path: Path) -> None:
|
||||
with (
|
||||
patch.object(framework, "check_and_install", return_value=_paths(tmp_path)),
|
||||
patch.object(framework, "get_build_env", return_value={}),
|
||||
# An unchanged manifest is what makes the -n probe run
|
||||
patch("esphome.build_gen.arduino8266.write_project", return_value=False),
|
||||
patch.object(
|
||||
toolchain.subprocess,
|
||||
"run",
|
||||
return_value=MagicMock(returncode=0, stdout="", stderr=""),
|
||||
) as mock_run,
|
||||
patch.object(toolchain, "_write_compile_commands") as mock_compdb,
|
||||
patch.object(toolchain, "_print_size_summary") as mock_size,
|
||||
patch.object(toolchain, "get_idedata") as mock_idedata,
|
||||
):
|
||||
rc = toolchain.run_compile(
|
||||
{CONF_ESPHOME: {CONF_COMPILE_PROCESS_LIMIT: 4}}, verbose=False
|
||||
)
|
||||
assert rc == 0
|
||||
# The -n probe runs first, then the real build (cwd, no -C banner)
|
||||
ninja_calls = [c for c in mock_run.call_args_list if "ninja" in str(c[0][0][0])]
|
||||
assert "-n" in ninja_calls[0][0][0]
|
||||
# Explicit targets: a manifest missing them fails as "unknown target"
|
||||
assert ninja_calls[0][0][0][-1] == "firmware.ota.bin"
|
||||
cmd = ninja_calls[1][0][0]
|
||||
assert cmd[-4:] == ["-j", "4", "firmware.factory.bin", "firmware.ota.bin"]
|
||||
assert "-C" not in cmd
|
||||
assert ninja_calls[1][1]["cwd"] is not None
|
||||
mock_compdb.assert_called_once()
|
||||
mock_size.assert_called_once()
|
||||
mock_idedata.assert_called_once()
|
||||
|
||||
|
||||
def test_run_compile_noop_skips_the_build_spawn(tmp_path: Path) -> None:
|
||||
"""A no-op rebuild stays quiet: the -n probe answers "no work to do"
|
||||
and the real ninja spawn (and its banner) never happens."""
|
||||
with (
|
||||
patch.object(framework, "check_and_install", return_value=_paths(tmp_path)),
|
||||
patch.object(framework, "get_build_env", return_value={}),
|
||||
# An unchanged manifest is what makes the -n probe run
|
||||
patch("esphome.build_gen.arduino8266.write_project", return_value=False),
|
||||
patch.object(
|
||||
toolchain.subprocess,
|
||||
"run",
|
||||
return_value=MagicMock(
|
||||
returncode=0, stdout="ninja: no work to do.\n", stderr=""
|
||||
),
|
||||
) as mock_run,
|
||||
patch.object(toolchain, "_write_compile_commands"),
|
||||
patch.object(toolchain, "_print_size_summary"),
|
||||
patch.object(toolchain, "get_idedata"),
|
||||
):
|
||||
rc = toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False)
|
||||
assert rc == 0
|
||||
ninja_calls = [c for c in mock_run.call_args_list if "ninja" in str(c[0][0][0])]
|
||||
assert len(ninja_calls) == 1
|
||||
assert "-n" in ninja_calls[0][0][0]
|
||||
# Explicit targets: a manifest missing them fails as "unknown target"
|
||||
assert ninja_calls[0][0][0][-1] == "firmware.ota.bin"
|
||||
|
||||
|
||||
def test_run_compile_regenerates_stale_compdb(tmp_path: Path) -> None:
|
||||
"""An interrupted run can leave build.ninja newer than the compile DB;
|
||||
mere existence must not skip regeneration."""
|
||||
build_dir = toolchain.get_build_dir()
|
||||
build_dir.mkdir(parents=True, exist_ok=True)
|
||||
(build_dir / "build.ninja").write_text("")
|
||||
compdb = build_dir / "compile_commands.json"
|
||||
compdb.write_text("[]")
|
||||
os.utime(compdb, ((build_dir / "build.ninja").stat().st_mtime - 5,) * 2)
|
||||
with (
|
||||
patch.object(framework, "check_and_install", return_value=_paths(tmp_path)),
|
||||
patch.object(framework, "get_build_env", return_value={}),
|
||||
patch("esphome.build_gen.arduino8266.write_project", return_value=False),
|
||||
patch.object(
|
||||
toolchain.subprocess,
|
||||
"run",
|
||||
return_value=MagicMock(
|
||||
returncode=0, stdout="ninja: no work to do.\n", stderr=""
|
||||
),
|
||||
),
|
||||
patch.object(toolchain, "_write_compile_commands") as mock_compdb,
|
||||
patch.object(toolchain, "_print_size_summary"),
|
||||
patch.object(toolchain, "get_idedata"),
|
||||
):
|
||||
assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0
|
||||
mock_compdb.assert_called_once()
|
||||
|
||||
|
||||
def test_run_compile_surfaces_probe_diagnostics(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A load-time ninja diagnostic (a generator bug signal) reaches the
|
||||
user even when the no-work branch skips the real spawn."""
|
||||
with (
|
||||
patch.object(framework, "check_and_install", return_value=_paths(tmp_path)),
|
||||
patch.object(framework, "get_build_env", return_value={}),
|
||||
# An unchanged manifest is what makes the -n probe run
|
||||
patch("esphome.build_gen.arduino8266.write_project", return_value=False),
|
||||
patch.object(
|
||||
toolchain.subprocess,
|
||||
"run",
|
||||
return_value=MagicMock(
|
||||
returncode=0,
|
||||
stdout="ninja: no work to do.\n",
|
||||
stderr="ninja: warning: multiple rules generate x\n",
|
||||
),
|
||||
),
|
||||
patch.object(toolchain, "_write_compile_commands"),
|
||||
patch.object(toolchain, "_print_size_summary"),
|
||||
patch.object(toolchain, "get_idedata"),
|
||||
):
|
||||
rc = toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False)
|
||||
assert rc == 0
|
||||
assert "multiple rules generate x" in caplog.text
|
||||
|
||||
|
||||
def test_run_compile_missing_artifact_fails(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A zero ninja exit that produced no firmware must not be a green
|
||||
build (size summary and idedata only warn)."""
|
||||
(toolchain.get_build_dir() / "firmware.elf").unlink()
|
||||
with (
|
||||
patch.object(framework, "check_and_install", return_value=_paths(tmp_path)),
|
||||
patch.object(framework, "get_build_env", return_value={}),
|
||||
patch("esphome.build_gen.arduino8266.write_project"),
|
||||
patch.object(
|
||||
toolchain.subprocess,
|
||||
"run",
|
||||
return_value=MagicMock(returncode=0, stdout="", stderr=""),
|
||||
),
|
||||
patch.object(toolchain, "_write_compile_commands"),
|
||||
patch.object(toolchain, "_print_size_summary") as mock_size,
|
||||
patch.object(toolchain, "get_idedata"),
|
||||
):
|
||||
rc = toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False)
|
||||
assert rc == 1
|
||||
assert "Build produced no" in caplog.text
|
||||
mock_size.assert_not_called()
|
||||
|
||||
|
||||
def test_run_compile_warns_when_idedata_fails(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A failed idedata generation right after a successful build is visible,
|
||||
not deferred to a misleading error in a later command."""
|
||||
with (
|
||||
patch.object(framework, "check_and_install", return_value=_paths(tmp_path)),
|
||||
patch.object(framework, "get_build_env", return_value={}),
|
||||
patch("esphome.build_gen.arduino8266.write_project"),
|
||||
patch.object(
|
||||
toolchain.subprocess,
|
||||
"run",
|
||||
return_value=MagicMock(returncode=0, stdout="", stderr=""),
|
||||
),
|
||||
patch.object(toolchain, "_write_compile_commands"),
|
||||
patch.object(toolchain, "_print_size_summary"),
|
||||
patch.object(toolchain, "get_idedata", return_value=None),
|
||||
):
|
||||
assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0
|
||||
assert "No idedata was generated for this build" in caplog.text
|
||||
|
||||
|
||||
def test_write_compile_commands(tmp_path: Path) -> None:
|
||||
build_dir = tmp_path / "build"
|
||||
build_dir.mkdir()
|
||||
entries = '[{"file": "a.cpp", "command": "cc"}]\n'
|
||||
with patch.object(
|
||||
toolchain.subprocess,
|
||||
"run",
|
||||
return_value=MagicMock(returncode=0, stdout=entries),
|
||||
):
|
||||
toolchain._write_compile_commands(tmp_path / "ninja", build_dir, {})
|
||||
assert (build_dir / "compile_commands.json").read_text() == entries
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("stdout", "match"),
|
||||
[
|
||||
("[]\n", "empty compile database"),
|
||||
# A parse failure names its cause, not the rule-name story
|
||||
("not json", "unparsable compile database.*not json"),
|
||||
],
|
||||
)
|
||||
def test_write_compile_commands_bad_db_raises(
|
||||
tmp_path: Path, stdout: str, match: str
|
||||
) -> None:
|
||||
"""An empty or unparsable compile database fails the build with its
|
||||
actual cause and drops any stale database."""
|
||||
build_dir = tmp_path / "build"
|
||||
build_dir.mkdir()
|
||||
(build_dir / "compile_commands.json").write_text("[stale]")
|
||||
with (
|
||||
patch.object(
|
||||
toolchain.subprocess,
|
||||
"run",
|
||||
return_value=MagicMock(returncode=0, stdout=stdout),
|
||||
),
|
||||
pytest.raises(EsphomeError, match=match),
|
||||
):
|
||||
toolchain._write_compile_commands(tmp_path / "ninja", build_dir, {})
|
||||
assert not (build_dir / "compile_commands.json").exists()
|
||||
|
||||
|
||||
def test_write_compile_commands_failure_removes_stale_db(tmp_path: Path) -> None:
|
||||
"""A failed compdb run must not leave a stale database behind."""
|
||||
stale = tmp_path / "compile_commands.json"
|
||||
stale.write_text("[]")
|
||||
with (
|
||||
patch.object(
|
||||
toolchain.subprocess,
|
||||
"run",
|
||||
return_value=MagicMock(returncode=1, stderr="boom"),
|
||||
),
|
||||
pytest.raises(EsphomeError, match="compile_commands"),
|
||||
):
|
||||
toolchain._write_compile_commands(tmp_path / "ninja", tmp_path, {})
|
||||
assert not stale.exists()
|
||||
|
||||
|
||||
def test_parse_app_size(tmp_path: Path) -> None:
|
||||
ld = tmp_path / "eagle.flash.4m.ld"
|
||||
ld.write_text("MEMORY\n{\n irom0_0_seg : org = 0x40201010, len = 0xfeff0\n}\n")
|
||||
with patch("esphome.build_gen.arduino8266.get_flash_ld_path", return_value=ld):
|
||||
assert toolchain._parse_app_size(tmp_path, _paths(tmp_path)) == 0xFEFF0
|
||||
|
||||
ld.write_text("MEMORY { }\n")
|
||||
with patch("esphome.build_gen.arduino8266.get_flash_ld_path", return_value=ld):
|
||||
assert toolchain._parse_app_size(tmp_path, _paths(tmp_path)) is None
|
||||
|
||||
# A zero-length segment is bad data, not a budget; warn and drop it
|
||||
ld.write_text("MEMORY\n{\n irom0_0_seg : org = 0x40201010, len = 0x0\n}\n")
|
||||
with patch("esphome.build_gen.arduino8266.get_flash_ld_path", return_value=ld):
|
||||
assert toolchain._parse_app_size(tmp_path, _paths(tmp_path)) is None
|
||||
|
||||
with patch(
|
||||
"esphome.build_gen.arduino8266.get_flash_ld_path",
|
||||
return_value=tmp_path / "missing.ld",
|
||||
):
|
||||
assert toolchain._parse_app_size(tmp_path, _paths(tmp_path)) is None
|
||||
|
||||
|
||||
def test_print_size_summary(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
with (
|
||||
patch.object(
|
||||
toolchain.subprocess,
|
||||
"run",
|
||||
return_value=MagicMock(returncode=0, stdout=_SIZE_OUTPUT),
|
||||
),
|
||||
patch.object(toolchain, "_parse_app_size", return_value=1044464),
|
||||
):
|
||||
toolchain._print_size_summary(tmp_path, _paths(tmp_path))
|
||||
out = capsys.readouterr().out
|
||||
# Exact PlatformIO shape so script/ci_memory_impact_extract.py can parse it
|
||||
assert "RAM: [==== ] 37.9% (used 31016 bytes from 81920 bytes)" in out
|
||||
assert "Flash: [==== ] 35.9% (used 375301 bytes from 1044464 bytes)" in out
|
||||
|
||||
|
||||
def test_print_size_summary_missing_size_tool_warns(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A missing size binary degrades to a warning; the firmware already
|
||||
linked and must not be discarded."""
|
||||
with patch.object(
|
||||
toolchain.subprocess, "run", side_effect=FileNotFoundError("no size")
|
||||
):
|
||||
toolchain._print_size_summary(tmp_path, _paths(tmp_path))
|
||||
assert "Could not summarize firmware size" in caplog.text
|
||||
|
||||
|
||||
def test_print_size_summary_no_app_size(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
with (
|
||||
patch.object(
|
||||
toolchain.subprocess,
|
||||
"run",
|
||||
return_value=MagicMock(returncode=0, stdout=_SIZE_OUTPUT),
|
||||
),
|
||||
patch.object(toolchain, "_parse_app_size", return_value=None),
|
||||
):
|
||||
toolchain._print_size_summary(tmp_path, _paths(tmp_path))
|
||||
out = capsys.readouterr().out
|
||||
# Both lines are skipped together: a RAM line without Flash would skew
|
||||
# CI's memory-impact sums across builds
|
||||
assert out == ""
|
||||
|
||||
|
||||
def test_print_size_summary_size_tool_failure(
|
||||
tmp_path: Path,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
with patch.object(
|
||||
toolchain.subprocess,
|
||||
"run",
|
||||
return_value=MagicMock(returncode=1, stdout="", stderr="bad elf"),
|
||||
):
|
||||
toolchain._print_size_summary(tmp_path, _paths(tmp_path))
|
||||
assert capsys.readouterr().out == ""
|
||||
assert "Could not summarize firmware size" in caplog.text
|
||||
|
||||
|
||||
def test_get_idedata_delegates(tmp_path: Path) -> None:
|
||||
with (
|
||||
patch(
|
||||
"esphome.build_helpers.idedata.load_or_build_idedata",
|
||||
return_value={"cc_path": "x"},
|
||||
) as mock_load,
|
||||
patch.object(toolchain, "resolve_ccache_path", return_value="/cc/ccache"),
|
||||
):
|
||||
assert toolchain.get_idedata() == {"cc_path": "x"}
|
||||
compile_commands, elf, cache = mock_load.call_args[0]
|
||||
assert compile_commands.name == "compile_commands.json"
|
||||
assert elf.name == "firmware.elf"
|
||||
assert cache.name == "test8266.arduino.json"
|
||||
# The exact configured launcher string is passed for compile DB parsing
|
||||
# (resolve_ccache_path returns a str, untouched on every platform)
|
||||
assert mock_load.call_args.kwargs["launcher"] == "/cc/ccache"
|
||||
|
||||
|
||||
def test_get_idedata_no_ccache(tmp_path: Path) -> None:
|
||||
with (
|
||||
patch(
|
||||
"esphome.build_helpers.idedata.load_or_build_idedata", return_value={}
|
||||
) as mock_load,
|
||||
patch.object(toolchain, "resolve_ccache_path", return_value=None),
|
||||
):
|
||||
toolchain.get_idedata()
|
||||
assert mock_load.call_args.kwargs["launcher"] is None
|
||||
|
||||
|
||||
def test_run_compile_skips_compdb_when_ninja_unchanged(tmp_path: Path) -> None:
|
||||
"""An unchanged build.ninja means the compile DB is already current."""
|
||||
build_dir = toolchain.get_build_dir()
|
||||
build_dir.mkdir(parents=True, exist_ok=True)
|
||||
# write_project (stubbed below) always leaves a build.ninja behind
|
||||
(build_dir / "build.ninja").write_text("# manifest")
|
||||
|
||||
def run(regenerate_expected: bool) -> None:
|
||||
with (
|
||||
patch.object(framework, "check_and_install", return_value=_paths(tmp_path)),
|
||||
patch.object(framework, "get_build_env", return_value={}),
|
||||
patch("esphome.build_gen.arduino8266.write_project", return_value=False),
|
||||
patch.object(
|
||||
toolchain.subprocess,
|
||||
"run",
|
||||
return_value=MagicMock(returncode=0, stdout="", stderr=""),
|
||||
),
|
||||
patch.object(toolchain, "_write_compile_commands") as mock_compdb,
|
||||
patch.object(toolchain, "_print_size_summary"),
|
||||
patch.object(toolchain, "get_idedata"),
|
||||
):
|
||||
assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0
|
||||
assert mock_compdb.called == regenerate_expected
|
||||
|
||||
# Missing compile DB: regenerated even though build.ninja is unchanged
|
||||
run(regenerate_expected=True)
|
||||
# Present compile DB + unchanged build.ninja: skipped
|
||||
(build_dir / "compile_commands.json").write_text("[]")
|
||||
run(regenerate_expected=False)
|
||||
|
||||
|
||||
def test_print_size_summary_unparsable_section(
|
||||
tmp_path: Path,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A totals-relevant section that fails to parse must not produce a
|
||||
confident wrong number; an irrelevant one only warns."""
|
||||
bad = _SIZE_OUTPUT.replace(".bss 26504", ".bss abc")
|
||||
with patch.object(
|
||||
toolchain.subprocess,
|
||||
"run",
|
||||
return_value=MagicMock(returncode=0, stdout=bad),
|
||||
):
|
||||
toolchain._print_size_summary(tmp_path, _paths(tmp_path))
|
||||
assert capsys.readouterr().out == ""
|
||||
assert "Unparsable size output" in caplog.text
|
||||
|
||||
caplog.clear()
|
||||
harmless = _SIZE_OUTPUT + ".broken abc 0\n"
|
||||
with (
|
||||
patch.object(
|
||||
toolchain.subprocess,
|
||||
"run",
|
||||
return_value=MagicMock(returncode=0, stdout=harmless),
|
||||
),
|
||||
patch.object(toolchain, "_parse_app_size", return_value=1044464),
|
||||
):
|
||||
toolchain._print_size_summary(tmp_path, _paths(tmp_path))
|
||||
assert "RAM:" in capsys.readouterr().out
|
||||
assert "Unparsable size output" in caplog.text
|
||||
|
||||
|
||||
def test_print_size_summary_missing_section_skips_summary(
|
||||
tmp_path: Path,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A totals section absent from the output must not default to zero."""
|
||||
without_bss = "\n".join(
|
||||
line for line in _SIZE_OUTPUT.splitlines() if ".bss" not in line
|
||||
)
|
||||
with patch.object(
|
||||
toolchain.subprocess,
|
||||
"run",
|
||||
return_value=MagicMock(returncode=0, stdout=without_bss),
|
||||
):
|
||||
toolchain._print_size_summary(tmp_path, _paths(tmp_path))
|
||||
assert capsys.readouterr().out == ""
|
||||
assert "missing section(s) .bss" in caplog.text
|
||||
|
||||
|
||||
def test_warn_ignored_platformio_options(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""Component-added options the native build drops are warned by name;
|
||||
the honored ones (lib_ignore, f_cpu, ldscript, build_src_flags,
|
||||
flash_mode) stay quiet."""
|
||||
CORE.platformio_options = {
|
||||
"board_build.ldscript": "eagle.flash.4m2m.ld",
|
||||
"board_build.f_cpu": "160000000L",
|
||||
"board_build.filesystem": "littlefs",
|
||||
"board_build.flash_mode": "dio",
|
||||
"build_src_flags": "-include throw_stubs.h",
|
||||
"lib_ignore": ["Updater"],
|
||||
"upload_speed": "460800",
|
||||
}
|
||||
toolchain._warn_ignored_platformio_options()
|
||||
assert "platformio_options->board_build.filesystem is ignored" in caplog.text
|
||||
assert "native 'arduino' toolchain" in caplog.text
|
||||
assert "board_build.ldscript is ignored" not in caplog.text
|
||||
assert "board_build.f_cpu is ignored" not in caplog.text
|
||||
assert "lib_ignore" not in caplog.text
|
||||
assert "build_src_flags" not in caplog.text
|
||||
assert "flash_mode" not in caplog.text
|
||||
# Component-added upload_speed never gets read under the native
|
||||
# toolchain, so it must warn
|
||||
assert "upload_speed" in caplog.text
|
||||
|
||||
|
||||
def test_run_compile_idedata_error_does_not_fail_build(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""An unusable compile DB after a successful build warns, never fails."""
|
||||
with (
|
||||
patch.object(framework, "check_and_install", return_value=_paths(tmp_path)),
|
||||
patch.object(framework, "get_build_env", return_value={}),
|
||||
patch("esphome.build_gen.arduino8266.write_project"),
|
||||
patch.object(
|
||||
toolchain.subprocess,
|
||||
"run",
|
||||
return_value=MagicMock(returncode=0, stdout="", stderr=""),
|
||||
),
|
||||
patch.object(toolchain, "_write_compile_commands"),
|
||||
patch.object(toolchain, "_print_size_summary"),
|
||||
patch.object(
|
||||
toolchain,
|
||||
"get_idedata",
|
||||
side_effect=EsphomeError("compile database is unusable"),
|
||||
),
|
||||
):
|
||||
assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0
|
||||
assert "Could not generate idedata: compile database is unusable" in caplog.text
|
||||
|
||||
|
||||
def test_run_compile_skipped_size_summary_names_consequence(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A skipped RAM/Flash summary warns about the missing metric; the
|
||||
per-cause warnings alone are invisible to CI harnesses."""
|
||||
with (
|
||||
patch.object(framework, "check_and_install", return_value=_paths(tmp_path)),
|
||||
patch.object(framework, "get_build_env", return_value={}),
|
||||
patch("esphome.build_gen.arduino8266.write_project"),
|
||||
patch.object(
|
||||
toolchain.subprocess,
|
||||
"run",
|
||||
return_value=MagicMock(returncode=0, stdout="", stderr=""),
|
||||
),
|
||||
patch.object(toolchain, "_write_compile_commands"),
|
||||
patch.object(toolchain, "_print_size_summary", return_value=False),
|
||||
patch.object(toolchain, "get_idedata", return_value=None),
|
||||
):
|
||||
assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0
|
||||
assert "Firmware size summary unavailable for this build" in caplog.text
|
||||
|
||||
|
||||
def test_parse_app_size_non_utf8_ld_warns(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A corrupt (non-UTF-8) linker script degrades to the same warning as
|
||||
an unreadable one, never a traceback after a successful link."""
|
||||
paths = _paths(tmp_path)
|
||||
ld = tmp_path / "corrupt.ld"
|
||||
ld.write_bytes(b"\xff\xfe not utf8")
|
||||
with patch("esphome.build_gen.arduino8266.get_flash_ld_path", return_value=ld):
|
||||
assert toolchain._parse_app_size(tmp_path, paths) is None
|
||||
assert "Cannot read linker script" in caplog.text
|
||||
|
||||
|
||||
def test_run_compile_failed_probe_runs_full_build(tmp_path: Path) -> None:
|
||||
"""A failing -n probe (e.g. unknown target from a defective manifest)
|
||||
falls through to the real build so the error prints attributably."""
|
||||
probe = MagicMock(returncode=1, stdout="", stderr="")
|
||||
ok = MagicMock(returncode=0, stdout="", stderr="")
|
||||
|
||||
def fake_run(cmd, *args, **kwargs):
|
||||
return probe if "-n" in cmd else ok
|
||||
|
||||
with (
|
||||
patch.object(framework, "check_and_install", return_value=_paths(tmp_path)),
|
||||
patch.object(framework, "get_build_env", return_value={}),
|
||||
patch("esphome.build_gen.arduino8266.write_project", return_value=False),
|
||||
patch.object(toolchain.subprocess, "run", side_effect=fake_run) as mock_run,
|
||||
patch.object(toolchain, "_write_compile_commands"),
|
||||
patch.object(toolchain, "_print_size_summary", return_value=True),
|
||||
patch.object(toolchain, "get_idedata", return_value={}),
|
||||
):
|
||||
assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0
|
||||
# The real build ran after the failed probe
|
||||
assert any(
|
||||
"firmware.ota.bin" in c[0][0] and "-n" not in c[0][0]
|
||||
for c in mock_run.call_args_list
|
||||
)
|
||||
|
||||
|
||||
def test_get_idedata_accepts_preresolved_ccache() -> None:
|
||||
"""run_compile threads its resolved ccache through; the probe must not
|
||||
run again."""
|
||||
with (
|
||||
patch(
|
||||
"esphome.build_helpers.idedata.load_or_build_idedata",
|
||||
return_value={"ok": True},
|
||||
) as mock_build,
|
||||
patch.object(toolchain, "resolve_ccache_path") as mock_resolve,
|
||||
):
|
||||
assert toolchain.get_idedata("/usr/bin/ccache") == {"ok": True}
|
||||
mock_resolve.assert_not_called()
|
||||
assert mock_build.call_args.kwargs["launcher"] == "/usr/bin/ccache"
|
||||
|
||||
|
||||
def test_ccache_env_includes_pch_settings() -> None:
|
||||
"""The native build exports the ccache settings the pch needs."""
|
||||
mark_pch_emitted()
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
env = framework.ccache_env("/usr/bin/ccache")
|
||||
assert env["CCACHE_SLOPPINESS"] == "pch_defines,time_macros"
|
||||
assert env["CCACHE_PCH_EXTSUM"] == "true"
|
||||
|
||||
|
||||
def test_ccache_env_pch_disabled() -> None:
|
||||
with patch.dict(os.environ, {"ESPHOME_PCH_ENABLE": "0"}, clear=True):
|
||||
env = framework.ccache_env("/usr/bin/ccache")
|
||||
assert "CCACHE_SLOPPINESS" not in env
|
||||
assert "CCACHE_PCH_EXTSUM" not in env
|
||||
|
||||
|
||||
def test_ccache_env_respects_user_sloppiness() -> None:
|
||||
mark_pch_emitted()
|
||||
with patch.dict(os.environ, {"CCACHE_SLOPPINESS": "locale"}, clear=True):
|
||||
env = framework.ccache_env("/usr/bin/ccache")
|
||||
# The user's tokens survive; the ones the pch needs are unioned on
|
||||
assert env["CCACHE_SLOPPINESS"] == "locale,pch_defines,time_macros"
|
||||
assert env["CCACHE_PCH_EXTSUM"] == "true"
|
||||
@@ -19,7 +19,6 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.build_helpers.pch import _PCHData
|
||||
from esphome.espidf.framework import (
|
||||
ESPHOME_STAMP_FILE,
|
||||
STAMP_SCHEMA_VERSION,
|
||||
@@ -1593,14 +1592,10 @@ def _ccache_patches(tmp_path: Path, which: str | None, build_path: Path | None):
|
||||
"esphome.espidf.framework.get_idf_tools_path",
|
||||
return_value=tmp_path / "tools",
|
||||
),
|
||||
# ccache_defaults_env and the pch emission flag read CORE at call time
|
||||
# ccache_defaults_env (build_helpers.ccache) reads CORE at call time
|
||||
patch(
|
||||
"esphome.core.CORE",
|
||||
SimpleNamespace(
|
||||
build_path=build_path,
|
||||
# Pre-marked: these env tests model a pch-emitting build
|
||||
data={"pch": _PCHData(emitted=True)},
|
||||
),
|
||||
SimpleNamespace(build_path=build_path),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1614,9 +1609,6 @@ def test_ccache_env_default_enabled_when_available(tmp_path: Path) -> None:
|
||||
assert env["CCACHE_NOHASHDIR"] == "true"
|
||||
assert env["CCACHE_DEPEND"] == "1"
|
||||
assert env["CCACHE_BASEDIR"] == str((tmp_path / "build").resolve())
|
||||
# The pch cannot cache under ccache without these
|
||||
assert env["CCACHE_SLOPPINESS"] == "pch_defines,time_macros"
|
||||
assert env["CCACHE_PCH_EXTSUM"] == "true"
|
||||
|
||||
|
||||
def test_ccache_env_disabled_when_binary_missing(tmp_path: Path) -> None:
|
||||
|
||||
@@ -93,13 +93,6 @@ def test_get_configured_targets_ci_installs_all(monkeypatch: pytest.MonkeyPatch)
|
||||
assert toolchain._get_configured_targets() is None
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_ccache(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Deterministic run_compile: no host ccache probe, no pch work."""
|
||||
monkeypatch.setenv("IDF_CCACHE_ENABLE", "0")
|
||||
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0")
|
||||
|
||||
|
||||
def _setup_build(setup_core: Path) -> tuple[Path, Path]:
|
||||
"""Point CORE at a build dir; return (compile_commands, idedata cache) paths."""
|
||||
CORE.name = "test"
|
||||
@@ -667,29 +660,3 @@ def test_get_core_framework_version_from_core_data():
|
||||
|
||||
CORE.data = {KEY_ESP32: {KEY_IDF_VERSION: cv.Version(5, 5, 4)}}
|
||||
assert toolchain._get_core_framework_version() == "5.5.4"
|
||||
|
||||
|
||||
def test_run_compile_invokes_prepare_pch_and_survives_failure(
|
||||
setup_core: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""The pch hook runs before the build and a failure never aborts it."""
|
||||
monkeypatch.setenv("ESPHOME_PCH_ENABLE", "1")
|
||||
_setup_build(setup_core)
|
||||
# A stale .gch must be discarded on the failure path, never consumed
|
||||
build = setup_core / "build" / "test" / "build"
|
||||
build.mkdir(parents=True, exist_ok=True)
|
||||
(build / "esphome_pch.h").write_text("")
|
||||
stale_gch = build / "esphome_pch.h.gch"
|
||||
stale_gch.write_bytes(b"stale")
|
||||
|
||||
with (
|
||||
patch.object(toolchain, "need_reconfigure", return_value=False),
|
||||
patch.object(toolchain, "run_idf_py", return_value=0),
|
||||
patch.object(toolchain, "print_summary"),
|
||||
patch(
|
||||
"esphome.build_gen.espidf.prepare_pch", side_effect=RuntimeError("boom")
|
||||
) as prepare,
|
||||
):
|
||||
assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0
|
||||
prepare.assert_called_once()
|
||||
assert not stale_gch.exists()
|
||||
|
||||
@@ -175,27 +175,6 @@ def test_esptool_upload_fast_path_does_not_import_heavy_modules(
|
||||
)
|
||||
|
||||
|
||||
def test_native_upload_fast_path_does_not_import_heavy_modules(
|
||||
fixture_path: Path,
|
||||
probe_env: dict[str, str],
|
||||
) -> None:
|
||||
"""The native-toolchain serial upload dispatches through a
|
||||
toolchain-keyed backend table; resolving the flash image must not drag
|
||||
in the esp32 or esp8266 component package or the validation stack.
|
||||
"""
|
||||
leaked = _leaked_from_fixture(
|
||||
fixture_path,
|
||||
probe_env,
|
||||
"native_upload_fast_path.py",
|
||||
extra=("esphome.components.esp8266",),
|
||||
)
|
||||
assert not leaked, (
|
||||
f"native upload_using_esptool pulls in heavy modules: {leaked}. "
|
||||
"The upload fast path skips validation; importing a platform "
|
||||
"component package executes its codegen module by design."
|
||||
)
|
||||
|
||||
|
||||
def test_api_client_does_not_import_heavy_modules() -> None:
|
||||
"""``esphome.api_client`` is on the logs fast path and must stay light.
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import sys
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, Self
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock, PropertyMock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
from pytest import CaptureFixture
|
||||
@@ -25,7 +25,6 @@ from esphome.__main__ import (
|
||||
Purpose,
|
||||
_get_configured_xtal_freq,
|
||||
_make_crystal_freq_callback,
|
||||
_native_toolchain_module,
|
||||
_redact_with_legacy_fallback,
|
||||
_resolve_network_devices,
|
||||
_should_subscribe_states,
|
||||
@@ -7031,7 +7030,7 @@ def test_command_run_rp2040_bootsel_redetects_serial_port() -> None:
|
||||
|
||||
def test_command_idedata_esp_idf_prints_json(capsys: CaptureFixture) -> None:
|
||||
"""Under the native ESP-IDF toolchain, idedata is emitted as JSON."""
|
||||
setup_core(platform=PLATFORM_ESP32)
|
||||
setup_core()
|
||||
CORE.toolchain = Toolchain.ESP_IDF
|
||||
data = {"cxx_path": "g++", "prog_path": "/build/firmware.elf"}
|
||||
|
||||
@@ -7045,7 +7044,7 @@ def test_command_idedata_esp_idf_prints_json(capsys: CaptureFixture) -> None:
|
||||
|
||||
def test_command_idedata_esp_idf_no_build_errors() -> None:
|
||||
"""Under ESP-IDF, a missing build (no idedata) returns an error, not a crash."""
|
||||
setup_core(platform=PLATFORM_ESP32)
|
||||
setup_core()
|
||||
CORE.toolchain = Toolchain.ESP_IDF
|
||||
|
||||
with patch("esphome.espidf.toolchain.get_idedata", return_value=None):
|
||||
@@ -7219,201 +7218,6 @@ def test_warn_source_tree_mismatch_falls_back_when_stat_fails(
|
||||
assert not caplog.text
|
||||
|
||||
|
||||
def test_upload_using_esptool_arduino_toolchain(
|
||||
tmp_path: Path,
|
||||
mock_run_external_command_main: Mock,
|
||||
) -> None:
|
||||
"""The native ESP8266 Arduino toolchain flashes its factory image at
|
||||
0x0, resolved from the toolchain-keyed backend table (deliberately not
|
||||
the platform hook: that import would break the upload fast path)."""
|
||||
setup_core(platform=PLATFORM_ESP8266, tmp_path=tmp_path, name="test")
|
||||
CORE.toolchain = Toolchain.ARDUINO
|
||||
from esphome.arduino8266 import toolchain as native
|
||||
|
||||
factory = native.get_factory_firmware_path()
|
||||
factory.parent.mkdir(parents=True, exist_ok=True)
|
||||
factory.touch()
|
||||
|
||||
config = {CONF_ESPHOME: {"platformio_options": {}}}
|
||||
result = upload_using_esptool(config, "/dev/ttyUSB0", None, None)
|
||||
|
||||
assert result == 0
|
||||
cmd_list = list(mock_run_external_command_main.call_args[0][1:])
|
||||
firmware_offset_idx = cmd_list.index("write-flash") + 4
|
||||
assert cmd_list[firmware_offset_idx] == "0x0"
|
||||
assert cmd_list[firmware_offset_idx + 1] == str(factory)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("toolchain", "pio_project_written"),
|
||||
[
|
||||
# The native toolchain generates its project at compile time, so
|
||||
# write_cpp_file must not write a platformio.ini; the default
|
||||
# toolchain writes the PlatformIO project files.
|
||||
(Toolchain.ARDUINO, False),
|
||||
(None, True),
|
||||
],
|
||||
)
|
||||
def test_write_cpp_file_project_generation_follows_toolchain(
|
||||
tmp_path: Path, toolchain: Toolchain | None, pio_project_written: bool
|
||||
) -> None:
|
||||
setup_core(platform=PLATFORM_ESP8266, tmp_path=tmp_path, name="test")
|
||||
CORE.toolchain = toolchain
|
||||
|
||||
with (
|
||||
patch("esphome.writer.write_cpp") as mock_write_cpp,
|
||||
patch("esphome.build_gen.platformio.write_project") as mock_pio_project,
|
||||
patch.object(
|
||||
type(CORE), "cpp_main_section", new_callable=PropertyMock
|
||||
) as mock_section,
|
||||
):
|
||||
mock_section.return_value = ""
|
||||
assert main.write_cpp_file() == 0
|
||||
|
||||
mock_write_cpp.assert_called_once()
|
||||
assert mock_pio_project.called is pio_project_written
|
||||
|
||||
|
||||
def test_command_idedata_arduino_prints_json(
|
||||
tmp_path: Path, capsys: CaptureFixture
|
||||
) -> None:
|
||||
"""Under the native ESP8266 Arduino toolchain, idedata is emitted as JSON."""
|
||||
setup_core(platform=PLATFORM_ESP8266, tmp_path=tmp_path)
|
||||
CORE.toolchain = Toolchain.ARDUINO
|
||||
data = {"cxx_path": "g++", "prog_path": "/build/firmware.elf"}
|
||||
|
||||
with patch(
|
||||
"esphome.arduino8266.toolchain.get_idedata", return_value=data
|
||||
) as mock_get:
|
||||
result = command_idedata(MagicMock(), CORE.config)
|
||||
|
||||
assert result == 0
|
||||
mock_get.assert_called_once_with()
|
||||
assert json.loads(capsys.readouterr().out) == data
|
||||
|
||||
|
||||
def test_command_idedata_arduino_no_build_errors(tmp_path: Path) -> None:
|
||||
"""A missing native build (no idedata) returns an error, not a crash."""
|
||||
setup_core(platform=PLATFORM_ESP8266, tmp_path=tmp_path)
|
||||
CORE.toolchain = Toolchain.ARDUINO
|
||||
|
||||
with patch("esphome.arduino8266.toolchain.get_idedata", return_value=None):
|
||||
result = command_idedata(MagicMock(), CORE.config)
|
||||
|
||||
assert result == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("platform", "toolchain", "module"),
|
||||
[
|
||||
(PLATFORM_ESP8266, Toolchain.ARDUINO, "esphome.arduino8266.toolchain"),
|
||||
(PLATFORM_ESP32, Toolchain.ESP_IDF, "esphome.espidf.toolchain"),
|
||||
],
|
||||
)
|
||||
def test_command_analyze_memory_native_toolchains(
|
||||
tmp_path: Path,
|
||||
mock_write_cpp: Mock,
|
||||
mock_compile_program: Mock,
|
||||
mock_get_esphome_components: Mock,
|
||||
mock_memory_analyzer_cli: Mock,
|
||||
mock_ram_strings_analyzer: Mock,
|
||||
platform: str,
|
||||
toolchain: Toolchain,
|
||||
module: str,
|
||||
) -> None:
|
||||
"""analyze-memory uses the native toolchain's binutils instead of
|
||||
falling into the PlatformIO branch."""
|
||||
setup_core(platform=platform, tmp_path=tmp_path, name="test_device")
|
||||
CORE.toolchain = toolchain
|
||||
|
||||
config = {CONF_ESPHOME: {CONF_NAME: "test_device"}}
|
||||
# The tools must exist: a missing binutils now fails by name instead of
|
||||
# silently falling back to host tools
|
||||
objdump = tmp_path / "objdump"
|
||||
readelf = tmp_path / "readelf"
|
||||
objdump.write_text("")
|
||||
readelf.write_text("")
|
||||
# The ELF must exist too: the analyzer swallows tool failures, so a
|
||||
# missing image would report zeroes with exit 0
|
||||
firmware_elf = tmp_path / "firmware.elf"
|
||||
firmware_elf.write_text("")
|
||||
with (
|
||||
patch(f"{module}.get_objdump_path", return_value=objdump),
|
||||
patch(f"{module}.get_readelf_path", return_value=readelf),
|
||||
patch(f"{module}.get_elf_path", return_value=firmware_elf),
|
||||
):
|
||||
result = command_analyze_memory(MockArgs(), config)
|
||||
|
||||
assert result == 0
|
||||
mock_memory_analyzer_cli.assert_called_once_with(
|
||||
str(firmware_elf),
|
||||
str(objdump),
|
||||
str(readelf),
|
||||
set(),
|
||||
idedata=None,
|
||||
)
|
||||
|
||||
|
||||
def test_command_analyze_memory_native_missing_elf_fails(
|
||||
tmp_path: Path,
|
||||
mock_write_cpp: Mock,
|
||||
mock_compile_program: Mock,
|
||||
mock_get_esphome_components: Mock,
|
||||
mock_memory_analyzer_cli: Mock,
|
||||
mock_ram_strings_analyzer: Mock,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A missing firmware.elf fails by name instead of an exit-0 zeroed
|
||||
report."""
|
||||
setup_core(platform=PLATFORM_ESP8266, tmp_path=tmp_path, name="test_device")
|
||||
CORE.toolchain = Toolchain.ARDUINO
|
||||
|
||||
config = {CONF_ESPHOME: {CONF_NAME: "test_device"}}
|
||||
objdump = tmp_path / "objdump"
|
||||
readelf = tmp_path / "readelf"
|
||||
objdump.write_text("")
|
||||
readelf.write_text("")
|
||||
module = "esphome.arduino8266.toolchain"
|
||||
with (
|
||||
patch(f"{module}.get_objdump_path", return_value=objdump),
|
||||
patch(f"{module}.get_readelf_path", return_value=readelf),
|
||||
patch(f"{module}.get_elf_path", return_value=tmp_path / "missing.elf"),
|
||||
):
|
||||
result = command_analyze_memory(MockArgs(), config)
|
||||
|
||||
assert result == 1
|
||||
assert "compile the configuration first" in caplog.text
|
||||
mock_memory_analyzer_cli.assert_not_called()
|
||||
|
||||
|
||||
def test_command_analyze_memory_missing_binutils_fails_by_name(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A truncated toolchain install fails naming the missing tool instead
|
||||
of silently analyzing with host binutils."""
|
||||
setup_core(platform="esp8266", tmp_path=tmp_path, name="test_device")
|
||||
CORE.toolchain = Toolchain.ARDUINO
|
||||
config = {CONF_ESPHOME: {CONF_NAME: "test_device"}}
|
||||
module = "esphome.arduino8266.toolchain"
|
||||
with (
|
||||
patch(f"{module}.get_objdump_path", return_value=tmp_path / "missing-objdump"),
|
||||
patch(f"{module}.get_readelf_path", return_value=tmp_path / "readelf"),
|
||||
patch("esphome.__main__.write_cpp", return_value=0),
|
||||
patch("esphome.__main__.compile_program", return_value=0),
|
||||
):
|
||||
assert command_analyze_memory(MockArgs(), config) == 1
|
||||
assert "missing-objdump" in caplog.text
|
||||
assert "toolchain install may be incomplete" in caplog.text
|
||||
|
||||
|
||||
def test_command_idedata_incompatible_toolchain(tmp_path: Path) -> None:
|
||||
"""A non-native, non-platformio toolchain errors out cleanly."""
|
||||
setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path)
|
||||
CORE.toolchain = Toolchain.SDK_NRF
|
||||
|
||||
assert command_idedata(MagicMock(), CORE.config) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"error",
|
||||
[
|
||||
@@ -7490,46 +7294,6 @@ def test_compile_program_espidf_idedata_none_warns(
|
||||
assert "No idedata was generated" in caplog.text
|
||||
|
||||
|
||||
def test_native_toolchain_table_serves_every_native_toolchain() -> None:
|
||||
"""Every member of NATIVE_TOOLCHAINS has a backend entry; a gap would
|
||||
surface as a targeted EsphomeError on the one affected config, and this
|
||||
pin keeps the table from drifting when a toolchain is added."""
|
||||
from esphome.const import NATIVE_TOOLCHAINS
|
||||
|
||||
assert {tc for _, tc in main._NATIVE_TOOLCHAIN_MODULES} == set(NATIVE_TOOLCHAINS)
|
||||
|
||||
|
||||
def test_native_toolchain_module_missing_backend_raises(tmp_path: Path) -> None:
|
||||
"""A native toolchain missing from the backend table is a bug and must
|
||||
fail, not silently degrade to the PlatformIO path."""
|
||||
setup_core(platform=PLATFORM_ESP8266, tmp_path=tmp_path, name="test_device")
|
||||
CORE.toolchain = Toolchain.ARDUINO
|
||||
with (
|
||||
patch.dict(main._NATIVE_TOOLCHAIN_MODULES, clear=True),
|
||||
pytest.raises(EsphomeError, match="no native build backend"),
|
||||
):
|
||||
_native_toolchain_module()
|
||||
|
||||
|
||||
def test_command_analyze_memory_unsupported_toolchain(
|
||||
tmp_path: Path,
|
||||
mock_write_cpp: Mock,
|
||||
mock_compile_program: Mock,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A hook-less non-PlatformIO toolchain is refused by name, never routed
|
||||
into the PlatformIO branch."""
|
||||
setup_core(platform=PLATFORM_NRF52, tmp_path=tmp_path, name="test_device")
|
||||
CORE.toolchain = Toolchain.SDK_NRF
|
||||
mock_write_cpp.return_value = 0
|
||||
mock_compile_program.return_value = 0
|
||||
|
||||
result = command_analyze_memory(MockArgs(), {CONF_ESPHOME: {CONF_NAME: "t"}})
|
||||
|
||||
assert result == 1
|
||||
assert "analyze-memory is not supported" in caplog.text
|
||||
|
||||
|
||||
def test_cli_toolchain_skips_the_validated_config_cache(tmp_path: Path) -> None:
|
||||
"""An explicit --toolchain must run the per-platform validators, so the
|
||||
upload/logs fast path becomes a cache miss."""
|
||||
@@ -7545,29 +7309,6 @@ def test_cli_toolchain_skips_the_validated_config_cache(tmp_path: Path) -> None:
|
||||
mock_read.assert_called_once()
|
||||
|
||||
|
||||
def test_upload_using_esptool_native_missing_firmware_raises(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""A stale or absent firmware.bin fails by name instead of flashing air."""
|
||||
setup_core(platform=PLATFORM_ESP8266, tmp_path=tmp_path, name="test")
|
||||
CORE.toolchain = Toolchain.ARDUINO
|
||||
with pytest.raises(EsphomeError, match="compile the configuration first"):
|
||||
upload_using_esptool(
|
||||
{CONF_ESPHOME: {"platformio_options": {}}}, "/dev/ttyUSB0", None, None
|
||||
)
|
||||
|
||||
|
||||
def test_compile_program_unclaimed_native_toolchain_raises(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""A resolved native toolchain no platform backend claims must fail,
|
||||
never fall through to the PlatformIO project path."""
|
||||
setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path, name="test_device")
|
||||
CORE.toolchain = Toolchain.ARDUINO # esp32 has no arduino-native backend
|
||||
with pytest.raises(EsphomeError, match="no platform backend claimed"):
|
||||
compile_program(MockArgs(), {})
|
||||
|
||||
|
||||
def test_cli_toolchain_still_refreshes_the_validated_config_cache(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
|
||||
@@ -1,460 +0,0 @@
|
||||
"""Tests for esphome/platformio/pch.py.script against a fake SCons env."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import stat
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.platformio import toolchain
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
os.name == "nt", reason="the fake compiler is a POSIX shell script"
|
||||
)
|
||||
|
||||
_SCRIPT = Path(toolchain.__file__).parent / "pch.py.script"
|
||||
|
||||
|
||||
class _FakePlatform:
|
||||
packages = {"framework-x": {}, "toolchain-y": {}}
|
||||
|
||||
def get_package_version(self, name: str) -> str:
|
||||
if name == "toolchain-y":
|
||||
raise KeyError(name)
|
||||
return "1.2.3"
|
||||
|
||||
def get_package(self, name: str) -> object | None:
|
||||
return None
|
||||
|
||||
|
||||
class _BrokenPlatform(_FakePlatform):
|
||||
def get_package_version(self, name: str) -> str:
|
||||
raise RuntimeError("manifest parse error")
|
||||
|
||||
|
||||
class _UnresolvedPlatform(_FakePlatform):
|
||||
"""KeyError from a package that IS installed: unresolved identity."""
|
||||
|
||||
def get_package_version(self, name: str) -> str:
|
||||
raise KeyError(name)
|
||||
|
||||
def get_package(self, name: str) -> object:
|
||||
return object()
|
||||
|
||||
|
||||
class _FakeSConsEnv(dict):
|
||||
"""Just enough of a SCons construction environment for pch.py."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
proj_dir: Path,
|
||||
src_dir: Path,
|
||||
cxx: str,
|
||||
flags: list[str],
|
||||
platform_cls: type[_FakePlatform] = _FakePlatform,
|
||||
):
|
||||
super().__init__(ENV={})
|
||||
self._subst = {
|
||||
"$PROJECT_DIR": str(proj_dir),
|
||||
"$PROJECT_SRC_DIR": str(src_dir),
|
||||
"$CXX": cxx,
|
||||
}
|
||||
self._flags = flags
|
||||
self._platform_cls = platform_cls
|
||||
self.prepended: list[str] = []
|
||||
|
||||
def subst(self, expr: str) -> str: # noqa: N802
|
||||
return self._subst[expr]
|
||||
|
||||
def subst_list(self, expr: str) -> list[list[str]]: # noqa: N802
|
||||
return [self._flags]
|
||||
|
||||
def PioPlatform(self) -> _FakePlatform: # noqa: N802
|
||||
return self._platform_cls()
|
||||
|
||||
def Prepend(self, CXXFLAGS: list[str]) -> None: # noqa: N802, N803
|
||||
self.prepended = CXXFLAGS
|
||||
|
||||
|
||||
def _fake_cxx(
|
||||
tmp_path: Path,
|
||||
fail: bool = False,
|
||||
fail_msg: str | None = None,
|
||||
reject_pch: bool = False,
|
||||
probe_exit: int = 0,
|
||||
) -> Path:
|
||||
"""A compiler stand-in that records its argv and writes the -o target.
|
||||
|
||||
With reject_pch it builds the .gch fine but, like GCC 10 on macOS arm64,
|
||||
warns on any consuming compile that the .gch cannot be loaded; probe_exit
|
||||
sets the exit code of non-header compiles (the load probe).
|
||||
"""
|
||||
cxx = tmp_path / "fake-gxx"
|
||||
body = (
|
||||
'printf -- ---call---\\\\n >> "$0.argv"; printf \'%s\\n\' "$@" >> "$0.argv"\n'
|
||||
)
|
||||
if fail:
|
||||
body += f"echo {fail_msg or 'boom'} >&2\nexit 1\n"
|
||||
else:
|
||||
# Only the c++-header compile has a -o; the load probe has none
|
||||
body += 'out=""; prev=""; mf=0; dep=0\nfor a in "$@"; do [ "$prev" = "-o" ] && out="$a"; prev="$a"; [ "$a" = "-MF" ] && mf=1; case "$a" in -M|-MM|-MD|-MMD) dep=1;; esac; done\n'
|
||||
# Real cc1plus rejects -MF without a dependency flag
|
||||
body += 'if [ "$mf" = 1 ] && [ "$dep" = 0 ]; then echo "cc1plus: error: to generate dependencies you must specify either \x27-M\x27 or \x27-MM\x27" >&2; exit 1; fi\n'
|
||||
body += '[ -n "$out" ] && echo gch > "$out"\n'
|
||||
if reject_pch:
|
||||
body += 'case " $* " in *c++-header*) ;; *) echo "warning: esphome_pch.h.gch: had text segment at different address" >&2;; esac\n'
|
||||
body += f'case " $* " in *c++-header*) exit 0;; *) exit {probe_exit};; esac\n'
|
||||
cxx.write_text("#!/bin/sh\n" + body)
|
||||
cxx.chmod(cxx.stat().st_mode | stat.S_IEXEC)
|
||||
return cxx
|
||||
|
||||
|
||||
def _run_script(
|
||||
tmp_path: Path,
|
||||
flags: list[str] | None = None,
|
||||
fail: bool = False,
|
||||
fail_msg: str | None = None,
|
||||
reject_pch: bool = False,
|
||||
probe_exit: int = 0,
|
||||
missing_cxx: bool = False,
|
||||
env_vars: dict[str, str] | None = None,
|
||||
name: str = "dev",
|
||||
platform_cls: type[_FakePlatform] = _FakePlatform,
|
||||
) -> _FakeSConsEnv:
|
||||
proj = tmp_path / name
|
||||
src = proj / "src"
|
||||
(src / "esphome" / "core").mkdir(parents=True, exist_ok=True)
|
||||
(src / "esphome" / "core" / "defines.h").write_text("#define USE_X\n")
|
||||
cxx = _fake_cxx(
|
||||
tmp_path,
|
||||
fail=fail,
|
||||
fail_msg=fail_msg,
|
||||
reject_pch=reject_pch,
|
||||
probe_exit=probe_exit,
|
||||
)
|
||||
if missing_cxx:
|
||||
cxx = tmp_path / "no-such-gxx"
|
||||
args = (proj, src, str(cxx), flags or ["-DX=1"], platform_cls)
|
||||
# Distinct objects: the -include flags must land on projenv only
|
||||
global_env = _FakeSConsEnv(*args)
|
||||
projenv = _FakeSConsEnv(*args)
|
||||
projenv.global_env = global_env
|
||||
source = _SCRIPT.read_text()
|
||||
with patch.dict(os.environ, env_vars or {}, clear=True):
|
||||
exec( # noqa: S102
|
||||
compile(source, "pch.py", "exec"),
|
||||
{"Import": lambda *_names: None, "env": global_env, "projenv": projenv},
|
||||
)
|
||||
return projenv
|
||||
|
||||
|
||||
def test_pch_script_builds_and_prepends_relative_include(tmp_path: Path) -> None:
|
||||
scons_env = _run_script(tmp_path)
|
||||
proj = tmp_path / "dev"
|
||||
assert (proj / "esphome_pch.h").read_text().endswith('"esphome/core/defines.h"\n')
|
||||
assert (proj / "esphome_pch.h.gch").is_file()
|
||||
assert len((proj / "esphome_pch.h.gch.sum").read_text().strip()) == 64
|
||||
# Relative include: an absolute path would poison ccache keys
|
||||
assert scons_env.prepended == [
|
||||
"-Winvalid-pch",
|
||||
"-Wno-error=invalid-pch",
|
||||
"-include",
|
||||
"esphome_pch.h",
|
||||
]
|
||||
# In production projenv["ENV"] aliases os.environ; only the -include
|
||||
# flags are genuinely scoped to projenv (src compiles)
|
||||
assert scons_env["ENV"]["CCACHE_SLOPPINESS"] == "pch_defines,time_macros"
|
||||
assert scons_env["ENV"]["CCACHE_PCH_EXTSUM"] == "true"
|
||||
assert scons_env.global_env.prepended == []
|
||||
|
||||
|
||||
def test_pch_script_preserves_spaced_flag_elements(tmp_path: Path) -> None:
|
||||
"""One SCons element stays one compiler argv; -include pairs are
|
||||
stripped from the .gch compile."""
|
||||
spaced = tmp_path / "My Configs"
|
||||
spaced.mkdir()
|
||||
(tmp_path / "dev" / "src").mkdir(parents=True, exist_ok=True)
|
||||
(tmp_path / "dev" / "src" / "other.h").write_text("")
|
||||
flags = ['-DUSB_PRODUCT=\\"Pico 2W\\"', "-I", str(spaced), "-include", "other.h"]
|
||||
_run_script(tmp_path, flags=flags)
|
||||
calls = (tmp_path / "fake-gxx.argv").read_text().split("---call---\n")
|
||||
gch_call = next(c for c in calls if "c++-header" in c).splitlines()
|
||||
assert '-DUSB_PRODUCT="Pico 2W"' in gch_call
|
||||
assert str(spaced) in gch_call
|
||||
assert "-include" not in gch_call
|
||||
# The stripped src-resolvable -include is folded into the prefix header
|
||||
pch = (tmp_path / "dev" / "esphome_pch.h").read_text()
|
||||
assert pch.splitlines()[0] == '#include "other.h"'
|
||||
|
||||
|
||||
def test_pch_script_folds_joined_force_include_spelling(tmp_path: Path) -> None:
|
||||
"""-includefoo.h folds like the separated form, matching the native path."""
|
||||
(tmp_path / "dev" / "src").mkdir(parents=True, exist_ok=True)
|
||||
(tmp_path / "dev" / "src" / "other.h").write_text("")
|
||||
_run_script(tmp_path, flags=["-DX=1", "-includeother.h"])
|
||||
pch = (tmp_path / "dev" / "esphome_pch.h").read_text()
|
||||
assert pch.splitlines()[0] == '#include "other.h"'
|
||||
|
||||
|
||||
def test_pch_script_leaves_absolute_force_includes_unfolded(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""An absolute -include resolves through src_dir / name; it must still
|
||||
stay consumer-only or the host path enters the .sum."""
|
||||
outside = tmp_path / "outside.h"
|
||||
outside.write_text("")
|
||||
_run_script(tmp_path, flags=["-DX=1", "-include", str(outside)])
|
||||
pch = (tmp_path / "dev" / "esphome_pch.h").read_text()
|
||||
assert "outside.h" not in pch
|
||||
|
||||
|
||||
def test_pch_script_leaves_non_src_force_includes_unfolded(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""A user -include outside src/ must not enter the prefix header:
|
||||
consumers keep their own copy, so folding an unguarded header would
|
||||
include it twice."""
|
||||
_run_script(tmp_path, flags=["-DX=1", "-include", "user_extra.h"])
|
||||
pch = (tmp_path / "dev" / "esphome_pch.h").read_text()
|
||||
assert "user_extra.h" not in pch
|
||||
assert pch.splitlines()[-1] == '#include "esphome/core/defines.h"'
|
||||
assert "not precompiling non-src force-includes" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_pch_script_sum_is_device_independent(tmp_path: Path) -> None:
|
||||
"""Regression: identical configs in different dirs share cache keys."""
|
||||
sums = []
|
||||
for name in ("dev_a", "dev_b"):
|
||||
proj = tmp_path / name
|
||||
_run_script(
|
||||
tmp_path,
|
||||
flags=["-DX=1", "-I", str(proj / "include")],
|
||||
env_vars={"CCACHE_BASEDIR": str(proj)},
|
||||
name=name,
|
||||
)
|
||||
sums.append((proj / "esphome_pch.h.gch.sum").read_text())
|
||||
(tmp_path / "fake-gxx").unlink()
|
||||
(tmp_path / "fake-gxx.argv").unlink(missing_ok=True)
|
||||
assert sums[0] == sums[1]
|
||||
|
||||
|
||||
def test_pch_script_transient_compiler_failure_does_not_latch(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""ENOSPC-style failures clear on their own; no .failed marker."""
|
||||
scons_env = _run_script(tmp_path, fail=True, fail_msg="No space left on device")
|
||||
proj = tmp_path / "dev"
|
||||
assert not (proj / "esphome_pch.h.gch.failed").exists()
|
||||
assert scons_env.prepended == []
|
||||
|
||||
|
||||
def test_pch_script_failure_marker_suppresses_retry(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
scons_env = _run_script(tmp_path, fail=True)
|
||||
proj = tmp_path / "dev"
|
||||
assert (proj / "esphome_pch.h.gch.failed").is_file()
|
||||
assert not (proj / "esphome_pch.h.gch.sum").exists()
|
||||
assert scons_env.prepended == []
|
||||
# Second run: same checksum, no compile attempt, but says so
|
||||
attempts = (tmp_path / "fake-gxx.argv").read_text().count("c++-header")
|
||||
_run_script(tmp_path, fail=True)
|
||||
out = capsys.readouterr().out
|
||||
assert (tmp_path / "fake-gxx.argv").read_text().count("c++-header") == attempts
|
||||
assert "delete esphome_pch.h.gch.failed to retry" in out
|
||||
|
||||
|
||||
def test_pch_script_probe_rejection_falls_back(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""A toolchain that cannot load its own .gch (GCC 10 on macOS arm64)
|
||||
must not leave consumers paying for a pch every compile rejects."""
|
||||
scons_env = _run_script(tmp_path, reject_pch=True)
|
||||
proj = tmp_path / "dev"
|
||||
assert not (proj / "esphome_pch.h.gch").exists()
|
||||
assert not (proj / "esphome_pch.h.gch.sum").exists()
|
||||
assert (proj / "esphome_pch.h.gch.failed").is_file()
|
||||
assert scons_env.prepended == []
|
||||
assert "toolchain cannot load the pch" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_pch_script_spawn_failure_is_transient(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""A spawn failure must not latch a .failed marker (matches espidf)."""
|
||||
scons_env = _run_script(tmp_path, missing_cxx=True)
|
||||
proj = tmp_path / "dev"
|
||||
assert not (proj / "esphome_pch.h.gch.failed").exists()
|
||||
assert not (proj / "esphome_pch.h.gch.sum").exists()
|
||||
assert scons_env.prepended == []
|
||||
assert "did not run" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_pch_script_probe_nonzero_exit_falls_back(tmp_path: Path) -> None:
|
||||
"""A probe failure whose stderr never mentions .gch must still count."""
|
||||
scons_env = _run_script(tmp_path, probe_exit=1)
|
||||
proj = tmp_path / "dev"
|
||||
assert not (proj / "esphome_pch.h.gch").exists()
|
||||
assert (proj / "esphome_pch.h.gch.failed").is_file()
|
||||
assert scons_env.prepended == []
|
||||
|
||||
|
||||
def test_pch_script_unresolved_package_version_skips_pch(tmp_path: Path) -> None:
|
||||
"""A KeyError for an installed package is unresolved identity, not absence."""
|
||||
scons_env = _run_script(tmp_path, platform_cls=_UnresolvedPlatform)
|
||||
assert not (tmp_path / "dev" / "esphome_pch.h.gch").exists()
|
||||
assert scons_env.prepended == []
|
||||
|
||||
|
||||
def test_pch_script_package_version_error_skips_pch(tmp_path: Path) -> None:
|
||||
"""Without trustworthy package identity a stale .gch could survive an
|
||||
upgrade, so the script must not build one at all."""
|
||||
scons_env = _run_script(tmp_path, platform_cls=_BrokenPlatform)
|
||||
proj = tmp_path / "dev"
|
||||
assert not (proj / "esphome_pch.h.gch").exists()
|
||||
assert scons_env.prepended == []
|
||||
|
||||
|
||||
def test_pch_script_corrupt_sidecar_reads_as_stale(tmp_path: Path) -> None:
|
||||
"""A truncated/corrupt .failed marker must not disable the pch forever."""
|
||||
_run_script(tmp_path, fail=True)
|
||||
proj = tmp_path / "dev"
|
||||
(proj / "esphome_pch.h.gch.failed").write_bytes(b"\xff\xfe corrupt")
|
||||
_run_script(tmp_path)
|
||||
assert (proj / "esphome_pch.h.gch").is_file()
|
||||
assert (proj / "esphome_pch.h.gch.sum").is_file()
|
||||
|
||||
|
||||
def test_pch_script_rebuilds_when_header_missing(tmp_path: Path) -> None:
|
||||
_run_script(tmp_path)
|
||||
proj = tmp_path / "dev"
|
||||
(proj / "esphome_pch.h").unlink()
|
||||
_run_script(tmp_path)
|
||||
assert (proj / "esphome_pch.h").is_file()
|
||||
|
||||
|
||||
def test_copy_pch_script(tmp_path: Path) -> None:
|
||||
from esphome.core import CORE
|
||||
|
||||
CORE.build_path = tmp_path
|
||||
toolchain.copy_pch_script()
|
||||
assert (tmp_path / "pch.py").read_text() == _SCRIPT.read_text()
|
||||
|
||||
|
||||
def test_pch_script_unions_user_sloppiness(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""A user CCACHE_SLOPPINESS without the pch tokens gets them unioned on,
|
||||
mirroring ccache_pch_env, or every src TU is a permanent miss."""
|
||||
scons_env = _run_script(tmp_path, env_vars={"CCACHE_SLOPPINESS": "locale"})
|
||||
assert scons_env["ENV"]["CCACHE_SLOPPINESS"] == "locale,pch_defines,time_macros"
|
||||
assert "adding pch_defines,time_macros" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_pch_script_unmodelable_flag_skips_pch(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""Unbalanced quotes and multi-token elements cannot be reproduced as
|
||||
one argv; the pch is skipped rather than built with diverging flags."""
|
||||
for bad in ("-DFOO='bar", "-DA=1\t-DB=2"):
|
||||
scons_env = _run_script(tmp_path, flags=["-DX=1", bad])
|
||||
assert scons_env.prepended == []
|
||||
assert not (tmp_path / "dev" / "esphome_pch.h.gch").exists()
|
||||
assert "unmodelable flag" in capsys.readouterr().out
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
getattr(os, "geteuid", lambda: -1)() == 0, reason="root ignores file modes"
|
||||
)
|
||||
def test_pch_script_unlistable_include_dir_skips_pch(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""An unlistable subtree must not silently drop out of the digest."""
|
||||
proj = tmp_path / "dev"
|
||||
override = proj / "lwip_override"
|
||||
hidden = override / "hidden"
|
||||
hidden.mkdir(parents=True)
|
||||
(hidden / "gen.h").write_text("")
|
||||
hidden.chmod(0)
|
||||
try:
|
||||
scons_env = _run_script(tmp_path, flags=["-DX=1", "-I", str(override)])
|
||||
finally:
|
||||
hidden.chmod(0o755)
|
||||
assert scons_env.prepended == []
|
||||
assert "skipping precompiled header" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_pch_script_nobuild_without_projenv_is_noop(tmp_path: Path) -> None:
|
||||
"""-t nobuild never exports projenv; the script must not abort."""
|
||||
proj = tmp_path / "dev"
|
||||
(proj / "src").mkdir(parents=True)
|
||||
|
||||
def strict_import(*names: str) -> None:
|
||||
if "projenv" in names:
|
||||
raise RuntimeError("Import of non-existent variable 'projenv'")
|
||||
|
||||
env = _FakeSConsEnv(proj, proj / "src", "g++", ["-DX=1"])
|
||||
exec( # noqa: S102
|
||||
compile(_SCRIPT.read_text(), "pch.py", "exec"),
|
||||
{"Import": strict_import, "env": env},
|
||||
)
|
||||
assert not (proj / "esphome_pch.h").exists()
|
||||
|
||||
|
||||
def test_pch_script_ignores_library_trees_and_non_headers(tmp_path: Path) -> None:
|
||||
""".piolibdeps and non-header files must not enter the digest (or be
|
||||
read at all); package versions already cover library identity."""
|
||||
proj = tmp_path / "dev"
|
||||
libdeps = proj / ".piolibdeps" / "lib" / "src"
|
||||
libdeps.mkdir(parents=True)
|
||||
(libdeps / "lib.h").write_text("#define A 1\n")
|
||||
override = proj / "lwip_override"
|
||||
override.mkdir(parents=True)
|
||||
(override / "lwipopts.h").write_text("#define TCP_MSS 1460\n")
|
||||
(override / "notes.txt").write_text("v1\n")
|
||||
flags = ["-DX=1", "-I", str(libdeps), "-I", str(override)]
|
||||
_run_script(tmp_path, flags=flags)
|
||||
first = (proj / "esphome_pch.h.gch.sum").read_text()
|
||||
(libdeps / "lib.h").write_text("#define A 2\n")
|
||||
(override / "notes.txt").write_text("v2\n")
|
||||
(tmp_path / "fake-gxx.argv").unlink(missing_ok=True)
|
||||
_run_script(tmp_path, flags=flags)
|
||||
assert (proj / "esphome_pch.h.gch.sum").read_text() == first
|
||||
|
||||
|
||||
def test_pch_script_hashes_project_local_include_dirs(tmp_path: Path) -> None:
|
||||
"""Generated headers in project-local -I dirs (e.g. rp2's lwip_override)
|
||||
must invalidate the checksum when they change."""
|
||||
proj = tmp_path / "dev"
|
||||
override = proj / "lwip_override"
|
||||
override.mkdir(parents=True)
|
||||
(override / "lwipopts.h").write_text("#define TCP_MSS 1460\n")
|
||||
flags = ["-DX=1", "-I", str(override)]
|
||||
_run_script(tmp_path, flags=flags)
|
||||
first = (proj / "esphome_pch.h.gch.sum").read_text()
|
||||
(override / "lwipopts.h").write_text("#define TCP_MSS 536\n")
|
||||
(tmp_path / "fake-gxx.argv").unlink(missing_ok=True)
|
||||
_run_script(tmp_path, flags=flags)
|
||||
assert (proj / "esphome_pch.h.gch.sum").read_text() != first
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
getattr(os, "geteuid", lambda: -1)() == 0, reason="root ignores file modes"
|
||||
)
|
||||
def test_pch_script_unreadable_local_header_skips_pch(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""An unreadable generated header means unknown identity: no pch."""
|
||||
proj = tmp_path / "dev"
|
||||
override = proj / "lwip_override"
|
||||
override.mkdir(parents=True)
|
||||
secret = override / "lwipopts.h"
|
||||
secret.write_text("#define TCP_MSS 1460\n")
|
||||
secret.chmod(0)
|
||||
scons_env = _run_script(tmp_path, flags=["-DX=1", "-I", str(override)])
|
||||
assert not (proj / "esphome_pch.h.gch.sum").exists()
|
||||
assert scons_env.prepended == []
|
||||
assert "skipping precompiled header" in capsys.readouterr().out
|
||||
@@ -516,13 +516,10 @@ def test_clean_build(
|
||||
dependencies_lock = tmp_path / "dependencies.lock"
|
||||
dependencies_lock.write_text("lock file")
|
||||
|
||||
# idedata caches live under the data dir, not the build path; the
|
||||
# .arduino.json variant is the native esp8266 toolchain's.
|
||||
# idedata cache lives under the data dir, not the build path.
|
||||
idedata_cache = tmp_path / "idedata" / "test.json"
|
||||
idedata_cache.parent.mkdir()
|
||||
idedata_cache.write_text("{}")
|
||||
arduino_idedata_cache = tmp_path / "idedata" / "test.arduino.json"
|
||||
arduino_idedata_cache.write_text("{}")
|
||||
|
||||
# Native ESP-IDF toolchain artifacts.
|
||||
idf_build_dir = tmp_path / "build"
|
||||
@@ -583,7 +580,6 @@ def test_clean_build(
|
||||
assert not piolibdeps_dir.exists()
|
||||
assert not dependencies_lock.exists()
|
||||
assert not idedata_cache.exists()
|
||||
assert not arduino_idedata_cache.exists()
|
||||
assert not idf_build_dir.exists()
|
||||
assert not managed_components_dir.exists()
|
||||
assert not pio_components_dir.exists()
|
||||
@@ -677,32 +673,6 @@ def test_clean_build_partial_exists(
|
||||
assert "dependencies.lock" not in caplog.text
|
||||
|
||||
|
||||
@patch("esphome.writer.CORE")
|
||||
def test_clean_build_partial_removes_pch_artifacts(
|
||||
mock_core: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""The PlatformIO pch sidecars live at the project root and must go in
|
||||
a partial clean, like the native backend's under .pioenvs."""
|
||||
names = (
|
||||
"esphome_pch.h",
|
||||
"esphome_pch.h.gch",
|
||||
"esphome_pch.h.gch.sum",
|
||||
"esphome_pch.h.gch.failed",
|
||||
)
|
||||
for name in names:
|
||||
(tmp_path / name).write_text("x")
|
||||
mock_core.relative_pioenvs_path.return_value = tmp_path / ".pioenvs"
|
||||
mock_core.relative_piolibdeps_path.return_value = tmp_path / ".piolibdeps"
|
||||
mock_core.relative_build_path.side_effect = lambda name: tmp_path / name
|
||||
mock_core.relative_internal_path.side_effect = tmp_path.joinpath
|
||||
|
||||
clean_build()
|
||||
|
||||
for name in names:
|
||||
assert not (tmp_path / name).exists()
|
||||
|
||||
|
||||
@patch("esphome.writer.CORE")
|
||||
def test_clean_build_nothing_exists(
|
||||
mock_core: MagicMock,
|
||||
|
||||
Reference in New Issue
Block a user