mirror of
https://github.com/esphome/esphome.git
synced 2026-09-08 22:08:49 +00:00
Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2a4981e25 | ||
|
|
097d487261 | ||
|
|
89082c7b2d | ||
|
|
fb3befe377 | ||
|
|
4a3a787756 | ||
|
|
5aec93d7e8 | ||
|
|
a79cd1550c | ||
|
|
6d23e4c842 | ||
|
|
cbdcfe640c | ||
|
|
2bc20d8721 | ||
|
|
2dbb3e055b | ||
|
|
7549309ad3 | ||
|
|
e600180417 | ||
|
|
0641dae9d2 | ||
|
|
bcf812d62b | ||
|
|
b0ce7f58f3 | ||
|
|
455e1d6374 | ||
|
|
3af1d50bce | ||
|
|
842f354a05 | ||
|
|
af9b59d4bd | ||
|
|
55fc5a10de | ||
|
|
79927b918b | ||
|
|
1b070629bc | ||
|
|
c4e1360cdf | ||
|
|
7c774699d7 | ||
|
|
ea71a24a9b | ||
|
|
709a1e1eb6 | ||
|
|
822b701792 | ||
|
|
d2e4d2c46a | ||
|
|
adbbda4072 | ||
|
|
252bf6ea6a | ||
|
|
8ec9305688 | ||
|
|
490aca17e6 | ||
|
|
b77e2441d4 | ||
|
|
3b14f4dfc8 |
@@ -1,39 +0,0 @@
|
||||
name: Cache Arduino ESP8266
|
||||
description: >
|
||||
Resolve the pinned Arduino core and xtensa toolchain versions and cache the
|
||||
native ESP8266 install (~110 MB framework + toolchain; no ccache store, the
|
||||
seed job saves before any compile runs). Exports
|
||||
ESPHOME_ARDUINO8266_PREFIX to the job so every later step installs into
|
||||
the cached path; the Python venv must already be restored. Mirrors
|
||||
cache-esp-idf: only dev-branch pushes write the shared cache, everything
|
||||
else restores.
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Resolve the native toolchain cache key
|
||||
# Versions are pinned in code, not a hashable file; resolve them so a
|
||||
# bump changes the cache key. Assignment form so errexit catches a
|
||||
# resolver failure.
|
||||
id: version
|
||||
shell: bash
|
||||
run: |
|
||||
# One owner for the install prefix: exported here and referenced by
|
||||
# the cache steps below via env, so the caller's install and the
|
||||
# cached path cannot diverge.
|
||||
echo "ESPHOME_ARDUINO8266_PREFIX=$HOME/.esphome-arduino8266" >> "$GITHUB_ENV"
|
||||
. venv/bin/activate
|
||||
key=$(python -c 'from esphome.components.esp8266 import RECOMMENDED_ARDUINO_FRAMEWORK_VERSION as f; from esphome.arduino8266.framework import TOOLCHAIN_VERSION as t; print(f"{f}-{t}")')
|
||||
[ -n "$key" ] || exit 1
|
||||
echo "key=$key" >> "$GITHUB_OUTPUT"
|
||||
- name: Cache the native toolchain (write on dev)
|
||||
if: github.ref == 'refs/heads/dev'
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ${{ env.ESPHOME_ARDUINO8266_PREFIX }}
|
||||
key: ${{ runner.os }}-esp8266-native-${{ steps.version.outputs.key }}
|
||||
- name: Restore the native toolchain (off dev)
|
||||
if: github.ref != 'refs/heads/dev'
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ${{ env.ESPHOME_ARDUINO8266_PREFIX }}
|
||||
key: ${{ runner.os }}-esp8266-native-${{ steps.version.outputs.key }}
|
||||
@@ -21,7 +21,6 @@ on:
|
||||
- "esphome/core/**"
|
||||
- "esphome/writer.py"
|
||||
- "esphome/build_gen/**"
|
||||
- "esphome/build_helpers/**"
|
||||
- "esphome/espidf/**"
|
||||
- "esphome/platformio/**"
|
||||
- "esphome/components/bk72xx/**"
|
||||
@@ -198,7 +197,6 @@ jobs:
|
||||
# the default.
|
||||
id:
|
||||
- esp8266-arduino
|
||||
- esp8266-arduino-native
|
||||
- esp32-arduino-platformio
|
||||
- esp32-arduino-esp-idf
|
||||
- esp32-idf-platformio
|
||||
@@ -209,16 +207,6 @@ jobs:
|
||||
- ln882x-arduino
|
||||
- nrf52
|
||||
- host
|
||||
# Strict by default so a new matrix id cannot silently join in the
|
||||
# degrade-quietly mode the knob exists to catch.
|
||||
# Opt-outs: libretiny GCC rejects its own pch until a toolchain bump.
|
||||
include:
|
||||
- id: bk72xx-arduino
|
||||
pch_strict: "0"
|
||||
- id: rtl87xx-arduino
|
||||
pch_strict: "0"
|
||||
- id: ln882x-arduino
|
||||
pch_strict: "0"
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Download image artifact
|
||||
@@ -230,7 +218,6 @@ jobs:
|
||||
- name: Compile ${{ matrix.id }}
|
||||
run: |
|
||||
docker run --rm \
|
||||
-e ESPHOME_PCH_STRICT="${{ matrix.pch_strict || '1' }}" \
|
||||
-v "${{ github.workspace }}/docker/test_configs:/config" \
|
||||
"ghcr.io/esphome/esphome-amd64:${{ needs.check-docker.outputs.tag }}" \
|
||||
compile "${{ matrix.id }}.yaml"
|
||||
|
||||
@@ -102,8 +102,6 @@ jobs:
|
||||
device-builder: ${{ steps.determine.outputs.device-builder }}
|
||||
esp32-platformio: ${{ steps.determine.outputs.esp32-platformio }}
|
||||
esp32-platformio-components: ${{ steps.determine.outputs.esp32-platformio-components }}
|
||||
esp8266-native: ${{ steps.determine.outputs.esp8266-native }}
|
||||
esp8266-native-components: ${{ steps.determine.outputs.esp8266-native-components }}
|
||||
changed-components: ${{ steps.determine.outputs.changed-components }}
|
||||
changed-components-with-tests: ${{ steps.determine.outputs.changed-components-with-tests }}
|
||||
directly-changed-components-with-tests: ${{ steps.determine.outputs.directly-changed-components-with-tests }}
|
||||
@@ -167,8 +165,6 @@ jobs:
|
||||
echo "device-builder=$(echo "$output" | jq -r '.device_builder')" >> $GITHUB_OUTPUT
|
||||
echo "esp32-platformio=$(echo "$output" | jq -r '.esp32_platformio')" >> $GITHUB_OUTPUT
|
||||
echo "esp32-platformio-components=$(echo "$output" | jq -r '.esp32_platformio_components')" >> $GITHUB_OUTPUT
|
||||
echo "esp8266-native=$(echo "$output" | jq -r '.esp8266_native')" >> $GITHUB_OUTPUT
|
||||
echo "esp8266-native-components=$(echo "$output" | jq -r '.esp8266_native_components')" >> $GITHUB_OUTPUT
|
||||
echo "changed-components=$(echo "$output" | jq -c '.changed_components')" >> $GITHUB_OUTPUT
|
||||
echo "changed-components-with-tests=$(echo "$output" | jq -c '.changed_components_with_tests')" >> $GITHUB_OUTPUT
|
||||
echo "directly-changed-components-with-tests=$(echo "$output" | jq -c '.directly_changed_components_with_tests')" >> $GITHUB_OUTPUT
|
||||
@@ -187,32 +183,6 @@ jobs:
|
||||
path: .temp/components_graph.json
|
||||
key: components-graph-${{ hashFiles('esphome/components/**/*.py') }}
|
||||
|
||||
seed-esp8266-native-cache:
|
||||
name: Seed the esp8266 native toolchain cache
|
||||
runs-on: ubuntu-24.04
|
||||
needs:
|
||||
- common
|
||||
# PR-branch cache saves are invisible to other PRs, so dev pushes seed
|
||||
# the shared entry test-esp8266-native restores. Only dev: the composite
|
||||
# action saves nowhere else, so a beta/release push would download the
|
||||
# toolchain and discard it.
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Restore Python
|
||||
uses: ./.github/actions/restore-python
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
cache-key: ${{ needs.common.outputs.cache-key }}
|
||||
- name: Cache the native toolchain
|
||||
uses: ./.github/actions/cache-arduino8266
|
||||
- name: Install the native toolchain
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
python -c "from esphome.arduino8266.framework import check_and_install; from esphome.components.esp8266 import RECOMMENDED_ARDUINO_FRAMEWORK_VERSION; check_and_install(RECOMMENDED_ARDUINO_FRAMEWORK_VERSION)"
|
||||
|
||||
ci-custom:
|
||||
name: Run script/ci-custom
|
||||
runs-on: ubuntu-24.04
|
||||
@@ -274,20 +244,11 @@ jobs:
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Read prek version from requirements_test.txt
|
||||
id: prek
|
||||
# requirements_test.txt is the only place the version is pinned, so a
|
||||
# Dependabot bump there is picked up here without a second edit.
|
||||
run: |
|
||||
if ! version=$(sed -nE 's/^prek==([^[:space:]#]+).*/\1/p' requirements_test.txt) || [ -z "$version" ]; then
|
||||
echo "::error::No prek== pin found in requirements_test.txt."
|
||||
exit 1
|
||||
fi
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
- name: Run prek
|
||||
uses: j178/prek-action@4e14d07f9231acabce116ccfca13b13dd9755ece # v3.0.0
|
||||
with:
|
||||
prek-version: ${{ steps.prek.outputs.version }}
|
||||
# Keep in sync with requirements_test.txt.
|
||||
prek-version: "0.4.11"
|
||||
# This job only runs on pull requests, so nothing ever populates
|
||||
# the cache on dev. Every run would miss and then write a per-pull
|
||||
# request copy, which is what the old seed-cache job existed to
|
||||
@@ -1267,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..."
|
||||
@@ -1276,42 +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. Strict pch:
|
||||
# exercises the native ninja pch (and its probe edge) against real
|
||||
# component configs; the docker matrix smoke-tests both toolchains.
|
||||
ESPHOME_PCH_STRICT=1 python3 script/test_build_components.py -e compile -t esp8266-ard -c "$TEST_COMPONENTS" -f --toolchain arduino --fail-on-no-tests
|
||||
|
||||
device-builder:
|
||||
name: Test downstream esphome/device-builder
|
||||
runs-on: ubuntu-24.04
|
||||
@@ -1674,7 +1599,6 @@ jobs:
|
||||
needs:
|
||||
- common
|
||||
- seed-apt-cache
|
||||
- seed-esp8266-native-cache
|
||||
- determine-jobs
|
||||
- ci-custom
|
||||
- pylint
|
||||
@@ -1690,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
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
# Keeps pre-commit hook revs in sync with the requirements files.
|
||||
#
|
||||
# Dependabot only bumps the pins in requirements*.txt. Some of those tools
|
||||
# are pinned again as hook revs in .pre-commit-config.yaml. This workflow
|
||||
# runs script/sync_dependency_versions.py against the pull request branch
|
||||
# and pushes a commit with the revs updated.
|
||||
|
||||
name: Sync dependency versions
|
||||
|
||||
on:
|
||||
# pull_request_target rather than pull_request so the App secret is
|
||||
# available on Dependabot pull requests (pull_request runs opened by
|
||||
# Dependabot only see Dependabot secrets). The job below only touches
|
||||
# branches in this repository and only ever executes the script from the
|
||||
# base branch checkout, so fork code never runs with the token.
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened]
|
||||
paths:
|
||||
- requirements_dev.txt
|
||||
- requirements_test.txt
|
||||
- .pre-commit-config.yaml
|
||||
- script/sync_dependency_versions.py
|
||||
|
||||
# The push to the pull request branch uses the App token minted below, so
|
||||
# the workflow's GITHUB_TOKEN does not need any scopes.
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
name: Sync pinned versions
|
||||
runs-on: ubuntu-latest
|
||||
# Same-repository branches only: a push to a fork is not possible with
|
||||
# this token, and it keeps untrusted heads out of a privileged job.
|
||||
if: >-
|
||||
github.repository == 'esphome/esphome'
|
||||
&& github.event.pull_request.head.repo.full_name == github.repository
|
||||
steps:
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
with:
|
||||
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
|
||||
# A push made with the workflow's own GITHUB_TOKEN would not start
|
||||
# CI on the new commit; a push with the App token does.
|
||||
permission-contents: write # git push of the sync commit to the pull request branch
|
||||
|
||||
- name: Check out base branch
|
||||
# Provides the script that runs below. Deliberately the base branch
|
||||
# so the pull request cannot change what executes here.
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check out pull request branch
|
||||
# No allow-unsafe-pr-checkout here on purpose: checkout v7 only
|
||||
# refuses heads that live in a different repository, and the job
|
||||
# condition above already limits runs to same-repository branches.
|
||||
# Leaving it off keeps that refusal as a backstop for fork heads.
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.ref }}
|
||||
path: pull-request
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install yamlrocks
|
||||
# The script edits YAML through yamlrocks. Take the pin from the
|
||||
# base branch requirements so this workflow has no copy of its own.
|
||||
run: pip install "$(grep -E '^yamlrocks==' requirements_test.txt | cut -d'#' -f1)"
|
||||
|
||||
- name: Sync pinned versions
|
||||
run: python script/sync_dependency_versions.py --root pull-request
|
||||
|
||||
- name: Push changes
|
||||
working-directory: pull-request
|
||||
run: |
|
||||
if git diff --quiet; then
|
||||
echo "All pinned versions already match the requirements files."
|
||||
exit 0
|
||||
fi
|
||||
git config user.name "esphome[bot]"
|
||||
git config user.email "115708604+esphome[bot]@users.noreply.github.com"
|
||||
git commit -am "Sync pinned tool versions with requirements files"
|
||||
git push
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
# See https://pre-commit.com for more information
|
||||
# See https://pre-commit.com/hooks.html for more hooks
|
||||
|
||||
ci:
|
||||
autoupdate_commit_msg: 'pre-commit: autoupdate'
|
||||
autoupdate_schedule: off # Disabled until ruff versions are synced between deps and pre-commit
|
||||
@@ -10,7 +11,7 @@ ci:
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
# Ruff version.
|
||||
rev: v0.16.6
|
||||
rev: v0.16.3
|
||||
hooks:
|
||||
# Run the linter.
|
||||
- id: ruff
|
||||
@@ -41,7 +42,7 @@ repos:
|
||||
- id: pyupgrade
|
||||
args: [--py312-plus]
|
||||
- repo: https://github.com/adrienverge/yamllint.git
|
||||
rev: v1.38.0
|
||||
rev: v1.37.1
|
||||
hooks:
|
||||
- id: yamllint
|
||||
exclude: ^(\.clang-format|\.clang-tidy)$
|
||||
|
||||
@@ -840,7 +840,7 @@ file does, and it is the authority when they disagree. The most useful starting
|
||||
cv.rename_key(
|
||||
CONF_OLD_KEY, CONF_NEW_KEY, removed_in="2026.6.0", component="my_component"
|
||||
),
|
||||
cv.Schema({...}),
|
||||
cv.Schema({ ... }),
|
||||
)
|
||||
```
|
||||
For other deprecations, warn manually during validation:
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ RUN \
|
||||
-r /requirements.txt
|
||||
|
||||
# Install the ESPHome Device Builder dashboard.
|
||||
RUN uv pip install --no-cache-dir esphome-device-builder==1.14.5
|
||||
RUN uv pip install --no-cache-dir esphome-device-builder==1.14.4
|
||||
|
||||
RUN \
|
||||
platformio settings set enable_telemetry No \
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
esphome:
|
||||
name: docker-test-esp8266-native
|
||||
|
||||
esp8266:
|
||||
board: d1_mini
|
||||
toolchain: arduino
|
||||
|
||||
logger:
|
||||
+31
-83
@@ -817,9 +817,7 @@ def write_cpp_file() -> int:
|
||||
from esphome.build_gen import espidf
|
||||
|
||||
espidf.write_project()
|
||||
elif not CORE.using_native_toolchain:
|
||||
# Other native builds generate their project at compile time;
|
||||
# never write a platformio.ini for them
|
||||
else:
|
||||
from esphome.build_gen import platformio
|
||||
|
||||
platformio.write_project()
|
||||
@@ -861,14 +859,20 @@ def compile_program(args: ArgsProtocol, config: ConfigType) -> int:
|
||||
toolchain.create_factory_bin()
|
||||
toolchain.create_ota_bin()
|
||||
toolchain.create_elf_copy()
|
||||
from esphome.build_helpers.idedata import warn_if_idedata_missing
|
||||
from esphome.build_helpers.idedata import IDEDATA_BEST_EFFORT_ERRORS
|
||||
|
||||
warn_if_idedata_missing(toolchain.get_idedata)
|
||||
elif CORE.using_native_toolchain:
|
||||
raise EsphomeError(
|
||||
f"Toolchain '{CORE.toolchain.value}' resolved but no platform "
|
||||
"backend claimed the build"
|
||||
)
|
||||
try:
|
||||
if toolchain.get_idedata() is None:
|
||||
_LOGGER.warning("No idedata was generated for this build")
|
||||
except IDEDATA_BEST_EFFORT_ERRORS as err:
|
||||
# The firmware already built; an idedata failure must not fail
|
||||
# a successful build.
|
||||
_LOGGER.warning(
|
||||
"Could not generate idedata: %s (IDE, clang-tidy, and "
|
||||
"memory-analysis data will be unavailable for this build)",
|
||||
err,
|
||||
)
|
||||
_LOGGER.debug("Idedata failure detail", exc_info=True)
|
||||
else:
|
||||
from esphome.platformio import toolchain
|
||||
|
||||
@@ -971,15 +975,12 @@ def upload_using_esptool(
|
||||
|
||||
if file is not None:
|
||||
flash_images = [FlashImage(path=file, offset="0x0")]
|
||||
elif (native := _native_toolchain_module()) is not None:
|
||||
# Every native backend supplies its own 0x0 flash image (bootloader
|
||||
# and partitions included where the target needs them)
|
||||
image = native.get_factory_firmware_path()
|
||||
if not image.is_file():
|
||||
raise EsphomeError(
|
||||
f"{image} does not exist; compile the configuration first"
|
||||
)
|
||||
flash_images = [FlashImage(path=image, offset="0x0")]
|
||||
elif CORE.using_toolchain_esp_idf:
|
||||
from esphome.espidf import toolchain
|
||||
|
||||
flash_images = [
|
||||
FlashImage(path=toolchain.get_factory_firmware_path(), offset="0x0")
|
||||
]
|
||||
else:
|
||||
from esphome.platformio import toolchain
|
||||
|
||||
@@ -1961,39 +1962,15 @@ def command_update_all(args: ArgsProtocol) -> int | None:
|
||||
return run_multiple_configs(files, build_command)
|
||||
|
||||
|
||||
# Native build backend per (target platform, toolchain). Keyed here rather
|
||||
# than through a platform hook so the serial upload/logs fast path never
|
||||
# imports the platform component package (see the esp32 variant comment in
|
||||
# upload_using_esptool); the platform half comes from CORE.data the same way.
|
||||
_NATIVE_TOOLCHAIN_MODULES = {
|
||||
("esp32", Toolchain.ESP_IDF): "esphome.espidf.toolchain",
|
||||
("esp8266", Toolchain.ARDUINO): "esphome.arduino8266.toolchain",
|
||||
}
|
||||
|
||||
|
||||
def _native_toolchain_module():
|
||||
"""The native build backend module for the resolved toolchain."""
|
||||
if not CORE.using_native_toolchain:
|
||||
return None
|
||||
key = (CORE.target_platform, CORE.toolchain)
|
||||
if (module_path := _NATIVE_TOOLCHAIN_MODULES.get(key)) is None:
|
||||
# Degrading to the PlatformIO path would build with the wrong backend
|
||||
raise EsphomeError(
|
||||
f"Toolchain '{CORE.toolchain.value}' has no native build backend "
|
||||
f"module for platform {CORE.target_platform}"
|
||||
)
|
||||
return importlib.import_module(module_path)
|
||||
|
||||
|
||||
def command_idedata(args: ArgsProtocol, config: ConfigType) -> int:
|
||||
import json
|
||||
|
||||
native_toolchain = _native_toolchain_module()
|
||||
if CORE.using_toolchain_esp_idf:
|
||||
# Native ESP-IDF derives idedata from the build's compile_commands.json,
|
||||
# so the configuration must already be compiled.
|
||||
from esphome.espidf import toolchain as espidf_toolchain
|
||||
|
||||
if native_toolchain is not None:
|
||||
# Native toolchains derive idedata from the build's
|
||||
# compile_commands.json, so the configuration must already be compiled.
|
||||
idedata = native_toolchain.get_idedata()
|
||||
idedata = espidf_toolchain.get_idedata()
|
||||
if idedata is None:
|
||||
_LOGGER.error(
|
||||
"No idedata available; compile the configuration first",
|
||||
@@ -2032,17 +2009,6 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int:
|
||||
from esphome.analyze_memory.cli import MemoryAnalyzerCLI
|
||||
from esphome.analyze_memory.ram_strings import RamStringsAnalyzer
|
||||
|
||||
# Refuse an unsupported toolchain before paying for a full compile
|
||||
native_toolchain = _native_toolchain_module()
|
||||
if native_toolchain is None and not CORE.using_toolchain_platformio:
|
||||
_LOGGER.error(
|
||||
"analyze-memory is not supported with the '%s' toolchain on %s; "
|
||||
"re-run with --toolchain platformio",
|
||||
CORE.toolchain.value if CORE.toolchain else "unresolved",
|
||||
CORE.target_platform,
|
||||
)
|
||||
return 1
|
||||
|
||||
# Always compile to ensure fresh data (fast if no changes - just relinks)
|
||||
exit_code = write_cpp(config)
|
||||
if exit_code != 0:
|
||||
@@ -2054,31 +2020,13 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int:
|
||||
|
||||
# Get idedata for analysis
|
||||
idedata = None
|
||||
if native_toolchain is not None:
|
||||
objdump = native_toolchain.get_objdump_path()
|
||||
readelf = native_toolchain.get_readelf_path()
|
||||
for tool in (objdump, readelf):
|
||||
if not tool.is_file():
|
||||
# The analyzer would silently fall back to host binutils,
|
||||
# which cannot read the target ELF. clean-all is heavy for
|
||||
# ESP-IDF, so suggest a recompile first.
|
||||
_LOGGER.error(
|
||||
"%s is missing; the toolchain install may be incomplete "
|
||||
"(recompile, or run 'esphome clean-all' if it persists)",
|
||||
tool,
|
||||
)
|
||||
return 1
|
||||
objdump_path = str(objdump)
|
||||
readelf_path = str(readelf)
|
||||
if CORE.using_toolchain_esp_idf:
|
||||
from esphome.espidf import toolchain
|
||||
|
||||
firmware_elf = native_toolchain.get_elf_path()
|
||||
if not firmware_elf.is_file():
|
||||
# The analyzer swallows tool failures, so a missing ELF would
|
||||
# produce an exit-0 zeroed report
|
||||
_LOGGER.error(
|
||||
"%s is missing; compile the configuration first", firmware_elf
|
||||
)
|
||||
return 1
|
||||
objdump_path = str(toolchain.get_objdump_path())
|
||||
readelf_path = str(toolchain.get_readelf_path())
|
||||
|
||||
firmware_elf = toolchain.get_elf_path()
|
||||
else:
|
||||
from esphome.platformio import toolchain
|
||||
|
||||
|
||||
@@ -23,7 +23,9 @@ from esphome.util import safe_print
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from aioesphomeapi.api_pb2 import SubscribeLogsResponse # pylint: disable=no-name-in-module
|
||||
from aioesphomeapi.api_pb2 import (
|
||||
SubscribeLogsResponse, # pylint: disable=no-name-in-module
|
||||
)
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -19,11 +19,10 @@ from typing import NamedTuple
|
||||
|
||||
from esphome.build_helpers.ccache import ccache_defaults_env
|
||||
from esphome.build_helpers.ninja import find_ninja
|
||||
from esphome.build_helpers.pch import ccache_pch_env
|
||||
from esphome.build_helpers.tools_cache import ARDUINO8266_TOOLS_CACHE, tools_cache_path
|
||||
from esphome.core import EsphomeError, Version
|
||||
from esphome.framework_helpers import str_to_lst_of_str
|
||||
from esphome.platformio.registry import install_packages, prefetch_packages
|
||||
from esphome.platformio.registry import install_package, prefetch_packages
|
||||
|
||||
FRAMEWORK_PACKAGE = "framework-arduinoespressif8266"
|
||||
TOOLCHAIN_PACKAGE = "toolchain-xtensa"
|
||||
@@ -116,9 +115,10 @@ def check_and_install(framework_version: Version) -> InstalledPaths:
|
||||
("bin", "xtensa-lx106-elf"),
|
||||
),
|
||||
)
|
||||
# Fetch both archives at once; the install verifies and extracts them
|
||||
# Fetch both archives at once; the installs below verify and extract
|
||||
prefetch_packages([spec[:4] for spec in specs], downloads_dir)
|
||||
install_packages(specs, downloads_dir)
|
||||
for name, version, dest, mirrors, expect in specs:
|
||||
install_package(name, version, dest, mirrors, downloads_dir, expect=expect)
|
||||
return InstalledPaths(
|
||||
framework=framework_path, toolchain=toolchain_path, ninja=ninja_path
|
||||
)
|
||||
@@ -156,6 +156,4 @@ def ccache_env(ccache: str | None) -> dict[str, str]:
|
||||
"""
|
||||
if ccache is None:
|
||||
return {}
|
||||
env = ccache_defaults_env(get_arduino8266_tools_path() / "ccache")
|
||||
env.update(ccache_pch_env())
|
||||
return env
|
||||
return ccache_defaults_env(get_arduino8266_tools_path() / "ccache")
|
||||
|
||||
@@ -1,329 +0,0 @@
|
||||
"""Native Arduino ESP8266 build driver (the PlatformIO ``run`` equivalent)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
from typing import Any
|
||||
|
||||
from esphome.arduino8266 import framework
|
||||
from esphome.build_helpers.ccache import resolve_ccache_path
|
||||
from esphome.const import (
|
||||
CONF_COMPILE_PROCESS_LIMIT,
|
||||
CONF_ESPHOME,
|
||||
KEY_CORE,
|
||||
KEY_FRAMEWORK_VERSION,
|
||||
)
|
||||
from esphome.core import CORE, EsphomeError
|
||||
from esphome.helpers import write_file_if_changed
|
||||
from esphome.types import ConfigType
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# ESP8266 user RAM (matches upload.maximum_ram_size in every board manifest)
|
||||
_MAX_RAM_SIZE = 81920
|
||||
|
||||
|
||||
def _warn_ignored_platformio_options() -> None:
|
||||
"""Warn for component-added platformio options the native build drops.
|
||||
|
||||
The consumed set is exported by core/config.py next to the routing that
|
||||
stores these options, so the two cannot drift; YAML upload_speed never
|
||||
reaches CORE.platformio_options here.
|
||||
"""
|
||||
from esphome.core.config import NATIVE_ARDUINO_CONSUMED_PIO_OPTIONS
|
||||
|
||||
consumed = NATIVE_ARDUINO_CONSUMED_PIO_OPTIONS
|
||||
for key in sorted(CORE.platformio_options or {}):
|
||||
if key not in consumed:
|
||||
_LOGGER.warning(
|
||||
"platformio_options->%s is ignored when building with the "
|
||||
"native 'arduino' toolchain",
|
||||
key,
|
||||
)
|
||||
|
||||
|
||||
_RAM_SECTIONS = (".data", ".rodata", ".bss")
|
||||
_FLASH_SECTIONS = (".irom0.text", ".text", ".text1", ".data", ".rodata")
|
||||
|
||||
|
||||
def get_build_dir() -> Path:
|
||||
return CORE.relative_pioenvs_path(CORE.name)
|
||||
|
||||
|
||||
def get_elf_path() -> Path:
|
||||
return get_build_dir() / "firmware.elf"
|
||||
|
||||
|
||||
def _toolchain_tool(name: str) -> Path:
|
||||
return framework.toolchain_tool(framework.get_toolchain_path(), name)
|
||||
|
||||
|
||||
def get_factory_firmware_path() -> Path:
|
||||
"""The image to serial-flash at 0x0 (same bytes as firmware.bin: the
|
||||
8266 factory copy exists for artifact-contract parity, not content)."""
|
||||
return get_build_dir() / "firmware.factory.bin"
|
||||
|
||||
|
||||
def get_addr2line_path() -> Path:
|
||||
return _toolchain_tool("addr2line")
|
||||
|
||||
|
||||
def get_objdump_path() -> Path:
|
||||
return _toolchain_tool("objdump")
|
||||
|
||||
|
||||
def get_readelf_path() -> Path:
|
||||
return _toolchain_tool("readelf")
|
||||
|
||||
|
||||
def run_compile(config: ConfigType, verbose: bool) -> int:
|
||||
from esphome.build_gen import arduino8266 as build_gen
|
||||
|
||||
_warn_ignored_platformio_options()
|
||||
paths = framework.check_and_install(CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION])
|
||||
# Resolved once per build: the resolution probes PATH and spawns the
|
||||
# runnability check, and three consumers need the same answer
|
||||
ccache = resolve_ccache_path()
|
||||
ninja_changed = build_gen.write_project(paths, ccache)
|
||||
|
||||
build_dir = get_build_dir()
|
||||
env = framework.get_build_env(paths.toolchain, ccache)
|
||||
|
||||
# Regenerate the compile DB before the build (a pure function of
|
||||
# build.ninja); skip only when it is at least as fresh as build.ninja
|
||||
# (an interrupted previous run may have rewritten the manifest without
|
||||
# regenerating the DB).
|
||||
compdb = build_dir / "compile_commands.json"
|
||||
compdb_stamp = build_dir / ".compile_commands.stamp"
|
||||
ninja_file = build_dir / "build.ninja"
|
||||
# Freshness rides a stamp: the DB itself is written through
|
||||
# write_file_if_changed (its mtime feeds get_idedata's cache), so a
|
||||
# regeneration with identical content would stay "stale" forever
|
||||
if (
|
||||
ninja_changed
|
||||
or not compdb.is_file()
|
||||
or not compdb_stamp.is_file()
|
||||
or compdb_stamp.stat().st_mtime < ninja_file.stat().st_mtime
|
||||
):
|
||||
_write_compile_commands(paths.ninja, build_dir, env)
|
||||
compdb_stamp.touch()
|
||||
|
||||
cmd = [str(paths.ninja)]
|
||||
if verbose:
|
||||
cmd.append("-v")
|
||||
if jobs := config[CONF_ESPHOME].get(CONF_COMPILE_PROCESS_LIMIT):
|
||||
cmd += ["-j", str(jobs)]
|
||||
# Explicit targets, not the default statement: a generator defect that
|
||||
# drops them fails loudly with "unknown target" instead of a green
|
||||
# no-op run that leaves stale artifacts in place
|
||||
targets = ["firmware.factory.bin", "firmware.ota.bin"]
|
||||
cmd += targets
|
||||
|
||||
# A dry-run probe keeps a no-op rebuild quiet: ninja would only print
|
||||
# "no work to do". A freshly rewritten manifest all but guarantees work,
|
||||
# so skip the probe (and its full stat pass) on that path. cwd instead
|
||||
# of -C also drops the "Entering directory" banner on real builds.
|
||||
skip_build = False
|
||||
if not ninja_changed:
|
||||
probe = subprocess.run(
|
||||
[str(paths.ninja), "-n", *targets],
|
||||
cwd=build_dir,
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
close_fds=False,
|
||||
)
|
||||
if probe.stderr.strip():
|
||||
# A load-time diagnostic (e.g. "multiple rules generate X")
|
||||
# flags a generator bug; the skip branch would otherwise
|
||||
# swallow it forever
|
||||
_LOGGER.warning("ninja: %s", probe.stderr.strip())
|
||||
if probe.returncode != 0:
|
||||
# An unknown target here is the defective-manifest case; fall
|
||||
# through to the real build so the error prints attributably
|
||||
_LOGGER.debug("ninja probe failed; running the full build")
|
||||
skip_build = probe.returncode == 0 and "no work to do" in probe.stdout
|
||||
if skip_build:
|
||||
_LOGGER.debug("ninja: nothing to rebuild")
|
||||
else:
|
||||
_LOGGER.debug("Running: %s", " ".join(cmd))
|
||||
rc = subprocess.run(
|
||||
cmd, cwd=build_dir, env=env, check=False, close_fds=False
|
||||
).returncode
|
||||
if rc != 0:
|
||||
return rc
|
||||
|
||||
# ninja already refused a manifest missing the explicit targets above;
|
||||
# existence covers the remaining hole (a rule that ran but wrote
|
||||
# elsewhere). The factory/ota copies are what upload and OTA consume.
|
||||
build_dir_artifacts = (
|
||||
get_elf_path(),
|
||||
build_dir / "firmware.bin",
|
||||
get_factory_firmware_path(),
|
||||
build_dir / "firmware.ota.bin",
|
||||
)
|
||||
for artifact in build_dir_artifacts:
|
||||
if not artifact.is_file():
|
||||
_LOGGER.error("Build produced no %s", artifact)
|
||||
return 1
|
||||
|
||||
if not _print_size_summary(build_dir, paths):
|
||||
# The cause was already warned; name the consequence so a build
|
||||
# contributing no RAM/Flash metric is visible to CI harnesses
|
||||
_LOGGER.warning("Firmware size summary unavailable for this build")
|
||||
from esphome.build_helpers.idedata import warn_if_idedata_missing
|
||||
|
||||
warn_if_idedata_missing(lambda: get_idedata(ccache))
|
||||
return 0
|
||||
|
||||
|
||||
def _write_compile_commands(
|
||||
ninja_path: Path, build_dir: Path, env: dict[str, str]
|
||||
) -> None:
|
||||
compdb = build_dir / "compile_commands.json"
|
||||
result = subprocess.run(
|
||||
[str(ninja_path), "-C", str(build_dir), "-t", "compdb", "c", "cxx", "asm"],
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
close_fds=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
# Drop any stale database so consumers (IDE integration, clang-tidy,
|
||||
# the memory analyzer) can't silently read outdated data.
|
||||
compdb.unlink(missing_ok=True)
|
||||
raise EsphomeError(f"Could not generate compile_commands.json: {result.stderr}")
|
||||
try:
|
||||
entries = json.loads(result.stdout)
|
||||
except ValueError as err:
|
||||
compdb.unlink(missing_ok=True)
|
||||
raise EsphomeError(
|
||||
f"ninja produced an unparsable compile database: {err} "
|
||||
f"(output starts {result.stdout[:120]!r})"
|
||||
) from err
|
||||
if not entries:
|
||||
# compdb exits 0 with [] for unknown rule names; a renamed compile
|
||||
# rule must fail the build, not silently strand every consumer
|
||||
compdb.unlink(missing_ok=True)
|
||||
raise EsphomeError(
|
||||
"ninja produced an empty compile database; the generator's rule "
|
||||
"names no longer match"
|
||||
)
|
||||
# write_file_if_changed keeps the mtime stable on no-op builds so the
|
||||
# idedata cache in get_idedata() stays valid.
|
||||
write_file_if_changed(compdb, result.stdout)
|
||||
|
||||
|
||||
def _parse_app_size(build_dir: Path, paths: framework.InstalledPaths) -> int | None:
|
||||
"""Read the app flash budget (irom0_0_seg length) from the linker script."""
|
||||
from esphome.build_gen.arduino8266 import get_flash_ld_path
|
||||
from esphome.components.esp8266.build_surgery import segment_length
|
||||
|
||||
# Warnings, not debug: without the app size the Flash summary line is
|
||||
# dropped and CI's memory-impact extraction loses its flash metric.
|
||||
ld_path = get_flash_ld_path(build_dir, paths)
|
||||
try:
|
||||
ld_text = ld_path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError) as err:
|
||||
# UnicodeDecodeError: a truncated/corrupt script must degrade to
|
||||
# the same warning, never abort an already-linked build
|
||||
_LOGGER.warning("Cannot read linker script for the Flash summary: %s", err)
|
||||
return None
|
||||
app_size = segment_length(ld_text, "irom0_0_seg")
|
||||
if app_size is None:
|
||||
_LOGGER.warning("irom0_0_seg not found in %s; skipping Flash summary", ld_path)
|
||||
return None
|
||||
if app_size == 0:
|
||||
_LOGGER.warning(
|
||||
"irom0_0_seg has zero length in %s; skipping Flash summary", ld_path
|
||||
)
|
||||
return None
|
||||
return app_size
|
||||
|
||||
|
||||
def _print_size_summary(build_dir: Path, paths: framework.InstalledPaths) -> bool:
|
||||
"""Print the PlatformIO-shaped RAM/Flash lines; False when skipped.
|
||||
|
||||
The exact shape (including the bar) is parsed by
|
||||
``script/ci_memory_impact_extract.py``; ``print_size_line`` matches it.
|
||||
"""
|
||||
from esphome.build_helpers.size_summary import print_size_line
|
||||
|
||||
size_tool = _toolchain_tool("size")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[str(size_tool), "-A", "-d", str(get_elf_path())],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
close_fds=False,
|
||||
)
|
||||
except OSError as err:
|
||||
# The summary is a bonus artifact like idedata; a truncated
|
||||
# toolchain extraction must not discard an already-linked build
|
||||
_LOGGER.warning("Could not summarize firmware size: %s", err)
|
||||
return False
|
||||
if result.returncode != 0:
|
||||
_LOGGER.warning("Could not summarize firmware size: %s", result.stderr)
|
||||
return False
|
||||
sections: dict[str, int] = {}
|
||||
for line in result.stdout.splitlines():
|
||||
parts = line.split()
|
||||
if len(parts) >= 2 and parts[0].startswith("."):
|
||||
try:
|
||||
sections[parts[0]] = int(parts[1])
|
||||
except ValueError:
|
||||
# An unparsed RAM/Flash section trips the missing-sections
|
||||
# guard below, so no total is built on a dropped value
|
||||
_LOGGER.warning("Unparsable size output for section %s", parts[0])
|
||||
if missing := set(_RAM_SECTIONS + _FLASH_SECTIONS) - set(sections):
|
||||
# A defaulted 0 would print a confidently wrong total for CI's metric
|
||||
_LOGGER.warning(
|
||||
"Size output is missing section(s) %s; skipping the size summary",
|
||||
", ".join(sorted(missing)),
|
||||
)
|
||||
return False
|
||||
# Resolve the flash budget before printing anything: a RAM line without
|
||||
# its Flash line would let CI's memory-impact extraction sum the two
|
||||
# metrics over different build counts (_parse_app_size already warned).
|
||||
app_size = _parse_app_size(build_dir, paths)
|
||||
if not app_size:
|
||||
return False
|
||||
ram = sum(sections[s] for s in _RAM_SECTIONS)
|
||||
flash = sum(sections[s] for s in _FLASH_SECTIONS)
|
||||
print_size_line("RAM", ram, _MAX_RAM_SIZE)
|
||||
print_size_line("Flash", flash, app_size)
|
||||
return True
|
||||
|
||||
|
||||
# Sentinel: "resolve for me"; None is a real value meaning disabled.
|
||||
_CCACHE_UNRESOLVED: Any = object()
|
||||
|
||||
|
||||
def get_idedata(ccache: str | None = _CCACHE_UNRESOLVED) -> dict | None:
|
||||
"""Derive idedata from the build's compile_commands.json.
|
||||
|
||||
Same contract as ``espidf.toolchain.get_idedata``: the fields IDE
|
||||
integrations, clang-tidy, and the memory analyzer expect.
|
||||
"""
|
||||
from esphome.build_helpers.idedata import load_or_build_idedata
|
||||
|
||||
if ccache is _CCACHE_UNRESOLVED:
|
||||
# Deliberately uncached: env/PATH can change between builds in a
|
||||
# long-lived host process
|
||||
ccache = resolve_ccache_path()
|
||||
return load_or_build_idedata(
|
||||
get_build_dir() / "compile_commands.json",
|
||||
get_elf_path(),
|
||||
# Suffixed so a platformio->arduino->platformio round trip on one
|
||||
# config never serves the other toolchain's cache shape
|
||||
CORE.relative_internal_path("idedata", f"{CORE.name}.arduino.json"),
|
||||
# The compile DB's commands carry the same ccache prefix the ninja
|
||||
# rules were generated with
|
||||
launcher=str(ccache) if ccache else None,
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,6 @@ started esphome and must not depend on the package being importable.
|
||||
Subcommands:
|
||||
ar <ar-binary> <archive> <rspfile> remove stale archive, then ``ar rcs``
|
||||
copy <src> <dst> copy a file
|
||||
touch <path> create/update a stamp file
|
||||
|
||||
The ar rspfile carries one object path per line (the generating rule must
|
||||
use ``$in_newline``, never ``$in``).
|
||||
@@ -84,18 +83,9 @@ def _run_copy(src: str, dst: str) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def _run_touch(path: str) -> int:
|
||||
try:
|
||||
Path(path).touch()
|
||||
except OSError as err:
|
||||
print(f"touch: {path} failed: {err}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
# mode -> (handler, expected operand count); surplus argv means a
|
||||
# mis-specified ninja rule and must error, not silently drop operands
|
||||
_MODES = {"ar": (_run_ar, 3), "copy": (_run_copy, 2), "touch": (_run_touch, 1)}
|
||||
_MODES = {"ar": (_run_ar, 3), "copy": (_run_copy, 2)}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
|
||||
@@ -4,14 +4,6 @@ import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from esphome.build_helpers import pch
|
||||
from esphome.build_helpers.pch import (
|
||||
PCH_DEFAULT_HEADERS,
|
||||
PCH_HEADER_NAME,
|
||||
mark_pch_emitted,
|
||||
pch_enabled,
|
||||
pch_header_text,
|
||||
)
|
||||
from esphome.components.esp32 import (
|
||||
get_esp32_variant,
|
||||
get_excluded_builtin_components,
|
||||
@@ -287,47 +279,7 @@ idf_component_register(
|
||||
target_link_options(${{COMPONENT_LIB}} PUBLIC
|
||||
{link_opts_str}
|
||||
)
|
||||
{_pch_cmake()}"""
|
||||
|
||||
|
||||
def _pch_cmake() -> str:
|
||||
"""Consumer block for the component CMakeLists. Baked at generation:
|
||||
a strict-knob flip takes effect on the next esphome compile; a
|
||||
hand-run idf.py keeps the old one."""
|
||||
return pch.pch_cmake_consumer("${COMPONENT_LIB}", "${app_sources}")
|
||||
|
||||
|
||||
def prepare_pch() -> None:
|
||||
"""Build the .gch right before ninja, after every reconfigure, so the
|
||||
compile_commands.json flags and the sdkconfig are the settled ones."""
|
||||
if not pch_enabled():
|
||||
# Self-cleaning escape hatch: drop any previously built .gch
|
||||
pch.discard_pch(CORE.relative_build_path("build"))
|
||||
pch.pch_disabled_degraded()
|
||||
return
|
||||
sdkconfig_path = CORE.relative_build_path(f"sdkconfig.{CORE.name}")
|
||||
try:
|
||||
sdkconfig = sdkconfig_path.read_text(encoding="utf-8")
|
||||
except OSError as err:
|
||||
# Fail closed: the sdkconfig is the .sum's only config identity for
|
||||
# sdkconfig.h-only options; a stand-in marker would collide
|
||||
_LOGGER.warning(
|
||||
"Could not read %s; compiling without the pch: %s", sdkconfig_path, err
|
||||
)
|
||||
pch.discard_pch(CORE.relative_build_path("build"))
|
||||
pch.pch_degraded(f"sdkconfig unreadable: {err}")
|
||||
return
|
||||
pch.prepare_pch(
|
||||
CORE.relative_build_path("build"),
|
||||
PCH_DEFAULT_HEADERS,
|
||||
(
|
||||
str(idf_version()),
|
||||
CORE.cpp_standard or "",
|
||||
sdkconfig,
|
||||
*get_project_compile_flags(),
|
||||
*get_project_cxx_compile_flags(),
|
||||
),
|
||||
)
|
||||
"""
|
||||
|
||||
|
||||
def write_project(
|
||||
@@ -349,14 +301,6 @@ def write_project(
|
||||
get_component_cmakelists(),
|
||||
)
|
||||
|
||||
if pch_enabled():
|
||||
write_file_if_changed(
|
||||
CORE.relative_build_path("build", PCH_HEADER_NAME),
|
||||
pch_header_text(PCH_DEFAULT_HEADERS),
|
||||
)
|
||||
# Consumers carry the -include; gate the ccache relaxation on it
|
||||
mark_pch_emitted()
|
||||
|
||||
# Snapshot the exclusion set so has_outdated_files() can trigger a
|
||||
# discovery reconfigure when it changes. Excluded components never
|
||||
# register in project_description.json, so re-including one (e.g. a
|
||||
|
||||
@@ -21,13 +21,12 @@ def _ccache_runs(ccache: str) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def parse_enable_env(name: str, strict: bool = False) -> bool | None:
|
||||
def parse_enable_env(name: str) -> bool | None:
|
||||
"""Strictly parse an on/off environment knob; None when unset or invalid.
|
||||
|
||||
``bool(str)`` truthiness would flip ``no``/``off`` to enabled, so only
|
||||
1/true/yes/on and 0/false/no/off count; anything else warns and reads
|
||||
as unset so the caller's default policy applies — or raises when
|
||||
``strict`` (a typo must not silently disable a CI gate).
|
||||
as unset so the caller's default policy applies.
|
||||
"""
|
||||
raw = os.environ.get(name)
|
||||
if raw is None:
|
||||
@@ -40,10 +39,6 @@ def parse_enable_env(name: str, strict: bool = False) -> bool | None:
|
||||
return True
|
||||
if lowered in FALSY_ENV_STRINGS:
|
||||
return False
|
||||
if strict:
|
||||
from esphome.core import EsphomeError
|
||||
|
||||
raise EsphomeError(f"Unrecognized {name}={raw!r}; use 1 or 0")
|
||||
_LOGGER.warning("Ignoring unrecognized %s=%r; use 1 or 0", name, raw)
|
||||
return None
|
||||
|
||||
@@ -92,19 +87,6 @@ def ccache_defaults_env(cache_dir: Path) -> dict[str, str]:
|
||||
"CCACHE_DIR": str(cache_dir),
|
||||
"CCACHE_NOHASHDIR": "true",
|
||||
"CCACHE_DEPEND": "1",
|
||||
# A user value wins via the filter below
|
||||
"CCACHE_BASEDIR": effective_ccache_basedir(),
|
||||
"CCACHE_BASEDIR": str(Path(CORE.build_path).resolve()),
|
||||
}
|
||||
return {k: v for k, v in defaults.items() if k not in os.environ}
|
||||
|
||||
|
||||
def effective_ccache_basedir() -> str:
|
||||
"""The prefix ccache rewrites out of hashed paths: a user CCACHE_BASEDIR
|
||||
wins, else the resolved build path (matching ccache_defaults_env)."""
|
||||
from esphome.core import CORE
|
||||
|
||||
raw = os.environ.get("CCACHE_BASEDIR")
|
||||
if raw is not None and Path(raw).is_absolute() and len(Path(raw).parts) > 1:
|
||||
return raw
|
||||
# Unset or degenerate ("", "/", relative): fall back to the build path
|
||||
return str(Path(CORE.build_path).resolve())
|
||||
|
||||
@@ -11,7 +11,6 @@ consumers (IDE integration, clang-tidy) expect:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -22,8 +21,6 @@ import subprocess
|
||||
from esphome.core import EsphomeError
|
||||
from esphome.helpers import write_file
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Everything idedata generation may raise after a successful link; idedata
|
||||
# is a bonus artifact, so consumers warn instead of failing the build
|
||||
IDEDATA_BEST_EFFORT_ERRORS = (
|
||||
@@ -34,36 +31,13 @@ IDEDATA_BEST_EFFORT_ERRORS = (
|
||||
ValueError,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
def warn_if_idedata_missing(get_idedata: Callable[[], dict | None]) -> None:
|
||||
"""Run an idedata generator, downgrading any failure to a warning.
|
||||
|
||||
Shared by the native backends: the firmware already built, so a missing
|
||||
or broken idedata must not fail a successful build.
|
||||
"""
|
||||
try:
|
||||
if get_idedata() is None:
|
||||
_LOGGER.warning("No idedata was generated for this build")
|
||||
except IDEDATA_BEST_EFFORT_ERRORS as err:
|
||||
_LOGGER.warning(
|
||||
"Could not generate idedata: %s (IDE, clang-tidy, and "
|
||||
"memory-analysis data will be unavailable for this build)",
|
||||
err,
|
||||
)
|
||||
if isinstance(err, (EsphomeError, OSError)):
|
||||
# Routine environmental failures keep the detail at debug
|
||||
_LOGGER.debug("Idedata failure detail", exc_info=True)
|
||||
else:
|
||||
# LookupError/ValueError/RuntimeError smell like a parsing bug;
|
||||
# a permanently masked traceback would hide it on every build
|
||||
_LOGGER.warning("Idedata failure detail", exc_info=True)
|
||||
|
||||
|
||||
# C++ translation-unit suffixes.
|
||||
CXX_SOURCE_SUFFIXES = (".cpp", ".cc", ".cxx")
|
||||
# C++ translation-unit suffixes used to identify ESPHome source files.
|
||||
_CXX_SUFFIXES = (".cpp", ".cc")
|
||||
# Suffixes of input/output files that appear bare on the command line (and so
|
||||
# must not be mistaken for compiler flags).
|
||||
_INPUT_FILE_SUFFIXES = (*CXX_SOURCE_SUFFIXES, ".c", ".o", ".S", ".s")
|
||||
_INPUT_FILE_SUFFIXES = (*_CXX_SUFFIXES, ".c", ".o", ".S", ".s")
|
||||
# Path marker identifying an ESPHome source translation unit.
|
||||
_ESPHOME_SRC_MARKER = "/src/esphome/"
|
||||
|
||||
@@ -72,11 +46,11 @@ def _is_esphome_src(file: str) -> bool:
|
||||
"""Whether ``file`` is an ESPHome C++ translation unit; normalized to
|
||||
``/`` first since Windows compile DBs use backslashes."""
|
||||
return _ESPHOME_SRC_MARKER in file.replace("\\", "/") and file.endswith(
|
||||
CXX_SOURCE_SUFFIXES
|
||||
_CXX_SUFFIXES
|
||||
)
|
||||
|
||||
|
||||
def split_command(command: str) -> list[str]:
|
||||
def _split_command(command: str) -> list[str]:
|
||||
r"""Tokenize a compile_commands.json / response-file command string.
|
||||
|
||||
On Windows, tokenize per Windows ``argv`` rules via ``CommandLineToArgvW``.
|
||||
@@ -112,7 +86,7 @@ def split_command(command: str) -> list[str]:
|
||||
ctypes.windll.kernel32.LocalFree(argv)
|
||||
|
||||
|
||||
def expand_response_files(tokens: list[str], directory: Path) -> list[str]:
|
||||
def _expand_response_files(tokens: list[str], directory: Path) -> list[str]:
|
||||
"""Inline any ``@response-file`` arguments (paths relative to ``directory``).
|
||||
|
||||
GCC response files embed flags that must be expanded so GCC-only flags
|
||||
@@ -127,8 +101,8 @@ def expand_response_files(tokens: list[str], directory: Path) -> list[str]:
|
||||
rf = directory / rf
|
||||
try:
|
||||
out.extend(
|
||||
expand_response_files(
|
||||
split_command(rf.read_text(encoding="utf-8")), directory
|
||||
_expand_response_files(
|
||||
_split_command(rf.read_text(encoding="utf-8")), directory
|
||||
)
|
||||
)
|
||||
continue
|
||||
@@ -147,7 +121,7 @@ def _pick_entry(entries: list[dict]) -> dict:
|
||||
if _is_esphome_src(entry["file"]):
|
||||
return entry
|
||||
for entry in entries:
|
||||
if entry["file"].endswith(CXX_SOURCE_SUFFIXES):
|
||||
if entry["file"].endswith(_CXX_SUFFIXES):
|
||||
return entry
|
||||
raise ValueError("no C++ translation unit found in compile_commands.json")
|
||||
|
||||
@@ -157,25 +131,16 @@ def _pick_entry(entries: list[dict]) -> dict:
|
||||
_LAUNCHER_STEMS = frozenset({"ccache", "sccache", "distcc", "icecc", "buildcache"})
|
||||
|
||||
|
||||
def is_launcher(token: str) -> bool:
|
||||
def _is_launcher(token: str) -> bool:
|
||||
return Path(token).stem.lower() in _LAUNCHER_STEMS
|
||||
|
||||
|
||||
def is_joined_include(tok: str) -> bool:
|
||||
"""The joined ``-includefoo.h`` spelling; excludes clang's -include-pch."""
|
||||
return (
|
||||
tok.startswith("-include")
|
||||
and tok != "-include"
|
||||
and not tok.startswith("-include-")
|
||||
)
|
||||
|
||||
|
||||
def parse_entry(
|
||||
entry: dict, launcher: str | None = None
|
||||
) -> tuple[str, list[str], list[str], list[str]]:
|
||||
"""Parse one compile_commands entry -> (cxx_path, defines, includes, cxx_flags)."""
|
||||
directory = Path(entry["directory"])
|
||||
tokens = expand_response_files(split_command(entry["command"]), directory)
|
||||
tokens = _expand_response_files(_split_command(entry["command"]), directory)
|
||||
|
||||
def _include(raw: str) -> str:
|
||||
# Resolve against the entry's ``directory`` so cached idedata works
|
||||
@@ -191,7 +156,7 @@ def parse_entry(
|
||||
if not tokens:
|
||||
# An empty command, or one that was only the launcher; fail by name
|
||||
raise ValueError(f"empty compile command for {entry.get('file')}")
|
||||
if is_launcher(tokens[0]) and len(tokens) > 1 and not tokens[1].startswith("-"):
|
||||
if _is_launcher(tokens[0]) and len(tokens) > 1 and not tokens[1].startswith("-"):
|
||||
# Stale DB built with a launcher this run no longer configures; the
|
||||
# real compiler is the next token
|
||||
_LOGGER.warning("Stripping unconfigured launcher %s", tokens[0])
|
||||
@@ -204,23 +169,11 @@ def parse_entry(
|
||||
defines: list[str] = []
|
||||
includes: list[str] = []
|
||||
cxx_flags: list[str] = []
|
||||
unresolved_force_includes: list[str] = []
|
||||
|
||||
it = iter(tokens[1:])
|
||||
for tok in it:
|
||||
if tok in ("-c", "-o"):
|
||||
next(it, None) # drop the flag and its argument (input/output)
|
||||
elif tok == "-include" or is_joined_include(tok):
|
||||
# Re-anchor only names next to the compile (the pch); a name
|
||||
# meant for the -I chain must stay untouched
|
||||
raw = next(it, "") if tok == "-include" else tok[len("-include") :]
|
||||
if not raw:
|
||||
_LOGGER.warning("Dropping -include with no argument")
|
||||
elif Path(resolved := _include(raw)).is_file():
|
||||
cxx_flags.extend(("-include", resolved))
|
||||
else:
|
||||
unresolved_force_includes.append(raw)
|
||||
cxx_flags.extend(("-include", raw))
|
||||
elif tok.startswith("-D"):
|
||||
# ``.strip()`` handles tokens like ``-D CONFIGURED=1`` (a single
|
||||
# quoted arg with a space after -D) that some flags arrive as.
|
||||
@@ -239,14 +192,6 @@ def parse_entry(
|
||||
pass # input/output files
|
||||
else:
|
||||
cxx_flags.append(tok)
|
||||
for raw in unresolved_force_includes:
|
||||
# A deleted build artifact would otherwise surface only downstream
|
||||
if not any((Path(inc) / raw).is_file() for inc in includes):
|
||||
_LOGGER.warning(
|
||||
"-include %s found neither next to the compile nor on the "
|
||||
"include path; cached idedata may not resolve it",
|
||||
raw,
|
||||
)
|
||||
return cxx_path, defines, includes, cxx_flags
|
||||
|
||||
|
||||
@@ -311,7 +256,7 @@ def _cache_usable(cached: object) -> bool:
|
||||
if not isinstance(cached, dict) or "cc_path" not in cached:
|
||||
return False
|
||||
cxx_path = cached.get("cxx_path")
|
||||
if not isinstance(cxx_path, str) or is_launcher(cxx_path):
|
||||
if not isinstance(cxx_path, str) or _is_launcher(cxx_path):
|
||||
return False
|
||||
includes = cached.get("includes")
|
||||
return isinstance(includes, dict) and isinstance(includes.get("build"), list)
|
||||
@@ -359,7 +304,7 @@ def load_or_build_idedata(
|
||||
def reject_launcher_compiler(cxx_path: str) -> None:
|
||||
"""Reject a compile DB naming a launcher (ccache) as the compiler; it
|
||||
must never be probed, cached, or consumed."""
|
||||
if is_launcher(cxx_path):
|
||||
if _is_launcher(cxx_path):
|
||||
raise EsphomeError(
|
||||
f"compile_commands.json names the launcher {cxx_path} as the "
|
||||
"compiler; the compile database is unusable"
|
||||
|
||||
@@ -1,603 +0,0 @@
|
||||
"""Shared precompiled-header policy for the build backends.
|
||||
|
||||
The prefix either mirrors the TUs' own force-includes (ESP8266) or is a
|
||||
curated core-header set (ESP-IDF). ``esphome: includes:`` sources receive
|
||||
it too; Arduino.h visibility there is intended (esphome#8693).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Iterable
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import posixpath
|
||||
import re
|
||||
import stat
|
||||
import subprocess
|
||||
|
||||
from esphome.build_helpers.ccache import effective_ccache_basedir, parse_enable_env
|
||||
from esphome.build_helpers.idedata import (
|
||||
CXX_SOURCE_SUFFIXES,
|
||||
expand_response_files,
|
||||
is_launcher,
|
||||
split_command,
|
||||
)
|
||||
|
||||
_DOMAIN = "pch"
|
||||
|
||||
|
||||
@dataclass
|
||||
class _PCHData:
|
||||
emitted: bool = False
|
||||
|
||||
|
||||
def _pch_data() -> _PCHData:
|
||||
from esphome.core import CORE
|
||||
|
||||
if _DOMAIN not in CORE.data:
|
||||
CORE.data[_DOMAIN] = _PCHData()
|
||||
return CORE.data[_DOMAIN]
|
||||
|
||||
|
||||
def mark_pch_emitted() -> None:
|
||||
"""Record that this build's consumers reference the pch."""
|
||||
_pch_data().emitted = True
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# The header and its .gch/.sum sidecars live in the build directory.
|
||||
PCH_HEADER_NAME = "esphome_pch.h"
|
||||
|
||||
# Every artifact the pch machinery can leave behind, for cleanup.
|
||||
PCH_ARTIFACT_NAMES = (
|
||||
PCH_HEADER_NAME,
|
||||
f"{PCH_HEADER_NAME}.gch",
|
||||
f"{PCH_HEADER_NAME}.gch.sum",
|
||||
f"{PCH_HEADER_NAME}.gch.failed",
|
||||
)
|
||||
|
||||
# The core defines header every backend anchors its prefix on.
|
||||
PCH_CORE_HEADER = "esphome/core/defines.h"
|
||||
|
||||
# Guarded curated-prefix wrapper for PlatformIO backends without framework
|
||||
# force-includes (host, esp32); folded by the pch script via build_src_flags.
|
||||
PCH_PREFIX_HEADER = "esphome/core/pch_prefix.h"
|
||||
|
||||
# Prefix-header contents for backends that inject a curated set (rather
|
||||
# than mirroring the TUs' own force-includes), defines.h first so USE_*
|
||||
# macros exist for the rest. Deliberately hard-coded: frequency-derived
|
||||
# sets measured no better and kept selecting headers that cannot compile
|
||||
# standalone (X-macro, platform-variant). Every entry must be safe to
|
||||
# include first in an empty TU. Caveat: application.h/automation.h become
|
||||
# ambiently visible, so a TU missing those #includes still builds on such
|
||||
# backends; ESPHOME_PCH_ENABLE=0 restores the strict view.
|
||||
PCH_DEFAULT_HEADERS = (
|
||||
PCH_CORE_HEADER,
|
||||
"esphome/core/component.h",
|
||||
"esphome/core/helpers.h",
|
||||
"esphome/core/log.h",
|
||||
"esphome/core/application.h",
|
||||
"esphome/core/automation.h",
|
||||
)
|
||||
|
||||
# ccache cannot hash through a .gch; CCACHE_PCH_EXTSUM makes it hash the
|
||||
# .sum sidecar instead of the .gch bytes, which are not reproducible.
|
||||
# Keep in sync with the literals in platformio/pch.py.script.
|
||||
_CCACHE_PCH_ENV = {
|
||||
"CCACHE_SLOPPINESS": "pch_defines,time_macros",
|
||||
"CCACHE_PCH_EXTSUM": "true",
|
||||
}
|
||||
|
||||
# Both include forms: an angle include resolving under src/ must enter the
|
||||
# digest too; ones that do not resolve simply end the walk
|
||||
# Compiler failures that clear on their own must not latch the .failed marker
|
||||
_TRANSIENT_ERRORS = ("No space left", "Cannot allocate", "Resource temporarily")
|
||||
|
||||
_INCLUDE_RE = re.compile(rb'^\s*#\s*include\s+["<]([^">]+)[">]', re.MULTILINE)
|
||||
|
||||
|
||||
def pch_enabled() -> bool:
|
||||
"""Precompiled-header knob: default on, ``ESPHOME_PCH_ENABLE=0`` opts out."""
|
||||
return parse_enable_env("ESPHOME_PCH_ENABLE") is not False
|
||||
|
||||
|
||||
def pch_strict() -> bool:
|
||||
"""CI knob: ``ESPHOME_PCH_STRICT=1`` turns pch degrade paths fatal.
|
||||
|
||||
A set-but-unrecognized value raises: a typo must not silently turn
|
||||
the gate into a no-op that proves nothing.
|
||||
"""
|
||||
return parse_enable_env("ESPHOME_PCH_STRICT", strict=True) is True
|
||||
|
||||
|
||||
def pch_degraded(reason: str) -> None:
|
||||
"""Every degrade path funnels through here; strict mode raises."""
|
||||
if pch_strict():
|
||||
from esphome.core import EsphomeError
|
||||
|
||||
raise EsphomeError(f"ESPHOME_PCH_STRICT: {reason}")
|
||||
|
||||
|
||||
def pch_disabled_degraded() -> None:
|
||||
"""Strict CI must not read "no pch at all" as success."""
|
||||
pch_degraded("pch disabled by ESPHOME_PCH_ENABLE")
|
||||
|
||||
|
||||
def pch_probe_tail(source: str = "-") -> list[str]:
|
||||
"""The syntax-only compile shared by the probe and its baseline."""
|
||||
return ["-fsyntax-only", "-x", "c++", source]
|
||||
|
||||
|
||||
def pch_probe_args(header: str, source: str = "-") -> list[str]:
|
||||
"""Flags that load-check a built .gch via a syntax-only compile.
|
||||
|
||||
Rejection must be a nonzero exit (never just a wording match), so the
|
||||
invalid-pch class is always escalated. ``source`` defaults to stdin
|
||||
(host independent); the ninja probe edge passes a real file.
|
||||
"""
|
||||
return [
|
||||
"-Winvalid-pch",
|
||||
"-Werror=invalid-pch",
|
||||
"-include",
|
||||
header,
|
||||
*pch_probe_tail(source),
|
||||
]
|
||||
|
||||
|
||||
def pch_consumer_escalation() -> str:
|
||||
"""Consumer-side invalid-pch flag: strict reds the build on rejection
|
||||
(per-process, so the probe alone cannot prove the consumers)."""
|
||||
return "-Werror=invalid-pch" if pch_strict() else "-Wno-error=invalid-pch"
|
||||
|
||||
|
||||
def pch_cmake_consumer(target: str, sources_var: str) -> str:
|
||||
"""Emit the CMake block making ``target``'s C++ sources consume the
|
||||
pch; empty when disabled. OBJECT_DEPENDS is on the header, not the
|
||||
.gch (pch-baked headers drop out of TU depfiles); the -include stays
|
||||
relative — an absolute path would poison ccache keys."""
|
||||
if not pch_enabled():
|
||||
return ""
|
||||
escalation = pch_consumer_escalation()
|
||||
return f"""
|
||||
# ESPHome precompiled header (see esphome/build_helpers/pch.py).
|
||||
# The touch keeps OBJECT_DEPENDS satisfiable when the build system itself
|
||||
# wiped the build dir after the header was written (west --pristine)
|
||||
if(NOT EXISTS "${{CMAKE_BINARY_DIR}}/{PCH_HEADER_NAME}")
|
||||
file(TOUCH "${{CMAKE_BINARY_DIR}}/{PCH_HEADER_NAME}")
|
||||
endif()
|
||||
target_compile_options({target} PRIVATE
|
||||
"$<$<COMPILE_LANGUAGE:CXX>:-Winvalid-pch>"
|
||||
"$<$<COMPILE_LANGUAGE:CXX>:{escalation}>"
|
||||
"$<$<COMPILE_LANGUAGE:CXX>:-include>"
|
||||
"$<$<COMPILE_LANGUAGE:CXX>:{PCH_HEADER_NAME}>"
|
||||
)
|
||||
set_source_files_properties({sources_var} PROPERTIES
|
||||
OBJECT_DEPENDS "${{CMAKE_BINARY_DIR}}/{PCH_HEADER_NAME}")
|
||||
"""
|
||||
|
||||
|
||||
def ccache_pch_env() -> dict[str, str]:
|
||||
"""Settings ccache needs to cache compiles that consume the .gch;
|
||||
empty unless this build actually emitted one. User-set values win.
|
||||
Native backends export these process-wide; only time_macros affects
|
||||
non-pch TUs."""
|
||||
if not (pch_enabled() and _pch_data().emitted):
|
||||
return {}
|
||||
extsum = os.environ.get("CCACHE_PCH_EXTSUM")
|
||||
if extsum is not None and extsum.strip().lower() not in ("1", "true", "yes", "on"):
|
||||
# ccache then hashes the non-reproducible .gch bytes: permanent misses
|
||||
_LOGGER.warning("CCACHE_PCH_EXTSUM=%s disables pch caching", extsum)
|
||||
env = {k: v for k, v in _CCACHE_PCH_ENV.items() if k not in os.environ}
|
||||
user_sloppiness = os.environ.get("CCACHE_SLOPPINESS")
|
||||
if user_sloppiness is not None and (
|
||||
missing := [
|
||||
t
|
||||
for t in ("pch_defines", "time_macros")
|
||||
if t not in {tok.strip() for tok in user_sloppiness.split(",")}
|
||||
]
|
||||
):
|
||||
# Without these ccache declines every pch-consuming compile
|
||||
env["CCACHE_SLOPPINESS"] = ",".join((user_sloppiness, *missing))
|
||||
_LOGGER.warning(
|
||||
"Adding %s to CCACHE_SLOPPINESS so ccache can cache compiles "
|
||||
"that use the precompiled header",
|
||||
",".join(missing),
|
||||
)
|
||||
return env
|
||||
|
||||
|
||||
def guarded_prepare(build_dir: Path, prepare: Callable[[], None]) -> None:
|
||||
"""Run a backend's pch preparation; an optional speedup must never
|
||||
abort the build. Strict is read first so its own knob error cannot
|
||||
mask the real failure; discard_pch raises if a stale .gch survives;
|
||||
the header is ensured so OBJECT_DEPENDS stays satisfiable."""
|
||||
try:
|
||||
prepare()
|
||||
except Exception: # noqa: BLE001 # pylint: disable=broad-exception-caught
|
||||
strict = pch_strict()
|
||||
discard_pch(build_dir)
|
||||
if strict:
|
||||
raise
|
||||
header = build_dir / PCH_HEADER_NAME
|
||||
if not header.exists():
|
||||
try:
|
||||
header.touch()
|
||||
except OSError as err:
|
||||
# The coming OBJECT_DEPENDS error would hide the real cause
|
||||
_LOGGER.warning("Could not create the pch placeholder: %s", err)
|
||||
_LOGGER.warning(
|
||||
"Precompiled header setup failed; compiling without it", exc_info=True
|
||||
)
|
||||
|
||||
|
||||
def pch_extra_scripts() -> list[str]:
|
||||
"""The extra_scripts entries a PlatformIO platform registers for the
|
||||
pch; empty when disabled (the script itself has no enable check)."""
|
||||
if not pch_enabled():
|
||||
pch_disabled_degraded()
|
||||
return []
|
||||
return ["post:pch.py"]
|
||||
|
||||
|
||||
def pch_header_text(include_headers: Iterable[str]) -> str:
|
||||
"""The prefix-header source: exactly these includes, in order."""
|
||||
return "".join(f'#include "{name}"\n' for name in include_headers)
|
||||
|
||||
|
||||
def _resolves(path: Path) -> bool:
|
||||
"""False when missing; other stat failures propagate (identity unknown,
|
||||
unlike is_file(), which would silently drop the header)."""
|
||||
try:
|
||||
return stat.S_ISREG(path.stat().st_mode)
|
||||
except (FileNotFoundError, NotADirectoryError):
|
||||
return False
|
||||
|
||||
|
||||
def _include_closure(src_dir: Path, roots: Iterable[str]) -> dict[str, bytes]:
|
||||
"""Include closure of ``roots``: src-relative name -> contents.
|
||||
|
||||
Resolution mirrors the compiler (includer's dir, then src root); names
|
||||
outside ``src_dir`` end the walk and are versioned by the caller. No
|
||||
#ifdef evaluation: over-approximating is the safe direction.
|
||||
"""
|
||||
seen: dict[str, bytes] = {}
|
||||
stack: list[tuple[str, str]] = [(name, "") for name in roots]
|
||||
while stack:
|
||||
name, from_dir = stack.pop()
|
||||
for candidate in (f"{from_dir}/{name}" if from_dir else name, name):
|
||||
rel = posixpath.normpath(candidate)
|
||||
if not rel.startswith("..") and _resolves(src_dir / rel):
|
||||
break
|
||||
else:
|
||||
continue
|
||||
if rel in seen:
|
||||
continue
|
||||
try:
|
||||
data = (src_dir / rel).read_bytes()
|
||||
except OSError as err:
|
||||
# A marker would truncate the transitive walk; fail closed
|
||||
_LOGGER.warning("Could not read %s for the pch checksum: %s", rel, err)
|
||||
raise
|
||||
seen[rel] = data
|
||||
parent = posixpath.dirname(rel)
|
||||
stack.extend(
|
||||
# surrogateescape: a non-UTF-8 name just fails to resolve
|
||||
(inc.decode(errors="surrogateescape"), parent)
|
||||
for inc in _INCLUDE_RE.findall(data)
|
||||
)
|
||||
return seen
|
||||
|
||||
|
||||
def pch_checksum(
|
||||
src_dir: Path, include_headers: Iterable[str], extra: Iterable[str]
|
||||
) -> str:
|
||||
"""Digest standing in for the .gch in ccache's hash: the include closure
|
||||
of the prefix header plus caller-supplied identity strings (versioned
|
||||
install paths, flags). Raises OSError when a header's identity cannot
|
||||
be established at all; callers must then compile without a pch."""
|
||||
digest = hashlib.sha256()
|
||||
closure = _include_closure(src_dir, include_headers)
|
||||
for name in sorted(closure):
|
||||
digest.update(name.encode(errors="surrogateescape"))
|
||||
digest.update(closure[name])
|
||||
digest.update(b"\0")
|
||||
for item in extra:
|
||||
digest.update(item.encode(errors="surrogateescape"))
|
||||
digest.update(b"\0")
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
# Tokens dropped when retargeting a TU's flags at the prefix header
|
||||
# (the pch compile must not touch depfiles)
|
||||
_PCH_STRIP_FLAGS_WITH_ARG = frozenset({"-o", "-c", "-MT", "-MF", "-MQ"})
|
||||
_PCH_STRIP_FLAGS = frozenset({"-MD", "-MMD", "-MP", "-MM", "-M"})
|
||||
|
||||
|
||||
def pch_compile_command(
|
||||
build_dir: Path, header: Path, gch: Path
|
||||
) -> tuple[list[str], Path] | None:
|
||||
"""The exact src C++ flags from compile_commands.json retargeted at the
|
||||
header, with the directory they resolve against (relative -I paths must
|
||||
be expanded and executed from the same root); None (logged) when no
|
||||
configured C++ TU is available yet."""
|
||||
from esphome.core import CORE
|
||||
|
||||
try:
|
||||
entries = json.loads(
|
||||
(build_dir / "compile_commands.json").read_text(encoding="utf-8")
|
||||
)
|
||||
except (OSError, json.JSONDecodeError) as err:
|
||||
# Configure already succeeded, so an unusable DB is a real anomaly
|
||||
_LOGGER.warning("No usable compile database, skipping pch: %s", err)
|
||||
return None
|
||||
if not isinstance(entries, list):
|
||||
_LOGGER.warning("Malformed compile database, skipping pch")
|
||||
return None
|
||||
# CMake may spell paths through a symlink differently than CORE does
|
||||
# (macOS /tmp vs /private/tmp), so compare resolved paths
|
||||
src_root = Path(CORE.relative_src_path()).resolve()
|
||||
entry = next(
|
||||
(
|
||||
e
|
||||
for e in entries
|
||||
if isinstance(e, dict)
|
||||
and isinstance(e.get("file"), str)
|
||||
and e["file"].endswith(CXX_SOURCE_SUFFIXES)
|
||||
and Path(e["file"]).resolve().is_relative_to(src_root)
|
||||
),
|
||||
None,
|
||||
)
|
||||
if entry is None:
|
||||
_LOGGER.warning("No src C++ entry in the compile database, skipping pch")
|
||||
return None
|
||||
directory = entry.get("directory")
|
||||
cmd_dir = Path(directory) if isinstance(directory, str) and directory else build_dir
|
||||
command = entry.get("command")
|
||||
tokens = expand_response_files(
|
||||
split_command(command if isinstance(command, str) else ""), cmd_dir
|
||||
)
|
||||
# A DB recorded with ccache enabled prefixes the compiler with the
|
||||
# launcher; the .gch must be compiled directly
|
||||
if tokens and is_launcher(tokens[0]):
|
||||
tokens = tokens[1:]
|
||||
if not tokens:
|
||||
# "arguments"-style or empty entries must skip, not spawn "-x ..."
|
||||
_LOGGER.warning("Compile database entry has no usable command, skipping pch")
|
||||
return None
|
||||
args: list[str] = []
|
||||
arg_it = iter(tokens)
|
||||
for tok in arg_it:
|
||||
if tok in _PCH_STRIP_FLAGS_WITH_ARG:
|
||||
next(arg_it, None)
|
||||
continue
|
||||
if tok in _PCH_STRIP_FLAGS:
|
||||
continue
|
||||
if tok == "-include":
|
||||
# Drop only the injected prefix; user force-includes must reach
|
||||
# the .gch compile or GCC rejects it over the macro mismatch
|
||||
inc = next(arg_it, "")
|
||||
if not inc.endswith(PCH_HEADER_NAME):
|
||||
args.extend(("-include", inc))
|
||||
continue
|
||||
args.append(tok)
|
||||
return [*args, "-x", "c++-header", "-c", str(header), "-o", str(gch)], cmd_dir
|
||||
|
||||
|
||||
def _log_pch_in_use() -> None:
|
||||
# The only place a user can discover the knob; emitted only once a
|
||||
# .gch is actually fresh or being built
|
||||
_LOGGER.info(
|
||||
"Compiling with a precompiled header (set ESPHOME_PCH_ENABLE=0 to disable)"
|
||||
)
|
||||
|
||||
|
||||
def _read_stamp(path: Path) -> str:
|
||||
"""A corrupt sidecar must read as stale, not kill the pch forever."""
|
||||
try:
|
||||
return path.read_text(encoding="utf-8").strip()
|
||||
except (OSError, UnicodeDecodeError):
|
||||
return ""
|
||||
|
||||
|
||||
def discard_pch(build_dir: Path) -> None:
|
||||
"""Remove the pch sidecars so a stale .gch is never consumed.
|
||||
|
||||
Bumps the header only when a .gch was actually removed: TUs compiled
|
||||
against it have incomplete depfiles, while a repeat failure with no
|
||||
.gch must not force a full rebuild every build. A .gch that survives
|
||||
an unlink failure would be consumed silently (wrong output, not a
|
||||
slow build), so that raises.
|
||||
"""
|
||||
header = build_dir / PCH_HEADER_NAME
|
||||
gch = Path(f"{header}.gch")
|
||||
had_gch = gch.is_file()
|
||||
errors = []
|
||||
for sidecar in (gch, Path(f"{gch}.sum")):
|
||||
try:
|
||||
sidecar.unlink(missing_ok=True)
|
||||
except OSError as err:
|
||||
if sidecar.is_file():
|
||||
from esphome.core import EsphomeError
|
||||
|
||||
raise EsphomeError(
|
||||
f"Could not discard the stale precompiled header: {err}"
|
||||
) from err
|
||||
errors.append(err)
|
||||
for err in errors:
|
||||
_LOGGER.warning("Could not discard the pch sidecars: %s", err)
|
||||
if had_gch and header.is_file():
|
||||
with suppress(OSError):
|
||||
os.utime(header)
|
||||
|
||||
|
||||
def prepare_pch(
|
||||
build_dir: Path, include_headers: tuple[str, ...], extra: Iterable[str]
|
||||
) -> None:
|
||||
"""Compile ``build_dir``'s .gch from compile_commands.json flags and
|
||||
write its ccache .sum.
|
||||
|
||||
The .sum doubles as the freshness stamp and folds in the compile
|
||||
command, so a flag-only change rebuilds the .gch; ``extra`` carries
|
||||
backend identity (framework version, sdkconfig, ...). A failed
|
||||
compile falls back to the plain header include.
|
||||
"""
|
||||
from esphome.core import CORE
|
||||
|
||||
header = build_dir / PCH_HEADER_NAME
|
||||
gch = Path(f"{header}.gch")
|
||||
sum_path = Path(f"{gch}.sum")
|
||||
cmd_and_dir = pch_compile_command(build_dir, header, gch)
|
||||
if cmd_and_dir is None:
|
||||
# Freshness cannot be validated; a leftover .gch must not be consumed
|
||||
discard_pch(build_dir)
|
||||
pch_degraded("no usable compile command")
|
||||
return
|
||||
cmd, cmd_dir = cmd_and_dir
|
||||
# Strip like ccache's rewriting (user CCACHE_BASEDIR wins); the raw
|
||||
# build path covers unresolved (symlinked) spellings
|
||||
cmd_id = (
|
||||
" ".join(cmd)
|
||||
.replace(effective_ccache_basedir(), "")
|
||||
.replace(str(CORE.build_path), "")
|
||||
)
|
||||
try:
|
||||
checksum = pch_checksum(
|
||||
CORE.relative_src_path(),
|
||||
include_headers,
|
||||
(
|
||||
# The closure is sorted, so root order only enters via the text
|
||||
pch_header_text(include_headers),
|
||||
*extra,
|
||||
cmd_id,
|
||||
),
|
||||
)
|
||||
except (OSError, UnicodeError) as err:
|
||||
# Identity unknown: a stale cache entry must never be served
|
||||
_LOGGER.warning(
|
||||
"Could not establish the pch identity; compiling without it: %s", err
|
||||
)
|
||||
discard_pch(build_dir)
|
||||
pch_degraded(f"identity unknown: {err}")
|
||||
return
|
||||
failed_marker = Path(f"{gch}.failed")
|
||||
|
||||
def _run(
|
||||
run_cmd: list[str], what: str, stdin: str | None = None
|
||||
) -> subprocess.CompletedProcess | None:
|
||||
"""Spawn one pch tool step; environmental failures discard and
|
||||
degrade (None): spawn/IO/timeout errors and signal kills never
|
||||
latch the marker."""
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
run_cmd,
|
||||
cwd=cmd_dir,
|
||||
# C locale keeps diagnostics matchable by _TRANSIENT_ERRORS
|
||||
env={**os.environ, "LC_ALL": "C"},
|
||||
input=stdin,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
timeout=300,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError) as err:
|
||||
_LOGGER.warning("Precompiled header %s did not run: %s", what, err)
|
||||
discard_pch(build_dir)
|
||||
pch_degraded(f"{what} did not run: {err}")
|
||||
return None
|
||||
if proc.returncode < 0:
|
||||
# Killed by a signal (OOM, ^C): environmental, do not latch
|
||||
_LOGGER.warning(
|
||||
"Precompiled header %s was killed (signal %d); retrying next build",
|
||||
what,
|
||||
-proc.returncode,
|
||||
)
|
||||
discard_pch(build_dir)
|
||||
pch_degraded(f"{what} killed by signal {-proc.returncode}")
|
||||
return None
|
||||
return proc
|
||||
|
||||
def _fail(error: str, reason: str, latch: bool) -> None:
|
||||
"""Discard and degrade; deterministic failures latch when asked."""
|
||||
_LOGGER.warning(
|
||||
"Precompiled header failed; compiling without it: %s", error[:400]
|
||||
)
|
||||
# Latching paths keep the full compiler output recoverable
|
||||
_LOGGER.debug("Full pch output: %s", error)
|
||||
discard_pch(build_dir)
|
||||
if latch and not any(m in error for m in _TRANSIENT_ERRORS):
|
||||
# Skip retries until a header/flag/backend-identity/command change
|
||||
failed_marker.write_text(checksum + "\n", encoding="utf-8")
|
||||
os.utime(header)
|
||||
pch_degraded(f"{reason}: {error[:200]}")
|
||||
|
||||
def _probe(latch: bool = True) -> None:
|
||||
"""Load-check the built .gch: some toolchains build one they then
|
||||
refuse to load (per-process ASLR). Dep flags are already stripped
|
||||
from cmd, so no -MF is needed; cmd ends with the fixed
|
||||
"-x c++-header -c -o" tail. A cached-header rejection may not
|
||||
reproduce (per-process), so that caller passes latch=False."""
|
||||
if cmd[-6:-4] != ["-x", "c++-header"]:
|
||||
# The slice below depends on pch_compile_command's fixed tail
|
||||
_LOGGER.warning("Unexpected pch command shape: %s", cmd[-6:])
|
||||
discard_pch(build_dir)
|
||||
pch_degraded("unexpected pch command shape")
|
||||
return
|
||||
base = cmd[:-6]
|
||||
probe = _run([*base, *pch_probe_args(str(header))], "probe", stdin="")
|
||||
if probe is None:
|
||||
return
|
||||
if probe.returncode != 0:
|
||||
# Disambiguate: only blame the pch when the same compile passes
|
||||
# without it; a failing baseline is its own (latchable) problem
|
||||
baseline = _run([*base, *pch_probe_tail()], "probe baseline", stdin="")
|
||||
if baseline is None:
|
||||
return
|
||||
if baseline.returncode == 0:
|
||||
error = probe.stderr.strip() or f"exit code {probe.returncode}"
|
||||
_fail(error, "toolchain cannot load the pch", latch=latch)
|
||||
else:
|
||||
error = baseline.stderr.strip() or f"exit code {baseline.returncode}"
|
||||
_fail(error, "probe cannot run at all", latch=latch)
|
||||
|
||||
if gch.is_file() and _read_stamp(sum_path) == checksum:
|
||||
_log_pch_in_use()
|
||||
if pch_strict():
|
||||
# Rejection is per-process, so a cached .gch must re-prove
|
||||
# loadability for the strict gate (CI-only cost); no latch,
|
||||
# since the rejection may not reproduce either
|
||||
_probe(latch=False)
|
||||
return
|
||||
if _read_stamp(failed_marker) == checksum:
|
||||
_LOGGER.info(
|
||||
"Precompiled header disabled after an earlier failure; delete %s to retry",
|
||||
failed_marker,
|
||||
)
|
||||
pch_degraded("earlier failure latched")
|
||||
return
|
||||
_log_pch_in_use()
|
||||
result = _run(cmd, "compile")
|
||||
if result is None:
|
||||
return
|
||||
error = None
|
||||
if result.returncode != 0:
|
||||
error = result.stderr.strip() or f"exit code {result.returncode}"
|
||||
elif not gch.is_file():
|
||||
error = "compiler produced no .gch"
|
||||
if error is not None:
|
||||
_fail(error, "compile failed", latch=True)
|
||||
return
|
||||
_probe()
|
||||
if not gch.is_file():
|
||||
# The probe discarded a rejected or unrunnable .gch
|
||||
return
|
||||
failed_marker.unlink(missing_ok=True)
|
||||
sum_path.write_text(checksum + "\n", encoding="utf-8")
|
||||
# Consumers depend on the header (depfiles cannot see through a .gch);
|
||||
# bump it so users of the previous .gch recompile
|
||||
os.utime(header)
|
||||
@@ -13,7 +13,7 @@ void Anova::dump_config() { LOG_CLIMATE("", "Anova BLE Cooker", this); }
|
||||
|
||||
void Anova::setup() {
|
||||
this->codec_ = make_unique<AnovaCodec>();
|
||||
this->poll_step_ = PollStep::IDLE;
|
||||
this->current_request_ = 0;
|
||||
}
|
||||
|
||||
void Anova::loop() {
|
||||
@@ -22,15 +22,6 @@ void Anova::loop() {
|
||||
this->disable_loop();
|
||||
}
|
||||
|
||||
void Anova::write_request_(AnovaPacket *pkt) {
|
||||
auto status =
|
||||
esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_,
|
||||
pkt->length, pkt->data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE);
|
||||
if (status) {
|
||||
ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str(), status);
|
||||
}
|
||||
}
|
||||
|
||||
void Anova::control(const ClimateCall &call) {
|
||||
auto mode_val = call.get_mode();
|
||||
if (mode_val.has_value()) {
|
||||
@@ -47,11 +38,22 @@ void Anova::control(const ClimateCall &call) {
|
||||
ESP_LOGW(TAG, "Unsupported mode: %d", mode);
|
||||
return;
|
||||
}
|
||||
this->write_request_(pkt);
|
||||
auto status =
|
||||
esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_,
|
||||
pkt->length, pkt->data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE);
|
||||
if (status) {
|
||||
ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str(), status);
|
||||
}
|
||||
}
|
||||
auto target_temp = call.get_target_temperature();
|
||||
if (target_temp.has_value()) {
|
||||
this->write_request_(this->codec_->get_set_target_temp_request(*target_temp));
|
||||
auto *pkt = this->codec_->get_set_target_temp_request(*target_temp);
|
||||
auto status =
|
||||
esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_,
|
||||
pkt->length, pkt->data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE);
|
||||
if (status) {
|
||||
ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str(), status);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +62,6 @@ void Anova::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_
|
||||
case ESP_GATTC_DISCONNECT_EVT: {
|
||||
this->current_temperature = NAN;
|
||||
this->target_temperature = NAN;
|
||||
this->poll_step_ = PollStep::IDLE;
|
||||
this->publish_state();
|
||||
break;
|
||||
}
|
||||
@@ -82,8 +83,8 @@ void Anova::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_
|
||||
}
|
||||
case ESP_GATTC_REG_FOR_NOTIFY_EVT: {
|
||||
this->node_state = espbt::ClientState::ESTABLISHED;
|
||||
this->poll_step_ = PollStep::IDLE;
|
||||
this->update(); // begin the first poll cycle immediately
|
||||
this->current_request_ = 0;
|
||||
this->update();
|
||||
break;
|
||||
}
|
||||
case ESP_GATTC_NOTIFY_EVT: {
|
||||
@@ -100,30 +101,33 @@ void Anova::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_
|
||||
this->mode = this->codec_->running_ ? climate::CLIMATE_MODE_HEAT : climate::CLIMATE_MODE_OFF;
|
||||
}
|
||||
if (this->codec_->has_unit()) {
|
||||
ESP_LOGD(TAG, "Anova units is %s", (this->codec_->unit_ == 'f') ? "fahrenheit" : "celsius");
|
||||
this->fahrenheit_ = (this->codec_->unit_ == 'f');
|
||||
ESP_LOGD(TAG, "Anova units is %s", this->fahrenheit_ ? "fahrenheit" : "celsius");
|
||||
this->current_request_++;
|
||||
}
|
||||
this->publish_state();
|
||||
|
||||
// Advance the poll cycle to its next request based on the reply we got.
|
||||
switch (this->poll_step_) {
|
||||
case PollStep::SET_UNIT:
|
||||
this->poll_step_ = PollStep::STATUS;
|
||||
this->write_request_(this->codec_->get_read_device_status_request());
|
||||
break;
|
||||
case PollStep::STATUS:
|
||||
this->poll_step_ = PollStep::TARGET;
|
||||
this->write_request_(this->codec_->get_read_target_temp_request());
|
||||
break;
|
||||
case PollStep::TARGET:
|
||||
this->poll_step_ = PollStep::CURRENT;
|
||||
this->write_request_(this->codec_->get_read_current_temp_request());
|
||||
break;
|
||||
case PollStep::CURRENT:
|
||||
this->poll_step_ = PollStep::IDLE; // full cycle complete
|
||||
break;
|
||||
default:
|
||||
// A reply to an ad-hoc control() write, outside a managed cycle.
|
||||
break;
|
||||
if (this->current_request_ > 1) {
|
||||
AnovaPacket *pkt = nullptr;
|
||||
switch (this->current_request_++) {
|
||||
case 2:
|
||||
pkt = this->codec_->get_read_target_temp_request();
|
||||
break;
|
||||
case 3:
|
||||
pkt = this->codec_->get_read_current_temp_request();
|
||||
break;
|
||||
default:
|
||||
this->current_request_ = 1;
|
||||
break;
|
||||
}
|
||||
if (pkt != nullptr) {
|
||||
auto status =
|
||||
esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_,
|
||||
pkt->length, pkt->data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE);
|
||||
if (status) {
|
||||
ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str(), status);
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -132,26 +136,27 @@ void Anova::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_
|
||||
}
|
||||
}
|
||||
|
||||
void Anova::set_unit_of_measurement(const char *unit) { this->want_fahrenheit_ = !strncmp(unit, "f", 1); }
|
||||
void Anova::set_unit_of_measurement(const char *unit) { this->fahrenheit_ = !strncmp(unit, "f", 1); }
|
||||
|
||||
void Anova::update() {
|
||||
if (this->node_state != espbt::ClientState::ESTABLISHED)
|
||||
return;
|
||||
if (this->poll_step_ != PollStep::IDLE) {
|
||||
// The previous cycle never finished within a full polling interval -- a
|
||||
// reply was missed or a write failed. Restart the cycle rather than stall;
|
||||
// the polling interval itself acts as the timeout. A late reply from the
|
||||
// abandoned cycle is harmless: state decoding happens on every notify
|
||||
// regardless of step, and each notify sends at most one follow-up request.
|
||||
ESP_LOGW(TAG, "[%s] Poll cycle incomplete (step %u); restarting cycle", this->parent_->address_str(),
|
||||
static_cast<uint8_t>(this->poll_step_));
|
||||
|
||||
if (this->current_request_ < 2) {
|
||||
AnovaPacket *pkt;
|
||||
if (this->current_request_ == 0) {
|
||||
pkt = this->codec_->get_set_unit_request(this->fahrenheit_ ? 'f' : 'c');
|
||||
} else {
|
||||
pkt = this->codec_->get_read_device_status_request();
|
||||
}
|
||||
auto status =
|
||||
esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_,
|
||||
pkt->length, pkt->data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE);
|
||||
if (status) {
|
||||
ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str(), status);
|
||||
}
|
||||
this->current_request_++;
|
||||
}
|
||||
// Re-assert the configured unit at the start of every poll cycle, then fall
|
||||
// through the status/temperature reads via the notification handler. Always
|
||||
// command the configured unit (want_fahrenheit_) -- never the last value the
|
||||
// device reported, or a drift to 'c' would lock itself in.
|
||||
this->poll_step_ = PollStep::SET_UNIT;
|
||||
this->write_request_(this->codec_->get_set_unit_request(this->want_fahrenheit_ ? 'f' : 'c'));
|
||||
}
|
||||
|
||||
} // namespace esphome::anova
|
||||
|
||||
@@ -37,20 +37,11 @@ class Anova final : public climate::Climate, public esphome::ble_client::BLEClie
|
||||
void set_unit_of_measurement(const char *unit);
|
||||
|
||||
protected:
|
||||
// A poll cycle re-asserts the configured unit, then reads device state.
|
||||
// Re-asserting every cycle prevents the cooker from silently reverting to
|
||||
// its default (Celsius); previously the unit was only set once on
|
||||
// connection, so a drift persisted (and corrupted the F/C interpretation of
|
||||
// subsequent readings) until the BLE link was re-established.
|
||||
enum class PollStep : uint8_t { SET_UNIT, STATUS, TARGET, CURRENT, IDLE };
|
||||
|
||||
void write_request_(AnovaPacket *pkt);
|
||||
|
||||
std::unique_ptr<AnovaCodec> codec_;
|
||||
void control(const climate::ClimateCall &call) override;
|
||||
uint16_t char_handle_;
|
||||
bool want_fahrenheit_{true}; // configured target unit; never overwritten by device replies
|
||||
PollStep poll_step_{PollStep::IDLE};
|
||||
uint8_t current_request_;
|
||||
bool fahrenheit_;
|
||||
};
|
||||
|
||||
} // namespace esphome::anova
|
||||
|
||||
@@ -2255,7 +2255,12 @@ bool APIConnection::send_message_(uint32_t payload_size, uint16_t message_type,
|
||||
// Capacity reserved above, cannot fail
|
||||
(void) shared_buf.resize(write_start + payload_size);
|
||||
ProtoWriteBuffer buffer{&shared_buf, write_start};
|
||||
encode_fn(msg, buffer PROTO_ENCODE_DEBUG_INIT(&shared_buf));
|
||||
uint8_t *end = encode_fn(msg, buffer PROTO_ENCODE_DEBUG_INIT(&shared_buf));
|
||||
#ifdef ESPHOME_DEBUG_API
|
||||
assert(end == shared_buf.data() + shared_buf.size());
|
||||
#else
|
||||
(void) end;
|
||||
#endif
|
||||
return this->send_buffer(ProtoWriteBuffer{&shared_buf}, message_type);
|
||||
}
|
||||
// encode_to_buffer is defined inline in api_connection.h (ESPHOME_ALWAYS_INLINE)
|
||||
|
||||
@@ -345,11 +345,7 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
/// Returns false as soon as the TCP buffer is full. Marked nodiscard so we
|
||||
/// have no silent failures: every caller must handle (or log) a refusal.
|
||||
template<typename T> [[nodiscard]] bool send_message(const T &msg) {
|
||||
if constexpr (T::ESTIMATED_SIZE == 0) {
|
||||
return this->send_message_(0, T::MESSAGE_TYPE, &encode_msg_noop, &msg);
|
||||
} else {
|
||||
return this->send_message_(msg.calculate_size(), T::MESSAGE_TYPE, &proto_encode_msg<T>, &msg);
|
||||
}
|
||||
return this->send_message_(T::calc_size_msg(&msg), T::MESSAGE_TYPE, &T::encode_msg, &msg);
|
||||
}
|
||||
|
||||
/// Clear the shared write buffer and reserve space for the first message.
|
||||
@@ -405,16 +401,6 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
void process_state_subscriptions_();
|
||||
#endif
|
||||
|
||||
// Size thunk — converts void* back to concrete type for direct calculate_size() call
|
||||
template<typename T> static uint32_t calc_size(const void *msg) {
|
||||
return static_cast<const T *>(msg)->calculate_size();
|
||||
}
|
||||
|
||||
// Shared no-op encode thunk for empty messages (ESTIMATED_SIZE == 0)
|
||||
static uint8_t *encode_msg_noop(const void *, ProtoWriteBuffer &buf PROTO_ENCODE_DEBUG_PARAM) {
|
||||
return buf.get_pos();
|
||||
}
|
||||
|
||||
// Non-template buffer management for send_message
|
||||
bool send_message_(uint32_t payload_size, uint16_t message_type, MessageEncodeFn encode_fn, const void *msg);
|
||||
|
||||
@@ -433,11 +419,7 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
// Hot paths (state/info) go through fill_and_encode_entity_state/info instead.
|
||||
// batch_message_type_ is already set by dispatch_message_ before reaching here.
|
||||
template<typename T> static uint16_t encode_message_to_buffer(T &msg, APIConnection *conn, uint32_t remaining_size) {
|
||||
if constexpr (T::ESTIMATED_SIZE == 0) {
|
||||
return encode_to_buffer_slow(0, &encode_msg_noop, &msg, conn, remaining_size);
|
||||
} else {
|
||||
return encode_to_buffer_slow(msg.calculate_size(), &proto_encode_msg<T>, &msg, conn, remaining_size);
|
||||
}
|
||||
return encode_to_buffer_slow(T::calc_size_msg(&msg), &T::encode_msg, &msg, conn, remaining_size);
|
||||
}
|
||||
|
||||
// Non-template core — fills state fields and encodes
|
||||
@@ -449,7 +431,7 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
template<typename T>
|
||||
static uint16_t fill_and_encode_entity_state(EntityBase *entity, T &msg, APIConnection *conn,
|
||||
uint32_t remaining_size) {
|
||||
return fill_and_encode_entity_state(entity, msg, &calc_size<T>, &proto_encode_msg<T>, conn, remaining_size);
|
||||
return fill_and_encode_entity_state(entity, msg, &T::calc_size_msg, &T::encode_msg, conn, remaining_size);
|
||||
}
|
||||
|
||||
// Non-template core — fills info fields, allocates buffers, and encodes
|
||||
@@ -461,7 +443,7 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
template<typename T>
|
||||
static uint16_t fill_and_encode_entity_info(EntityBase *entity, T &msg, APIConnection *conn,
|
||||
uint32_t remaining_size) {
|
||||
return fill_and_encode_entity_info(entity, msg, &calc_size<T>, &proto_encode_msg<T>, conn, remaining_size);
|
||||
return fill_and_encode_entity_info(entity, msg, &T::calc_size_msg, &T::encode_msg, conn, remaining_size);
|
||||
}
|
||||
|
||||
// Non-template core — fills device_class, then delegates to fill_and_encode_entity_info
|
||||
@@ -475,8 +457,8 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
static uint16_t fill_and_encode_entity_info_with_device_class(EntityBase *entity, T &msg,
|
||||
StringRef &device_class_field, APIConnection *conn,
|
||||
uint32_t remaining_size) {
|
||||
return fill_and_encode_entity_info_with_device_class(entity, msg, device_class_field, &calc_size<T>,
|
||||
&proto_encode_msg<T>, conn, remaining_size);
|
||||
return fill_and_encode_entity_info_with_device_class(entity, msg, device_class_field, &T::calc_size_msg,
|
||||
&T::encode_msg, conn, remaining_size);
|
||||
}
|
||||
|
||||
#ifdef USE_VOICE_ASSISTANT
|
||||
|
||||
@@ -46,7 +46,13 @@ inline uint16_t ESPHOME_ALWAYS_INLINE APIConnection::encode_to_buffer(uint32_t c
|
||||
return 0;
|
||||
}
|
||||
ProtoWriteBuffer buffer{&shared_buf, shared_buf.size() - calculated_size};
|
||||
encode_fn(msg, buffer PROTO_ENCODE_DEBUG_INIT(&shared_buf));
|
||||
uint8_t *end = encode_fn(msg, buffer PROTO_ENCODE_DEBUG_INIT(&shared_buf));
|
||||
#ifdef ESPHOME_DEBUG_API
|
||||
// A body that writes fewer bytes than calculate_size() promised would ship stale buffer bytes
|
||||
assert(end == shared_buf.data() + shared_buf.size());
|
||||
#else
|
||||
(void) end;
|
||||
#endif
|
||||
|
||||
return total_calculated_size;
|
||||
}
|
||||
|
||||
+2490
-2676
File diff suppressed because it is too large
Load Diff
+653
-304
File diff suppressed because it is too large
Load Diff
@@ -214,73 +214,74 @@ void ProtoDecodableMessage::decode(const uint8_t *buffer, size_t length) {
|
||||
const uint8_t *ptr = buffer;
|
||||
const uint8_t *end = buffer + length;
|
||||
|
||||
while (ptr < end) {
|
||||
// Parse field header - ptr < end guarantees len >= 1
|
||||
// Single-byte varints dominate, so that case advances the cursor inline.
|
||||
auto read_varint = [&](proto_varint_value_t &value) ESPHOME_ALWAYS_INLINE {
|
||||
if (ptr == end)
|
||||
return false;
|
||||
if (*ptr < 0x80) [[likely]] {
|
||||
value = *ptr++;
|
||||
return true;
|
||||
}
|
||||
auto res = ProtoVarInt::parse_non_empty(ptr, end - ptr);
|
||||
if (!res.has_value()) {
|
||||
if (!res.has_value())
|
||||
return false;
|
||||
value = res.value;
|
||||
ptr += res.consumed;
|
||||
return true;
|
||||
};
|
||||
|
||||
while (ptr < end) {
|
||||
proto_varint_value_t tag_value;
|
||||
if (!read_varint(tag_value)) {
|
||||
ESP_LOGV(TAG, "Invalid field start at offset %ld", (long) (ptr - buffer));
|
||||
return;
|
||||
}
|
||||
|
||||
uint32_t tag = static_cast<uint32_t>(res.value);
|
||||
uint32_t tag = static_cast<uint32_t>(tag_value);
|
||||
uint32_t field_type = tag & WIRE_TYPE_MASK;
|
||||
uint32_t field_id = tag >> 3;
|
||||
ptr += res.consumed;
|
||||
// Length-delimited fields move this past the length prefix
|
||||
const uint8_t *data = ptr;
|
||||
proto_varint_value_t scalar;
|
||||
|
||||
switch (field_type) {
|
||||
case WIRE_TYPE_VARINT: { // VarInt
|
||||
res = ProtoVarInt::parse(ptr, end - ptr);
|
||||
if (!res.has_value()) {
|
||||
ESP_LOGV(TAG, "Invalid VarInt at offset %ld", (long) (ptr - buffer));
|
||||
return;
|
||||
}
|
||||
if (!this->decode_varint(field_id, res.value)) {
|
||||
ESP_LOGV(TAG, "Cannot decode VarInt field %" PRIu32 " with value %" PRIu64 "!", field_id,
|
||||
static_cast<uint64_t>(res.value));
|
||||
}
|
||||
ptr += res.consumed;
|
||||
break;
|
||||
}
|
||||
case WIRE_TYPE_LENGTH_DELIMITED: { // Length-delimited
|
||||
res = ProtoVarInt::parse(ptr, end - ptr);
|
||||
if (!res.has_value()) {
|
||||
ESP_LOGV(TAG, "Invalid Length Delimited at offset %ld", (long) (ptr - buffer));
|
||||
return;
|
||||
}
|
||||
uint32_t field_length = static_cast<uint32_t>(res.value);
|
||||
ptr += res.consumed;
|
||||
if (field_length > static_cast<size_t>(end - ptr)) {
|
||||
ESP_LOGV(TAG, "Out-of-bounds Length Delimited at offset %ld", (long) (ptr - buffer));
|
||||
return;
|
||||
}
|
||||
if (!this->decode_length(field_id, ProtoLengthDelimited(ptr, field_length))) {
|
||||
ESP_LOGV(TAG, "Cannot decode Length Delimited field %" PRIu32 "!", field_id);
|
||||
}
|
||||
ptr += field_length;
|
||||
break;
|
||||
}
|
||||
case WIRE_TYPE_FIXED32: { // 32-bit
|
||||
if (end - ptr < 4) {
|
||||
ESP_LOGV(TAG, "Out-of-bounds Fixed32-bit at offset %ld", (long) (ptr - buffer));
|
||||
return;
|
||||
}
|
||||
uint32_t val;
|
||||
#if __BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__
|
||||
// Protobuf fixed32 is little-endian — direct load on LE platforms
|
||||
memcpy(&val, ptr, 4);
|
||||
#else
|
||||
val = encode_uint32(ptr[3], ptr[2], ptr[1], ptr[0]);
|
||||
#endif
|
||||
if (!this->decode_32bit(field_id, Proto32Bit(val))) {
|
||||
ESP_LOGV(TAG, "Cannot decode 32-bit field %" PRIu32 " with value %" PRIu32 "!", field_id, val);
|
||||
}
|
||||
ptr += 4;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
ESP_LOGV(TAG, "Invalid field type %" PRIu32 " at offset %ld", field_type, (long) (ptr - buffer));
|
||||
if (field_type == WIRE_TYPE_VARINT) [[likely]] {
|
||||
if (!read_varint(scalar)) {
|
||||
ESP_LOGV(TAG, "Invalid VarInt at offset %ld", (long) (ptr - buffer));
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
switch (field_type) {
|
||||
case WIRE_TYPE_LENGTH_DELIMITED: {
|
||||
proto_varint_value_t length_value;
|
||||
if (!read_varint(length_value)) {
|
||||
ESP_LOGV(TAG, "Invalid Length Delimited at offset %ld", (long) (ptr - buffer));
|
||||
return;
|
||||
}
|
||||
uint32_t field_length = static_cast<uint32_t>(length_value);
|
||||
if (field_length > static_cast<size_t>(end - ptr)) {
|
||||
ESP_LOGV(TAG, "Out-of-bounds Length Delimited at offset %ld", (long) (ptr - buffer));
|
||||
return;
|
||||
}
|
||||
data = ptr;
|
||||
scalar = field_length;
|
||||
ptr += field_length;
|
||||
break;
|
||||
}
|
||||
case WIRE_TYPE_FIXED32: {
|
||||
if (end - ptr < 4) {
|
||||
ESP_LOGV(TAG, "Out-of-bounds Fixed32-bit at offset %ld", (long) (ptr - buffer));
|
||||
return;
|
||||
}
|
||||
// Byte loads instead of memcpy: ESP-IDF passes -fno-builtin-memcpy, which made this a call
|
||||
scalar = encode_uint32(ptr[3], ptr[2], ptr[1], ptr[0]);
|
||||
ptr += 4;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
ESP_LOGV(TAG, "Invalid field type %" PRIu32 " at offset %ld", field_type, (long) (ptr - buffer));
|
||||
return;
|
||||
}
|
||||
}
|
||||
this->decode_field(tag, data, scalar);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+228
-166
@@ -170,40 +170,43 @@ class ProtoVarInt {
|
||||
class ProtoMessage;
|
||||
class ProtoSize;
|
||||
|
||||
class ProtoLengthDelimited {
|
||||
/// Case label for decode_field(): the wire tag of a field, so a field that arrives with another wire
|
||||
/// type matches no case.
|
||||
constexpr uint32_t proto_tag(uint32_t field_id, uint32_t wire_type) { return (field_id << 3) | wire_type; }
|
||||
|
||||
/// One decoded field: the payload pointer and a scalar holding the varint or fixed32 value, or the
|
||||
/// length of a length-delimited field. The wire type in the tag says which applies; accessors do not check.
|
||||
class ProtoFieldValue {
|
||||
public:
|
||||
explicit ProtoLengthDelimited(const uint8_t *value, size_t length) : value_(value), length_(length) {}
|
||||
std::string as_string() const { return std::string(reinterpret_cast<const char *>(this->value_), this->length_); }
|
||||
ProtoFieldValue(const uint8_t *data, proto_varint_value_t scalar) : data_(data), scalar_(scalar) {}
|
||||
|
||||
// Direct access to raw data without string allocation
|
||||
const uint8_t *data() const { return this->value_; }
|
||||
size_t size() const { return this->length_; }
|
||||
proto_varint_value_t as_varint() const { return this->scalar_; }
|
||||
// A bool is sent as 0 or 1, so the low word is enough and saves a second compare with 64 bit varints
|
||||
bool as_bool() const { return static_cast<uint32_t>(this->scalar_) != 0; }
|
||||
|
||||
/// Decode the length-delimited data into a message instance.
|
||||
// Length-delimited accessors
|
||||
const uint8_t *data() const { return this->data_; }
|
||||
size_t size() const { return static_cast<size_t>(this->scalar_); }
|
||||
std::string as_string() const { return std::string(reinterpret_cast<const char *>(this->data_), this->size()); }
|
||||
/// Decode the length-delimited payload into a message instance.
|
||||
/// Template preserves concrete type so decode() resolves statically.
|
||||
template<typename T> void decode_to_message(T &msg) const;
|
||||
template<typename T> void decode_to_message(T &msg) const { msg.decode(this->data_, this->size()); }
|
||||
|
||||
protected:
|
||||
const uint8_t *const value_;
|
||||
const size_t length_;
|
||||
};
|
||||
|
||||
class Proto32Bit {
|
||||
public:
|
||||
explicit Proto32Bit(uint32_t value) : value_(value) {}
|
||||
uint32_t as_fixed32() const { return this->value_; }
|
||||
int32_t as_sfixed32() const { return static_cast<int32_t>(this->value_); }
|
||||
// Fixed32 accessors
|
||||
uint32_t as_fixed32() const { return static_cast<uint32_t>(this->scalar_); }
|
||||
int32_t as_sfixed32() const { return static_cast<int32_t>(this->as_fixed32()); }
|
||||
float as_float() const {
|
||||
union {
|
||||
uint32_t raw;
|
||||
float value;
|
||||
} s{};
|
||||
s.raw = this->value_;
|
||||
s.raw = this->as_fixed32();
|
||||
return s.value;
|
||||
}
|
||||
|
||||
protected:
|
||||
const uint32_t value_;
|
||||
private:
|
||||
const uint8_t *data_;
|
||||
proto_varint_value_t scalar_;
|
||||
};
|
||||
|
||||
// NOTE: Proto64Bit class removed - wire type 1 (64-bit fixed) not supported
|
||||
@@ -252,7 +255,7 @@ class ProtoWriteBuffer {
|
||||
*
|
||||
* Following https://protobuf.dev/programming-guides/encoding/#structure
|
||||
*/
|
||||
void encode_field_raw(uint32_t field_id, uint32_t type) { this->encode_varint_raw((field_id << 3) | type); }
|
||||
void encode_field_raw(uint32_t field_id, uint32_t type) { this->encode_varint_raw(proto_tag(field_id, type)); }
|
||||
/// Single-pass encode for repeated submessage elements.
|
||||
/// Thin template wrapper; all buffer work is in the non-template core.
|
||||
template<typename T> void encode_sub_message(uint32_t field_id, const T &value);
|
||||
@@ -287,19 +290,31 @@ class ProtoWriteBuffer {
|
||||
uint8_t *pos_;
|
||||
};
|
||||
|
||||
// A four byte unaligned store is a memcpy call on ESP-IDF (-fno-builtin-memcpy) and on ARM cores without
|
||||
// unaligned access (Cortex-M0+, ARM9), so those targets share one outlined byte store helper per fixed32
|
||||
// field. Elsewhere the write inlines to a single store, or on ESP8266 to a few stores that measured
|
||||
// faster than a call, so it stays inline.
|
||||
#if defined(USE_ESP32) || (defined(__arm__) && !defined(__ARM_FEATURE_UNALIGNED))
|
||||
#define PROTO_OUTLINE_FOR_SIZE __attribute__((noinline))
|
||||
#define PROTO_FIXED32_BYTE_STORES true
|
||||
#else
|
||||
#define PROTO_OUTLINE_FOR_SIZE inline
|
||||
#define PROTO_FIXED32_BYTE_STORES false
|
||||
#endif
|
||||
|
||||
// Varint encoding thresholds — used by both proto_encode_* free functions and ProtoSize.
|
||||
constexpr uint32_t VARINT_MAX_1_BYTE = 1 << 7; // 128
|
||||
constexpr uint32_t VARINT_MAX_2_BYTE = 1 << 14; // 16384
|
||||
|
||||
/// Static encode helpers for generated encode() functions.
|
||||
/// Generated code hoists buffer.pos_ into a local uint8_t *__restrict__ pos,
|
||||
/// then calls these methods which take pos by reference. No struct, no overhead.
|
||||
/// For sub-messages, pos is synced back to buffer before the call and reloaded after.
|
||||
/// Static encode helpers for the generated encode bodies. Each takes the write cursor by value and
|
||||
/// returns it advanced, so outlined calls at -Os chain through the return register instead of a
|
||||
/// stack slot. Helpers without a _force suffix skip fields holding the proto3 default.
|
||||
class ProtoEncode {
|
||||
public:
|
||||
/// Write a multi-byte varint directly through a pos pointer.
|
||||
template<typename T>
|
||||
static inline void encode_varint_raw_loop(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, T value) {
|
||||
[[nodiscard]] static inline uint8_t *encode_varint_raw_loop(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM,
|
||||
T value) {
|
||||
do {
|
||||
PROTO_ENCODE_CHECK_BOUNDS(pos, 1);
|
||||
*pos++ = static_cast<uint8_t>(value | 0x80);
|
||||
@@ -307,48 +322,49 @@ class ProtoEncode {
|
||||
} while (value > 0x7F);
|
||||
PROTO_ENCODE_CHECK_BOUNDS(pos, 1);
|
||||
*pos++ = static_cast<uint8_t>(value);
|
||||
return pos;
|
||||
}
|
||||
static inline void ESPHOME_ALWAYS_INLINE encode_varint_raw(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM,
|
||||
uint32_t value) {
|
||||
[[nodiscard]] static inline uint8_t *ESPHOME_ALWAYS_INLINE
|
||||
encode_varint_raw(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, uint32_t value) {
|
||||
if (value < VARINT_MAX_1_BYTE) [[likely]] {
|
||||
PROTO_ENCODE_CHECK_BOUNDS(pos, 1);
|
||||
*pos++ = static_cast<uint8_t>(value);
|
||||
return;
|
||||
return pos;
|
||||
}
|
||||
encode_varint_raw_loop(pos PROTO_ENCODE_DEBUG_ARG, value);
|
||||
return encode_varint_raw_loop(pos PROTO_ENCODE_DEBUG_ARG, value);
|
||||
}
|
||||
/// Encode a varint that is expected to be 1-2 bytes (e.g. zigzag RSSI, small lengths).
|
||||
static inline void ESPHOME_ALWAYS_INLINE encode_varint_raw_short(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM,
|
||||
uint32_t value) {
|
||||
[[nodiscard]] static inline uint8_t *ESPHOME_ALWAYS_INLINE
|
||||
encode_varint_raw_short(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, uint32_t value) {
|
||||
if (value < VARINT_MAX_1_BYTE) [[likely]] {
|
||||
PROTO_ENCODE_CHECK_BOUNDS(pos, 1);
|
||||
*pos++ = static_cast<uint8_t>(value);
|
||||
return;
|
||||
return pos;
|
||||
}
|
||||
if (value < VARINT_MAX_2_BYTE) [[likely]] {
|
||||
PROTO_ENCODE_CHECK_BOUNDS(pos, 2);
|
||||
*pos++ = static_cast<uint8_t>(value | 0x80);
|
||||
*pos++ = static_cast<uint8_t>(value >> 7);
|
||||
return;
|
||||
return pos;
|
||||
}
|
||||
encode_varint_raw_loop(pos PROTO_ENCODE_DEBUG_ARG, value);
|
||||
return encode_varint_raw_loop(pos PROTO_ENCODE_DEBUG_ARG, value);
|
||||
}
|
||||
static inline void ESPHOME_ALWAYS_INLINE encode_varint_raw_64(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM,
|
||||
uint64_t value) {
|
||||
[[nodiscard]] static inline uint8_t *ESPHOME_ALWAYS_INLINE
|
||||
encode_varint_raw_64(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, uint64_t value) {
|
||||
if (value < VARINT_MAX_1_BYTE) [[likely]] {
|
||||
PROTO_ENCODE_CHECK_BOUNDS(pos, 1);
|
||||
*pos++ = static_cast<uint8_t>(value);
|
||||
return;
|
||||
return pos;
|
||||
}
|
||||
encode_varint_raw_loop(pos PROTO_ENCODE_DEBUG_ARG, value);
|
||||
return encode_varint_raw_loop(pos PROTO_ENCODE_DEBUG_ARG, value);
|
||||
}
|
||||
/// Encode a 48-bit MAC address (stored in a uint64) as varint.
|
||||
/// Real MAC addresses occupy the full 48 bits (OUI in upper 24), so the
|
||||
/// fast path -- any non-zero bit in the top 6 of 48 -- emits exactly 7 bytes
|
||||
/// with no per-byte branch. Falls back to the general loop otherwise.
|
||||
/// Caller must guarantee value fits in 48 bits (checked in debug builds).
|
||||
static inline void ESPHOME_ALWAYS_INLINE encode_varint_raw_48bit(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM,
|
||||
uint64_t value) {
|
||||
[[nodiscard]] static inline uint8_t *ESPHOME_ALWAYS_INLINE
|
||||
encode_varint_raw_48bit(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, uint64_t value) {
|
||||
#ifdef ESPHOME_DEBUG_API
|
||||
assert(value < (1ULL << (MAC_ADDRESS_SIZE * 8)) && "encode_varint_raw_48bit: value exceeds 48 bits");
|
||||
#endif
|
||||
@@ -363,38 +379,39 @@ class ProtoEncode {
|
||||
pos[4] = static_cast<uint8_t>((value >> 28) | 0x80);
|
||||
pos[5] = static_cast<uint8_t>((value >> 35) | 0x80);
|
||||
pos[6] = static_cast<uint8_t>(value >> 42);
|
||||
pos += 7;
|
||||
return;
|
||||
return pos + 7;
|
||||
}
|
||||
encode_varint_raw_64(pos PROTO_ENCODE_DEBUG_ARG, value);
|
||||
return encode_varint_raw_64(pos PROTO_ENCODE_DEBUG_ARG, value);
|
||||
}
|
||||
static inline void ESPHOME_ALWAYS_INLINE encode_field_raw(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM,
|
||||
uint32_t field_id, uint32_t type) {
|
||||
encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, (field_id << 3) | type);
|
||||
[[nodiscard]] static inline uint8_t *ESPHOME_ALWAYS_INLINE
|
||||
encode_field_raw(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, uint32_t field_id, uint32_t type) {
|
||||
return encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, proto_tag(field_id, type));
|
||||
}
|
||||
/// Write a single precomputed tag byte. Tag must be < 128.
|
||||
static inline void ESPHOME_ALWAYS_INLINE write_raw_byte(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM,
|
||||
uint8_t b) {
|
||||
[[nodiscard]] static inline uint8_t *ESPHOME_ALWAYS_INLINE
|
||||
write_raw_byte(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, uint8_t b) {
|
||||
PROTO_ENCODE_CHECK_BOUNDS(pos, 1);
|
||||
*pos++ = b;
|
||||
return pos;
|
||||
}
|
||||
/// Reserve one byte for later backpatch (e.g., sub-message length).
|
||||
/// Advances pos past the reserved byte without writing a value.
|
||||
static inline void ESPHOME_ALWAYS_INLINE reserve_byte(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM) {
|
||||
[[nodiscard]] static inline uint8_t *ESPHOME_ALWAYS_INLINE
|
||||
reserve_byte(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) {
|
||||
PROTO_ENCODE_CHECK_BOUNDS(pos, 1);
|
||||
pos++;
|
||||
return pos + 1;
|
||||
}
|
||||
/// Write raw bytes to the buffer (no tag, no length prefix).
|
||||
static inline void ESPHOME_ALWAYS_INLINE encode_raw(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM,
|
||||
const void *data, size_t len) {
|
||||
[[nodiscard]] static inline uint8_t *ESPHOME_ALWAYS_INLINE
|
||||
encode_raw(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, const void *data, size_t len) {
|
||||
PROTO_ENCODE_CHECK_BOUNDS(pos, len);
|
||||
std::memcpy(pos, data, len);
|
||||
pos += len;
|
||||
return pos + len;
|
||||
}
|
||||
/// Encode tag + 1-byte length + raw string data. For strings with max_data_length < 128.
|
||||
/// Tag must be a single-byte varint (< 128). Always encodes (no zero check).
|
||||
static inline void encode_short_string_force(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, uint8_t tag,
|
||||
const StringRef &ref) {
|
||||
[[nodiscard]] static inline uint8_t *encode_short_string_force(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM,
|
||||
uint8_t tag, const StringRef &ref) {
|
||||
#ifdef ESPHOME_DEBUG_API
|
||||
assert(ref.size() < 128 && "encode_short_string_force: string exceeds max_data_length < 128");
|
||||
#endif
|
||||
@@ -402,137 +419,191 @@ class ProtoEncode {
|
||||
pos[0] = tag;
|
||||
pos[1] = static_cast<uint8_t>(ref.size());
|
||||
std::memcpy(pos + 2, ref.c_str(), ref.size());
|
||||
pos += 2 + ref.size();
|
||||
return pos + 2 + ref.size();
|
||||
}
|
||||
/// Write a precomputed tag byte + 32-bit value in one operation.
|
||||
static inline void ESPHOME_ALWAYS_INLINE write_tag_and_fixed32(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM,
|
||||
uint8_t tag, uint32_t value) {
|
||||
/// Write a precomputed tag byte + 32-bit value. Outlined on embedded: one copy beats inline stores per field.
|
||||
[[nodiscard]] static PROTO_OUTLINE_FOR_SIZE uint8_t *write_tag_and_fixed32(
|
||||
uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, uint8_t tag, uint32_t value) {
|
||||
PROTO_ENCODE_CHECK_BOUNDS(pos, 5);
|
||||
pos[0] = tag;
|
||||
#if __BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__
|
||||
std::memcpy(pos + 1, &value, 4);
|
||||
#else
|
||||
pos[1] = static_cast<uint8_t>(value & 0xFF);
|
||||
pos[2] = static_cast<uint8_t>((value >> 8) & 0xFF);
|
||||
pos[3] = static_cast<uint8_t>((value >> 16) & 0xFF);
|
||||
pos[4] = static_cast<uint8_t>((value >> 24) & 0xFF);
|
||||
#endif
|
||||
pos += 5;
|
||||
write_fixed32_le(pos + 1, value);
|
||||
return pos + 5;
|
||||
}
|
||||
static inline void encode_string(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, uint32_t field_id,
|
||||
const char *string, size_t len, bool force = false) {
|
||||
if (len == 0 && !force)
|
||||
return;
|
||||
encode_field_raw(pos PROTO_ENCODE_DEBUG_ARG, field_id, 2); // type 2: Length-delimited string
|
||||
[[nodiscard]] static inline uint8_t *encode_string_force(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM,
|
||||
uint32_t field_id, const char *string, size_t len) {
|
||||
pos = encode_field_raw(pos PROTO_ENCODE_DEBUG_ARG, field_id, 2); // type 2: Length-delimited string
|
||||
// NOLINTNEXTLINE(readability-inconsistent-ifelse-braces) -- false positive on [[likely]] attribute
|
||||
if (len < VARINT_MAX_1_BYTE) [[likely]] {
|
||||
PROTO_ENCODE_CHECK_BOUNDS(pos, 1 + len);
|
||||
*pos++ = static_cast<uint8_t>(len);
|
||||
} else {
|
||||
encode_varint_raw_loop(pos PROTO_ENCODE_DEBUG_ARG, len);
|
||||
pos = encode_varint_raw_loop(pos PROTO_ENCODE_DEBUG_ARG, len);
|
||||
PROTO_ENCODE_CHECK_BOUNDS(pos, len);
|
||||
}
|
||||
std::memcpy(pos, string, len);
|
||||
pos += len;
|
||||
return pos + len;
|
||||
}
|
||||
static inline void encode_string(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, uint32_t field_id,
|
||||
const std::string &value, bool force = false) {
|
||||
encode_string(pos PROTO_ENCODE_DEBUG_ARG, field_id, value.data(), value.size(), force);
|
||||
[[nodiscard]] static inline uint8_t *encode_string(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM,
|
||||
uint32_t field_id, const char *string, size_t len) {
|
||||
if (len == 0)
|
||||
return pos;
|
||||
return encode_string_force(pos PROTO_ENCODE_DEBUG_ARG, field_id, string, len);
|
||||
}
|
||||
static inline void encode_string(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, uint32_t field_id,
|
||||
const StringRef &ref, bool force = false) {
|
||||
encode_string(pos PROTO_ENCODE_DEBUG_ARG, field_id, ref.c_str(), ref.size(), force);
|
||||
[[nodiscard]] static inline uint8_t *encode_string_force(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM,
|
||||
uint32_t field_id, const std::string &value) {
|
||||
return encode_string_force(pos PROTO_ENCODE_DEBUG_ARG, field_id, value.data(), value.size());
|
||||
}
|
||||
static inline void encode_bytes(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, uint32_t field_id,
|
||||
const uint8_t *data, size_t len, bool force = false) {
|
||||
encode_string(pos PROTO_ENCODE_DEBUG_ARG, field_id, reinterpret_cast<const char *>(data), len, force);
|
||||
[[nodiscard]] static inline uint8_t *encode_string(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM,
|
||||
uint32_t field_id, const StringRef &ref) {
|
||||
return encode_string(pos PROTO_ENCODE_DEBUG_ARG, field_id, ref.c_str(), ref.size());
|
||||
}
|
||||
static inline void encode_uint32(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, uint32_t field_id,
|
||||
uint32_t value, bool force = false) {
|
||||
if (value == 0 && !force)
|
||||
return;
|
||||
encode_field_raw(pos PROTO_ENCODE_DEBUG_ARG, field_id, 0);
|
||||
encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, value);
|
||||
[[nodiscard]] static inline uint8_t *encode_string_force(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM,
|
||||
uint32_t field_id, const StringRef &ref) {
|
||||
return encode_string_force(pos PROTO_ENCODE_DEBUG_ARG, field_id, ref.c_str(), ref.size());
|
||||
}
|
||||
static inline void encode_uint64(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, uint32_t field_id,
|
||||
uint64_t value, bool force = false) {
|
||||
if (value == 0 && !force)
|
||||
return;
|
||||
encode_field_raw(pos PROTO_ENCODE_DEBUG_ARG, field_id, 0);
|
||||
encode_varint_raw_64(pos PROTO_ENCODE_DEBUG_ARG, value);
|
||||
[[nodiscard]] static inline uint8_t *encode_bytes(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM,
|
||||
uint32_t field_id, const uint8_t *data, size_t len) {
|
||||
return encode_string(pos PROTO_ENCODE_DEBUG_ARG, field_id, reinterpret_cast<const char *>(data), len);
|
||||
}
|
||||
static inline void encode_bool(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, uint32_t field_id, bool value,
|
||||
bool force = false) {
|
||||
if (!value && !force)
|
||||
return;
|
||||
encode_field_raw(pos PROTO_ENCODE_DEBUG_ARG, field_id, 0);
|
||||
[[nodiscard]] static inline uint8_t *encode_bytes_force(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM,
|
||||
uint32_t field_id, const uint8_t *data, size_t len) {
|
||||
return encode_string_force(pos PROTO_ENCODE_DEBUG_ARG, field_id, reinterpret_cast<const char *>(data), len);
|
||||
}
|
||||
[[nodiscard]] static inline uint8_t *encode_uint32_force(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM,
|
||||
uint32_t field_id, uint32_t value) {
|
||||
pos = encode_field_raw(pos PROTO_ENCODE_DEBUG_ARG, field_id, 0);
|
||||
return encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, value);
|
||||
}
|
||||
[[nodiscard]] static inline uint8_t *encode_uint32(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM,
|
||||
uint32_t field_id, uint32_t value) {
|
||||
if (value == 0)
|
||||
return pos;
|
||||
return encode_uint32_force(pos PROTO_ENCODE_DEBUG_ARG, field_id, value);
|
||||
}
|
||||
[[nodiscard]] static inline uint8_t *encode_uint64_force(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM,
|
||||
uint32_t field_id, uint64_t value) {
|
||||
pos = encode_field_raw(pos PROTO_ENCODE_DEBUG_ARG, field_id, 0);
|
||||
return encode_varint_raw_64(pos PROTO_ENCODE_DEBUG_ARG, value);
|
||||
}
|
||||
[[nodiscard]] static inline uint8_t *encode_uint64(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM,
|
||||
uint32_t field_id, uint64_t value) {
|
||||
if (value == 0)
|
||||
return pos;
|
||||
return encode_uint64_force(pos PROTO_ENCODE_DEBUG_ARG, field_id, value);
|
||||
}
|
||||
[[nodiscard]] static inline uint8_t *encode_bool_force(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM,
|
||||
uint32_t field_id, bool value) {
|
||||
pos = encode_field_raw(pos PROTO_ENCODE_DEBUG_ARG, field_id, 0);
|
||||
PROTO_ENCODE_CHECK_BOUNDS(pos, 1);
|
||||
*pos++ = value ? 0x01 : 0x00;
|
||||
return pos;
|
||||
}
|
||||
static inline void encode_fixed32(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, uint32_t field_id,
|
||||
uint32_t value, bool force = false) {
|
||||
if (value == 0 && !force)
|
||||
return;
|
||||
encode_field_raw(pos PROTO_ENCODE_DEBUG_ARG, field_id, 5);
|
||||
[[nodiscard]] static inline uint8_t *encode_bool(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM,
|
||||
uint32_t field_id, bool value) {
|
||||
if (!value)
|
||||
return pos;
|
||||
return encode_bool_force(pos PROTO_ENCODE_DEBUG_ARG, field_id, value);
|
||||
}
|
||||
/// Tag + fixed32 for multi-byte tags; single-byte tags use write_tag_and_fixed32.
|
||||
[[nodiscard]] static PROTO_OUTLINE_FOR_SIZE uint8_t *encode_fixed32_force(
|
||||
uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, uint32_t field_id, uint32_t value) {
|
||||
pos = encode_field_raw(pos PROTO_ENCODE_DEBUG_ARG, field_id, 5);
|
||||
PROTO_ENCODE_CHECK_BOUNDS(pos, 4);
|
||||
#if __BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__
|
||||
std::memcpy(pos, &value, 4);
|
||||
pos += 4;
|
||||
#else
|
||||
*pos++ = (value >> 0) & 0xFF;
|
||||
*pos++ = (value >> 8) & 0xFF;
|
||||
*pos++ = (value >> 16) & 0xFF;
|
||||
*pos++ = (value >> 24) & 0xFF;
|
||||
#endif
|
||||
write_fixed32_le(pos, value);
|
||||
return pos + 4;
|
||||
}
|
||||
[[nodiscard]] static inline uint8_t *encode_fixed32(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM,
|
||||
uint32_t field_id, uint32_t value) {
|
||||
if (value == 0)
|
||||
return pos;
|
||||
return encode_fixed32_force(pos PROTO_ENCODE_DEBUG_ARG, field_id, value);
|
||||
}
|
||||
// NOTE: Wire type 1 (64-bit fixed: double, fixed64, sfixed64) is intentionally
|
||||
// not supported to reduce overhead on embedded systems. All ESPHome devices are
|
||||
// 32-bit microcontrollers where 64-bit operations are expensive. If 64-bit support
|
||||
// is needed in the future, the necessary encoding/decoding functions must be added.
|
||||
static inline void encode_float(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, uint32_t field_id, float value,
|
||||
bool force = false) {
|
||||
uint32_t raw = float_to_raw(value);
|
||||
if (raw == 0 && !force)
|
||||
return;
|
||||
encode_fixed32(pos PROTO_ENCODE_DEBUG_ARG, field_id, raw);
|
||||
[[nodiscard]] static inline uint8_t *encode_float(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM,
|
||||
uint32_t field_id, float value) {
|
||||
return encode_fixed32(pos PROTO_ENCODE_DEBUG_ARG, field_id, float_to_raw(value));
|
||||
}
|
||||
static inline void encode_int32(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, uint32_t field_id, int32_t value,
|
||||
bool force = false) {
|
||||
[[nodiscard]] static inline uint8_t *encode_float_force(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM,
|
||||
uint32_t field_id, float value) {
|
||||
return encode_fixed32_force(pos PROTO_ENCODE_DEBUG_ARG, field_id, float_to_raw(value));
|
||||
}
|
||||
[[nodiscard]] static inline uint8_t *encode_int32_force(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM,
|
||||
uint32_t field_id, int32_t value) {
|
||||
if (value < 0) {
|
||||
// negative int32 is always 10 byte long
|
||||
encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, field_id, static_cast<uint64_t>(value), force);
|
||||
return;
|
||||
return encode_uint64_force(pos PROTO_ENCODE_DEBUG_ARG, field_id, static_cast<uint64_t>(value));
|
||||
}
|
||||
encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, field_id, static_cast<uint32_t>(value), force);
|
||||
return encode_uint32_force(pos PROTO_ENCODE_DEBUG_ARG, field_id, static_cast<uint32_t>(value));
|
||||
}
|
||||
static inline void encode_int64(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, uint32_t field_id, int64_t value,
|
||||
bool force = false) {
|
||||
encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, field_id, static_cast<uint64_t>(value), force);
|
||||
[[nodiscard]] static inline uint8_t *encode_int32(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM,
|
||||
uint32_t field_id, int32_t value) {
|
||||
if (value == 0)
|
||||
return pos;
|
||||
return encode_int32_force(pos PROTO_ENCODE_DEBUG_ARG, field_id, value);
|
||||
}
|
||||
static inline void encode_sint32(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, uint32_t field_id,
|
||||
int32_t value, bool force = false) {
|
||||
encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, field_id, encode_zigzag32(value), force);
|
||||
[[nodiscard]] static inline uint8_t *encode_int64(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM,
|
||||
uint32_t field_id, int64_t value) {
|
||||
return encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, field_id, static_cast<uint64_t>(value));
|
||||
}
|
||||
static inline void encode_sint64(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, uint32_t field_id,
|
||||
int64_t value, bool force = false) {
|
||||
encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, field_id, encode_zigzag64(value), force);
|
||||
[[nodiscard]] static inline uint8_t *encode_int64_force(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM,
|
||||
uint32_t field_id, int64_t value) {
|
||||
return encode_uint64_force(pos PROTO_ENCODE_DEBUG_ARG, field_id, static_cast<uint64_t>(value));
|
||||
}
|
||||
/// Sub-message encoding: sync pos to buffer, delegate, get pos from return value.
|
||||
[[nodiscard]] static inline uint8_t *encode_sint32(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM,
|
||||
uint32_t field_id, int32_t value) {
|
||||
return encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, field_id, encode_zigzag32(value));
|
||||
}
|
||||
[[nodiscard]] static inline uint8_t *encode_sint32_force(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM,
|
||||
uint32_t field_id, int32_t value) {
|
||||
return encode_uint32_force(pos PROTO_ENCODE_DEBUG_ARG, field_id, encode_zigzag32(value));
|
||||
}
|
||||
[[nodiscard]] static inline uint8_t *encode_sint64(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM,
|
||||
uint32_t field_id, int64_t value) {
|
||||
return encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, field_id, encode_zigzag64(value));
|
||||
}
|
||||
[[nodiscard]] static inline uint8_t *encode_sint64_force(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM,
|
||||
uint32_t field_id, int64_t value) {
|
||||
return encode_uint64_force(pos PROTO_ENCODE_DEBUG_ARG, field_id, encode_zigzag64(value));
|
||||
}
|
||||
/// Sub-message encoding: sync pos to buffer, delegate, read the cursor back.
|
||||
template<typename T>
|
||||
static inline void encode_sub_message(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, ProtoWriteBuffer &buffer,
|
||||
uint32_t field_id, const T &value) {
|
||||
[[nodiscard]] static inline uint8_t *encode_sub_message(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM,
|
||||
ProtoWriteBuffer &buffer, uint32_t field_id, const T &value) {
|
||||
buffer.set_pos(pos);
|
||||
buffer.encode_sub_message(field_id, value);
|
||||
pos = buffer.get_pos();
|
||||
return buffer.get_pos();
|
||||
}
|
||||
template<typename T>
|
||||
static inline void encode_optional_sub_message(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM,
|
||||
ProtoWriteBuffer &buffer, uint32_t field_id, const T &value) {
|
||||
[[nodiscard]] static inline uint8_t *encode_optional_sub_message(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM,
|
||||
ProtoWriteBuffer &buffer, uint32_t field_id,
|
||||
const T &value) {
|
||||
buffer.set_pos(pos);
|
||||
buffer.encode_optional_sub_message(field_id, value);
|
||||
pos = buffer.get_pos();
|
||||
return buffer.get_pos();
|
||||
}
|
||||
|
||||
private:
|
||||
/// Unaligned little endian store of four bytes: byte stores where the outlined helper lives (ESP-IDF, ARM
|
||||
/// without unaligned access), otherwise a memcpy the compiler folds into one store. Callers bounds check
|
||||
/// and advance the cursor themselves.
|
||||
static inline void ESPHOME_ALWAYS_INLINE write_fixed32_le(uint8_t *__restrict__ pos, uint32_t value) {
|
||||
if constexpr (PROTO_FIXED32_BYTE_STORES) {
|
||||
// Spelled out so the outlined helper does not itself become a memcpy call
|
||||
pos[0] = static_cast<uint8_t>(value);
|
||||
pos[1] = static_cast<uint8_t>(value >> 8);
|
||||
pos[2] = static_cast<uint8_t>(value >> 16);
|
||||
pos[3] = static_cast<uint8_t>(value >> 24);
|
||||
} else {
|
||||
const uint32_t le = convert_little_endian(value);
|
||||
__builtin_memcpy(pos, &le, 4);
|
||||
}
|
||||
}
|
||||
};
|
||||
#undef PROTO_OUTLINE_FOR_SIZE
|
||||
#undef PROTO_FIXED32_BYTE_STORES
|
||||
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
/**
|
||||
@@ -624,11 +695,12 @@ class DumpBuffer {
|
||||
|
||||
class ProtoMessage {
|
||||
public:
|
||||
// Non-virtual defaults for messages with no fields.
|
||||
// Concrete message classes hide these with their own implementations.
|
||||
// All call sites use templates to preserve the concrete type, so virtual
|
||||
// dispatch is not needed. This eliminates per-message vtable entries for
|
||||
// encode/calculate_size, saving ~1.3 KB of flash across all message types.
|
||||
// Non-virtual defaults for messages with no fields; generated classes hide all four. The
|
||||
// static encode_msg/calc_size_msg take const void * so &T::encode_msg needs no thunk.
|
||||
static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) {
|
||||
return buffer.get_pos();
|
||||
}
|
||||
static uint32_t calc_size_msg(const void *self) { return 0; }
|
||||
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { return buffer.get_pos(); }
|
||||
uint32_t calculate_size() const { return 0; }
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
@@ -663,10 +735,10 @@ class ProtoDecodableMessage : public ProtoMessage {
|
||||
|
||||
protected:
|
||||
~ProtoDecodableMessage() = default;
|
||||
virtual bool decode_varint(uint32_t field_id, proto_varint_value_t value) { return false; }
|
||||
virtual bool decode_length(uint32_t field_id, ProtoLengthDelimited value) { return false; }
|
||||
virtual bool decode_32bit(uint32_t field_id, Proto32Bit value) { return false; }
|
||||
// NOTE: decode_64bit removed - wire type 1 not supported
|
||||
/// Store one decoded field; \p scalar is the varint or fixed32 value, or the length of the
|
||||
/// length-delimited payload at \p data. An unknown field or wrong wire type matches no case and is skipped.
|
||||
/// Three register arguments keep the decode loop free of spills.
|
||||
virtual void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) {}
|
||||
};
|
||||
|
||||
class ProtoSize {
|
||||
@@ -792,7 +864,7 @@ class ProtoSize {
|
||||
* @return The number of bytes needed to encode the field ID and wire type
|
||||
*/
|
||||
static constexpr uint32_t field(uint32_t field_id, uint32_t type) {
|
||||
uint32_t tag = (field_id << 3) | (type & WIRE_TYPE_MASK);
|
||||
uint32_t tag = proto_tag(field_id, type & WIRE_TYPE_MASK);
|
||||
return varint(tag);
|
||||
}
|
||||
|
||||
@@ -876,24 +948,14 @@ class ProtoSize {
|
||||
|
||||
// Implementation of methods that depend on ProtoSize being fully defined
|
||||
|
||||
// Encode thunk — converts void* back to concrete type for direct encode() call
|
||||
template<typename T> uint8_t *proto_encode_msg(const void *msg, ProtoWriteBuffer &buf PROTO_ENCODE_DEBUG_PARAM) {
|
||||
return static_cast<const T *>(msg)->encode(buf PROTO_ENCODE_DEBUG_ARG);
|
||||
}
|
||||
|
||||
// Thin template wrapper; delegates to non-template core in proto.cpp.
|
||||
template<typename T> inline void ProtoWriteBuffer::encode_sub_message(uint32_t field_id, const T &value) {
|
||||
this->encode_sub_message(field_id, &value, &proto_encode_msg<T>);
|
||||
this->encode_sub_message(field_id, &value, &T::encode_msg);
|
||||
}
|
||||
|
||||
// Thin template wrapper; delegates to non-template core.
|
||||
template<typename T> inline void ProtoWriteBuffer::encode_optional_sub_message(uint32_t field_id, const T &value) {
|
||||
this->encode_optional_sub_message(field_id, value.calculate_size(), &value, &proto_encode_msg<T>);
|
||||
}
|
||||
|
||||
// Template decode_to_message - preserves concrete type so decode() resolves statically
|
||||
template<typename T> void ProtoLengthDelimited::decode_to_message(T &msg) const {
|
||||
msg.decode(this->value_, this->length_);
|
||||
this->encode_optional_sub_message(field_id, T::calc_size_msg(&value), &value, &T::encode_msg);
|
||||
}
|
||||
|
||||
template<typename T> const char *proto_enum_to_string(T value);
|
||||
|
||||
@@ -9,10 +9,6 @@ namespace esphome::atm90e32 {
|
||||
|
||||
static const char *const TAG = "atm90e32";
|
||||
|
||||
static const LogString *offset_calibration_name(bool power_offsets) {
|
||||
return power_offsets ? LOG_STR("Power offset") : LOG_STR("Offset");
|
||||
}
|
||||
|
||||
static uint32_t pref_hash(const char *prefix, const char *name_space) {
|
||||
auto hash = fnv1_hash(prefix);
|
||||
return fnv1_hash_extend(hash, name_space);
|
||||
@@ -207,12 +203,13 @@ void ATM90E32Component::setup() {
|
||||
|
||||
// Initialize flash storage for power offset calibrations
|
||||
uint32_t po_hash = pref_hash("_power_offset_calibration_", cs);
|
||||
this->power_offset_pref_ = global_preferences->make_preference<OffsetCalibration[3]>(po_hash, true);
|
||||
this->power_offset_pref_ = global_preferences->make_preference<PowerOffsetCalibration[3]>(po_hash, true);
|
||||
bool migrated_power_offset = false;
|
||||
if (has_distinct_legacy_namespace) {
|
||||
uint32_t legacy_po_hash = pref_hash("_power_offset_calibration_", legacy_cs);
|
||||
auto legacy_power_offset_pref = global_preferences->make_preference<OffsetCalibration[3]>(legacy_po_hash, true);
|
||||
OffsetCalibration power_offset_data[3]{};
|
||||
auto legacy_power_offset_pref =
|
||||
global_preferences->make_preference<PowerOffsetCalibration[3]>(legacy_po_hash, true);
|
||||
PowerOffsetCalibration power_offset_data[3]{};
|
||||
int migration_status =
|
||||
migrate_legacy_pref_if_needed(this->power_offset_pref_, legacy_power_offset_pref, &power_offset_data);
|
||||
migrated_power_offset = migration_status > 0;
|
||||
@@ -227,20 +224,20 @@ void ATM90E32Component::setup() {
|
||||
global_preferences->sync();
|
||||
}
|
||||
|
||||
this->restore_offset_calibrations_(OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_VOLTAGE_CURRENT);
|
||||
this->restore_offset_calibrations_(OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_POWER);
|
||||
this->restore_offset_calibrations_();
|
||||
this->restore_power_offset_calibrations_();
|
||||
} else {
|
||||
ESP_LOGI(TAG, "[CALIBRATION][%s] Power & Voltage/Current offset calibration is disabled. Using config file values.",
|
||||
cs);
|
||||
for (uint8_t phase = 0; phase < 3; ++phase) {
|
||||
this->write16_(this->voltage_offset_registers[phase],
|
||||
static_cast<uint16_t>(this->offset_phase_[phase].first_offset));
|
||||
static_cast<uint16_t>(this->offset_phase_[phase].voltage_offset_));
|
||||
this->write16_(this->current_offset_registers[phase],
|
||||
static_cast<uint16_t>(this->offset_phase_[phase].second_offset));
|
||||
static_cast<uint16_t>(this->offset_phase_[phase].current_offset_));
|
||||
this->write16_(this->power_offset_registers[phase],
|
||||
static_cast<uint16_t>(this->power_offset_phase_[phase].first_offset));
|
||||
static_cast<uint16_t>(this->power_offset_phase_[phase].active_power_offset));
|
||||
this->write16_(this->reactive_power_offset_registers[phase],
|
||||
static_cast<uint16_t>(this->power_offset_phase_[phase].second_offset));
|
||||
static_cast<uint16_t>(this->power_offset_phase_[phase].reactive_power_offset));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -320,8 +317,8 @@ void ATM90E32Component::log_calibration_status_() {
|
||||
cs);
|
||||
for (uint8_t phase = 0; phase < 3; ++phase) {
|
||||
ESP_LOGW(TAG, "[CALIBRATION][%s] | %c | %6d | %6d | %6d | %6d |", cs, 'A' + phase,
|
||||
this->config_offset_phase_[phase].first_offset, this->offset_phase_[phase].first_offset,
|
||||
this->config_offset_phase_[phase].second_offset, this->offset_phase_[phase].second_offset);
|
||||
this->config_offset_phase_[phase].voltage_offset_, this->offset_phase_[phase].voltage_offset_,
|
||||
this->config_offset_phase_[phase].current_offset_, this->offset_phase_[phase].current_offset_);
|
||||
}
|
||||
ESP_LOGW(TAG,
|
||||
"[CALIBRATION][%s] ===============================================================================", cs);
|
||||
@@ -338,8 +335,10 @@ void ATM90E32Component::log_calibration_status_() {
|
||||
cs);
|
||||
for (uint8_t phase = 0; phase < 3; ++phase) {
|
||||
ESP_LOGW(TAG, "[CALIBRATION][%s] | %c | %6d | %6d | %6d | %6d |", cs, 'A' + phase,
|
||||
this->config_power_offset_phase_[phase].first_offset, this->power_offset_phase_[phase].first_offset,
|
||||
this->config_power_offset_phase_[phase].second_offset, this->power_offset_phase_[phase].second_offset);
|
||||
this->config_power_offset_phase_[phase].active_power_offset,
|
||||
this->power_offset_phase_[phase].active_power_offset,
|
||||
this->config_power_offset_phase_[phase].reactive_power_offset,
|
||||
this->power_offset_phase_[phase].reactive_power_offset);
|
||||
}
|
||||
ESP_LOGW(TAG,
|
||||
"[CALIBRATION][%s] ===============================================================================", cs);
|
||||
@@ -373,7 +372,7 @@ void ATM90E32Component::log_calibration_status_() {
|
||||
ESP_LOGI(TAG, "[CALIBRATION][%s] --------------------------------------------------------------", cs);
|
||||
for (uint8_t phase = 0; phase < 3; phase++) {
|
||||
ESP_LOGI(TAG, "[CALIBRATION][%s] | %c | %6d | %6d |", cs, 'A' + phase,
|
||||
this->offset_phase_[phase].first_offset, this->offset_phase_[phase].second_offset);
|
||||
this->offset_phase_[phase].voltage_offset_, this->offset_phase_[phase].current_offset_);
|
||||
}
|
||||
ESP_LOGI(TAG, "[CALIBRATION][%s] ==============================================================\\n", cs);
|
||||
}
|
||||
@@ -386,7 +385,8 @@ void ATM90E32Component::log_calibration_status_() {
|
||||
ESP_LOGI(TAG, "[CALIBRATION][%s] ---------------------------------------------------------------------", cs);
|
||||
for (uint8_t phase = 0; phase < 3; phase++) {
|
||||
ESP_LOGI(TAG, "[CALIBRATION][%s] | %c | %6d | %6d |", cs, 'A' + phase,
|
||||
this->power_offset_phase_[phase].first_offset, this->power_offset_phase_[phase].second_offset);
|
||||
this->power_offset_phase_[phase].active_power_offset,
|
||||
this->power_offset_phase_[phase].reactive_power_offset);
|
||||
}
|
||||
ESP_LOGI(TAG, "[CALIBRATION][%s] =====================================================================\n", cs);
|
||||
}
|
||||
@@ -756,68 +756,36 @@ void ATM90E32Component::save_gain_calibration_to_memory_() {
|
||||
}
|
||||
}
|
||||
|
||||
void ATM90E32Component::finish_offset_calibration_(const OffsetCalibration (&previous)[3], bool previous_restored,
|
||||
bool previous_using_saved, OffsetCalibrationType type) {
|
||||
const bool power_offsets = type == OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_POWER;
|
||||
void ATM90E32Component::save_offset_calibration_to_memory_() {
|
||||
const char *cs = this->get_calibration_id_();
|
||||
const LogString *name = offset_calibration_name(power_offsets);
|
||||
OffsetCalibration(*offsets)[3] = power_offsets ? &this->power_offset_phase_ : &this->offset_phase_;
|
||||
ESPPreferenceObject *preference = power_offsets ? &this->power_offset_pref_ : &this->offset_pref_;
|
||||
bool *has_stored =
|
||||
power_offsets ? &this->has_stored_power_offset_calibration_ : &this->has_stored_offset_calibration_;
|
||||
bool *restored = power_offsets ? &this->restored_power_offset_calibration_ : &this->restored_offset_calibration_;
|
||||
bool *mismatches = power_offsets ? this->power_offset_calibration_mismatch_ : this->offset_calibration_mismatch_;
|
||||
|
||||
const bool writes_verified = this->verify_offset_writes_(type);
|
||||
bool saved = false;
|
||||
bool synced = false;
|
||||
if (writes_verified) {
|
||||
saved = preference->save(offsets);
|
||||
synced = global_preferences->sync();
|
||||
}
|
||||
|
||||
if (writes_verified && saved && synced) {
|
||||
bool success = this->offset_pref_.save(&this->offset_phase_);
|
||||
global_preferences->sync();
|
||||
if (success) {
|
||||
this->using_saved_calibrations_ = true;
|
||||
*has_stored = true;
|
||||
*restored = true;
|
||||
for (uint8_t phase = 0; phase < 3; phase++)
|
||||
mismatches[phase] = false;
|
||||
ESP_LOGI(TAG, "[CALIBRATION][%s] %s calibration saved to memory. %s calibration completed and verified.", cs,
|
||||
LOG_STR_ARG(name), LOG_STR_ARG(name));
|
||||
return;
|
||||
this->restored_offset_calibration_ = true;
|
||||
for (bool &phase : this->offset_calibration_mismatch_)
|
||||
phase = false;
|
||||
ESP_LOGI(TAG, "[CALIBRATION][%s] Offset calibration saved to memory.", cs);
|
||||
} else {
|
||||
this->using_saved_calibrations_ = false;
|
||||
ESP_LOGE(TAG, "[CALIBRATION][%s] Failed to save offset calibration to memory!", cs);
|
||||
}
|
||||
}
|
||||
|
||||
if (writes_verified) {
|
||||
ESP_LOGE(TAG, "[CALIBRATION][%s] Failed to save %s calibration to memory!", cs, LOG_STR_ARG(name));
|
||||
void ATM90E32Component::save_power_offset_calibration_to_memory_() {
|
||||
const char *cs = this->get_calibration_id_();
|
||||
bool success = this->power_offset_pref_.save(&this->power_offset_phase_);
|
||||
global_preferences->sync();
|
||||
if (success) {
|
||||
this->using_saved_calibrations_ = true;
|
||||
this->restored_power_offset_calibration_ = true;
|
||||
for (bool &phase : this->power_offset_calibration_mismatch_)
|
||||
phase = false;
|
||||
ESP_LOGI(TAG, "[CALIBRATION][%s] Power offset calibration saved to memory.", cs);
|
||||
} else {
|
||||
this->using_saved_calibrations_ = false;
|
||||
ESP_LOGE(TAG, "[CALIBRATION][%s] Failed to save power offset calibration to memory!", cs);
|
||||
}
|
||||
|
||||
for (uint8_t phase = 0; phase < 3; phase++) {
|
||||
this->write_offsets_to_registers_(phase, previous[phase].first_offset, previous[phase].second_offset, type);
|
||||
}
|
||||
const bool rollback_verified = this->verify_offset_writes_(type);
|
||||
|
||||
bool rollback_persisted = false;
|
||||
if (writes_verified) {
|
||||
OffsetCalibration rollback[3]{};
|
||||
prepare_offset_rollback(previous, previous_restored, rollback);
|
||||
const bool rollback_saved = preference->save(&rollback);
|
||||
const bool rollback_synced = global_preferences->sync();
|
||||
rollback_persisted = rollback_saved && rollback_synced;
|
||||
if (!rollback_saved || !rollback_synced) {
|
||||
ESP_LOGE(TAG, "[CALIBRATION][%s] Failed to persist restored %s calibration values!", cs, LOG_STR_ARG(name));
|
||||
}
|
||||
}
|
||||
|
||||
*restored = previous_restored;
|
||||
if (rollback_persisted)
|
||||
*has_stored = previous_restored;
|
||||
this->using_saved_calibrations_ = previous_using_saved;
|
||||
if (!rollback_verified) {
|
||||
ESP_LOGE(TAG, "[CALIBRATION][%s] %s calibration failed; rollback readback verification failed.", cs,
|
||||
LOG_STR_ARG(name));
|
||||
return;
|
||||
}
|
||||
ESP_LOGE(TAG, "[CALIBRATION][%s] %s calibration failed; previous values restored.", cs, LOG_STR_ARG(name));
|
||||
}
|
||||
|
||||
void ATM90E32Component::run_offset_calibrations() {
|
||||
@@ -835,16 +803,11 @@ void ATM90E32Component::run_offset_calibrations() {
|
||||
ESP_LOGI(TAG, "[CALIBRATION][%s] | Phase | offset_voltage | offset_current |", cs);
|
||||
ESP_LOGI(TAG, "[CALIBRATION][%s] ------------------------------------------------------------------", cs);
|
||||
|
||||
OffsetCalibration previous_offsets[3] = {this->offset_phase_[0], this->offset_phase_[1], this->offset_phase_[2]};
|
||||
const bool previous_restored = this->restored_offset_calibration_;
|
||||
const bool previous_using_saved = this->using_saved_calibrations_;
|
||||
|
||||
for (uint8_t phase = 0; phase < 3; phase++) {
|
||||
int16_t voltage_offset = calibrate_offset(phase, true);
|
||||
int16_t current_offset = calibrate_offset(phase, false);
|
||||
|
||||
this->write_offsets_to_registers_(phase, voltage_offset, current_offset,
|
||||
OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_VOLTAGE_CURRENT);
|
||||
this->write_offsets_to_registers_(phase, voltage_offset, current_offset);
|
||||
|
||||
ESP_LOGI(TAG, "[CALIBRATION][%s] | %c | %6d | %6d |", cs, 'A' + phase, voltage_offset,
|
||||
current_offset);
|
||||
@@ -852,8 +815,7 @@ void ATM90E32Component::run_offset_calibrations() {
|
||||
|
||||
ESP_LOGI(TAG, "[CALIBRATION][%s] ==================================================================\n", cs);
|
||||
|
||||
this->finish_offset_calibration_(previous_offsets, previous_restored, previous_using_saved,
|
||||
OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_VOLTAGE_CURRENT);
|
||||
this->save_offset_calibration_to_memory_();
|
||||
}
|
||||
|
||||
void ATM90E32Component::run_power_offset_calibrations() {
|
||||
@@ -872,25 +834,18 @@ void ATM90E32Component::run_power_offset_calibrations() {
|
||||
ESP_LOGI(TAG, "[CALIBRATION][%s] | Phase | offset_active_power | offset_reactive_power |", cs);
|
||||
ESP_LOGI(TAG, "[CALIBRATION][%s] ---------------------------------------------------------------------", cs);
|
||||
|
||||
OffsetCalibration previous_offsets[3] = {this->power_offset_phase_[0], this->power_offset_phase_[1],
|
||||
this->power_offset_phase_[2]};
|
||||
const bool previous_restored = this->restored_power_offset_calibration_;
|
||||
const bool previous_using_saved = this->using_saved_calibrations_;
|
||||
|
||||
for (uint8_t phase = 0; phase < 3; ++phase) {
|
||||
int16_t active_offset = calibrate_power_offset(phase, false);
|
||||
int16_t reactive_offset = calibrate_power_offset(phase, true);
|
||||
|
||||
this->write_offsets_to_registers_(phase, active_offset, reactive_offset,
|
||||
OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_POWER);
|
||||
this->write_power_offsets_to_registers_(phase, active_offset, reactive_offset);
|
||||
|
||||
ESP_LOGI(TAG, "[CALIBRATION][%s] | %c | %6d | %6d |", cs, 'A' + phase, active_offset,
|
||||
reactive_offset);
|
||||
}
|
||||
ESP_LOGI(TAG, "[CALIBRATION][%s] =====================================================================\n", cs);
|
||||
|
||||
this->finish_offset_calibration_(previous_offsets, previous_restored, previous_using_saved,
|
||||
OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_POWER);
|
||||
this->save_power_offset_calibration_to_memory_();
|
||||
}
|
||||
|
||||
void ATM90E32Component::write_gains_to_registers_() {
|
||||
@@ -904,26 +859,35 @@ void ATM90E32Component::write_gains_to_registers_() {
|
||||
this->write16_(ATM90E32_REGISTER_CFGREGACCEN, 0x0000);
|
||||
}
|
||||
|
||||
void ATM90E32Component::write_offsets_to_registers_(uint8_t phase, int16_t first_offset, int16_t second_offset,
|
||||
OffsetCalibrationType type) {
|
||||
const bool power_offsets = type == OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_POWER;
|
||||
OffsetCalibration &offsets = power_offsets ? this->power_offset_phase_[phase] : this->offset_phase_[phase];
|
||||
offsets.first_offset = first_offset;
|
||||
offsets.second_offset = second_offset;
|
||||
if (power_offsets) {
|
||||
this->phase_[phase].active_power_offset_ = first_offset;
|
||||
this->phase_[phase].reactive_power_offset_ = second_offset;
|
||||
} else {
|
||||
this->phase_[phase].voltage_offset_ = first_offset;
|
||||
this->phase_[phase].current_offset_ = second_offset;
|
||||
}
|
||||
void ATM90E32Component::write_offsets_to_registers_(uint8_t phase, int16_t voltage_offset, int16_t current_offset) {
|
||||
// Save to runtime
|
||||
this->offset_phase_[phase].voltage_offset_ = voltage_offset;
|
||||
this->phase_[phase].voltage_offset_ = voltage_offset;
|
||||
|
||||
const uint16_t *first_registers = power_offsets ? this->power_offset_registers : this->voltage_offset_registers;
|
||||
const uint16_t *second_registers =
|
||||
power_offsets ? this->reactive_power_offset_registers : this->current_offset_registers;
|
||||
// Save to flash-storable struct
|
||||
this->offset_phase_[phase].current_offset_ = current_offset;
|
||||
this->phase_[phase].current_offset_ = current_offset;
|
||||
|
||||
// Write to registers
|
||||
this->write16_(ATM90E32_REGISTER_CFGREGACCEN, 0x55AA);
|
||||
this->write16_(first_registers[phase], static_cast<uint16_t>(first_offset));
|
||||
this->write16_(second_registers[phase], static_cast<uint16_t>(second_offset));
|
||||
this->write16_(voltage_offset_registers[phase], static_cast<uint16_t>(voltage_offset));
|
||||
this->write16_(current_offset_registers[phase], static_cast<uint16_t>(current_offset));
|
||||
this->write16_(ATM90E32_REGISTER_CFGREGACCEN, 0x0000);
|
||||
}
|
||||
|
||||
void ATM90E32Component::write_power_offsets_to_registers_(uint8_t phase, int16_t p_offset, int16_t q_offset) {
|
||||
// Save to runtime
|
||||
this->phase_[phase].active_power_offset_ = p_offset;
|
||||
this->phase_[phase].reactive_power_offset_ = q_offset;
|
||||
|
||||
// Save to flash-storable struct
|
||||
this->power_offset_phase_[phase].active_power_offset = p_offset;
|
||||
this->power_offset_phase_[phase].reactive_power_offset = q_offset;
|
||||
|
||||
// Write to registers
|
||||
this->write16_(ATM90E32_REGISTER_CFGREGACCEN, 0x55AA);
|
||||
this->write16_(this->power_offset_registers[phase], static_cast<uint16_t>(p_offset));
|
||||
this->write16_(this->reactive_power_offset_registers[phase], static_cast<uint16_t>(q_offset));
|
||||
this->write16_(ATM90E32_REGISTER_CFGREGACCEN, 0x0000);
|
||||
}
|
||||
|
||||
@@ -983,78 +947,89 @@ void ATM90E32Component::restore_gain_calibrations_() {
|
||||
ESP_LOGW(TAG, "[CALIBRATION][%s] No stored gain calibrations found. Using config file values.", cs);
|
||||
}
|
||||
|
||||
void ATM90E32Component::restore_offset_calibrations_(OffsetCalibrationType type) {
|
||||
const bool power_offsets = type == OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_POWER;
|
||||
void ATM90E32Component::restore_offset_calibrations_() {
|
||||
const char *cs = this->get_calibration_id_();
|
||||
const LogString *name = power_offsets ? LOG_STR("power offset") : LOG_STR("offset");
|
||||
OffsetCalibration(*offsets)[3] = power_offsets ? &this->power_offset_phase_ : &this->offset_phase_;
|
||||
OffsetCalibration(*config_offsets)[3] =
|
||||
power_offsets ? &this->config_power_offset_phase_ : &this->config_offset_phase_;
|
||||
ESPPreferenceObject *preference = power_offsets ? &this->power_offset_pref_ : &this->offset_pref_;
|
||||
bool *has_stored =
|
||||
power_offsets ? &this->has_stored_power_offset_calibration_ : &this->has_stored_offset_calibration_;
|
||||
bool *restored = power_offsets ? &this->restored_power_offset_calibration_ : &this->restored_offset_calibration_;
|
||||
bool *mismatches = power_offsets ? this->power_offset_calibration_mismatch_ : this->offset_calibration_mismatch_;
|
||||
const bool *has_first = power_offsets ? this->has_config_active_power_offset_ : this->has_config_voltage_offset_;
|
||||
const bool *has_second = power_offsets ? this->has_config_reactive_power_offset_ : this->has_config_current_offset_;
|
||||
|
||||
for (uint8_t i = 0; i < 3; ++i)
|
||||
(*config_offsets)[i] = (*offsets)[i];
|
||||
this->config_offset_phase_[i] = this->offset_phase_[i];
|
||||
|
||||
bool have_data = this->offset_pref_.load(&this->offset_phase_);
|
||||
|
||||
const bool have_data = preference->load(offsets);
|
||||
bool all_zero = true;
|
||||
if (have_data) {
|
||||
for (const auto &phase : *offsets) {
|
||||
if (phase.first_offset != 0 || phase.second_offset != 0) {
|
||||
for (auto &phase : this->offset_phase_) {
|
||||
if (phase.voltage_offset_ != 0 || phase.current_offset_ != 0) {
|
||||
all_zero = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
*has_stored = have_data && !all_zero;
|
||||
*restored = false;
|
||||
if (have_data && !all_zero) {
|
||||
this->restored_offset_calibration_ = true;
|
||||
for (uint8_t phase = 0; phase < 3; phase++) {
|
||||
auto &offset = this->offset_phase_[phase];
|
||||
bool mismatch = false;
|
||||
if (this->has_config_voltage_offset_[phase] &&
|
||||
offset.voltage_offset_ != this->config_offset_phase_[phase].voltage_offset_)
|
||||
mismatch = true;
|
||||
if (this->has_config_current_offset_[phase] &&
|
||||
offset.current_offset_ != this->config_offset_phase_[phase].current_offset_)
|
||||
mismatch = true;
|
||||
if (mismatch)
|
||||
this->offset_calibration_mismatch_[phase] = true;
|
||||
}
|
||||
} else {
|
||||
for (uint8_t phase = 0; phase < 3; phase++)
|
||||
this->offset_phase_[phase] = this->config_offset_phase_[phase];
|
||||
ESP_LOGW(TAG, "[CALIBRATION][%s] No stored offset calibrations found. Using default values.", cs);
|
||||
}
|
||||
|
||||
for (uint8_t phase = 0; phase < 3; phase++) {
|
||||
mismatches[phase] = false;
|
||||
if (*has_stored) {
|
||||
mismatches[phase] =
|
||||
(has_first[phase] && (*offsets)[phase].first_offset != (*config_offsets)[phase].first_offset) ||
|
||||
(has_second[phase] && (*offsets)[phase].second_offset != (*config_offsets)[phase].second_offset);
|
||||
write_offsets_to_registers_(phase, this->offset_phase_[phase].voltage_offset_,
|
||||
this->offset_phase_[phase].current_offset_);
|
||||
}
|
||||
}
|
||||
|
||||
void ATM90E32Component::restore_power_offset_calibrations_() {
|
||||
const char *cs = this->get_calibration_id_();
|
||||
for (uint8_t i = 0; i < 3; ++i)
|
||||
this->config_power_offset_phase_[i] = this->power_offset_phase_[i];
|
||||
|
||||
bool have_data = this->power_offset_pref_.load(&this->power_offset_phase_);
|
||||
|
||||
bool all_zero = true;
|
||||
if (have_data) {
|
||||
for (auto &phase : this->power_offset_phase_) {
|
||||
if (phase.active_power_offset != 0 || phase.reactive_power_offset != 0) {
|
||||
all_zero = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!*has_stored) {
|
||||
for (uint8_t phase = 0; phase < 3; phase++)
|
||||
(*offsets)[phase] = (*config_offsets)[phase];
|
||||
ESP_LOGW(TAG, "[CALIBRATION][%s] No stored %s calibrations found. Using default values.", cs, LOG_STR_ARG(name));
|
||||
}
|
||||
|
||||
for (uint8_t phase = 0; phase < 3; phase++) {
|
||||
this->write_offsets_to_registers_(phase, (*offsets)[phase].first_offset, (*offsets)[phase].second_offset, type);
|
||||
}
|
||||
const bool initial_values_verified = this->verify_offset_writes_(type);
|
||||
if (initial_values_verified) {
|
||||
const auto state = resolve_offset_restore_state(*has_stored, true, false);
|
||||
*restored = state.restored;
|
||||
ESP_LOGI(TAG, "[CALIBRATION][%s] %s calibration values verified.", cs, LOG_STR_ARG(name));
|
||||
return;
|
||||
}
|
||||
|
||||
this->using_saved_calibrations_ = false;
|
||||
for (uint8_t phase = 0; phase < 3; phase++)
|
||||
mismatches[phase] = false;
|
||||
for (uint8_t phase = 0; phase < 3; phase++) {
|
||||
(*offsets)[phase] = (*config_offsets)[phase];
|
||||
this->write_offsets_to_registers_(phase, (*offsets)[phase].first_offset, (*offsets)[phase].second_offset, type);
|
||||
}
|
||||
const auto state = resolve_offset_restore_state(*has_stored, false, this->verify_offset_writes_(type));
|
||||
*restored = state.restored;
|
||||
if (state.values_verified) {
|
||||
ESP_LOGE(TAG, "[CALIBRATION][%s] %s calibration restore failed verification; config values verified.", cs,
|
||||
LOG_STR_ARG(name));
|
||||
if (have_data && !all_zero) {
|
||||
this->restored_power_offset_calibration_ = true;
|
||||
for (uint8_t phase = 0; phase < 3; ++phase) {
|
||||
auto &offset = this->power_offset_phase_[phase];
|
||||
bool mismatch = false;
|
||||
if (this->has_config_active_power_offset_[phase] &&
|
||||
offset.active_power_offset != this->config_power_offset_phase_[phase].active_power_offset)
|
||||
mismatch = true;
|
||||
if (this->has_config_reactive_power_offset_[phase] &&
|
||||
offset.reactive_power_offset != this->config_power_offset_phase_[phase].reactive_power_offset)
|
||||
mismatch = true;
|
||||
if (mismatch)
|
||||
this->power_offset_calibration_mismatch_[phase] = true;
|
||||
}
|
||||
} else {
|
||||
ESP_LOGE(TAG, "[CALIBRATION][%s] %s calibration restore and config fallback both failed verification.", cs,
|
||||
LOG_STR_ARG(name));
|
||||
for (uint8_t phase = 0; phase < 3; ++phase)
|
||||
this->power_offset_phase_[phase] = this->config_power_offset_phase_[phase];
|
||||
ESP_LOGW(TAG, "[CALIBRATION][%s] No stored power offsets found. Using default values.", cs);
|
||||
}
|
||||
|
||||
for (uint8_t phase = 0; phase < 3; ++phase) {
|
||||
write_power_offsets_to_registers_(phase, this->power_offset_phase_[phase].active_power_offset,
|
||||
this->power_offset_phase_[phase].reactive_power_offset);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1109,14 +1084,14 @@ void ATM90E32Component::clear_gain_calibrations() {
|
||||
|
||||
void ATM90E32Component::clear_offset_calibrations() {
|
||||
const char *cs = this->get_calibration_id_();
|
||||
if (!this->has_stored_offset_calibration_) {
|
||||
if (!this->restored_offset_calibration_) {
|
||||
ESP_LOGI(TAG, "[CALIBRATION][%s] No stored offset calibrations to clear. Current values:", cs);
|
||||
ESP_LOGI(TAG, "[CALIBRATION][%s] --------------------------------------------------------------", cs);
|
||||
ESP_LOGI(TAG, "[CALIBRATION][%s] | Phase | offset_voltage | offset_current |", cs);
|
||||
ESP_LOGI(TAG, "[CALIBRATION][%s] --------------------------------------------------------------", cs);
|
||||
for (uint8_t phase = 0; phase < 3; phase++) {
|
||||
ESP_LOGI(TAG, "[CALIBRATION][%s] | %c | %6d | %6d |", cs, 'A' + phase,
|
||||
this->offset_phase_[phase].first_offset, this->offset_phase_[phase].second_offset);
|
||||
this->offset_phase_[phase].voltage_offset_, this->offset_phase_[phase].current_offset_);
|
||||
}
|
||||
ESP_LOGI(TAG, "[CALIBRATION][%s] ==============================================================\n", cs);
|
||||
return;
|
||||
@@ -1129,11 +1104,10 @@ void ATM90E32Component::clear_offset_calibrations() {
|
||||
|
||||
for (uint8_t phase = 0; phase < 3; phase++) {
|
||||
int16_t voltage_offset =
|
||||
this->has_config_voltage_offset_[phase] ? this->config_offset_phase_[phase].first_offset : 0;
|
||||
this->has_config_voltage_offset_[phase] ? this->config_offset_phase_[phase].voltage_offset_ : 0;
|
||||
int16_t current_offset =
|
||||
this->has_config_current_offset_[phase] ? this->config_offset_phase_[phase].second_offset : 0;
|
||||
this->write_offsets_to_registers_(phase, voltage_offset, current_offset,
|
||||
OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_VOLTAGE_CURRENT);
|
||||
this->has_config_current_offset_[phase] ? this->config_offset_phase_[phase].current_offset_ : 0;
|
||||
this->write_offsets_to_registers_(phase, voltage_offset, current_offset);
|
||||
ESP_LOGI(TAG, "[CALIBRATION][%s] | %c | %6d | %6d |", cs, 'A' + phase, voltage_offset,
|
||||
current_offset);
|
||||
}
|
||||
@@ -1143,7 +1117,6 @@ void ATM90E32Component::clear_offset_calibrations() {
|
||||
this->offset_pref_.save(&zero_offsets); // Clear stored values in flash
|
||||
global_preferences->sync();
|
||||
|
||||
this->has_stored_offset_calibration_ = false;
|
||||
this->restored_offset_calibration_ = false;
|
||||
for (bool &phase : this->offset_calibration_mismatch_)
|
||||
phase = false;
|
||||
@@ -1153,14 +1126,15 @@ void ATM90E32Component::clear_offset_calibrations() {
|
||||
|
||||
void ATM90E32Component::clear_power_offset_calibrations() {
|
||||
const char *cs = this->get_calibration_id_();
|
||||
if (!this->has_stored_power_offset_calibration_) {
|
||||
if (!this->restored_power_offset_calibration_) {
|
||||
ESP_LOGI(TAG, "[CALIBRATION][%s] No stored power offsets to clear. Current values:", cs);
|
||||
ESP_LOGI(TAG, "[CALIBRATION][%s] ---------------------------------------------------------------------", cs);
|
||||
ESP_LOGI(TAG, "[CALIBRATION][%s] | Phase | offset_active_power | offset_reactive_power |", cs);
|
||||
ESP_LOGI(TAG, "[CALIBRATION][%s] ---------------------------------------------------------------------", cs);
|
||||
for (uint8_t phase = 0; phase < 3; phase++) {
|
||||
ESP_LOGI(TAG, "[CALIBRATION][%s] | %c | %6d | %6d |", cs, 'A' + phase,
|
||||
this->power_offset_phase_[phase].first_offset, this->power_offset_phase_[phase].second_offset);
|
||||
this->power_offset_phase_[phase].active_power_offset,
|
||||
this->power_offset_phase_[phase].reactive_power_offset);
|
||||
}
|
||||
ESP_LOGI(TAG, "[CALIBRATION][%s] =====================================================================\n", cs);
|
||||
return;
|
||||
@@ -1173,21 +1147,20 @@ void ATM90E32Component::clear_power_offset_calibrations() {
|
||||
|
||||
for (uint8_t phase = 0; phase < 3; phase++) {
|
||||
int16_t active_offset =
|
||||
this->has_config_active_power_offset_[phase] ? this->config_power_offset_phase_[phase].first_offset : 0;
|
||||
int16_t reactive_offset =
|
||||
this->has_config_reactive_power_offset_[phase] ? this->config_power_offset_phase_[phase].second_offset : 0;
|
||||
this->write_offsets_to_registers_(phase, active_offset, reactive_offset,
|
||||
OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_POWER);
|
||||
this->has_config_active_power_offset_[phase] ? this->config_power_offset_phase_[phase].active_power_offset : 0;
|
||||
int16_t reactive_offset = this->has_config_reactive_power_offset_[phase]
|
||||
? this->config_power_offset_phase_[phase].reactive_power_offset
|
||||
: 0;
|
||||
this->write_power_offsets_to_registers_(phase, active_offset, reactive_offset);
|
||||
ESP_LOGI(TAG, "[CALIBRATION][%s] | %c | %6d | %6d |", cs, 'A' + phase, active_offset,
|
||||
reactive_offset);
|
||||
}
|
||||
ESP_LOGI(TAG, "[CALIBRATION][%s] =====================================================================\n", cs);
|
||||
|
||||
OffsetCalibration zero_power_offsets[3]{{0, 0}, {0, 0}, {0, 0}};
|
||||
PowerOffsetCalibration zero_power_offsets[3]{{0, 0}, {0, 0}, {0, 0}};
|
||||
this->power_offset_pref_.save(&zero_power_offsets);
|
||||
global_preferences->sync();
|
||||
|
||||
this->has_stored_power_offset_calibration_ = false;
|
||||
this->restored_power_offset_calibration_ = false;
|
||||
for (bool &phase : this->power_offset_calibration_mismatch_)
|
||||
phase = false;
|
||||
@@ -1242,31 +1215,6 @@ bool ATM90E32Component::verify_gain_writes_() {
|
||||
return success; // Return true if all writes were successful, false otherwise
|
||||
}
|
||||
|
||||
bool ATM90E32Component::verify_offset_writes_(OffsetCalibrationType type) {
|
||||
const bool power_offsets = type == OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_POWER;
|
||||
const char *cs = this->get_calibration_id_();
|
||||
const LogString *name = offset_calibration_name(power_offsets);
|
||||
const LogString *first_name = power_offsets ? LOG_STR("active") : LOG_STR("voltage");
|
||||
const LogString *second_name = power_offsets ? LOG_STR("reactive") : LOG_STR("current");
|
||||
const OffsetCalibration *offsets = power_offsets ? this->power_offset_phase_ : this->offset_phase_;
|
||||
const uint16_t *first_registers = power_offsets ? this->power_offset_registers : this->voltage_offset_registers;
|
||||
const uint16_t *second_registers =
|
||||
power_offsets ? this->reactive_power_offset_registers : this->current_offset_registers;
|
||||
bool success = true;
|
||||
for (uint8_t phase = 0; phase < 3; phase++) {
|
||||
const uint16_t first = this->read16_(first_registers[phase]);
|
||||
const uint16_t second = this->read16_(second_registers[phase]);
|
||||
if (!offset_register_value_matches(first, offsets[phase].first_offset) ||
|
||||
!offset_register_value_matches(second, offsets[phase].second_offset)) {
|
||||
ESP_LOGE(TAG, "[CALIBRATION][%s] %s readback failed for Phase %s: %s %d/%d, %s %d/%d.", cs, LOG_STR_ARG(name),
|
||||
phase_labels[phase], LOG_STR_ARG(first_name), static_cast<int16_t>(first), offsets[phase].first_offset,
|
||||
LOG_STR_ARG(second_name), static_cast<int16_t>(second), offsets[phase].second_offset);
|
||||
success = false;
|
||||
}
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
#ifdef USE_TEXT_SENSOR
|
||||
void ATM90E32Component::check_phase_status() {
|
||||
uint16_t state0 = this->read16_(ATM90E32_REGISTER_EMMSTATE0);
|
||||
|
||||
@@ -13,40 +13,6 @@
|
||||
|
||||
namespace esphome::atm90e32 {
|
||||
|
||||
inline bool offset_register_value_matches(uint16_t actual, int16_t expected) {
|
||||
return actual == static_cast<uint16_t>(expected);
|
||||
}
|
||||
|
||||
struct OffsetCalibration {
|
||||
int16_t first_offset{0};
|
||||
int16_t second_offset{0};
|
||||
};
|
||||
|
||||
static_assert(sizeof(OffsetCalibration[3]) == 12, "Offset calibration preference layout must remain compatible");
|
||||
|
||||
enum class OffsetCalibrationType : uint8_t {
|
||||
OFFSET_CALIBRATION_TYPE_VOLTAGE_CURRENT,
|
||||
OFFSET_CALIBRATION_TYPE_POWER,
|
||||
};
|
||||
|
||||
struct OffsetRestoreState {
|
||||
bool restored;
|
||||
bool values_verified;
|
||||
};
|
||||
|
||||
inline OffsetRestoreState resolve_offset_restore_state(bool has_stored_values, bool initial_values_verified,
|
||||
bool fallback_values_verified) {
|
||||
if (initial_values_verified)
|
||||
return {has_stored_values, true};
|
||||
return {false, fallback_values_verified};
|
||||
}
|
||||
|
||||
inline void prepare_offset_rollback(const OffsetCalibration (&previous)[3], bool had_stored_values,
|
||||
OffsetCalibration (&rollback)[3]) {
|
||||
for (uint8_t phase = 0; phase < 3; phase++)
|
||||
rollback[phase] = had_stored_values ? previous[phase] : OffsetCalibration{};
|
||||
}
|
||||
|
||||
class ATM90E32Component final : public PollingComponent,
|
||||
public spi::SPIDevice<spi::BIT_ORDER_MSB_FIRST, spi::CLOCK_POLARITY_HIGH,
|
||||
spi::CLOCK_PHASE_TRAILING, spi::DATA_RATE_1MHZ> {
|
||||
@@ -105,19 +71,19 @@ class ATM90E32Component final : public PollingComponent,
|
||||
this->has_config_current_gain_[phase] = true;
|
||||
}
|
||||
void set_voltage_offset(uint8_t phase, int16_t offset) {
|
||||
this->offset_phase_[phase].first_offset = offset;
|
||||
this->offset_phase_[phase].voltage_offset_ = offset;
|
||||
this->has_config_voltage_offset_[phase] = true;
|
||||
}
|
||||
void set_current_offset(uint8_t phase, int16_t offset) {
|
||||
this->offset_phase_[phase].second_offset = offset;
|
||||
this->offset_phase_[phase].current_offset_ = offset;
|
||||
this->has_config_current_offset_[phase] = true;
|
||||
}
|
||||
void set_active_power_offset(uint8_t phase, int16_t offset) {
|
||||
this->power_offset_phase_[phase].first_offset = offset;
|
||||
this->power_offset_phase_[phase].active_power_offset = offset;
|
||||
this->has_config_active_power_offset_[phase] = true;
|
||||
}
|
||||
void set_reactive_power_offset(uint8_t phase, int16_t offset) {
|
||||
this->power_offset_phase_[phase].second_offset = offset;
|
||||
this->power_offset_phase_[phase].reactive_power_offset = offset;
|
||||
this->has_config_reactive_power_offset_[phase] = true;
|
||||
}
|
||||
void set_freq_sensor(sensor::Sensor *freq_sensor) { freq_sensor_ = freq_sensor; }
|
||||
@@ -205,16 +171,16 @@ class ATM90E32Component final : public PollingComponent,
|
||||
float get_chip_temperature_();
|
||||
bool get_publish_interval_flag_() { return publish_interval_flag_; };
|
||||
void set_publish_interval_flag_(bool flag) { publish_interval_flag_ = flag; };
|
||||
void restore_offset_calibrations_(OffsetCalibrationType type);
|
||||
void restore_offset_calibrations_();
|
||||
void restore_power_offset_calibrations_();
|
||||
void restore_gain_calibrations_();
|
||||
void save_offset_calibration_to_memory_();
|
||||
void save_gain_calibration_to_memory_();
|
||||
void finish_offset_calibration_(const OffsetCalibration (&previous)[3], bool previous_restored,
|
||||
bool previous_using_saved, OffsetCalibrationType type);
|
||||
void write_offsets_to_registers_(uint8_t phase, int16_t first_offset, int16_t second_offset,
|
||||
OffsetCalibrationType type);
|
||||
void save_power_offset_calibration_to_memory_();
|
||||
void write_offsets_to_registers_(uint8_t phase, int16_t voltage_offset, int16_t current_offset);
|
||||
void write_power_offsets_to_registers_(uint8_t phase, int16_t p_offset, int16_t q_offset);
|
||||
void write_gains_to_registers_();
|
||||
bool verify_gain_writes_();
|
||||
bool verify_offset_writes_(OffsetCalibrationType type);
|
||||
bool validate_spi_read_(uint16_t expected, const char *context = nullptr);
|
||||
void log_calibration_status_();
|
||||
const char *get_calibration_id_();
|
||||
@@ -253,10 +219,19 @@ class ATM90E32Component final : public PollingComponent,
|
||||
uint32_t cumulative_reverse_active_energy_{0};
|
||||
} phase_[3];
|
||||
|
||||
OffsetCalibration offset_phase_[3];
|
||||
struct OffsetCalibration {
|
||||
int16_t voltage_offset_{0};
|
||||
int16_t current_offset_{0};
|
||||
} offset_phase_[3];
|
||||
|
||||
OffsetCalibration config_offset_phase_[3];
|
||||
OffsetCalibration power_offset_phase_[3];
|
||||
OffsetCalibration config_power_offset_phase_[3];
|
||||
|
||||
struct PowerOffsetCalibration {
|
||||
int16_t active_power_offset{0};
|
||||
int16_t reactive_power_offset{0};
|
||||
} power_offset_phase_[3];
|
||||
|
||||
PowerOffsetCalibration config_power_offset_phase_[3];
|
||||
|
||||
struct GainCalibration {
|
||||
uint16_t voltage_gain{1};
|
||||
@@ -290,8 +265,6 @@ class ATM90E32Component final : public PollingComponent,
|
||||
bool enable_offset_calibration_{false};
|
||||
bool enable_gain_calibration_{false};
|
||||
const char *instance_id_{nullptr};
|
||||
bool has_stored_offset_calibration_{false};
|
||||
bool has_stored_power_offset_calibration_{false};
|
||||
bool restored_offset_calibration_{false};
|
||||
bool restored_power_offset_calibration_{false};
|
||||
bool restored_gain_calibration_{false};
|
||||
|
||||
@@ -313,10 +313,9 @@ FileDecoderState AudioDecoder::decode_mp3_() {
|
||||
this->output_transfer_buffer_->increase_buffer_length(
|
||||
this->audio_stream_info_.value().frames_to_bytes(samples_decoded));
|
||||
}
|
||||
} else if (result == micro_mp3::MP3_STREAM_INFO_READY || result == micro_mp3::MP3_STREAM_INFO_CHANGED) {
|
||||
// Header parsed: capture stream info and resize the output buffer to fit one full frame.
|
||||
// microMP3 always outputs 16-bit PCM. MP3_STREAM_INFO_CHANGED is handled identically: despite its
|
||||
// negative value it is documented as recoverable, so it must not reach the catch-all below.
|
||||
} else if (result == micro_mp3::MP3_STREAM_INFO_READY) {
|
||||
// First successful header parse: capture stream info and resize the output buffer to fit one full frame.
|
||||
// microMP3 always outputs 16-bit PCM.
|
||||
this->audio_stream_info_ =
|
||||
audio::AudioStreamInfo(16, this->mp3_decoder_->get_channels(), this->mp3_decoder_->get_sample_rate());
|
||||
this->free_buffer_required_ =
|
||||
|
||||
@@ -22,23 +22,6 @@ class Automation {
|
||||
static const char *const TAG;
|
||||
};
|
||||
|
||||
// Base for nodes that never read the parent's services.
|
||||
// The parent releases its services only once every node reports Established, so a node that never
|
||||
// reports it keeps that memory allocated for the life of the connection.
|
||||
class BLEClientServicelessNode : public BLEClientNode {
|
||||
public:
|
||||
// Final so that Established is always reported on SEARCH_CMPL, before the derived node sees the event.
|
||||
void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param) final {
|
||||
if (event == ESP_GATTC_SEARCH_CMPL_EVT)
|
||||
this->node_state = espbt::ClientState::ESTABLISHED;
|
||||
this->on_gattc_event(event, gattc_if, param);
|
||||
}
|
||||
|
||||
protected:
|
||||
// Derived nodes handle GATT events here rather than by overriding the handler above.
|
||||
virtual void on_gattc_event(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param) {}
|
||||
};
|
||||
|
||||
// implement on_connect automation.
|
||||
class BLEClientConnectTrigger final : public Trigger<>, public BLEClientNode {
|
||||
public:
|
||||
@@ -78,7 +61,7 @@ class BLEClientDisconnectTrigger final : public Trigger<>, public BLEClientNode
|
||||
}
|
||||
};
|
||||
|
||||
class BLEClientPasskeyRequestTrigger final : public Trigger<>, public BLEClientServicelessNode {
|
||||
class BLEClientPasskeyRequestTrigger final : public Trigger<>, public BLEClientNode {
|
||||
public:
|
||||
explicit BLEClientPasskeyRequestTrigger(BLEClient *parent) { parent->register_ble_node(this); }
|
||||
void loop() override {}
|
||||
@@ -88,7 +71,7 @@ class BLEClientPasskeyRequestTrigger final : public Trigger<>, public BLEClientS
|
||||
}
|
||||
};
|
||||
|
||||
class BLEClientPasskeyNotificationTrigger final : public Trigger<uint32_t>, public BLEClientServicelessNode {
|
||||
class BLEClientPasskeyNotificationTrigger final : public Trigger<uint32_t>, public BLEClientNode {
|
||||
public:
|
||||
explicit BLEClientPasskeyNotificationTrigger(BLEClient *parent) { parent->register_ble_node(this); }
|
||||
void loop() override {}
|
||||
@@ -99,7 +82,7 @@ class BLEClientPasskeyNotificationTrigger final : public Trigger<uint32_t>, publ
|
||||
}
|
||||
};
|
||||
|
||||
class BLEClientNumericComparisonRequestTrigger final : public Trigger<uint32_t>, public BLEClientServicelessNode {
|
||||
class BLEClientNumericComparisonRequestTrigger final : public Trigger<uint32_t>, public BLEClientNode {
|
||||
public:
|
||||
explicit BLEClientNumericComparisonRequestTrigger(BLEClient *parent) { parent->register_ble_node(this); }
|
||||
void loop() override {}
|
||||
@@ -332,17 +315,19 @@ template<typename... Ts> class BLEClientRemoveBondAction final : public Action<T
|
||||
BLEClient *parent_{nullptr};
|
||||
};
|
||||
|
||||
template<typename... Ts> class BLEClientConnectAction final : public Action<Ts...>, public BLEClientServicelessNode {
|
||||
template<typename... Ts> class BLEClientConnectAction final : public Action<Ts...>, public BLEClientNode {
|
||||
public:
|
||||
BLEClientConnectAction(BLEClient *ble_client) {
|
||||
ble_client->register_ble_node(this);
|
||||
ble_client_ = ble_client;
|
||||
}
|
||||
void on_gattc_event(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param) override {
|
||||
void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if,
|
||||
esp_ble_gattc_cb_param_t *param) override {
|
||||
if (this->num_running_ == 0)
|
||||
return;
|
||||
switch (event) {
|
||||
case ESP_GATTC_SEARCH_CMPL_EVT:
|
||||
this->node_state = espbt::ClientState::ESTABLISHED;
|
||||
this->parent()->run_later([this]() { this->play_next_tuple_(this->var_); });
|
||||
break;
|
||||
// if the connection is closed, terminate the automation chain.
|
||||
@@ -379,13 +364,14 @@ template<typename... Ts> class BLEClientConnectAction final : public Action<Ts..
|
||||
std::tuple<Ts...> var_{};
|
||||
};
|
||||
|
||||
template<typename... Ts> class BLEClientDisconnectAction final : public Action<Ts...>, public BLEClientServicelessNode {
|
||||
template<typename... Ts> class BLEClientDisconnectAction final : public Action<Ts...>, public BLEClientNode {
|
||||
public:
|
||||
BLEClientDisconnectAction(BLEClient *ble_client) {
|
||||
ble_client->register_ble_node(this);
|
||||
ble_client_ = ble_client;
|
||||
}
|
||||
void on_gattc_event(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param) override {
|
||||
void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if,
|
||||
esp_ble_gattc_cb_param_t *param) override {
|
||||
if (this->num_running_ == 0)
|
||||
return;
|
||||
switch (event) {
|
||||
|
||||
@@ -6,7 +6,6 @@ namespace esphome::dallas_temp {
|
||||
static const char *const TAG = "dallas.temp.sensor";
|
||||
|
||||
static const uint8_t DALLAS_MODEL_DS18S20 = 0x10;
|
||||
static const uint8_t DALLAS_MODEL_DS18B20 = 0x28;
|
||||
static const uint8_t DALLAS_COMMAND_START_CONVERSION = 0x44;
|
||||
static const uint8_t DALLAS_COMMAND_READ_SCRATCH_PAD = 0xBE;
|
||||
static const uint8_t DALLAS_COMMAND_WRITE_SCRATCH_PAD = 0x4E;
|
||||
@@ -155,14 +154,7 @@ float DallasTemperatureSensor::get_temp_c_() {
|
||||
default:
|
||||
break;
|
||||
}
|
||||
// undocumented test for powerup measurement of 85
|
||||
// https://github.com/cpetrich/counterfeit_DS18B20#solution-to-the-85-c-problem
|
||||
if ((this->address_ & 0xff) == DALLAS_MODEL_DS18B20) {
|
||||
if ((temp == 85 * 16) && (this->scratch_pad_[6] == 0xc)) {
|
||||
ESP_LOGD(TAG, "dropping reading caused by sensor reset");
|
||||
return NAN;
|
||||
}
|
||||
}
|
||||
|
||||
return temp / 16.0f;
|
||||
}
|
||||
|
||||
|
||||
@@ -66,15 +66,11 @@ const char *DebugComponent::get_reset_reason_(std::span<char, RESET_REASON_BUFFE
|
||||
|
||||
unsigned reason = esp_reset_reason();
|
||||
if (reason < sizeof(RESET_REASONS) / sizeof(RESET_REASONS[0])) {
|
||||
if (reason == ESP_RST_SW || reason == ESP_RST_WDT) {
|
||||
// On some ESP32-S3 configurations (e.g. SPIRAM with fetch-instructions/rodata),
|
||||
// esp_restart() intermittently produces RTCWDT_RTC_RST (ESP_RST_WDT) instead of
|
||||
// ESP_RST_SW. Check the stored reboot source for both reset reasons so a software
|
||||
// reboot that ends up as WDT still reports the correct source.
|
||||
if (reason == ESP_RST_SW) {
|
||||
auto pref = global_preferences->make_preference(REBOOT_MAX_LEN,
|
||||
fnv1_hash_extend(fnv1_hash(REBOOT_KEY), App.get_name().c_str()));
|
||||
char reboot_source[REBOOT_MAX_LEN]{};
|
||||
if (pref.load(&reboot_source) && reboot_source[0] != '\0') {
|
||||
if (pref.load(&reboot_source)) {
|
||||
reboot_source[REBOOT_MAX_LEN - 1] = '\0';
|
||||
snprintf(buf, size, "Reboot request from %s", reboot_source);
|
||||
} else {
|
||||
|
||||
@@ -23,7 +23,11 @@ from esphome.const import (
|
||||
)
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from . import CONF_DEBUG_ID, FILTER_SOURCE_FILES, DebugComponent # noqa: F401 pylint: disable=unused-import
|
||||
from . import ( # noqa: F401 pylint: disable=unused-import
|
||||
CONF_DEBUG_ID,
|
||||
FILTER_SOURCE_FILES,
|
||||
DebugComponent,
|
||||
)
|
||||
|
||||
DEPENDENCIES = ["debug"]
|
||||
|
||||
|
||||
@@ -9,7 +9,11 @@ from esphome.const import (
|
||||
)
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from . import CONF_DEBUG_ID, FILTER_SOURCE_FILES, DebugComponent # noqa: F401 pylint: disable=unused-import
|
||||
from . import ( # noqa: F401 pylint: disable=unused-import
|
||||
CONF_DEBUG_ID,
|
||||
FILTER_SOURCE_FILES,
|
||||
DebugComponent,
|
||||
)
|
||||
|
||||
DEPENDENCIES = ["debug"]
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import subprocess
|
||||
from typing import Any
|
||||
|
||||
from esphome import yaml_util
|
||||
from esphome.build_helpers.pch import PCH_PREFIX_HEADER, pch_enabled, pch_extra_scripts
|
||||
import esphome.codegen as cg
|
||||
from esphome.components.const import CONF_ENABLE_OTA_DOWNGRADE_PROTECTION
|
||||
from esphome.config_helpers import filter_source_files_from_defines
|
||||
@@ -58,7 +57,6 @@ from esphome.coroutine import CoroPriority, coroutine_with_priority
|
||||
from esphome.espidf.component import generate_idf_components
|
||||
import esphome.final_validate as fv
|
||||
from esphome.helpers import copy_file_if_changed, rmtree, write_file_if_changed
|
||||
from esphome.platformio.toolchain import copy_pch_script
|
||||
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
|
||||
from esphome.types import ConfigType
|
||||
from esphome.writer import clean_build, clean_cmake_cache
|
||||
@@ -2457,11 +2455,6 @@ async def to_code(config):
|
||||
|
||||
cg.add_platformio_option("lib_ldf_mode", "off")
|
||||
cg.add_platformio_option("lib_compat_mode", "strict")
|
||||
# CI-speed only: this toolchain is being dropped, so the pch gets
|
||||
# the same curated prefix as host with no further investment
|
||||
cg.add_platformio_option("extra_scripts", pch_extra_scripts())
|
||||
if pch_enabled():
|
||||
cg.add_platformio_option("build_src_flags", f"-include {PCH_PREFIX_HEADER}")
|
||||
cg.add_platformio_option("platform", conf[CONF_PLATFORM_VERSION])
|
||||
cg.add_platformio_option("board", config[CONF_BOARD])
|
||||
cg.add_platformio_option("board_upload.flash_size", config[CONF_FLASH_SIZE])
|
||||
@@ -3379,8 +3372,6 @@ def _write_idf_component_yml():
|
||||
def copy_files():
|
||||
_write_sdkconfig()
|
||||
_write_idf_component_yml()
|
||||
if not CORE.using_toolchain_esp_idf:
|
||||
copy_pch_script()
|
||||
|
||||
if "partitions.csv" not in CORE.data[KEY_ESP32][KEY_EXTRA_BUILD_FILES]:
|
||||
flash_size = CORE.data[KEY_ESP32][KEY_FLASH_SIZE]
|
||||
|
||||
@@ -3,11 +3,18 @@ import esphome.codegen as cg
|
||||
# Re-exported for the many esp32-side users; defined in esphome.const
|
||||
# and esphome.espidf so the upload/logs fast path can use them without
|
||||
# importing this package.
|
||||
from esphome.const import KEY_ESP32, KEY_FLASH_SIZE, KEY_IDF_VERSION, KEY_VARIANT # noqa: F401 # pylint: disable=unused-import
|
||||
from esphome.const import ( # noqa: F401 # pylint: disable=unused-import
|
||||
KEY_ESP32,
|
||||
KEY_FLASH_SIZE,
|
||||
KEY_IDF_VERSION,
|
||||
KEY_VARIANT,
|
||||
)
|
||||
|
||||
# Back compat for external components only; in-tree callers import it
|
||||
# from esphome.espidf directly.
|
||||
from esphome.espidf import variant_to_idf_target # noqa: F401 # pylint: disable=unused-import
|
||||
from esphome.espidf import ( # noqa: F401 # pylint: disable=unused-import
|
||||
variant_to_idf_target,
|
||||
)
|
||||
|
||||
KEY_BOARD = "board"
|
||||
KEY_SDKCONFIG_OPTIONS = "sdkconfig_options"
|
||||
|
||||
@@ -3,10 +3,8 @@ from pathlib import Path
|
||||
import platform
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from esphome.build_helpers.pch import pch_extra_scripts
|
||||
import esphome.codegen as cg
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
@@ -16,7 +14,6 @@ from esphome.const import (
|
||||
CONF_FRAMEWORK,
|
||||
CONF_PLATFORM_VERSION,
|
||||
CONF_SOURCE,
|
||||
CONF_TOOLCHAIN,
|
||||
CONF_VERSION,
|
||||
KEY_CORE,
|
||||
KEY_FRAMEWORK_VERSION,
|
||||
@@ -24,7 +21,6 @@ from esphome.const import (
|
||||
KEY_TARGET_PLATFORM,
|
||||
PLATFORM_ESP8266,
|
||||
ThreadModel,
|
||||
Toolchain,
|
||||
)
|
||||
from esphome.core import (
|
||||
CORE,
|
||||
@@ -35,13 +31,12 @@ from esphome.core import (
|
||||
)
|
||||
from esphome.core.config import BOARD_MAX_LENGTH
|
||||
from esphome.helpers import IS_MACOS, copy_file_if_changed
|
||||
from esphome.platformio.toolchain import copy_ccache_script, copy_pch_script
|
||||
from esphome.platformio.toolchain import copy_ccache_script
|
||||
from esphome.storage_json import StorageJSON
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .boards import BOARDS, ESP8266_BOARD_BUILD, board_ld_script
|
||||
from .boards import BOARDS, board_ld_script
|
||||
from .const import (
|
||||
BUILD_FLASH_MODES,
|
||||
CONF_EARLY_PIN_INIT,
|
||||
CONF_ENABLE_SERIAL,
|
||||
CONF_ENABLE_SERIAL1,
|
||||
@@ -49,7 +44,6 @@ from .const import (
|
||||
KEY_BOARD,
|
||||
KEY_ESP8266,
|
||||
KEY_PIN_INITIAL_STATES,
|
||||
KEY_SCANF_FLOAT,
|
||||
KEY_SERIAL1_REQUIRED,
|
||||
KEY_SERIAL_REQUIRED,
|
||||
KEY_WAVEFORM_REQUIRED,
|
||||
@@ -109,53 +103,6 @@ def set_core_data(config: ConfigType) -> ConfigType:
|
||||
return config
|
||||
|
||||
|
||||
_TOOLCHAINS = (Toolchain.PLATFORMIO, Toolchain.ARDUINO)
|
||||
_validate_toolchain = cv.toolchain_enum(_TOOLCHAINS)
|
||||
_resolve_toolchain = cv.resolve_toolchain("ESP8266", _TOOLCHAINS, Toolchain.PLATFORMIO)
|
||||
|
||||
|
||||
def _validate_native_toolchain(config: ConfigType) -> ConfigType:
|
||||
"""Constraints of the native (non-PlatformIO) Arduino toolchain."""
|
||||
if not CORE.using_toolchain_arduino:
|
||||
return config
|
||||
from esphome.arduino8266.framework import MIN_FRAMEWORK_VERSION
|
||||
|
||||
conf = config[CONF_FRAMEWORK]
|
||||
version = cv.Version.parse(conf[CONF_VERSION])
|
||||
if version < MIN_FRAMEWORK_VERSION:
|
||||
raise cv.Invalid(
|
||||
"'toolchain: arduino' requires framework version "
|
||||
f"{MIN_FRAMEWORK_VERSION} or newer"
|
||||
)
|
||||
# platform_version is a PlatformIO concept; drop it (as esp32's native
|
||||
# toolchain does), warning when a custom pin is discarded. The floor
|
||||
# above guarantees the schema-derived default is the ARDUINO_4 spec.
|
||||
if (
|
||||
conf.pop(CONF_PLATFORM_VERSION, _ARDUINO_4_PLATFORM_SPEC)
|
||||
!= _ARDUINO_4_PLATFORM_SPEC
|
||||
):
|
||||
_LOGGER.warning(
|
||||
"'platform_version' is ignored by 'toolchain: arduino'; the native "
|
||||
"toolchain downloads the framework and compiler directly"
|
||||
)
|
||||
if conf[CONF_SOURCE] != _format_framework_arduino_version(version):
|
||||
raise cv.Invalid(
|
||||
"'toolchain: arduino' does not support a custom framework source; "
|
||||
"use 'toolchain: platformio'"
|
||||
)
|
||||
# BOARDS is a subset of ESP8266_BOARD_BUILD today; the second clause is
|
||||
# a drift guard for the independently regenerated tables
|
||||
if (
|
||||
config[CONF_BOARD] not in BOARDS
|
||||
or config[CONF_BOARD] not in ESP8266_BOARD_BUILD
|
||||
):
|
||||
raise cv.Invalid(
|
||||
f"Board '{config[CONF_BOARD]}' is not supported by "
|
||||
"'toolchain: arduino'; use 'toolchain: platformio'"
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
def get_download_types(storage_json: StorageJSON) -> list[dict[str, str]]:
|
||||
"""Binary-download entries for a built ESP8266 firmware.
|
||||
|
||||
@@ -185,7 +132,7 @@ def _format_framework_arduino_version(ver: cv.Version) -> str:
|
||||
# a PIO platformio/framework-arduinoespressif8266 value
|
||||
# List of package versions: https://api.registry.platformio.org/v3/packages/platformio/tool/framework-arduinoespressif8266
|
||||
# Same encoding the native toolchain uses for its package download, so a
|
||||
# custom-source check against this value cannot drift from what it fetches.
|
||||
# version bump cannot drift between the two paths.
|
||||
from esphome.arduino8266.framework import framework_package_version
|
||||
|
||||
try:
|
||||
@@ -247,7 +194,7 @@ def _arduino_check_versions(value: ConfigType) -> ConfigType:
|
||||
platform_version = value.get(CONF_PLATFORM_VERSION)
|
||||
if platform_version is None:
|
||||
if version >= cv.Version(3, 1, 0):
|
||||
platform_version = _ARDUINO_4_PLATFORM_SPEC
|
||||
platform_version = _parse_platform_version(str(ARDUINO_4_PLATFORM_VERSION))
|
||||
else:
|
||||
platform_version = _parse_platform_version(str(ARDUINO_3_PLATFORM_VERSION))
|
||||
value[CONF_PLATFORM_VERSION] = platform_version
|
||||
@@ -270,10 +217,6 @@ def _parse_platform_version(value: Any) -> str:
|
||||
return value
|
||||
|
||||
|
||||
# The platform_version derived for every core >= 3.1.0 config
|
||||
_ARDUINO_4_PLATFORM_SPEC = _parse_platform_version(str(ARDUINO_4_PLATFORM_VERSION))
|
||||
|
||||
|
||||
ARDUINO_FRAMEWORK_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
@@ -290,6 +233,7 @@ ARDUINO_FRAMEWORK_SCHEMA = cv.All(
|
||||
)
|
||||
|
||||
|
||||
BUILD_FLASH_MODES = ["qio", "qout", "dio", "dout"]
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
@@ -306,30 +250,15 @@ CONFIG_SCHEMA = cv.All(
|
||||
cv.Optional(CONF_ENABLE_SERIAL1): cv.boolean,
|
||||
cv.Optional(CONF_ENABLE_FULL_PRINTF, default=False): cv.boolean,
|
||||
cv.Optional(CONF_ENABLE_SCANF_FLOAT): cv.boolean,
|
||||
cv.Optional(
|
||||
CONF_TOOLCHAIN, visibility=cv.Visibility.ADVANCED
|
||||
): _validate_toolchain,
|
||||
}
|
||||
),
|
||||
_resolve_toolchain,
|
||||
_validate_native_toolchain,
|
||||
# Until the native toolchain lands, PlatformIO is the only backend;
|
||||
# reject a --toolchain this platform cannot serve yet.
|
||||
cv.require_platformio_toolchain("ESP8266"),
|
||||
set_core_data,
|
||||
)
|
||||
|
||||
|
||||
def native_toolchain_module():
|
||||
"""The native build backend for the resolved toolchain, if any.
|
||||
|
||||
``__main__`` dispatches from its own toolchain-keyed table; this helper
|
||||
serves the component's internal callers.
|
||||
"""
|
||||
if not CORE.using_toolchain_arduino:
|
||||
return None
|
||||
from esphome.arduino8266 import toolchain
|
||||
|
||||
return toolchain
|
||||
|
||||
|
||||
def check_rosetta() -> None:
|
||||
"""Fail fast when the x86_64 ESP8266 toolchain cannot run on this Mac.
|
||||
|
||||
@@ -365,13 +294,12 @@ def _choose_ld_script(board: str) -> str:
|
||||
|
||||
@coroutine_with_priority(CoroPriority.PLATFORM)
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
use_platformio = CORE.using_toolchain_platformio
|
||||
cg.add(esp8266_ns.setup_preferences())
|
||||
|
||||
if use_platformio:
|
||||
cg.add_platformio_option("lib_ldf_mode", "off")
|
||||
cg.add_platformio_option("lib_compat_mode", "strict")
|
||||
cg.add_platformio_option("board", config[CONF_BOARD])
|
||||
cg.add_platformio_option("lib_ldf_mode", "off")
|
||||
cg.add_platformio_option("lib_compat_mode", "strict")
|
||||
|
||||
cg.add_platformio_option("board", config[CONF_BOARD])
|
||||
cg.add_build_flag("-DUSE_ESP8266")
|
||||
cg.set_cpp_standard("gnu++20")
|
||||
cg.add_define("ESPHOME_BOARD", config[CONF_BOARD])
|
||||
@@ -387,33 +315,28 @@ async def to_code(config: ConfigType) -> None:
|
||||
"enabling scanf float support (~8KB flash)"
|
||||
)
|
||||
|
||||
# The native generator reads the same decision (KEY_SCANF_FLOAT)
|
||||
CORE.data[KEY_ESP8266][KEY_SCANF_FLOAT] = bool(enable_scanf_float)
|
||||
if use_platformio:
|
||||
extra_scripts = [
|
||||
"pre:ccache.py",
|
||||
"pre:testing_mode.py",
|
||||
"pre:exclude_updater.py",
|
||||
"pre:exclude_waveform.py",
|
||||
"pre:relocate_ratetable.py",
|
||||
]
|
||||
if not enable_scanf_float:
|
||||
extra_scripts.append("pre:remove_float_scanf.py")
|
||||
extra_scripts.extend(pch_extra_scripts())
|
||||
extra_scripts.append("post:post_build.py")
|
||||
cg.add_platformio_option("extra_scripts", extra_scripts)
|
||||
extra_scripts = [
|
||||
"pre:ccache.py",
|
||||
"pre:testing_mode.py",
|
||||
"pre:exclude_updater.py",
|
||||
"pre:exclude_waveform.py",
|
||||
"pre:relocate_ratetable.py",
|
||||
]
|
||||
if not enable_scanf_float:
|
||||
extra_scripts.append("pre:remove_float_scanf.py")
|
||||
extra_scripts.append("post:post_build.py")
|
||||
cg.add_platformio_option("extra_scripts", extra_scripts)
|
||||
|
||||
conf = config[CONF_FRAMEWORK]
|
||||
cg.add_platformio_option("framework", "arduino")
|
||||
cg.add_build_flag("-DUSE_ARDUINO")
|
||||
cg.add_build_flag("-DUSE_ESP8266_FRAMEWORK_ARDUINO")
|
||||
cg.add_build_flag("-Wno-nonnull-compare")
|
||||
if use_platformio:
|
||||
cg.add_platformio_option("framework", "arduino")
|
||||
cg.add_platformio_option("platform", conf[CONF_PLATFORM_VERSION])
|
||||
cg.add_platformio_option(
|
||||
"platform_packages",
|
||||
[f"platformio/framework-arduinoespressif8266@{conf[CONF_SOURCE]}"],
|
||||
)
|
||||
cg.add_platformio_option("platform", conf[CONF_PLATFORM_VERSION])
|
||||
cg.add_platformio_option(
|
||||
"platform_packages",
|
||||
[f"platformio/framework-arduinoespressif8266@{conf[CONF_SOURCE]}"],
|
||||
)
|
||||
|
||||
# Default for platformio is LWIP2_LOW_MEMORY with:
|
||||
# - MSS=536
|
||||
@@ -451,8 +374,7 @@ async def to_code(config: ConfigType) -> None:
|
||||
# Force-include inline std::__throw_* overrides so GCC dead-strips the unused
|
||||
# libstdc++ error message strings (e.g. "basic_string::_M_create") from DRAM.
|
||||
# See throw_stubs.h for details. Must be prepended before <string>, so this
|
||||
# uses build_src_flags with -include. Unconditional: the native build
|
||||
# generator reads the same option, keeping one source of truth.
|
||||
# uses build_src_flags with -include.
|
||||
cg.add_platformio_option(
|
||||
"build_src_flags", "-include esphome/components/esp8266/throw_stubs.h"
|
||||
)
|
||||
@@ -482,8 +404,6 @@ async def to_code(config: ConfigType) -> None:
|
||||
# implementation in the Arduino ESP8266 core.
|
||||
cg.add_build_flag("-Wl,--wrap=millis")
|
||||
|
||||
# Unconditional: the native build generator reads the same option,
|
||||
# keeping one source of truth
|
||||
cg.add_platformio_option("board_build.flash_mode", config[CONF_BOARD_FLASH_MODE])
|
||||
|
||||
ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]
|
||||
@@ -492,7 +412,7 @@ async def to_code(config: ConfigType) -> None:
|
||||
cg.RawExpression(f"VERSION_CODE({ver.major}, {ver.minor}, {ver.patch})"),
|
||||
)
|
||||
|
||||
if use_platformio and config[CONF_BOARD] in BOARDS:
|
||||
if config[CONF_BOARD] in BOARDS:
|
||||
cg.add_platformio_option(
|
||||
"board_build.ldscript", _choose_ld_script(config[CONF_BOARD])
|
||||
)
|
||||
@@ -533,24 +453,8 @@ async def finalize_serial_config() -> None:
|
||||
cg.add_build_flag("-DNO_GLOBAL_SERIAL1")
|
||||
|
||||
|
||||
# Called by __main__.compile_program; returning False falls through to the
|
||||
# PlatformIO toolchain.
|
||||
def run_compile(args, config: ConfigType) -> bool:
|
||||
# Positive check: the native backend only runs when explicitly resolved
|
||||
toolchain = native_toolchain_module()
|
||||
if toolchain is None:
|
||||
return False
|
||||
if toolchain.run_compile(config, CORE.verbose) != 0:
|
||||
raise EsphomeError("ESP8266 native build failed")
|
||||
return True
|
||||
|
||||
|
||||
# Called by writer.py
|
||||
def copy_files() -> None:
|
||||
# Native builds skip the PlatformIO extra scripts; the build generator
|
||||
# carries their logic
|
||||
if CORE.using_toolchain_arduino:
|
||||
return
|
||||
dir = Path(__file__).parent
|
||||
for script in (
|
||||
"post_build",
|
||||
@@ -565,7 +469,6 @@ def copy_files() -> None:
|
||||
CORE.relative_build_path(f"{script}.py"),
|
||||
)
|
||||
copy_ccache_script()
|
||||
copy_pch_script()
|
||||
|
||||
|
||||
# ESP logs stack trace decoder, based on https://github.com/me-no-dev/EspExceptionDecoder
|
||||
@@ -608,75 +511,22 @@ ESP8266_EXCEPTION_CODES = {
|
||||
}
|
||||
|
||||
|
||||
_DECODE_WARNED_AT: dict[str, float] = {}
|
||||
def _decode_pc(config: ConfigType, addr: str) -> None:
|
||||
from esphome.platformio import toolchain
|
||||
|
||||
|
||||
def _warn_decode_problem(key: str, message: str, *args) -> bool:
|
||||
"""Warn, deduplicated briefly so a burst of stack-dump addresses warns
|
||||
once but a later dump warns again; returns whether it warned so the
|
||||
caller can mark suppressed addresses individually."""
|
||||
now = time.monotonic()
|
||||
last = _DECODE_WARNED_AT.get(key)
|
||||
if last is not None and now - last < 30:
|
||||
return False
|
||||
_DECODE_WARNED_AT[key] = now
|
||||
_LOGGER.warning(message, *args)
|
||||
return True
|
||||
|
||||
|
||||
def _decode_pc(config: ConfigType, addr: str, *, bulk: bool = False) -> None:
|
||||
"""Decode one crash address. ``bulk``: the caller is scanning every
|
||||
8-hex stack word, most of which are not code addresses -- unmappable
|
||||
ones log at debug so real frames are not buried."""
|
||||
if (native_toolchain := native_toolchain_module()) is not None:
|
||||
addr2line = native_toolchain.get_addr2line_path()
|
||||
elf = native_toolchain.get_elf_path()
|
||||
for path in (addr2line, elf):
|
||||
if not path.is_file():
|
||||
_warn_decode_problem(
|
||||
str(path), "Cannot decode crash addresses: %s missing", path
|
||||
)
|
||||
# The detailed warning names no address; mark named
|
||||
# registers, but bulk stack words at debug (~150 per dump)
|
||||
log = _LOGGER.debug if bulk else _LOGGER.warning
|
||||
log("Not decoded %s (toolchain file missing)", addr)
|
||||
return
|
||||
addr2line, elf = str(addr2line), str(elf)
|
||||
else:
|
||||
from esphome.platformio import toolchain
|
||||
|
||||
idedata = toolchain.get_idedata(config)
|
||||
if not idedata.addr2line_path or not idedata.firmware_elf_path:
|
||||
_warn_decode_problem(
|
||||
"no-addr2line",
|
||||
"Cannot decode crash addresses: no addr2line or ELF in idedata",
|
||||
)
|
||||
log = _LOGGER.debug if bulk else _LOGGER.warning
|
||||
log("Not decoded %s (no addr2line or ELF)", addr)
|
||||
return
|
||||
addr2line, elf = idedata.addr2line_path, idedata.firmware_elf_path
|
||||
command = [addr2line, "-pfiaC", "-e", elf, addr]
|
||||
idedata = toolchain.get_idedata(config)
|
||||
if not idedata.addr2line_path or not idedata.firmware_elf_path:
|
||||
_LOGGER.debug("decode_pc no addr2line")
|
||||
return
|
||||
command = [idedata.addr2line_path, "-pfiaC", "-e", idedata.firmware_elf_path, addr]
|
||||
try:
|
||||
translation = subprocess.check_output(command, close_fds=False).decode().strip()
|
||||
except Exception as err: # noqa: BLE001 # pylint: disable=broad-except
|
||||
# Warn, not debug: a failing addr2line must be visible. The warning
|
||||
# is rate-limited across a dump, so mark every undecoded address
|
||||
# inline or the rest read as merely unmappable
|
||||
if not _warn_decode_problem(
|
||||
"addr2line-failed", "Could not decode crash address %s (%s)", addr, err
|
||||
):
|
||||
# The detailed warning already named this address; mark only
|
||||
# the rate-limited ones, and bulk stack words at debug
|
||||
log = _LOGGER.debug if bulk else _LOGGER.warning
|
||||
log("Not decoded %s (addr2line failed)", addr)
|
||||
except Exception: # noqa: BLE001 # pylint: disable=broad-except
|
||||
_LOGGER.debug("Caught exception for command %s", command, exc_info=1)
|
||||
return
|
||||
|
||||
if "?? ??:0" in translation:
|
||||
# A named register that fails to decode is confusing silence; a
|
||||
# bulk stack word failing is the expected common case
|
||||
log = _LOGGER.debug if bulk else _LOGGER.warning
|
||||
log("Not decoded %s (address not in %s)", addr, elf)
|
||||
# Nothing useful
|
||||
return
|
||||
translation = translation.replace(" at ??:?", "").replace(":?", "")
|
||||
_LOGGER.warning("Decoded %s", translation)
|
||||
@@ -746,6 +596,6 @@ def process_stacktrace(config: ConfigType, line: str, backtrace_state: bool) ->
|
||||
|
||||
if backtrace_state:
|
||||
for addr in re.finditer(STACKTRACE_ESP8266_BACKTRACE_PC_RE, line):
|
||||
_decode_pc(config, addr.group(), bulk=True)
|
||||
_decode_pc(config, addr.group())
|
||||
|
||||
return backtrace_state
|
||||
|
||||
@@ -16,6 +16,7 @@ KEY_WAVEFORM_REQUIRED = "waveform_required"
|
||||
KEY_SERIAL_REQUIRED = "serial_required"
|
||||
KEY_SERIAL1_REQUIRED = "serial1_required"
|
||||
# Set for the native (non-PlatformIO) toolchain's build generator
|
||||
KEY_FLASH_MODE = "flash_mode"
|
||||
KEY_SCANF_FLOAT = "scanf_float"
|
||||
# Per-board flash-layout override consumed by board_ld_script()
|
||||
KEY_LDSCRIPT = "ldscript"
|
||||
@@ -72,6 +73,3 @@ def enable_serial1() -> None:
|
||||
enable_serial1()
|
||||
"""
|
||||
CORE.data.setdefault(KEY_ESP8266, {})[KEY_SERIAL1_REQUIRED] = True
|
||||
|
||||
|
||||
BUILD_FLASH_MODES = ("qio", "qout", "dio", "dout")
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
from esphome.build_helpers.pch import PCH_PREFIX_HEADER, pch_enabled, pch_extra_scripts
|
||||
import esphome.codegen as cg
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
@@ -11,7 +10,7 @@ from esphome.const import (
|
||||
ThreadModel,
|
||||
)
|
||||
from esphome.core import CORE
|
||||
from esphome.platformio.toolchain import copy_ccache_script, copy_pch_script
|
||||
from esphome.platformio.toolchain import copy_ccache_script
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .const import KEY_HOST
|
||||
@@ -56,18 +55,9 @@ async def to_code(config: ConfigType) -> None:
|
||||
cg.add_platformio_option("platform", "platformio/native")
|
||||
cg.add_platformio_option("lib_ldf_mode", "off")
|
||||
cg.add_platformio_option("lib_compat_mode", "strict")
|
||||
cg.add_platformio_option("extra_scripts", ["pre:ccache.py", *pch_extra_scripts()])
|
||||
if pch_enabled():
|
||||
# Curated prefix for the pch (the script folds it plus defines.h):
|
||||
# host has no framework force-includes, and the per-TU cost is the
|
||||
# STL closure behind the core headers. Measured -43% compile CPU.
|
||||
# Gated so ESPHOME_PCH_ENABLE=0 restores the strict view. When the
|
||||
# .gch fails to build or load, the force-include stays and every TU
|
||||
# parses the closure as text: correct, but slower than no pch.
|
||||
cg.add_platformio_option("build_src_flags", f"-include {PCH_PREFIX_HEADER}")
|
||||
cg.add_platformio_option("extra_scripts", ["pre:ccache.py"])
|
||||
|
||||
|
||||
# Called by writer.py
|
||||
def copy_files() -> None:
|
||||
copy_ccache_script()
|
||||
copy_pch_script()
|
||||
|
||||
@@ -91,14 +91,7 @@ void I2SAudioSpeakerBase::loop() {
|
||||
this->speaker_task_handle_ = nullptr;
|
||||
|
||||
this->stop_i2s_driver_();
|
||||
// ALL_BITS includes COMMAND_START. Take the bits from the clear itself, not from the snapshot at
|
||||
// the top of loop(): the audio source's task can raise a start at any point above, including
|
||||
// during stop_i2s_driver_(), and nothing would ever re-issue it.
|
||||
const EventBits_t bits_before_clear = xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::ALL_BITS);
|
||||
if (bits_before_clear & SpeakerEventGroupBits::COMMAND_START) {
|
||||
ESP_LOGD(TAG, "Start requested while stopping; keeping the request");
|
||||
xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::COMMAND_START);
|
||||
}
|
||||
xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::ALL_BITS);
|
||||
this->status_clear_error();
|
||||
|
||||
this->on_task_stopped();
|
||||
|
||||
@@ -2,7 +2,6 @@ import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from esphome.build_helpers.pch import pch_extra_scripts
|
||||
import esphome.codegen as cg
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
@@ -27,7 +26,7 @@ from esphome.const import (
|
||||
from esphome.core import CORE
|
||||
from esphome.core.config import BOARD_MAX_LENGTH
|
||||
from esphome.helpers import copy_file_if_changed
|
||||
from esphome.platformio.toolchain import copy_ccache_script, copy_pch_script
|
||||
from esphome.platformio.toolchain import copy_ccache_script
|
||||
from esphome.storage_json import StorageJSON
|
||||
|
||||
from . import gpio # noqa: F401
|
||||
@@ -514,7 +513,7 @@ async def component_to_code(config):
|
||||
# it for project source files only. GCC uses the last -O flag.
|
||||
build_src_flags += " -Os"
|
||||
cg.add_platformio_option("build_src_flags", build_src_flags)
|
||||
cg.add_platformio_option("extra_scripts", ["pre:ccache.py", *pch_extra_scripts()])
|
||||
cg.add_platformio_option("extra_scripts", ["pre:ccache.py"])
|
||||
# IRAM_ATTR is a no-op on BK72xx (SDK masks FIQ+IRQ around flash ops).
|
||||
# On other families, patch_linker.py routes .sram.text into the right
|
||||
# RAM-executable output section and prints a post-link placement summary.
|
||||
@@ -620,4 +619,3 @@ def copy_files() -> None:
|
||||
CORE.relative_build_path("patch_linker.py"),
|
||||
)
|
||||
copy_ccache_script()
|
||||
copy_pch_script()
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
#include <esp_log.h>
|
||||
|
||||
#include <driver/uart.h>
|
||||
#include <soc/soc_caps.h>
|
||||
|
||||
#ifdef USE_LOGGER_UART_SELECTION_USB_SERIAL_JTAG
|
||||
#include <driver/usb_serial_jtag.h>
|
||||
@@ -77,11 +76,7 @@ void init_uart(uart_port_t uart_num, uint32_t baud_rate, int tx_buffer_size) {
|
||||
uart_config.parity = UART_PARITY_DISABLE;
|
||||
uart_config.stop_bits = UART_STOP_BITS_1;
|
||||
uart_config.flow_ctrl = UART_HW_FLOWCTRL_DISABLE;
|
||||
#if SOC_UART_SUPPORT_XTAL_CLK
|
||||
uart_config.source_clk = UART_SCLK_XTAL;
|
||||
#else
|
||||
uart_config.source_clk = UART_SCLK_DEFAULT;
|
||||
#endif
|
||||
uart_param_config(uart_num, &uart_config);
|
||||
// The logger only writes to UART, never reads, so use the minimum RX buffer.
|
||||
// ESP-IDF requires rx_buffer_size > UART_HW_FIFO_LEN (128 bytes).
|
||||
|
||||
@@ -15,7 +15,6 @@ from ..defines import (
|
||||
from ..types import LvCompound, LvType
|
||||
from . import Widget, WidgetType, get_widgets
|
||||
from .buttonmatrix import CONF_BUTTONMATRIX
|
||||
from .label import CONF_LABEL
|
||||
from .textarea import CONF_TEXTAREA, lv_textarea_t
|
||||
|
||||
CONF_KEYBOARD = "keyboard"
|
||||
@@ -50,7 +49,7 @@ class KeyboardType(WidgetType):
|
||||
)
|
||||
|
||||
def get_uses(self):
|
||||
return CONF_KEYBOARD, CONF_TEXTAREA, CONF_BUTTONMATRIX, CONF_LABEL
|
||||
return CONF_KEYBOARD, CONF_TEXTAREA, CONF_BUTTONMATRIX
|
||||
|
||||
async def to_code(self, w: Widget, config: dict):
|
||||
add_lv_use("KEY_LISTENER")
|
||||
|
||||
@@ -10,7 +10,6 @@ from ..types import lv_obj_t
|
||||
from . import Widget, WidgetType
|
||||
from .canvas import CONF_CANVAS
|
||||
from .img import CONF_IMAGE
|
||||
from .label import CONF_LABEL
|
||||
|
||||
CONF_QRCODE = "qrcode"
|
||||
CONF_DARK_COLOR = "dark_color"
|
||||
@@ -42,7 +41,7 @@ class QrCodeType(WidgetType):
|
||||
)
|
||||
|
||||
def get_uses(self):
|
||||
return CONF_CANVAS, CONF_IMAGE, CONF_LABEL
|
||||
return CONF_CANVAS, CONF_IMAGE
|
||||
|
||||
async def to_code(self, w: Widget, config):
|
||||
await w.set_property(
|
||||
|
||||
@@ -28,7 +28,6 @@ from ..types import LV_EVENT, LvType, ObjUpdateAction, lv_obj_t, lv_obj_t_ptr
|
||||
from . import Widget, WidgetType, add_widgets, get_widgets, set_obj_properties
|
||||
from .button import button_spec
|
||||
from .buttonmatrix import CONF_BUTTONMATRIX, buttonmatrix_spec
|
||||
from .label import CONF_LABEL
|
||||
from .obj import obj_spec
|
||||
|
||||
CONF_TABVIEW = "tabview"
|
||||
@@ -75,7 +74,7 @@ class TabviewType(WidgetType):
|
||||
)
|
||||
|
||||
def get_uses(self):
|
||||
return CONF_BUTTONMATRIX, TYPE_FLEX, CONF_BUTTON, CONF_LABEL
|
||||
return CONF_BUTTONMATRIX, TYPE_FLEX, CONF_BUTTON
|
||||
|
||||
async def to_code(self, w: Widget, config: dict):
|
||||
await w.set_property(
|
||||
|
||||
@@ -67,9 +67,6 @@ void MQTTJSONLightComponent::send_discovery(JsonObject root, mqtt::SendDiscovery
|
||||
if (traits.supports_color_mode(ColorMode::RGB_COLD_WARM_WHITE))
|
||||
color_modes.add(ESPHOME_F("rgbww"));
|
||||
|
||||
if (traits.supports_color_capability(ColorCapability::BRIGHTNESS))
|
||||
root[ESPHOME_F("brightness")] = true;
|
||||
|
||||
if (traits.supports_color_mode(ColorMode::COLOR_TEMPERATURE) ||
|
||||
traits.supports_color_mode(ColorMode::COLD_WARM_WHITE)) {
|
||||
root[MQTT_MIN_MIREDS] = traits.get_min_mireds();
|
||||
|
||||
@@ -14,7 +14,12 @@ from esphome.const import (
|
||||
)
|
||||
from esphome.core import CORE, TimePeriod
|
||||
|
||||
from . import FILTER_SOURCE_FILES, Nextion, nextion_ns, nextion_ref # noqa: F401 pylint: disable=unused-import
|
||||
from . import ( # noqa: F401 pylint: disable=unused-import
|
||||
FILTER_SOURCE_FILES,
|
||||
Nextion,
|
||||
nextion_ns,
|
||||
nextion_ref,
|
||||
)
|
||||
from .base_component import (
|
||||
CONF_AUTO_WAKE_ON_TOUCH,
|
||||
CONF_COMMAND_SPACING,
|
||||
|
||||
@@ -1,24 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from functools import partial
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
|
||||
from esphome import pins
|
||||
from esphome.build_helpers import pch
|
||||
from esphome.build_helpers.pch import (
|
||||
PCH_DEFAULT_HEADERS,
|
||||
PCH_HEADER_NAME,
|
||||
mark_pch_emitted,
|
||||
pch_cmake_consumer,
|
||||
pch_enabled,
|
||||
pch_header_text,
|
||||
)
|
||||
import esphome.codegen as cg
|
||||
from esphome.components.zephyr import (
|
||||
add_extra_script,
|
||||
@@ -816,9 +805,6 @@ def _generate_cmake_lists() -> bool:
|
||||
")",
|
||||
]
|
||||
|
||||
if consumer := pch_cmake_consumer("app", "${APP_SOURCES}"):
|
||||
lines += consumer.splitlines()
|
||||
|
||||
if link_flags:
|
||||
lines += [
|
||||
"",
|
||||
@@ -833,66 +819,6 @@ def _generate_cmake_lists() -> bool:
|
||||
)
|
||||
|
||||
|
||||
def _app_build_dir(build_dir: Path) -> Path:
|
||||
"""The CMake binary dir of the app image: sysbuild nests it in a
|
||||
domain dir named after the app source dir. Probed on disk (the
|
||||
non-sysbuild zephyr/ output dir has no CMakeCache.txt) so it stays
|
||||
truthful mid-build, unlike an SDK-version check."""
|
||||
sysbuild_app = build_dir / "zephyr"
|
||||
try:
|
||||
cache = (sysbuild_app / "CMakeCache.txt").stat()
|
||||
except (FileNotFoundError, NotADirectoryError):
|
||||
return build_dir
|
||||
# Other stat errors propagate; is_file() would silently mislocate the pch
|
||||
return sysbuild_app if stat.S_ISREG(cache.st_mode) else build_dir
|
||||
|
||||
|
||||
def _prepare_pch(app_dir: Path) -> None:
|
||||
"""Build the .gch between the cmake and compile phases of west."""
|
||||
if not pch_enabled():
|
||||
pch.discard_pch(app_dir)
|
||||
pch.pch_disabled_degraded()
|
||||
return
|
||||
# First, so OBJECT_DEPENDS is satisfied even when the pch degrades
|
||||
app_dir.mkdir(parents=True, exist_ok=True)
|
||||
write_file_if_changed(
|
||||
app_dir / PCH_HEADER_NAME, pch_header_text(PCH_DEFAULT_HEADERS)
|
||||
)
|
||||
# New layout first (Zephyr >= 3.4 nests under zephyr/); fixed candidates
|
||||
# keep the .sum identity deterministic and skip walking generated/
|
||||
generated = app_dir / "zephyr" / "include" / "generated"
|
||||
autoconf = None
|
||||
for candidate in (generated / "zephyr" / "autoconf.h", generated / "autoconf.h"):
|
||||
if candidate.exists():
|
||||
autoconf = candidate
|
||||
break
|
||||
if autoconf is None:
|
||||
# Fail closed: autoconf.h is the .sum's Kconfig identity
|
||||
_LOGGER.warning("No autoconf.h found; compiling without the pch")
|
||||
pch.discard_pch(app_dir)
|
||||
pch.pch_degraded("autoconf.h missing")
|
||||
return
|
||||
try:
|
||||
autoconf_text = autoconf.read_text(encoding="utf-8")
|
||||
except OSError as err:
|
||||
_LOGGER.warning(
|
||||
"Could not read %s; compiling without the pch: %s", autoconf, err
|
||||
)
|
||||
pch.discard_pch(app_dir)
|
||||
pch.pch_degraded(f"autoconf unreadable: {err}")
|
||||
return
|
||||
pch.prepare_pch(
|
||||
app_dir,
|
||||
PCH_DEFAULT_HEADERS,
|
||||
(
|
||||
str(CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]),
|
||||
zephyr_data()[KEY_BOARD],
|
||||
autoconf_text,
|
||||
*get_project_compile_flags(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _copy_if_exists(src: Path, dst: Path) -> None:
|
||||
if src.is_file():
|
||||
shutil.copy2(src, dst)
|
||||
@@ -945,58 +871,6 @@ def run_compile(args, config: ConfigType) -> bool:
|
||||
str(source_dir),
|
||||
]
|
||||
|
||||
if pch_enabled():
|
||||
# Consumers carry the -include; gate the ccache relaxation on it
|
||||
# (Zephyr auto-enables ccache as the compiler launcher when found)
|
||||
mark_pch_emitted()
|
||||
env.update(pch.ccache_pch_env())
|
||||
|
||||
# Configure first so the .gch compiles from settled compile DB
|
||||
# flags. Only when the app DB is missing: input changes wipe the
|
||||
# build dir, so an existing DB is settled and --cmake-only would
|
||||
# reconfigure for nothing.
|
||||
prepare = True
|
||||
app_dir = _app_build_dir(build_dir)
|
||||
if not (app_dir / "compile_commands.json").is_file():
|
||||
if not run_command_ok(
|
||||
west_cmd + ["--cmake-only", "--", "-DCMAKE_EXPORT_COMPILE_COMMANDS=ON"],
|
||||
env=env,
|
||||
stream_output=True,
|
||||
cwd=str(paths["framework_path"]),
|
||||
):
|
||||
raise EsphomeError("nRF52 native build configure failed")
|
||||
# The configure phase creates the sysbuild domain dir: re-resolve
|
||||
app_dir = _app_build_dir(build_dir)
|
||||
# kernel.h needs the build-time syscall headers; under sysbuild
|
||||
# the target exists only in the app domain's ninja
|
||||
if not run_command_ok(
|
||||
[
|
||||
"cmake",
|
||||
"--build",
|
||||
str(app_dir),
|
||||
"--target",
|
||||
"zephyr_generated_headers",
|
||||
],
|
||||
env=env,
|
||||
stream_output=True,
|
||||
cwd=str(paths["framework_path"]),
|
||||
):
|
||||
# A pch-only prerequisite: degrade, let the real build report.
|
||||
# Also skip the .gch compile: it would fail on the missing
|
||||
# headers and latch .gch.failed until an identity change
|
||||
_LOGGER.warning(
|
||||
"Zephyr header generation failed; compiling without the pch"
|
||||
)
|
||||
pch.discard_pch(app_dir)
|
||||
pch.pch_degraded("zephyr_generated_headers failed")
|
||||
prepare = False
|
||||
else:
|
||||
prepare = True
|
||||
app_dir = _app_build_dir(build_dir)
|
||||
|
||||
if prepare:
|
||||
pch.guarded_prepare(app_dir, partial(_prepare_pch, app_dir))
|
||||
|
||||
if not run_command_ok(
|
||||
west_cmd,
|
||||
env=env,
|
||||
|
||||
@@ -18,16 +18,6 @@ void RFBridgeComponent::ack_() {
|
||||
}
|
||||
|
||||
bool RFBridgeComponent::parse_bridge_byte_(uint8_t byte) {
|
||||
if (this->bucket_frame_candidate_ && byte == RF_CODE_START) {
|
||||
// A queued next frame proves the trailing 0x55 really was the bucket
|
||||
// frame's terminator: Portisch builds pulse entries from alternating
|
||||
// signal edges, so the two level bits inside one pulse byte are always
|
||||
// opposite — 0xAA (two high-level nibbles) cannot occur in pulse data.
|
||||
// Finalize before this byte starts the new frame, so back-to-back
|
||||
// deliveries are split even when loop() never observed a quiet gap
|
||||
// between them.
|
||||
this->finish_bucket_frame_();
|
||||
}
|
||||
size_t at = this->rx_buffer_.size();
|
||||
this->rx_buffer_.push_back(byte);
|
||||
const uint8_t *raw = &this->rx_buffer_[0];
|
||||
@@ -94,21 +84,26 @@ bool RFBridgeComponent::parse_bridge_byte_(uint8_t byte) {
|
||||
break;
|
||||
}
|
||||
case RF_CODE_RFIN_BUCKET: {
|
||||
if (at == 2) {
|
||||
// The count byte: Portisch sends at most 7 buckets + sync, so 0 or
|
||||
// >8 cannot be a genuine capture — reject before it can occupy the
|
||||
// buffer for a full frame timeout.
|
||||
return byte != 0 && byte <= B1_MAX_BUCKET_COUNT;
|
||||
if (byte != RF_CODE_STOP) {
|
||||
return true;
|
||||
}
|
||||
// 0x55 is legal DATA inside a B1 frame: bucket durations are sent
|
||||
// with only their HIGH byte masked to 7 bits, so a duration such as
|
||||
// 0x0155 puts a raw 0x55 low byte inside the table — the first 0x55
|
||||
// must therefore not end the capture. The header declares the table
|
||||
// length (raw[2] pairs), so a 0x55 there is always data; one at or
|
||||
// past the first pulse index is a terminator CANDIDATE, confirmed
|
||||
// once the UART goes quiet (finish_bucket_frame_ in loop()).
|
||||
this->bucket_frame_candidate_ = byte == RF_CODE_STOP && at >= 3 + static_cast<size_t>(raw[2]) * 2;
|
||||
return true;
|
||||
|
||||
uint8_t buckets = raw[2] << 1;
|
||||
std::string str;
|
||||
char next_byte[3]; // 2 hex chars + null
|
||||
|
||||
for (uint32_t i = 0; i <= at; i++) {
|
||||
buf_append_printf(next_byte, sizeof(next_byte), 0, "%02X", raw[i]);
|
||||
str += next_byte;
|
||||
if ((i > 3) && buckets) {
|
||||
buckets--;
|
||||
}
|
||||
if ((i < 3) || (buckets % 2) || (i == at - 1)) {
|
||||
str += " ";
|
||||
}
|
||||
}
|
||||
ESP_LOGI(TAG, "Received RFBridge Bucket: %s", str.c_str());
|
||||
break;
|
||||
}
|
||||
default:
|
||||
ESP_LOGW(TAG, "Unknown action: 0x%02X", action);
|
||||
@@ -124,47 +119,6 @@ bool RFBridgeComponent::parse_bridge_byte_(uint8_t byte) {
|
||||
return false;
|
||||
}
|
||||
|
||||
void RFBridgeComponent::finish_bucket_frame_() {
|
||||
if (this->rx_buffer_.size() < 4) {
|
||||
// The candidate flag requires a header + non-empty bucket table, so
|
||||
// this cannot happen while flag and buffer stay consistent; guard the
|
||||
// raw[2] / size-1 reads against any future divergence anyway.
|
||||
this->rx_buffer_.clear();
|
||||
this->bucket_frame_candidate_ = false;
|
||||
return;
|
||||
}
|
||||
const uint8_t *raw = this->rx_buffer_.data();
|
||||
const size_t at = this->rx_buffer_.size() - 1;
|
||||
|
||||
uint8_t buckets = raw[2] << 1;
|
||||
std::string str;
|
||||
char next_byte[3]; // 2 hex chars + null
|
||||
|
||||
for (uint32_t i = 0; i <= at; i++) {
|
||||
buf_append_printf(next_byte, sizeof(next_byte), 0, "%02X", raw[i]);
|
||||
str += next_byte;
|
||||
if ((i > 3) && buckets) {
|
||||
buckets--;
|
||||
}
|
||||
if ((i < 3) || (buckets % 2) || (i == at - 1)) {
|
||||
str += " ";
|
||||
}
|
||||
}
|
||||
ESP_LOGI(TAG, "Received RFBridge Bucket: %s", str.c_str());
|
||||
|
||||
// Deliberately NOT ACKed: Portisch's B1 command handler leaves its
|
||||
// last_sniffing_command at the previous mode (RF_CODE_RFIN), and its
|
||||
// host-ACK handler re-arms sniffing from that stale value — so ACKing a
|
||||
// bucket delivery silently reverts the radio to standard sniffing and
|
||||
// ends bucket capture. Its delivery path is fire-and-forget and never
|
||||
// waits for a host ACK. Stock Itead firmware never sends B1 frames, so
|
||||
// suppressing this ACK cannot change stock-firmware behavior.
|
||||
// https://github.com/esphome/esphome/issues/17682
|
||||
|
||||
this->rx_buffer_.clear();
|
||||
this->bucket_frame_candidate_ = false;
|
||||
}
|
||||
|
||||
void RFBridgeComponent::write_byte_str_(const std::string &codes) {
|
||||
uint8_t code;
|
||||
int size = codes.length();
|
||||
@@ -176,31 +130,12 @@ void RFBridgeComponent::write_byte_str_(const std::string &codes) {
|
||||
|
||||
void RFBridgeComponent::loop() {
|
||||
const uint32_t now = App.get_loop_component_start_time();
|
||||
size_t avail = this->available();
|
||||
if (avail == 0 && this->bucket_frame_candidate_ && now - this->last_bridge_byte_ > BUCKET_CANDIDATE_QUIET_MS) {
|
||||
// The trailing 0x55 was followed by UART quiet, so it really was the
|
||||
// frame terminator and not an interior data byte.
|
||||
this->finish_bucket_frame_();
|
||||
this->last_bridge_byte_ = now;
|
||||
}
|
||||
const bool receiving_bucket = this->rx_buffer_.size() >= 2 && this->rx_buffer_[1] == RF_CODE_RFIN_BUCKET;
|
||||
if (receiving_bucket) {
|
||||
// Never declare an in-progress bucket frame dead while its continuation
|
||||
// bytes are already queued: a stalled loop() otherwise discards a live
|
||||
// frame that the UART buffer proves is still arriving.
|
||||
if (avail == 0 && now - this->last_bridge_byte_ > BUCKET_FRAME_TIMEOUT_MS) {
|
||||
ESP_LOGD(TAG, "Discarding incomplete RFBridge Bucket frame (%u bytes)",
|
||||
static_cast<unsigned>(this->rx_buffer_.size()));
|
||||
this->rx_buffer_.clear();
|
||||
this->bucket_frame_candidate_ = false;
|
||||
this->last_bridge_byte_ = now;
|
||||
}
|
||||
} else if (now - this->last_bridge_byte_ > 50) {
|
||||
if (now - this->last_bridge_byte_ > 50) {
|
||||
this->rx_buffer_.clear();
|
||||
this->bucket_frame_candidate_ = false;
|
||||
this->last_bridge_byte_ = now;
|
||||
}
|
||||
|
||||
size_t avail = this->available();
|
||||
while (avail > 0) {
|
||||
uint8_t buf[64];
|
||||
size_t to_read = std::min(avail, sizeof(buf));
|
||||
@@ -211,14 +146,12 @@ void RFBridgeComponent::loop() {
|
||||
for (size_t i = 0; i < to_read; i++) {
|
||||
if (this->rx_buffer_.size() > MAX_RX_BUFFER_SIZE) {
|
||||
this->rx_buffer_.clear();
|
||||
this->bucket_frame_candidate_ = false;
|
||||
}
|
||||
if (this->parse_bridge_byte_(buf[i])) {
|
||||
ESP_LOGVV(TAG, "Parsed: 0x%02X", buf[i]);
|
||||
this->last_bridge_byte_ = now;
|
||||
} else {
|
||||
this->rx_buffer_.clear();
|
||||
this->bucket_frame_candidate_ = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,17 +30,6 @@ static const uint8_t RF_CODE_BEEP = 0xC0;
|
||||
static const uint8_t RF_CODE_STOP = 0x55;
|
||||
static const uint8_t RF_DEBOUNCE = 200;
|
||||
static const size_t MAX_RX_BUFFER_SIZE = 512;
|
||||
// ~10 byte times at 19200 baud: long enough to prove the UART went quiet
|
||||
// after a possible bucket-frame terminator, short enough to finish well
|
||||
// before the next radio capture can be delivered.
|
||||
static const uint32_t BUCKET_CANDIDATE_QUIET_MS = 5;
|
||||
// Portisch drains a B1 frame's header, bucket table, and pulse data as
|
||||
// separate UART writes, so an in-progress bucket frame tolerates a longer
|
||||
// inter-region gap than the generic 50 ms inter-byte timeout.
|
||||
static const uint32_t BUCKET_FRAME_TIMEOUT_MS = 250;
|
||||
// Portisch's uart_put_RF_buckets sends at most 7 buckets plus the sync
|
||||
// bucket, so a B1 count byte above 8 (or 0) is malformed for any protocol.
|
||||
static const uint8_t B1_MAX_BUCKET_COUNT = 8;
|
||||
|
||||
struct RFBridgeData {
|
||||
uint16_t sync;
|
||||
@@ -78,12 +67,10 @@ class RFBridgeComponent final : public uart::UARTDevice, public Component {
|
||||
void ack_();
|
||||
void decode_();
|
||||
bool parse_bridge_byte_(uint8_t byte);
|
||||
void finish_bucket_frame_();
|
||||
void write_byte_str_(const std::string &codes);
|
||||
|
||||
std::vector<uint8_t> rx_buffer_;
|
||||
uint32_t last_bridge_byte_{0};
|
||||
bool bucket_frame_candidate_{false};
|
||||
|
||||
CallbackManager<void(RFBridgeData)> data_callback_;
|
||||
CallbackManager<void(RFBridgeAdvancedData)> advanced_data_callback_;
|
||||
|
||||
@@ -6,7 +6,6 @@ from string import ascii_letters, digits
|
||||
import subprocess
|
||||
from typing import Any
|
||||
|
||||
from esphome.build_helpers.pch import pch_extra_scripts
|
||||
import esphome.codegen as cg
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
@@ -34,7 +33,7 @@ from esphome.core import (
|
||||
)
|
||||
from esphome.core.config import BOARD_MAX_LENGTH
|
||||
from esphome.helpers import copy_file_if_changed, read_file, write_file_if_changed
|
||||
from esphome.platformio.toolchain import copy_ccache_script, copy_pch_script
|
||||
from esphome.platformio.toolchain import copy_ccache_script
|
||||
from esphome.storage_json import StorageJSON
|
||||
from esphome.types import ConfigType
|
||||
|
||||
@@ -341,10 +340,7 @@ async def to_code(config: ConfigType) -> None:
|
||||
cg.add_define("ESPHOME_VARIANT", VARIANT_FRIENDLY[variant])
|
||||
cg.add_define(ThreadModel.SINGLE)
|
||||
|
||||
cg.add_platformio_option(
|
||||
"extra_scripts",
|
||||
["pre:ccache.py", *pch_extra_scripts(), "post:post_build.py"],
|
||||
)
|
||||
cg.add_platformio_option("extra_scripts", ["pre:ccache.py", "post:post_build.py"])
|
||||
|
||||
conf = config[CONF_FRAMEWORK]
|
||||
cg.add_platformio_option("framework", "arduino")
|
||||
@@ -648,7 +644,6 @@ def copy_files() -> None:
|
||||
CORE.relative_build_path("inject_lwip_include.py"),
|
||||
)
|
||||
copy_ccache_script()
|
||||
copy_pch_script()
|
||||
_generate_lwipopts_h()
|
||||
if generate_pio_files():
|
||||
path = CORE.relative_src_path("esphome.h")
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
#include "tuya.h"
|
||||
#include "esphome/components/network/util.h"
|
||||
#include "esphome/core/gpio.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/util.h"
|
||||
|
||||
#ifdef USE_NETWORK
|
||||
#include "esphome/components/network/util.h"
|
||||
#endif
|
||||
|
||||
#ifdef USE_WIFI
|
||||
#include "esphome/components/wifi/wifi_component.h"
|
||||
#endif
|
||||
@@ -25,14 +22,6 @@ static const int MAX_RETRIES = 5;
|
||||
// Max bytes to log for datapoint values (larger values are truncated)
|
||||
static constexpr size_t MAX_DATAPOINT_LOG_BYTES = 16;
|
||||
|
||||
static bool network_is_connected() {
|
||||
#ifdef USE_NETWORK
|
||||
return network::is_connected();
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
void Tuya::setup() {
|
||||
this->set_interval("heartbeat", 15000, [this] { this->send_empty_command_(TuyaCommandType::HEARTBEAT); });
|
||||
if (this->status_pin_ != nullptr) {
|
||||
@@ -565,14 +554,14 @@ void Tuya::send_empty_command_(TuyaCommandType command) {
|
||||
}
|
||||
|
||||
void Tuya::set_status_pin_() {
|
||||
bool is_network_ready = network_is_connected() && remote_is_connected();
|
||||
bool is_network_ready = network::is_connected() && remote_is_connected();
|
||||
this->status_pin_->digital_write(is_network_ready);
|
||||
}
|
||||
|
||||
uint8_t Tuya::get_wifi_status_code_() {
|
||||
uint8_t status = 0x02;
|
||||
|
||||
if (network_is_connected()) {
|
||||
if (network::is_connected()) {
|
||||
status = 0x03;
|
||||
|
||||
// Protocol version 3 also supports specifying when connected to "the cloud"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from typing import Any
|
||||
from collections.abc import Callable
|
||||
from typing import Any, NoReturn
|
||||
|
||||
from esphome import automation
|
||||
from esphome.automation import Trigger
|
||||
@@ -47,10 +48,17 @@ UDP_SCHEMA = cv.Schema(
|
||||
)
|
||||
|
||||
|
||||
def is_relocated(option: str) -> Callable[[Any], NoReturn]:
|
||||
def validator(value: Any) -> NoReturn:
|
||||
raise cv.Invalid(
|
||||
f"The '{option}' option should now be configured in the 'packet_transport' component"
|
||||
)
|
||||
|
||||
return validator
|
||||
|
||||
|
||||
RELOCATED = {
|
||||
cv.Optional(x): cv.invalid(
|
||||
f"The '{x}' option should now be configured in the 'packet_transport' component"
|
||||
)
|
||||
cv.Optional(x): is_relocated(x)
|
||||
for x in (
|
||||
CONF_PROVIDERS,
|
||||
CONF_ENCRYPTION,
|
||||
|
||||
@@ -1143,14 +1143,13 @@ class EsphomeCore:
|
||||
def add_platformio_option(
|
||||
self, key: str, value: str | list[str], *, replace: bool = False
|
||||
) -> None:
|
||||
"""Set a platformio.ini option; values append to an existing list
|
||||
(a string as one element) unless ``replace`` is True, which
|
||||
overwrites any existing value."""
|
||||
"""Set a platformio.ini option; list values append to an existing list
|
||||
unless ``replace`` is True, which overwrites any existing value."""
|
||||
new_val = value
|
||||
old_val = self.platformio_options.get(key)
|
||||
if not replace and isinstance(old_val, list):
|
||||
# A user platformio_options string must merge, not assert
|
||||
new_val = old_val + ([value] if isinstance(value, str) else value)
|
||||
assert isinstance(value, list)
|
||||
new_val = old_val + value
|
||||
self.platformio_options[key] = new_val
|
||||
|
||||
def _get_variable_generator(self, id):
|
||||
|
||||
@@ -555,14 +555,7 @@ NATIVE_ARDUINO_PIO_OPTIONS = frozenset({"board_build.f_cpu", "board_build.ldscri
|
||||
# that is stored rather than translated away. Consumed by the esp8266 native
|
||||
# backend (later in this chain) for its ignored-option warning; defined here
|
||||
# so it stays adjacent to the routing.
|
||||
# build_src_flags and board_build.flash_mode: set unconditionally by
|
||||
# esp8266/__init__ and read by the native generator; not user-routable, so
|
||||
# not in the set above
|
||||
NATIVE_ARDUINO_CONSUMED_PIO_OPTIONS = NATIVE_ARDUINO_PIO_OPTIONS | {
|
||||
"lib_ignore",
|
||||
"build_src_flags",
|
||||
"board_build.flash_mode",
|
||||
}
|
||||
NATIVE_ARDUINO_CONSUMED_PIO_OPTIONS = NATIVE_ARDUINO_PIO_OPTIONS | {"lib_ignore"}
|
||||
|
||||
|
||||
@coroutine_with_priority(CoroPriority.FINAL)
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
#pragma once
|
||||
// Curated precompiled-header prefix for backends that force-include it via
|
||||
// build_src_flags (the pch script folds it into the .gch). Guarded because
|
||||
// build_src_flags also reaches C and assembly src edges.
|
||||
#ifdef __cplusplus
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/automation.h"
|
||||
#endif
|
||||
@@ -1,68 +0,0 @@
|
||||
"""Shared tool resolution for the sibling idf_tools-backed scripts.
|
||||
|
||||
Importable because ``python <script>`` puts this directory first on
|
||||
sys.path; ``idf_tools`` itself comes from PYTHONPATH.
|
||||
"""
|
||||
|
||||
# pylint: disable=import-error # idf_tools is on PYTHONPATH at runtime only
|
||||
|
||||
from collections.abc import Callable, Iterator
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from idf_tools import (
|
||||
CURRENT_PLATFORM,
|
||||
TOOLS_FILE,
|
||||
IDFEnv,
|
||||
ToolBinaryError,
|
||||
add_and_check_targets,
|
||||
expand_tools_arg,
|
||||
g,
|
||||
load_tools_info,
|
||||
)
|
||||
|
||||
|
||||
def init_idf_tools(idf_framework_root: str) -> None:
|
||||
"""Point idf_tools' globals at the framework and IDF_TOOLS_PATH."""
|
||||
g.idf_path = idf_framework_root
|
||||
g.idf_tools_path = os.environ.get("IDF_TOOLS_PATH")
|
||||
g.tools_json = str(Path(g.idf_path) / TOOLS_FILE)
|
||||
|
||||
|
||||
def archive_name(download: object) -> str:
|
||||
"""The dist/ filename idf_tools downloads and installs this from."""
|
||||
return download.rename_dist or Path(download.url).name
|
||||
|
||||
|
||||
def iter_tool_downloads(
|
||||
targets_csv: str,
|
||||
tool_specs: list[str],
|
||||
on_broken: Callable[[str, ToolBinaryError], bool],
|
||||
) -> Iterator[tuple[object, str, str, object]]:
|
||||
"""Yield (tool, name, version, download) per uninstalled tool, mirroring
|
||||
``idf_tools.py install``'s expansion; ``on_broken(name, err)`` returns
|
||||
True to treat a tool with a failing installed binary as not installed."""
|
||||
targets = add_and_check_targets(IDFEnv.get_idf_env(), targets_csv)
|
||||
tools_info = load_tools_info()
|
||||
for name in expand_tools_arg(tool_specs, tools_info, targets):
|
||||
if "@" in name:
|
||||
name, version = name.split("@", 1)
|
||||
else:
|
||||
version = None
|
||||
tool = tools_info.get(name)
|
||||
if tool is None or not tool.compatible_with_platform():
|
||||
continue
|
||||
version = version or tool.get_recommended_version()
|
||||
if version is None:
|
||||
continue
|
||||
try:
|
||||
tool.find_installed_versions()
|
||||
except ToolBinaryError as e:
|
||||
if not on_broken(name, e):
|
||||
continue
|
||||
if version in tool.versions_installed or version not in tool.versions:
|
||||
continue
|
||||
download = tool.versions[version].get_download_for_platform(CURRENT_PLATFORM)
|
||||
if download is None:
|
||||
continue
|
||||
yield tool, name, version, download
|
||||
+27
-89
@@ -16,11 +16,9 @@ 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 (
|
||||
BATCH_EXTRACT_WORKERS,
|
||||
PathType,
|
||||
create_venv,
|
||||
download_and_extract,
|
||||
@@ -28,7 +26,6 @@ from esphome.framework_helpers import (
|
||||
failure_reason,
|
||||
get_python_env_executable_path,
|
||||
get_system_python_path,
|
||||
is_expected_fetch_error,
|
||||
resume_fetch_job,
|
||||
rmdir,
|
||||
run_batch_downloads,
|
||||
@@ -36,9 +33,9 @@ from esphome.framework_helpers import (
|
||||
run_command_ok,
|
||||
str_to_lst_of_str,
|
||||
tool_version_runs,
|
||||
warn_batch_failures,
|
||||
warn_prefetch_failures,
|
||||
)
|
||||
from esphome.helpers import get_usable_cpu_count, write_file_if_changed
|
||||
from esphome.helpers import write_file_if_changed
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -294,13 +291,11 @@ def _run_idf_tools_script(
|
||||
msg: str,
|
||||
args: list[str] | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
stream_output: bool = False,
|
||||
) -> tuple[bool, str | None, str | None]:
|
||||
"""Run one of the sibling idf_tools-backed helper scripts.
|
||||
|
||||
PYTHONPATH carries this directory (sibling imports like
|
||||
``_tool_resolution``), the esphome package root (``esphome.helpers``),
|
||||
and the framework's ``tools`` dir (its own ``idf_tools`` module).
|
||||
The script is executed with the framework's ``tools`` directory on
|
||||
PYTHONPATH so it imports the framework's own ``idf_tools`` module.
|
||||
"""
|
||||
cmd = [
|
||||
get_system_python_path(),
|
||||
@@ -308,20 +303,11 @@ def _run_idf_tools_script(
|
||||
str(idf_framework_root),
|
||||
*(args or []),
|
||||
]
|
||||
# Explicit paths: the scripts dir (sibling imports must survive
|
||||
# PYTHONSAFEPATH), the esphome package root, and the framework's idf_tools
|
||||
pythonpath = os.pathsep.join(
|
||||
(
|
||||
str(_SCRIPTS_DIR),
|
||||
str(_SCRIPTS_DIR.parents[1]),
|
||||
str(Path(idf_framework_root) / "tools"),
|
||||
)
|
||||
)
|
||||
return run_command(
|
||||
cmd,
|
||||
msg=msg,
|
||||
env=(env or os.environ) | {"PYTHONPATH": pythonpath},
|
||||
stream_output=stream_output,
|
||||
env=(env or os.environ)
|
||||
| {"PYTHONPATH": str(Path(idf_framework_root) / "tools")},
|
||||
)
|
||||
|
||||
|
||||
@@ -739,9 +725,9 @@ def _prefetch_idf_tool_archives(
|
||||
dist_path = get_idf_tools_path() / "dist"
|
||||
entries = []
|
||||
seen_dests: set[str] = set()
|
||||
# Pre-existing archives are not skipped: download_with_resume keeps
|
||||
# them only on a sha256 match, so the pre-extraction can trust dist/
|
||||
for entry in json.loads(stdout):
|
||||
if (dist_path / entry["dest"]).is_file():
|
||||
continue
|
||||
# Never download unverified: an entry without sha256/size is
|
||||
# left to the installer, which fails loudly on a bad archive.
|
||||
# Checked before the dedupe so it cannot shadow a verifiable
|
||||
@@ -761,11 +747,9 @@ def _prefetch_idf_tool_archives(
|
||||
entries.append(entry)
|
||||
if not entries:
|
||||
return
|
||||
cached = sum((dist_path / entry["dest"]).is_file() for entry in entries)
|
||||
_LOGGER.info(
|
||||
"Downloading %d ESP-IDF tool archive(s)%s: %s",
|
||||
"Downloading %d ESP-IDF tool archive(s): %s",
|
||||
len(entries),
|
||||
f" ({cached} cached, verifying)" if cached else "",
|
||||
", ".join(entry["name"] for entry in entries),
|
||||
)
|
||||
|
||||
@@ -789,7 +773,7 @@ def _prefetch_idf_tool_archives(
|
||||
for entry in entries
|
||||
],
|
||||
)
|
||||
warn_batch_failures(failures)
|
||||
warn_prefetch_failures(failures)
|
||||
if len(failures) == len(entries):
|
||||
# A systematic fault, not one flaky mirror: the resume
|
||||
# workaround (#17703) is off for this whole install
|
||||
@@ -804,42 +788,6 @@ def _prefetch_idf_tool_archives(
|
||||
_LOGGER.debug("Prefetch failure detail", exc_info=True)
|
||||
|
||||
|
||||
def _preinstall_idf_tool_archives(
|
||||
framework_path: Path,
|
||||
targets_str: str,
|
||||
tools: list[str],
|
||||
env: dict[str, str] | None,
|
||||
) -> None:
|
||||
"""Run install_tool_archives.py to extract the prefetched tool archives
|
||||
in parallel. Strictly best-effort: the sequential installer remains the
|
||||
authority (see that script's docstring)."""
|
||||
try:
|
||||
success, _stdout, _stderr = _run_idf_tools_script(
|
||||
framework_path,
|
||||
"install_tool_archives.py",
|
||||
"ESP-IDF tool archive extraction",
|
||||
args=[
|
||||
targets_str,
|
||||
str(min(get_usable_cpu_count(), BATCH_EXTRACT_WORKERS)),
|
||||
*tools,
|
||||
],
|
||||
env=env,
|
||||
stream_output=True,
|
||||
)
|
||||
if not success:
|
||||
# Detail already streamed to the terminal by the script; a
|
||||
# surviving torn dir prints its own guidance there
|
||||
_LOGGER.warning("ESP-IDF tool pre-extraction failed; see above")
|
||||
except Exception as e: # noqa: BLE001 # pylint: disable=broad-exception-caught
|
||||
# A programming error keeps its traceback at WARNING
|
||||
_LOGGER.warning(
|
||||
"ESP-IDF tool pre-extraction failed: %s",
|
||||
failure_reason(e),
|
||||
exc_info=None if is_expected_fetch_error(e) else e,
|
||||
)
|
||||
_LOGGER.debug("Pre-extraction failure detail", exc_info=True)
|
||||
|
||||
|
||||
def _check_esphome_idf_framework_install(
|
||||
version: str,
|
||||
targets: list[str],
|
||||
@@ -991,7 +939,6 @@ def _check_esphome_idf_framework_install(
|
||||
_LOGGER.info("Installing ESP-IDF %s framework ...", version)
|
||||
targets_str = ",".join(targets)
|
||||
_prefetch_idf_tool_archives(framework_path, targets_str, tools, env)
|
||||
_preinstall_idf_tool_archives(framework_path, targets_str, tools, env)
|
||||
cmd = [
|
||||
get_system_python_path(),
|
||||
str(idf_tools_path),
|
||||
@@ -1258,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,
|
||||
@@ -1304,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,9 +1,8 @@
|
||||
"""Print JSON download info for the ESP-IDF tools an install would fetch.
|
||||
|
||||
Run via ``python <this file> <idf_framework_root> <targets-csv> <tool-spec>...``.
|
||||
PYTHONPATH must include this directory (for ``_tool_resolution``) and
|
||||
``<idf_framework_root>/tools`` (for ``idf_tools``), and IDF_TOOLS_PATH must
|
||||
be set. Prints a JSON list of
|
||||
PYTHONPATH must include ``<idf_framework_root>/tools`` so ``idf_tools`` is
|
||||
importable, and IDF_TOOLS_PATH must be set. Prints a JSON list of
|
||||
``{name, url, size, sha256, dest}`` for every tool version that is not yet
|
||||
installed, where ``dest`` is the archive filename ``idf_tools.py install``
|
||||
expects to find in ``<IDF_TOOLS_PATH>/dist``. Tools with no download for the
|
||||
@@ -19,36 +18,67 @@ or written — this script only reports what the install would download.
|
||||
|
||||
from contextlib import redirect_stdout
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
from _tool_resolution import archive_name, init_idf_tools, iter_tool_downloads
|
||||
from idf_tools import ToolBinaryError, get_idf_download_url_apply_mirrors
|
||||
from idf_tools import (
|
||||
CURRENT_PLATFORM,
|
||||
TOOLS_FILE,
|
||||
IDFEnv,
|
||||
ToolBinaryError,
|
||||
add_and_check_targets,
|
||||
expand_tools_arg,
|
||||
g,
|
||||
get_idf_download_url_apply_mirrors,
|
||||
load_tools_info,
|
||||
)
|
||||
|
||||
|
||||
def collect_downloads() -> list[dict]:
|
||||
init_idf_tools(sys.argv[1])
|
||||
g.idf_path = sys.argv[1]
|
||||
g.idf_tools_path = os.environ.get("IDF_TOOLS_PATH")
|
||||
g.tools_json = str(Path(g.idf_path) / TOOLS_FILE)
|
||||
|
||||
def on_broken(name: str, e: ToolBinaryError) -> bool:
|
||||
# A broken installed binary is idf_tools' problem to repair on
|
||||
# install; note it and treat the version as not installed.
|
||||
print(f"tool {name} failed its binary check: {e}", file=sys.stderr)
|
||||
return True
|
||||
targets = add_and_check_targets(IDFEnv.get_idf_env(), sys.argv[2])
|
||||
tools_info = load_tools_info()
|
||||
downloads: list[dict] = []
|
||||
|
||||
return [
|
||||
{
|
||||
"name": f"{name}@{version}",
|
||||
# Apply the same IDF_MIRROR_PREFIX_MAP / IDF_GITHUB_ASSETS
|
||||
# rewriting the installer's own downloader applies, so users
|
||||
# behind a mirror prefetch from the mirror too.
|
||||
"url": get_idf_download_url_apply_mirrors(None, download.url),
|
||||
"size": download.size,
|
||||
"sha256": download.sha256,
|
||||
"dest": archive_name(download),
|
||||
}
|
||||
for _tool, name, version, download in iter_tool_downloads(
|
||||
sys.argv[2], sys.argv[3:], on_broken
|
||||
for name in expand_tools_arg(sys.argv[3:], tools_info, targets):
|
||||
if "@" in name:
|
||||
name, version = name.split("@", 1)
|
||||
else:
|
||||
version = None
|
||||
tool = tools_info.get(name)
|
||||
if tool is None or not tool.compatible_with_platform():
|
||||
continue
|
||||
version = version or tool.get_recommended_version()
|
||||
if version is None:
|
||||
continue
|
||||
try:
|
||||
tool.find_installed_versions()
|
||||
except ToolBinaryError as e:
|
||||
# A broken installed binary is idf_tools' problem to repair on
|
||||
# install; note it and treat the version as not installed.
|
||||
print(f"tool {name} failed its binary check: {e}", file=sys.stderr)
|
||||
if version in tool.versions_installed or version not in tool.versions:
|
||||
continue
|
||||
download = tool.versions[version].get_download_for_platform(CURRENT_PLATFORM)
|
||||
if download is None:
|
||||
continue
|
||||
downloads.append(
|
||||
{
|
||||
"name": f"{name}@{version}",
|
||||
# Apply the same IDF_MIRROR_PREFIX_MAP / IDF_GITHUB_ASSETS
|
||||
# rewriting the installer's own downloader applies, so users
|
||||
# behind a mirror prefetch from the mirror too.
|
||||
"url": get_idf_download_url_apply_mirrors(None, download.url),
|
||||
"size": download.size,
|
||||
"sha256": download.sha256,
|
||||
"dest": download.rename_dist or Path(download.url).name,
|
||||
}
|
||||
)
|
||||
]
|
||||
return downloads
|
||||
|
||||
|
||||
# idf_tools prints informational lines (e.g. mirror URL rewrites) to stdout;
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
"""Extract prefetched ESP-IDF tool archives in parallel.
|
||||
|
||||
Run via ``python <this file> <idf_framework_root> <targets-csv> <workers>
|
||||
<tool-spec>...`` with idf_tools and the esphome package root on PYTHONPATH
|
||||
and IDF_TOOLS_PATH set.
|
||||
Drives idf_tools' own ``IDFTool.install()`` so extraction semantics match
|
||||
the sequential installer, which still runs afterwards as the authority and
|
||||
redoes anything this best-effort pass failed on. Archives are trusted from
|
||||
the prefetch's sha256 verification, not re-hashed here.
|
||||
"""
|
||||
|
||||
# pylint: disable=import-error # idf_tools is on PYTHONPATH at runtime only
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
from _tool_resolution import archive_name, init_idf_tools, iter_tool_downloads
|
||||
from idf_tools import ToolBinaryError, g
|
||||
|
||||
from esphome.helpers import rmtree
|
||||
|
||||
|
||||
def collect_pending(
|
||||
targets_csv: str, tool_specs: list[str]
|
||||
) -> tuple[dict[tuple[str, str], object], int]:
|
||||
"""The {(name, version): tool} jobs whose verified archive is on disk,
|
||||
and how many distinct uninstalled tools were resolved overall."""
|
||||
dist_path = Path(g.idf_tools_path) / "dist"
|
||||
|
||||
def on_broken(name: str, e: ToolBinaryError) -> bool:
|
||||
# Repairing a broken installed binary is the installer's job
|
||||
print(f"leaving broken {name} to the installer: {e}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
pending: dict[tuple[str, str], object] = {}
|
||||
resolved: set[tuple[str, str]] = set()
|
||||
for tool, name, version, download in iter_tool_downloads(
|
||||
targets_csv, tool_specs, on_broken
|
||||
):
|
||||
resolved.add((name, version))
|
||||
# Mirror the prefetch: an entry it could not verify is never trusted
|
||||
if not (download.sha256 and download.size):
|
||||
continue
|
||||
# Trusted as-is: the prefetch verifies archives at their final name,
|
||||
# and the installer redoes anything this pass fails on
|
||||
if (name, version) in pending or not (
|
||||
dist_path / archive_name(download)
|
||||
).is_file():
|
||||
continue
|
||||
pending[(name, version)] = tool
|
||||
return pending, len(resolved)
|
||||
|
||||
|
||||
def install_one(tool: object, name: str, version: str) -> bool | None:
|
||||
"""True on success, False on a cleaned-up failure, None when the torn
|
||||
dest dir survived and could fool the installer's binary probe."""
|
||||
try:
|
||||
tool.install(version)
|
||||
# check_binary_valid exits via SystemExit; the installer redoes failures
|
||||
except (Exception, SystemExit) as e: # noqa: BLE001 # pylint: disable=broad-exception-caught
|
||||
# Name the type: idf_tools' fatal() raises SystemExit(1), which
|
||||
# would render as a bare "1"
|
||||
print(
|
||||
f"pre-extracting {name}@{version} failed, leaving it to the "
|
||||
f"installer: {type(e).__name__}: {e}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
# A torn dest dir must not look installed to the installer
|
||||
dest = tool.get_path_for_version(version)
|
||||
try:
|
||||
rmtree(dest)
|
||||
except FileNotFoundError: # pragma: no cover # failed before mkdir
|
||||
pass
|
||||
except OSError as cleanup_err:
|
||||
print(
|
||||
f"could not remove {dest}: {cleanup_err}; the installer may "
|
||||
"trust the partial tool dir, delete it manually if the build "
|
||||
"fails",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return None
|
||||
return False
|
||||
# Per-tool completion keeps the multi-minute unpack phase visibly alive
|
||||
print(f"extracted {name}@{version}", flush=True)
|
||||
return True
|
||||
|
||||
|
||||
def main() -> None:
|
||||
_script, idf_framework_root, targets_csv, workers_str, *tool_specs = sys.argv
|
||||
init_idf_tools(idf_framework_root)
|
||||
pending, resolved = collect_pending(targets_csv, tool_specs)
|
||||
if len(pending) < 2:
|
||||
# Nothing to parallelize; the count makes a naming/resolution drift
|
||||
# that would silently disable this pass observable
|
||||
print(
|
||||
f"{len(pending)} of {resolved} uninstalled tool(s) have a "
|
||||
"prefetched archive; leaving them to the installer",
|
||||
flush=True,
|
||||
)
|
||||
return
|
||||
workers = min(int(workers_str), len(pending))
|
||||
print(
|
||||
f"Extracting {len(pending)} ESP-IDF tool archive(s) with "
|
||||
f"{workers} worker(s): "
|
||||
+ ", ".join(f"{name}@{version}" for name, version in pending),
|
||||
flush=True,
|
||||
)
|
||||
with ThreadPoolExecutor(max_workers=workers) as ex:
|
||||
futures = [
|
||||
ex.submit(install_one, tool, name, version)
|
||||
for (name, version), tool in pending.items()
|
||||
]
|
||||
try:
|
||||
results = [future.result() for future in futures]
|
||||
except BaseException: # pragma: no cover
|
||||
# Ctrl-C: drop queued extractions; in-flight ones finish whole
|
||||
ex.shutdown(wait=True, cancel_futures=True)
|
||||
raise
|
||||
# A survivor could fool the installer; every job failing is systematic.
|
||||
# Either way a nonzero exit makes the caller warn
|
||||
failed = sum(result is not True for result in results)
|
||||
if failed:
|
||||
print(f"{failed} of {len(results)} pre-extractions failed", file=sys.stderr)
|
||||
if None in results or failed == len(results):
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
main()
|
||||
@@ -528,12 +528,6 @@ def run_compile(config, verbose: bool) -> int:
|
||||
return result.returncode
|
||||
_patch_memory_segments()
|
||||
|
||||
# After every reconfigure so compile_commands and sdkconfig are settled
|
||||
from esphome.build_gen.espidf import prepare_pch
|
||||
from esphome.build_helpers.pch import guarded_prepare
|
||||
|
||||
guarded_prepare(CORE.relative_build_path("build"), prepare_pch)
|
||||
|
||||
# Build
|
||||
args = []
|
||||
|
||||
|
||||
@@ -288,25 +288,10 @@ def _detect_archive_root(names: Iterable[str]) -> str | None:
|
||||
return root if has_descendant else None
|
||||
|
||||
|
||||
def _resolve_progress(
|
||||
progress: Callable[[float], None] | None,
|
||||
progress_header: str | None,
|
||||
has_work: bool,
|
||||
) -> Callable[[float], None] | None:
|
||||
"""Fraction reporter for an extractor: the caller's callback wins over a
|
||||
private ``progress_header`` bar."""
|
||||
if progress is not None:
|
||||
return progress
|
||||
if progress_header and has_work:
|
||||
return ProgressBar(progress_header).update
|
||||
return None
|
||||
|
||||
|
||||
def _tar_extract_all(
|
||||
data: io.BufferedIOBase,
|
||||
extract_dir: PathType = ".",
|
||||
progress_header: str | None = None,
|
||||
progress: Callable[[float], None] | None = None,
|
||||
):
|
||||
"""
|
||||
Extract a TAR archive to the specified directory.
|
||||
@@ -321,7 +306,6 @@ def _tar_extract_all(
|
||||
data: File-like object containing the TAR archive
|
||||
extract_dir: Directory to extract contents to
|
||||
progress_header: If set, show a progress bar with this header
|
||||
progress: fraction callback (0..1, ends at 1.0); overrides progress_header
|
||||
"""
|
||||
import tarfile
|
||||
|
||||
@@ -380,20 +364,21 @@ def _tar_extract_all(
|
||||
safe_members.append(member)
|
||||
|
||||
total = len(safe_members)
|
||||
report = _resolve_progress(progress, progress_header, total > 0)
|
||||
progress = (
|
||||
ProgressBar(progress_header) if progress_header and total > 0 else None
|
||||
)
|
||||
for i, member in enumerate(safe_members, 1):
|
||||
tar_ref.extract(member, abs_dest)
|
||||
if report is not None:
|
||||
report(i / total)
|
||||
if report is not None:
|
||||
report(1)
|
||||
if progress is not None:
|
||||
progress.update(i / total)
|
||||
if progress is not None:
|
||||
progress.update(1)
|
||||
|
||||
|
||||
def _zip_extract_all(
|
||||
data: io.BufferedIOBase,
|
||||
extract_dir: PathType = ".",
|
||||
progress_header: str | None = None,
|
||||
progress: Callable[[float], None] | None = None,
|
||||
):
|
||||
"""
|
||||
Extract a ZIP archive to the specified directory.
|
||||
@@ -402,7 +387,6 @@ def _zip_extract_all(
|
||||
data: File-like object containing the ZIP archive
|
||||
extract_dir: Directory to extract contents to
|
||||
progress_header: If set, show a progress bar with this header
|
||||
progress: fraction callback (0..1, ends at 1.0); overrides progress_header
|
||||
"""
|
||||
import zipfile
|
||||
|
||||
@@ -419,7 +403,9 @@ def _zip_extract_all(
|
||||
strip_prefix = f"{strip_root}/" if strip_root is not None else None
|
||||
|
||||
total = len(all_members)
|
||||
report = _resolve_progress(progress, progress_header, total > 0)
|
||||
progress = (
|
||||
ProgressBar(progress_header) if progress_header and total > 0 else None
|
||||
)
|
||||
|
||||
for i, member in enumerate(all_members, 1):
|
||||
# 1. Normalize name
|
||||
@@ -452,10 +438,10 @@ def _zip_extract_all(
|
||||
# 6. Extract
|
||||
zip_ref.extract(member, extract_dir)
|
||||
|
||||
if report is not None:
|
||||
report(i / total)
|
||||
if report is not None:
|
||||
report(1)
|
||||
if progress is not None:
|
||||
progress.update(i / total)
|
||||
if progress is not None:
|
||||
progress.update(1)
|
||||
|
||||
|
||||
def _rename_with_retry(
|
||||
@@ -486,7 +472,6 @@ def _7z_extract_all(
|
||||
data: io.BufferedIOBase,
|
||||
extract_dir: PathType = ".",
|
||||
progress_header: str | None = None,
|
||||
progress: Callable[[float], None] | None = None,
|
||||
):
|
||||
"""
|
||||
Extract a 7z archive to the specified directory.
|
||||
@@ -501,7 +486,6 @@ def _7z_extract_all(
|
||||
data: File-like object containing the 7z archive (must be seekable)
|
||||
extract_dir: Directory to extract contents to
|
||||
progress_header: If set, show a progress bar with this header
|
||||
progress: called with 1.0 on completion; overrides progress_header
|
||||
"""
|
||||
import py7zr
|
||||
|
||||
@@ -540,15 +524,19 @@ def _7z_extract_all(
|
||||
continue
|
||||
safe_targets.append(raw)
|
||||
|
||||
report = _resolve_progress(progress, progress_header, bool(safe_targets))
|
||||
progress = (
|
||||
ProgressBar(progress_header)
|
||||
if progress_header and safe_targets
|
||||
else None
|
||||
)
|
||||
|
||||
if len(safe_targets) == len(all_names):
|
||||
z.extractall(path=staging)
|
||||
else:
|
||||
z.extract(path=staging, targets=safe_targets)
|
||||
|
||||
if report is not None:
|
||||
report(1)
|
||||
if progress is not None:
|
||||
progress.update(1)
|
||||
|
||||
src_root = staging / strip_root if strip_root else staging
|
||||
for item in src_root.iterdir():
|
||||
@@ -579,7 +567,6 @@ def archive_extract_all(
|
||||
archive: PathType | io.RawIOBase | IO[bytes],
|
||||
extract_dir: PathType = ".",
|
||||
progress_header: str | None = None,
|
||||
progress: Callable[[float], None] | None = None,
|
||||
):
|
||||
"""
|
||||
Extract an archive file to the specified directory.
|
||||
@@ -588,7 +575,6 @@ def archive_extract_all(
|
||||
archive: Path to archive file or file-like object
|
||||
extract_dir: Directory to extract contents to
|
||||
progress_header: If set, show a progress bar with this header
|
||||
progress: fraction callback (0..1, ends at 1.0); overrides progress_header
|
||||
|
||||
Raises:
|
||||
TypeError: If archive is not a valid type
|
||||
@@ -619,9 +605,7 @@ def archive_extract_all(
|
||||
break
|
||||
if matched_fct is None:
|
||||
raise ValueError("Unsupported archive format")
|
||||
matched_fct(
|
||||
archive_ref, extract_dir, progress_header=progress_header, progress=progress
|
||||
)
|
||||
matched_fct(archive_ref, extract_dir, progress_header=progress_header)
|
||||
|
||||
|
||||
def _open_ranged(
|
||||
@@ -785,17 +769,13 @@ def _stream_response_to_file(
|
||||
# hammering the host or the mirrors.
|
||||
BATCH_DOWNLOAD_WORKERS = 4
|
||||
|
||||
# Concurrent archive extractions per batch; unpacking stops scaling well
|
||||
# before high core counts since the workers share one disk.
|
||||
BATCH_EXTRACT_WORKERS = 10
|
||||
|
||||
|
||||
def run_batch_downloads(
|
||||
header: str,
|
||||
jobs: list[tuple[str, int, Callable[[Callable[[int], None]], None]]],
|
||||
max_workers: int = BATCH_DOWNLOAD_WORKERS,
|
||||
) -> list[tuple[str, BaseException]]:
|
||||
"""Run ``(name, size, fetch)`` jobs concurrently under one bar.
|
||||
"""Run ``(name, size, fetch)`` download jobs concurrently under one bar.
|
||||
|
||||
Each ``fetch(tracker)`` reports absolute byte counts; the bar total is
|
||||
the sum of the sizes. Failures are returned after the bar is done so
|
||||
@@ -1025,26 +1005,15 @@ def resume_fetch_job(
|
||||
return fetch
|
||||
|
||||
|
||||
def is_expected_fetch_error(err: BaseException) -> bool:
|
||||
"""Download failures the callers degrade on, vs programming errors."""
|
||||
from esphome.core import EsphomeError # local import avoids circular dependency
|
||||
|
||||
return isinstance(err, (EsphomeError, OSError))
|
||||
|
||||
|
||||
def warn_batch_failures(
|
||||
def warn_prefetch_failures(
|
||||
failures: list[tuple[str, BaseException]],
|
||||
message: str = "Could not prefetch %s: %s",
|
||||
) -> None:
|
||||
"""Warn per failed batch job, keeping the traceback of unexpected errors."""
|
||||
"""Warn per failed batch-prefetch job; the caller's installer retries them."""
|
||||
for name, err in failures:
|
||||
# failure_reason: a message-less exception must not log blank
|
||||
if is_expected_fetch_error(err):
|
||||
_LOGGER.warning(message, name, failure_reason(err))
|
||||
_LOGGER.debug("Failure detail", exc_info=err)
|
||||
else:
|
||||
# A programming error must not be reduced to a bare message
|
||||
_LOGGER.warning(message, name, failure_reason(err), exc_info=err)
|
||||
_LOGGER.warning(message, name, failure_reason(err))
|
||||
_LOGGER.debug("Prefetch failure detail", exc_info=err)
|
||||
|
||||
|
||||
def download_with_resume(
|
||||
|
||||
@@ -69,7 +69,10 @@ def _make_create_connection() -> Callable[..., socket.socket]:
|
||||
|
||||
from aiohappyeyeballs import start_connection
|
||||
from urllib3.exceptions import LocationParseError
|
||||
from urllib3.util.connection import _set_socket_options, allowed_gai_family # noqa: PLC2701
|
||||
from urllib3.util.connection import ( # noqa: PLC2701
|
||||
_set_socket_options,
|
||||
allowed_gai_family,
|
||||
)
|
||||
from urllib3.util.timeout import _DEFAULT_TIMEOUT # noqa: PLC2701
|
||||
|
||||
from esphome import async_thread
|
||||
|
||||
@@ -35,7 +35,7 @@ from esphome.framework_helpers import (
|
||||
failure_reason,
|
||||
rmdir,
|
||||
run_batch_downloads,
|
||||
warn_batch_failures,
|
||||
warn_prefetch_failures,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -616,15 +616,11 @@ def _make_registry_client() -> Any:
|
||||
elsewhere, not by the PlatformIO registry.
|
||||
"""
|
||||
from platformio.package.manager._registry import PackageManagerRegistryMixin
|
||||
from platformio.registry.client import RegistryClient
|
||||
|
||||
class _Registry(PackageManagerRegistryMixin):
|
||||
def __init__(self) -> None:
|
||||
self._registry_client = None
|
||||
self.pkg_type = "library"
|
||||
self._registry_client = RegistryClient()
|
||||
# The probe sleeps ~500 ms per lookup (see runner.patch_registry_private_packages);
|
||||
# instance-level so the ESPHome process never patches PlatformIO's class
|
||||
self._registry_client.allowed_private_packages = lambda: False
|
||||
|
||||
@staticmethod
|
||||
def is_system_compatible(value: Any, custom_system: Any = None) -> bool:
|
||||
@@ -1095,7 +1091,7 @@ def _prefetch_wave(
|
||||
+ [(c.name, 0, partial(_clone_source, c, salt, namespace)) for c in clones],
|
||||
)
|
||||
# The sequential call below retries and raises the real error
|
||||
warn_batch_failures(
|
||||
warn_prefetch_failures(
|
||||
failures, "Prefetch of %s failed (retrying sequentially): %s"
|
||||
)
|
||||
except Exception as err: # noqa: BLE001 # pylint: disable=broad-exception-caught
|
||||
|
||||
@@ -1,416 +0,0 @@
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
import posixpath
|
||||
import re
|
||||
import shlex
|
||||
import stat
|
||||
import subprocess
|
||||
import traceback
|
||||
|
||||
# pylint: disable=E0602
|
||||
Import("env") # noqa: F821
|
||||
_projenv_error = None
|
||||
try:
|
||||
Import("projenv") # noqa: F821
|
||||
except Exception as err: # noqa: BLE001 -- not exported under -t nobuild
|
||||
projenv = None
|
||||
_projenv_error = err
|
||||
|
||||
# Precompile the src force-includes plus defines.h and force-include the
|
||||
# result into C++ src compiles only; their preprocessed output is unchanged.
|
||||
# Registration is gated host-side (pch_enabled()). Keep the closure, ccache
|
||||
# values, probe flow, stamp flow, and env-knob spellings in sync with
|
||||
# build_helpers/pch.py.
|
||||
|
||||
# Compiler failures that clear on their own must not latch the .failed marker
|
||||
_TRANSIENT_ERRORS = ("No space left", "Cannot allocate", "Resource temporarily")
|
||||
|
||||
# Keep in sync with helpers.TRUTHY_ENV_STRINGS / FALSY_ENV_STRINGS
|
||||
_TRUTHY = ("1", "true", "yes", "on", "enable")
|
||||
_FALSY = ("", "0", "false", "no", "off", "disable")
|
||||
_STRICT_RAW = os.environ.get("ESPHOME_PCH_STRICT")
|
||||
_STRICT_VALUE = (_STRICT_RAW or "").strip().lower()
|
||||
_STRICT = _STRICT_VALUE in _TRUTHY
|
||||
if _STRICT_RAW is not None and _STRICT_VALUE not in _TRUTHY + _FALSY:
|
||||
# A typo must not silently turn the gate into a no-op
|
||||
raise RuntimeError(f"Unrecognized ESPHOME_PCH_STRICT={_STRICT_RAW!r}; use 1 or 0")
|
||||
|
||||
_INCLUDE_RE = re.compile(rb'^\s*#\s*include\s+["<]([^">]+)[">]', re.MULTILINE)
|
||||
_CORE_HEADER = "esphome/core/defines.h"
|
||||
|
||||
|
||||
def _raise_walk_error(err: OSError) -> None:
|
||||
raise err
|
||||
|
||||
|
||||
def _resolves_dir(path: Path) -> bool:
|
||||
"""False when missing; other stat failures propagate."""
|
||||
try:
|
||||
return stat.S_ISDIR(path.stat().st_mode)
|
||||
except (FileNotFoundError, NotADirectoryError):
|
||||
return False
|
||||
|
||||
|
||||
def _resolves(path: Path) -> bool:
|
||||
"""False when missing; other stat failures propagate (identity unknown)."""
|
||||
try:
|
||||
return stat.S_ISREG(path.stat().st_mode)
|
||||
except (FileNotFoundError, NotADirectoryError):
|
||||
return False
|
||||
|
||||
|
||||
def _include_closure(src_dir: Path, roots: list) -> dict:
|
||||
"""Quoted-include closure: src-relative name -> contents (mirror of
|
||||
build_helpers/pch.py)."""
|
||||
seen = {}
|
||||
stack = [(name, "") for name in roots]
|
||||
while stack:
|
||||
name, from_dir = stack.pop()
|
||||
for candidate in (f"{from_dir}/{name}" if from_dir else name, name):
|
||||
rel = posixpath.normpath(candidate)
|
||||
if not rel.startswith("..") and _resolves(src_dir / rel):
|
||||
break
|
||||
else:
|
||||
continue
|
||||
if rel in seen:
|
||||
continue
|
||||
try:
|
||||
data = (src_dir / rel).read_bytes()
|
||||
except OSError as err:
|
||||
# A marker would truncate the transitive walk; fail closed
|
||||
print(f"ESPHome: could not read {rel} for the pch checksum: {err}")
|
||||
raise
|
||||
seen[rel] = data
|
||||
parent = posixpath.dirname(rel)
|
||||
stack.extend(
|
||||
(inc.decode(errors="surrogateescape"), parent)
|
||||
for inc in _INCLUDE_RE.findall(data)
|
||||
)
|
||||
return seen
|
||||
|
||||
|
||||
def _shell_arg(element) -> str | None:
|
||||
"""One compiler argv from one SCons element, matching the real spawn:
|
||||
spaced elements pass whole, the rest get one shell unquote (skipped on
|
||||
Windows, where shlex would eat path backslashes)."""
|
||||
arg = str(element)
|
||||
if " " in arg or os.name == "nt":
|
||||
return arg.replace('\\"', '"')
|
||||
if not arg.strip():
|
||||
return arg
|
||||
try:
|
||||
tokens = shlex.split(arg)
|
||||
except ValueError as err:
|
||||
print(f"ESPHome: could not lex flag {arg!r} for the pch: {err}")
|
||||
return None
|
||||
if len(tokens) != 1:
|
||||
# A flag the model cannot reproduce would diverge the .gch's flags
|
||||
print(f"ESPHome: cannot model flag {arg!r} for the pch")
|
||||
return None
|
||||
return tokens[0]
|
||||
|
||||
|
||||
def _compile_gch(cxx, flags, header: Path, gch: Path, proj_dir: Path):
|
||||
"""Compile the .gch, then probe that the toolchain can load it back
|
||||
(GCC 10 on macOS arm64 rejects its own per-process). Returns an error
|
||||
string or None; OSError propagates as transient."""
|
||||
result = subprocess.run( # noqa: PLW1510
|
||||
[cxx, "-x", "c++-header", *flags, "-c", str(header), "-o", str(gch)],
|
||||
cwd=proj_dir,
|
||||
# C locale keeps diagnostics matchable by _TRANSIENT_ERRORS
|
||||
env={**os.environ, "LC_ALL": "C"},
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode < 0:
|
||||
# Signal-killed (OOM, ^C): route to the transient no-marker path
|
||||
raise OSError(f"compiler killed by signal {-result.returncode}")
|
||||
if result.returncode != 0:
|
||||
return result.stderr
|
||||
return _probe_gch(cxx, flags, header, proj_dir)
|
||||
|
||||
|
||||
def _probe_run(cxx, flags, extra, proj_dir: Path):
|
||||
probe = subprocess.run( # noqa: PLW1510
|
||||
[cxx, *flags, *extra, "-fsyntax-only", "-x", "c++", "-"],
|
||||
cwd=proj_dir,
|
||||
env={**os.environ, "LC_ALL": "C"},
|
||||
input="",
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if probe.returncode < 0:
|
||||
raise OSError(f"probe killed by signal {-probe.returncode}")
|
||||
return probe
|
||||
|
||||
|
||||
def _probe_gch(cxx, flags, header: Path, proj_dir: Path):
|
||||
"""Load-check an existing .gch; error string or None. Rejection must
|
||||
be a nonzero exit (keep in sync with pch_probe_args); a baseline run
|
||||
without the pch keeps environmental failures from being blamed on it."""
|
||||
# -MF is only legal alongside a dependency flag; pass it solely to
|
||||
# redirect a depfile that -MD/-MMD in the flags would otherwise write
|
||||
dep_redirect = (
|
||||
["-MF", os.devnull]
|
||||
if any(f in ("-MD", "-MMD", "-M", "-MM") for f in flags)
|
||||
else []
|
||||
)
|
||||
probe = _probe_run(
|
||||
cxx,
|
||||
flags,
|
||||
[*dep_redirect, "-Winvalid-pch", "-Werror=invalid-pch", "-include", str(header)],
|
||||
proj_dir,
|
||||
)
|
||||
if probe.returncode == 0:
|
||||
return None
|
||||
baseline = _probe_run(cxx, flags, dep_redirect, proj_dir)
|
||||
if baseline.returncode != 0:
|
||||
# Deterministic and latchable; the transient filter at the caller
|
||||
# keeps resource exhaustion from latching
|
||||
return f"probe cannot run at all: {baseline.stderr.strip()[:200]}"
|
||||
return f"toolchain cannot load the pch: {probe.stderr.strip()}"
|
||||
|
||||
|
||||
def _read_stamp(path: Path) -> str:
|
||||
"""A corrupt sidecar must read as stale, not kill the pch forever."""
|
||||
try:
|
||||
return path.read_text(encoding="utf-8").strip()
|
||||
except (OSError, UnicodeDecodeError):
|
||||
return ""
|
||||
|
||||
|
||||
def _setup_pch() -> bool | None:
|
||||
if projenv is None:
|
||||
print(f"ESPHome: projenv unavailable ({_projenv_error}); skipping pch")
|
||||
try:
|
||||
from SCons.Script import COMMAND_LINE_TARGETS
|
||||
except ImportError:
|
||||
# No SCons is an anomaly under PlatformIO: the unknown state
|
||||
# must not read as success (strict decides fatality at the gate)
|
||||
return False
|
||||
# Expected under -t nobuild (nothing compiles); a missing
|
||||
# projenv on a real compile must not pass strict
|
||||
return "nobuild" in [str(t) for t in COMMAND_LINE_TARGETS]
|
||||
# Project root: SCons compiles run here, so the relative -include
|
||||
# resolves; an absolute path would break cross-device ccache sharing.
|
||||
proj_dir = Path(env.subst("$PROJECT_DIR")) # noqa: F821
|
||||
src_dir = Path(env.subst("$PROJECT_SRC_DIR")) # noqa: F821
|
||||
header = proj_dir / "esphome_pch.h"
|
||||
gch = Path(f"{header}.gch")
|
||||
sum_path = Path(f"{gch}.sum")
|
||||
|
||||
cxx = projenv.subst("$CXX") # noqa: F821
|
||||
# The header holds the -include entries itself, so the .gch compile must
|
||||
# not see them; consumers keep theirs, which the .gch then satisfies.
|
||||
flags = []
|
||||
include_headers = []
|
||||
raw_args = [
|
||||
_shell_arg(element)
|
||||
for element in projenv.subst_list("$CXXFLAGS $CCFLAGS $_CCCOMCOM")[0] # noqa: F821
|
||||
]
|
||||
if any(arg is None for arg in raw_args):
|
||||
print("ESPHome: skipping precompiled header: unmodelable flag")
|
||||
return
|
||||
flag_it = iter(raw_args)
|
||||
for tok in flag_it:
|
||||
if tok == "-include":
|
||||
include_headers.append(next(flag_it, ""))
|
||||
elif tok.startswith("-include") and not tok.startswith("-include-"):
|
||||
include_headers.append(tok[len("-include") :])
|
||||
else:
|
||||
flags.append(tok)
|
||||
if any(not name for name in include_headers):
|
||||
print("ESPHome: build_src_flags has a trailing -include; skipping pch")
|
||||
return
|
||||
# Fold only relative names resolving under src/: consumers keep their
|
||||
# own -include entries, so folding an unguarded user header would
|
||||
# include it twice; unfolded ones stay consumer-only.
|
||||
try:
|
||||
folded = [
|
||||
name
|
||||
for name in include_headers
|
||||
if not Path(name).is_absolute() and _resolves(src_dir / name)
|
||||
]
|
||||
except OSError as err:
|
||||
print(f"ESPHome: skipping precompiled header: {err}")
|
||||
return
|
||||
if unfolded := [n for n in include_headers if n not in folded]:
|
||||
print(f"ESPHome: not precompiling non-src force-includes: {unfolded}")
|
||||
content = "".join(f'#include "{name}"\n' for name in (*folded, _CORE_HEADER))
|
||||
|
||||
digest = hashlib.sha256()
|
||||
digest.update(content.encode(errors="surrogateescape"))
|
||||
digest.update(cxx.encode(errors="surrogateescape"))
|
||||
# Mirror CCACHE_BASEDIR: strip the per-device build path so identical
|
||||
# configs produce identical .sum files and share cache entries
|
||||
flags_id = " ".join(flags)
|
||||
if basedir := os.environ.get("CCACHE_BASEDIR"):
|
||||
flags_id = flags_id.replace(basedir, "")
|
||||
digest.update(flags_id.encode(errors="surrogateescape"))
|
||||
# GCC never validates a .gch against its source headers, and PlatformIO
|
||||
# package paths carry no version, so a package bump must invalidate here
|
||||
platform = env.PioPlatform() # noqa: F821
|
||||
for package in sorted(platform.packages):
|
||||
try:
|
||||
version = platform.get_package_version(package)
|
||||
except KeyError:
|
||||
# An unresolved manifest must not hash as a constant
|
||||
if platform.get_package(package) is not None:
|
||||
print(f"ESPHome: skipping precompiled header: no version for {package}")
|
||||
return
|
||||
version = None # absent optional package
|
||||
except Exception as err: # noqa: BLE001
|
||||
# No trustworthy package identity: a stale .gch could survive
|
||||
print(f"ESPHome: skipping precompiled header: {err}")
|
||||
return
|
||||
digest.update(f"{package}={version}".encode())
|
||||
digest.update(b"\0")
|
||||
try:
|
||||
closure = _include_closure(src_dir, [*folded, _CORE_HEADER])
|
||||
except OSError as err:
|
||||
print(f"ESPHome: skipping precompiled header: {err}")
|
||||
return
|
||||
for rel in sorted(closure):
|
||||
digest.update(rel.encode(errors="surrogateescape"))
|
||||
digest.update(closure[rel])
|
||||
digest.update(b"\0")
|
||||
# Project-local -I dirs (e.g. rp2's lwip_override) hold generated
|
||||
# headers the src closure cannot see; hash them too
|
||||
prev = ""
|
||||
try:
|
||||
for tok in flags:
|
||||
inc = tok[2:] if tok.startswith("-I") and len(tok) > 2 else ""
|
||||
if prev == "-I":
|
||||
inc = tok
|
||||
prev = tok
|
||||
if not inc:
|
||||
continue
|
||||
inc_dir = Path(inc)
|
||||
if not (
|
||||
_resolves_dir(inc_dir)
|
||||
and inc_dir.is_relative_to(proj_dir)
|
||||
and not inc_dir.is_relative_to(src_dir)
|
||||
# Library trees never enter the prefix closure; walking them
|
||||
# would read every library file each build
|
||||
and not inc_dir.is_relative_to(proj_dir / ".piolibdeps")
|
||||
and not inc_dir.is_relative_to(proj_dir / ".pioenvs")
|
||||
):
|
||||
continue
|
||||
local_headers = []
|
||||
# os.walk with onerror: rglob would swallow unlistable subtrees
|
||||
for root, _dirs, files in os.walk(inc_dir, onerror=_raise_walk_error):
|
||||
local_headers.extend(
|
||||
Path(root) / f
|
||||
for f in files
|
||||
if f.endswith((".h", ".hpp", ".hh", ".inc"))
|
||||
)
|
||||
for local in sorted(local_headers):
|
||||
digest.update(str(local.relative_to(proj_dir)).encode())
|
||||
digest.update(local.read_bytes())
|
||||
digest.update(b"\0")
|
||||
except OSError as err:
|
||||
print(f"ESPHome: skipping precompiled header: {err}")
|
||||
return
|
||||
checksum = digest.hexdigest()
|
||||
|
||||
# The ccache .sum sidecar doubles as the freshness stamp
|
||||
fresh = (
|
||||
header.is_file()
|
||||
and gch.is_file()
|
||||
and sum_path.is_file()
|
||||
and (_read_stamp(sum_path) == checksum)
|
||||
)
|
||||
if fresh and _STRICT:
|
||||
# Rejection is per-process: strict re-proves a cached .gch loads
|
||||
# (mirrors the pch_strict() re-probe in build_helpers/pch.py)
|
||||
error = _probe_gch(cxx, flags, header, proj_dir)
|
||||
if error is not None:
|
||||
print(f"ESPHome: {error}")
|
||||
gch.unlink(missing_ok=True)
|
||||
sum_path.unlink(missing_ok=True)
|
||||
return
|
||||
if not fresh:
|
||||
failed_marker = Path(f"{gch}.failed")
|
||||
if _read_stamp(failed_marker) == checksum:
|
||||
print(
|
||||
"ESPHome: skipping precompiled header (previous attempt "
|
||||
f"failed); delete {failed_marker.name} to retry"
|
||||
)
|
||||
return
|
||||
header.write_text(content, encoding="utf-8")
|
||||
try:
|
||||
error = _compile_gch(cxx, flags, header, gch, proj_dir)
|
||||
except OSError as err:
|
||||
# Transient spawn/IO failure: no marker, retry next build
|
||||
print(f"ESPHome: precompiled header compile did not run: {err}")
|
||||
gch.unlink(missing_ok=True)
|
||||
sum_path.unlink(missing_ok=True)
|
||||
return
|
||||
if error is not None:
|
||||
print("ESPHome: precompiled header failed; compiling without it")
|
||||
print(error)
|
||||
gch.unlink(missing_ok=True)
|
||||
sum_path.unlink(missing_ok=True)
|
||||
if any(m in error for m in _TRANSIENT_ERRORS):
|
||||
# Resource exhaustion clears on its own; retry next build
|
||||
return
|
||||
# Skip retries until a flag/header/platform change alters the checksum
|
||||
failed_marker.write_text(checksum + "\n", encoding="utf-8")
|
||||
return
|
||||
failed_marker.unlink(missing_ok=True)
|
||||
sum_path.write_text(checksum + "\n", encoding="utf-8")
|
||||
|
||||
# Computed first so the flags and env land together: a raise between
|
||||
# them would leave a pch-consuming build without its ccache settings.
|
||||
# projenv["ENV"] aliases os.environ, so these reach all TUs; only
|
||||
# time_macros affects non-pch TUs. User values win.
|
||||
ccache_updates = {
|
||||
key: value
|
||||
for key, value in (
|
||||
("CCACHE_SLOPPINESS", "pch_defines,time_macros"),
|
||||
("CCACHE_PCH_EXTSUM", "true"),
|
||||
)
|
||||
if key not in os.environ
|
||||
}
|
||||
sloppiness = os.environ.get("CCACHE_SLOPPINESS")
|
||||
if sloppiness is not None:
|
||||
tokens = {tok.strip() for tok in sloppiness.split(",")}
|
||||
missing = [t for t in ("pch_defines", "time_macros") if t not in tokens]
|
||||
if missing:
|
||||
# Without these ccache declines every pch-consuming compile
|
||||
ccache_updates["CCACHE_SLOPPINESS"] = ",".join((sloppiness, *missing))
|
||||
print(f"ESPHome: adding {','.join(missing)} to CCACHE_SLOPPINESS for the pch")
|
||||
extsum = os.environ.get("CCACHE_PCH_EXTSUM")
|
||||
if extsum is not None and extsum.strip().lower() not in ("1", "true", "yes", "on"):
|
||||
# ccache then hashes the non-reproducible .gch bytes: permanent misses
|
||||
print(f"ESPHome: CCACHE_PCH_EXTSUM={extsum} disables pch caching")
|
||||
# Prepended: GCC only uses a .gch while no other tokens precede it.
|
||||
# The relative name also reaches "pio run -t idedata" output.
|
||||
# -Wno-error: the per-process probe can pass while a later cc1plus
|
||||
# rejects the .gch; that must stay a warning under user -Werror.
|
||||
projenv.Prepend( # noqa: F821
|
||||
CXXFLAGS=[
|
||||
"-Winvalid-pch",
|
||||
# Strict inverts: a per-process consumer rejection reds the build
|
||||
"-Werror=invalid-pch" if _STRICT else "-Wno-error=invalid-pch",
|
||||
"-include",
|
||||
header.name,
|
||||
]
|
||||
)
|
||||
projenv["ENV"].update(ccache_updates) # noqa: F821
|
||||
print("ESPHome: Compiling with precompiled header")
|
||||
return True
|
||||
|
||||
|
||||
|
||||
try:
|
||||
_used = _setup_pch()
|
||||
except Exception: # noqa: BLE001 -- a speedup must never break the build
|
||||
if _STRICT:
|
||||
raise
|
||||
print("ESPHome: pch internal error; compiling without it")
|
||||
traceback.print_exc()
|
||||
else:
|
||||
if _STRICT and not _used:
|
||||
raise RuntimeError("ESPHOME_PCH_STRICT: precompiled header was not used")
|
||||
@@ -33,7 +33,6 @@ import time
|
||||
from typing import Any, NamedTuple
|
||||
|
||||
from esphome.framework_helpers import (
|
||||
BATCH_EXTRACT_WORKERS,
|
||||
DownloadLockUnavailable,
|
||||
content_length,
|
||||
discard_partial_download,
|
||||
@@ -42,7 +41,7 @@ from esphome.framework_helpers import (
|
||||
resume_fetch_job,
|
||||
run_batch_downloads,
|
||||
wait_for_download_lock,
|
||||
warn_batch_failures,
|
||||
warn_prefetch_failures,
|
||||
)
|
||||
from esphome.helpers import get_bool_env, get_usable_cpu_count, rmtree
|
||||
|
||||
@@ -703,7 +702,7 @@ def _preinstall(
|
||||
would hang, not fail). Waves skip dependencies; the installed
|
||||
manifests feed the next wave. Any failure falls back to pio run.
|
||||
"""
|
||||
workers = min(get_usable_cpu_count(), len(entries), BATCH_EXTRACT_WORKERS)
|
||||
workers = min(get_usable_cpu_count(), len(entries))
|
||||
# One manager per worker (_install mutates instance state); built
|
||||
# serially because construction rewires the shared manager logger
|
||||
managers: SimpleQueue = SimpleQueue()
|
||||
@@ -892,7 +891,7 @@ def _prefetch(build_dir: Path, env: str) -> None:
|
||||
)
|
||||
# PlatformIO retries failed packages itself, without resume
|
||||
failures = run_batch_downloads("Downloading PlatformIO packages", jobs)
|
||||
warn_batch_failures(failures)
|
||||
warn_prefetch_failures(failures)
|
||||
failed_names = {name for name, _ in failures}
|
||||
elif not groups and not unresolved:
|
||||
# Record the no-work run so the parent skips the next spawn.
|
||||
@@ -952,10 +951,8 @@ def main(argv: list[str]) -> int:
|
||||
"""Subprocess entry point: ``prefetch <build_dir> <env_name>``."""
|
||||
from esphome.core import CORE
|
||||
from esphome.log import setup_log
|
||||
from esphome.platformio.runner import patch_registry_private_packages
|
||||
|
||||
signal.signal(signal.SIGTERM, _sigterm)
|
||||
patch_registry_private_packages()
|
||||
raw_level = os.environ.get("ESPHOME_PREFETCH_LOG_LEVEL")
|
||||
try:
|
||||
level = int(raw_level) if raw_level is not None else logging.INFO
|
||||
|
||||
@@ -14,18 +14,14 @@ from typing import NamedTuple
|
||||
|
||||
from esphome.core import EsphomeError
|
||||
from esphome.framework_helpers import (
|
||||
BATCH_EXTRACT_WORKERS,
|
||||
archive_extract_all,
|
||||
download_from_mirrors,
|
||||
download_with_resume,
|
||||
downloaded_bytes,
|
||||
is_expected_fetch_error,
|
||||
rmdir,
|
||||
run_batch_downloads,
|
||||
wait_for_download_lock,
|
||||
warn_batch_failures,
|
||||
)
|
||||
from esphome.helpers import get_usable_cpu_count
|
||||
from esphome.net_retry import fetch_with_retry, http_request
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -166,10 +162,6 @@ def _check_layout(name: str, dest: Path, expect: Collection[str]) -> None:
|
||||
)
|
||||
|
||||
|
||||
# (name, version, dest, mirrors, expect) as accepted by install_packages
|
||||
PackageSpec = tuple[str, str, Path, list[str], Collection[str]]
|
||||
|
||||
|
||||
class _PendingArchive(NamedTuple):
|
||||
name: str
|
||||
version: str
|
||||
@@ -190,26 +182,6 @@ def _already_installed(dest: Path) -> bool:
|
||||
return (dest / ".esphome_extracted").is_file()
|
||||
|
||||
|
||||
def _batched_download_progress(
|
||||
name: str, version: str, size: int | None, extract_progress: Callable[[float], None]
|
||||
) -> Callable[[int], None]:
|
||||
"""Download tracker for a batched install: no private bar, no bytes (the
|
||||
shared bar must never run backwards); the zero tick keeps cancellation
|
||||
observable and a real refetch is announced once."""
|
||||
announced = False
|
||||
|
||||
def progress(done: int) -> None:
|
||||
nonlocal announced
|
||||
# size-less registry entries still announce: streaming starts at
|
||||
# done=0, while a verify no-op credits the full file in one tick
|
||||
if not announced and done < (size or 1):
|
||||
_LOGGER.info("Re-downloading %s %s ...", name, version)
|
||||
announced = True
|
||||
extract_progress(0.0)
|
||||
|
||||
return progress
|
||||
|
||||
|
||||
def prefetch_packages(
|
||||
packages: list[tuple[str, str, Path, list[str]]], downloads_dir: Path
|
||||
) -> None:
|
||||
@@ -293,7 +265,7 @@ def prefetch_packages(
|
||||
[(entry.name, entry.size, partial(_fetch, entry)) for entry in pending],
|
||||
)
|
||||
for name, err in failures:
|
||||
if is_expected_fetch_error(err):
|
||||
if isinstance(err, (EsphomeError, OSError)):
|
||||
# Expected download failures: install_package retries this one
|
||||
# itself, with a visible bar
|
||||
_LOGGER.debug("Prefetch of %s failed: %s", name, err)
|
||||
@@ -310,7 +282,6 @@ def install_package(
|
||||
mirrors: list[str],
|
||||
downloads_dir: Path,
|
||||
expect: Collection[str],
|
||||
extract_progress: Callable[[float], None] | None = None,
|
||||
) -> None:
|
||||
"""Download, verify, and extract one package if not already installed.
|
||||
|
||||
@@ -318,9 +289,6 @@ def install_package(
|
||||
publishes; a mirror override (URL templates with ``{VERSION}``/``{SYSTEM}``
|
||||
substitution) is trusted as configured. ``downloads_dir`` holds the
|
||||
archive between runs so an interrupted download resumes.
|
||||
|
||||
``extract_progress`` receives extraction fractions in [0, 1] instead of
|
||||
the private per-file bars (see ``install_packages``).
|
||||
"""
|
||||
if not expect:
|
||||
# Layout validation before marker.touch() is the only guard against
|
||||
@@ -344,14 +312,7 @@ def install_package(
|
||||
# Persistent location so an interrupted download resumes across runs.
|
||||
downloads_dir.mkdir(parents=True, exist_ok=True)
|
||||
archive = _archive_path(downloads_dir, name, version)
|
||||
# Batch header names each package; keep INFO when an archive
|
||||
# unexpectedly needs a real download (the shared bar won't move)
|
||||
log = (
|
||||
_LOGGER.debug
|
||||
if extract_progress is not None and archive.is_file()
|
||||
else _LOGGER.info
|
||||
)
|
||||
log("Downloading %s %s ...", name, version)
|
||||
_LOGGER.info("Downloading %s %s ...", name, version)
|
||||
if mirrors:
|
||||
_LOGGER.warning(
|
||||
"Downloading %s from a mirror override; checksum verification "
|
||||
@@ -363,85 +324,11 @@ def install_package(
|
||||
)
|
||||
else:
|
||||
url, sha256, size = registry_download(name, version)
|
||||
download_with_resume(
|
||||
url,
|
||||
archive,
|
||||
sha256=sha256,
|
||||
size=size,
|
||||
progress=None
|
||||
if extract_progress is None
|
||||
else _batched_download_progress(name, version, size, extract_progress),
|
||||
)
|
||||
log("Extracting %s ...", name)
|
||||
archive_extract_all(
|
||||
archive, dest, progress_header="Extracting", progress=extract_progress
|
||||
)
|
||||
download_with_resume(url, archive, sha256=sha256, size=size)
|
||||
_LOGGER.info("Extracting %s ...", name)
|
||||
archive_extract_all(archive, dest, progress_header="Extracting")
|
||||
# Validate the layout before recording success, so an unexpected
|
||||
# package is never cached as a working install.
|
||||
_check_layout(name, dest, expect)
|
||||
marker.touch()
|
||||
archive.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def install_packages(specs: Collection[PackageSpec], downloads_dir: Path) -> None:
|
||||
"""Install several packages; prefetched archives extract in parallel under
|
||||
one shared bar, the rest take the sequential ``install_package`` path.
|
||||
The first failure is re-raised."""
|
||||
pending: list[tuple[PackageSpec, int]] = []
|
||||
rest: list[PackageSpec] = []
|
||||
seen: set[str] = set()
|
||||
for spec in specs:
|
||||
name, version, dest, mirrors, _expect = spec
|
||||
archive = _archive_path(downloads_dir, name, version)
|
||||
# Duplicate entries share one archive and would race each other
|
||||
# between two workers; mirror prefetch_packages' dedupe
|
||||
if _already_installed(dest) or mirrors or archive.name in seen:
|
||||
rest.append(spec)
|
||||
continue
|
||||
try:
|
||||
# An archive at its final name already passed sha256/size
|
||||
# verification
|
||||
size = archive.stat().st_size
|
||||
except FileNotFoundError:
|
||||
rest.append(spec)
|
||||
continue
|
||||
seen.add(archive.name)
|
||||
pending.append((spec, size))
|
||||
if len(pending) < 2:
|
||||
for name, version, dest, mirrors, expect in specs:
|
||||
install_package(name, version, dest, mirrors, downloads_dir, expect=expect)
|
||||
return
|
||||
workers = min(get_usable_cpu_count(), len(pending), BATCH_EXTRACT_WORKERS)
|
||||
_LOGGER.info(
|
||||
"Extracting %d package archive(s) with %d worker(s): %s",
|
||||
len(pending),
|
||||
workers,
|
||||
", ".join(spec[0] for spec, _ in pending),
|
||||
)
|
||||
|
||||
def _install(spec: PackageSpec, size: int, tracker: Callable[[int], None]) -> None:
|
||||
name, version, dest, mirrors, expect = spec
|
||||
install_package(
|
||||
name,
|
||||
version,
|
||||
dest,
|
||||
mirrors,
|
||||
downloads_dir,
|
||||
expect=expect,
|
||||
extract_progress=lambda frac: tracker(int(frac * size)),
|
||||
)
|
||||
|
||||
failures = run_batch_downloads(
|
||||
"Extracting packages",
|
||||
[(spec[0], size, partial(_install, spec, size)) for spec, size in pending],
|
||||
max_workers=workers,
|
||||
)
|
||||
if failures:
|
||||
# Warn on the first failure too: the raised exception's message may
|
||||
# not name which package failed
|
||||
warn_batch_failures(failures, "Could not install %s: %s")
|
||||
raise failures[0][1]
|
||||
# Sequential remainder after the batch, so a duplicate spec cannot
|
||||
# unlink the archive its batched twin was sized from
|
||||
for name, version, dest, mirrors, expect in rest:
|
||||
install_package(name, version, dest, mirrors, downloads_dir, expect=expect)
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
Invoked via ``python -m esphome.platformio.runner`` instead of
|
||||
``python -m platformio`` so that the patches (incremental rebuild
|
||||
preservation, download retries, skipping the private-package probe) apply
|
||||
inside the subprocess. Running
|
||||
preservation, download retries) apply inside the subprocess. Running
|
||||
PlatformIO in a subprocess keeps its ``sys.path`` mutations and other
|
||||
global state from leaking into the ESPHome process.
|
||||
"""
|
||||
@@ -106,16 +105,6 @@ def patch_file_downloader() -> None:
|
||||
FileDownloader.__init__ = patched_init
|
||||
|
||||
|
||||
def patch_registry_private_packages() -> None:
|
||||
"""Skip PlatformIO's private-package probe; it sleeps ~500 ms per lookup.
|
||||
|
||||
ESPHome never uses private packages, so the answer is always False.
|
||||
"""
|
||||
from platformio.registry.client import RegistryClient
|
||||
|
||||
RegistryClient.allowed_private_packages = staticmethod(lambda: False) # type: ignore[method-assign]
|
||||
|
||||
|
||||
_IGNORE_LIB_WARNINGS = "(?:Hash|Update)"
|
||||
# Regex patterns matched against each line of PlatformIO output. Lines that
|
||||
# match are dropped by RedirectText before they reach the parent process.
|
||||
@@ -163,7 +152,6 @@ FILTER_PLATFORMIO_LINES = [
|
||||
def main() -> int:
|
||||
patch_structhash()
|
||||
patch_file_downloader()
|
||||
patch_registry_private_packages()
|
||||
|
||||
# Wrap stdout/stderr with RedirectText before PlatformIO runs:
|
||||
#
|
||||
|
||||
@@ -289,15 +289,6 @@ def copy_ccache_script() -> None:
|
||||
)
|
||||
|
||||
|
||||
def copy_pch_script() -> None:
|
||||
"""Copy the shared precompiled-header SCons post-script into the build
|
||||
dir; platform components pair it with ``post:pch.py`` in extra_scripts."""
|
||||
copy_file_if_changed(
|
||||
Path(__file__).parent / "pch.py.script",
|
||||
CORE.relative_build_path("pch.py"),
|
||||
)
|
||||
|
||||
|
||||
def default_libdeps_dir() -> str:
|
||||
"""The PLATFORMIO_LIBDEPS_DIR value a pio run defaults to; the package
|
||||
prefetch must resolve installed libraries against the same dir."""
|
||||
|
||||
+4
-15
@@ -7,7 +7,6 @@ import re
|
||||
import time
|
||||
|
||||
from esphome import loader
|
||||
from esphome.build_helpers.pch import PCH_ARTIFACT_NAMES, PCH_PREFIX_HEADER
|
||||
from esphome.compiled_config import save_compiled_config
|
||||
from esphome.config import iter_component_configs, iter_components
|
||||
from esphome.const import (
|
||||
@@ -247,7 +246,6 @@ def copy_src_tree():
|
||||
Path(
|
||||
"esphome/core/ring_buffer.h"
|
||||
), # moved to components/ring_buffer/, removed in 2026.11.0
|
||||
Path(PCH_PREFIX_HEADER), # build machinery, not user API
|
||||
}
|
||||
include_l = []
|
||||
for target, _ in source_files_l:
|
||||
@@ -611,20 +609,11 @@ def clean_build(clear_pio_cache: bool = True, *, full: bool = False):
|
||||
if idf_path.is_dir():
|
||||
_LOGGER.info("Deleting %s", idf_path)
|
||||
rmtree(idf_path)
|
||||
# The PlatformIO pch artifacts live at the project root so the
|
||||
# relative -include resolves; a partial clean must drop them too
|
||||
for name in PCH_ARTIFACT_NAMES:
|
||||
CORE.relative_build_path(name).unlink(missing_ok=True)
|
||||
|
||||
# The idedata caches are derived from the build but live under the data
|
||||
# dir, not the build path, so they must be removed separately in both
|
||||
# modes. Globbed (name.json plus name.<backend>.json) so a future
|
||||
# backend suffix cannot silently drift out of clean-all.
|
||||
idedata_dir = CORE.relative_internal_path("idedata")
|
||||
for idedata_cache in (
|
||||
*idedata_dir.glob(f"{CORE.name}.json"),
|
||||
*idedata_dir.glob(f"{CORE.name}.*.json"),
|
||||
):
|
||||
# The idedata cache is derived from the build but lives under the data dir,
|
||||
# not the build path, so it must be removed separately in both modes.
|
||||
idedata_cache = CORE.relative_internal_path("idedata", f"{CORE.name}.json")
|
||||
if idedata_cache.is_file():
|
||||
_LOGGER.info("Deleting %s", idedata_cache)
|
||||
idedata_cache.unlink()
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Useful stuff when working in a development environment
|
||||
clang-format==13.0.1 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py
|
||||
clang-format==13.0.1 # also change in .pre-commit-config.yaml and Dockerfile when updating
|
||||
clang-tidy==22.1.8
|
||||
yamllint==1.38.0 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py
|
||||
yamllint==1.38.0 # also change in .pre-commit-config.yaml when updating
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
pylint==4.0.8
|
||||
flake8==7.3.0 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py
|
||||
ruff==0.16.6 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py
|
||||
pyupgrade==3.21.2 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py
|
||||
prek==0.5.2 # .github/workflows/ci.yml reads this pin
|
||||
yamlrocks==0.6.1 # used by script/sync_dependency_versions.py
|
||||
flake8==7.3.0 # also change in .pre-commit-config.yaml when updating
|
||||
ruff==0.16.5 # also change in .pre-commit-config.yaml when updating
|
||||
pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating
|
||||
prek==0.5.1 # also change in .github/workflows/ci.yml when updating
|
||||
|
||||
# Unit tests
|
||||
pytest==9.1.1
|
||||
|
||||
+213
-292
@@ -28,6 +28,11 @@ class WireType(IntEnum):
|
||||
END_GROUP = 4 # groups (deprecated)
|
||||
FIXED32 = 5 # fixed32, sfixed32, float
|
||||
|
||||
@property
|
||||
def cpp_name(self) -> str:
|
||||
"""The matching constant in proto.h."""
|
||||
return f"WIRE_TYPE_{self.name}"
|
||||
|
||||
|
||||
# Generate with
|
||||
# protoc --python_out=script/api_protobuf -I esphome/components/api/ api_options.proto
|
||||
@@ -126,9 +131,10 @@ def camel_to_snake(name: str) -> str:
|
||||
return re.sub("([a-z0-9])([A-Z])", r"\1_\2", s1).lower()
|
||||
|
||||
|
||||
def force_str(force: bool) -> str:
|
||||
"""Convert a boolean force value to string format for C++ code."""
|
||||
return str(force).lower()
|
||||
def _encode_call(func: str, *args: str, force: bool = False) -> str:
|
||||
"""Emit one ProtoEncode call; every helper takes the cursor and returns it advanced."""
|
||||
suffix = "_force" if force else ""
|
||||
return f"pos = ProtoEncode::{func}{suffix}({', '.join(('pos', *args))});"
|
||||
|
||||
|
||||
class TypeInfo(ABC):
|
||||
@@ -223,55 +229,39 @@ class TypeInfo(ABC):
|
||||
def class_member(self) -> str:
|
||||
return f"{self.cpp_type} {self.field_name}{{{self.default_value}}};"
|
||||
|
||||
@property
|
||||
def decode_varint_content(self) -> str:
|
||||
content = self.decode_varint
|
||||
if content is None:
|
||||
return None
|
||||
return f"case {self.number}: this->{self.field_name} = {content}; break;"
|
||||
def decode_case(self, body: str) -> str:
|
||||
"""Emit one decode_field() case, keyed on the field's wire tag."""
|
||||
return f"case proto_tag({self.number}, {self.wire_type.cpp_name}):\n" + indent(
|
||||
f"{body}\nbreak;"
|
||||
)
|
||||
|
||||
decode_varint = None
|
||||
# Expression that reads this field from `value`; None when the type is never decoded.
|
||||
decode_expr: str | None = None
|
||||
|
||||
def _decode_store(self, expr: str) -> str:
|
||||
return f"this->{self.field_name} = {expr};"
|
||||
|
||||
@property
|
||||
def decode_length_content(self) -> str:
|
||||
content = self.decode_length
|
||||
if content is None:
|
||||
return None
|
||||
return f"case {self.number}: this->{self.field_name} = {content}; break;"
|
||||
|
||||
decode_length = None
|
||||
|
||||
@property
|
||||
def decode_32bit_content(self) -> str:
|
||||
content = self.decode_32bit
|
||||
if content is None:
|
||||
return None
|
||||
return f"case {self.number}: this->{self.field_name} = {content}; break;"
|
||||
|
||||
decode_32bit = None
|
||||
|
||||
@property
|
||||
def decode_64bit_content(self) -> str:
|
||||
content = self.decode_64bit
|
||||
if content is None:
|
||||
return None
|
||||
return f"case {self.number}: this->{self.field_name} = {content}; break;"
|
||||
|
||||
decode_64bit = None
|
||||
def decode_content(self) -> str | None:
|
||||
"""The decode_field() case for this field, or None when it is never decoded."""
|
||||
expr = self.decode_expr
|
||||
return None if expr is None else self.decode_case(self._decode_store(expr))
|
||||
|
||||
# Mapping from encode_func to raw encode expression template.
|
||||
# When a forced field has a single-byte tag, the code generator emits
|
||||
# write_raw_byte(tag) + raw encode instead of the full encode_* method,
|
||||
# eliminating the zero-check branch and encode_field_raw indirection.
|
||||
# {value} is replaced with the actual field expression.
|
||||
RAW_ENCODE_MAP: dict[str, str] = {
|
||||
"encode_uint32": "ProtoEncode::encode_varint_raw(pos, {value});",
|
||||
"encode_uint64": "ProtoEncode::encode_varint_raw_64(pos, {value});",
|
||||
"encode_sint32": "ProtoEncode::encode_varint_raw_short(pos, encode_zigzag32({value}));",
|
||||
"encode_sint64": "ProtoEncode::encode_varint_raw_64(pos, encode_zigzag64({value}));",
|
||||
"encode_int64": "ProtoEncode::encode_varint_raw_64(pos, static_cast<uint64_t>({value}));",
|
||||
"encode_bool": "ProtoEncode::write_raw_byte(pos, {value} ? 0x01 : 0x00);",
|
||||
RAW_ENCODE_MAP: dict[str, tuple[str, str]] = {
|
||||
"encode_uint32": ("encode_varint_raw", "{value}"),
|
||||
"encode_uint64": ("encode_varint_raw_64", "{value}"),
|
||||
"encode_sint32": ("encode_varint_raw_short", "encode_zigzag32({value})"),
|
||||
"encode_sint64": ("encode_varint_raw_64", "encode_zigzag64({value})"),
|
||||
"encode_int64": ("encode_varint_raw_64", "static_cast<uint64_t>({value})"),
|
||||
"encode_bool": ("write_raw_byte", "{value} ? 0x01 : 0x00"),
|
||||
}
|
||||
# Fixed32 value expression for the shared tag+fixed32 writer; None for other wire types
|
||||
fixed32_value_template: str | None = None
|
||||
|
||||
def _encode_with_precomputed_tag(self, value_expr: str) -> str | None:
|
||||
"""Try to emit a precomputed-tag encode for a field.
|
||||
@@ -288,12 +278,17 @@ class TypeInfo(ABC):
|
||||
return None
|
||||
max_val = self.max_value
|
||||
# Only use RAW_ENCODE_MAP for forced fields or fields with max_value
|
||||
raw_expr = None
|
||||
raw = None
|
||||
if self.force or max_val is not None:
|
||||
raw_expr = self.RAW_ENCODE_MAP.get(self.encode_func)
|
||||
if raw_expr is None:
|
||||
raw = self.RAW_ENCODE_MAP.get(self.encode_func)
|
||||
if raw is None:
|
||||
return None
|
||||
body = f"ProtoEncode::write_raw_byte(pos, {tag});\n{raw_expr.format(value=value_expr)}"
|
||||
func, arg = raw
|
||||
body = (
|
||||
_encode_call("write_raw_byte", str(tag))
|
||||
+ "\n"
|
||||
+ _encode_call(func, arg.format(value=value_expr))
|
||||
)
|
||||
if self.force:
|
||||
return body
|
||||
# Non-forced with max_value: inline zero-check + raw encode
|
||||
@@ -314,23 +309,44 @@ class TypeInfo(ABC):
|
||||
return None
|
||||
# When max_len < 128, length varint is always 1 byte
|
||||
len_encode = (
|
||||
f"ProtoEncode::write_raw_byte(pos, static_cast<uint8_t>({len_expr}));"
|
||||
_encode_call("write_raw_byte", f"static_cast<uint8_t>({len_expr})")
|
||||
if max_len is not None and max_len < 128
|
||||
else f"ProtoEncode::encode_varint_raw(pos, {len_expr});"
|
||||
else _encode_call("encode_varint_raw", len_expr)
|
||||
)
|
||||
return "\n".join(
|
||||
(
|
||||
_encode_call("write_raw_byte", str(tag)),
|
||||
len_encode,
|
||||
_encode_call("encode_raw", data_expr, len_expr),
|
||||
)
|
||||
)
|
||||
|
||||
def _encode_fixed32_with_precomputed_tag(self, value: str) -> str | None:
|
||||
"""Single-byte tag fixed32 write, or None for other types and multi-byte tags."""
|
||||
tag = self.calculate_tag()
|
||||
if self.fixed32_value_template is None or tag >= 128:
|
||||
return None
|
||||
value_expr = self.fixed32_value_template.format(value=value)
|
||||
if self.force:
|
||||
return _encode_call("write_tag_and_fixed32", str(tag), value_expr)
|
||||
return (
|
||||
f"ProtoEncode::write_raw_byte(pos, {tag});\n"
|
||||
f"{len_encode}\n"
|
||||
f"ProtoEncode::encode_raw(pos, {data_expr}, {len_expr});"
|
||||
f"if (uint32_t raw = {value_expr}; raw != 0) [[likely]] {{\n"
|
||||
f" {_encode_call('write_tag_and_fixed32', str(tag), 'raw')}\n"
|
||||
"}"
|
||||
)
|
||||
|
||||
@property
|
||||
def encode_content(self) -> str:
|
||||
if result := self._encode_with_precomputed_tag(f"this->{self.field_name}"):
|
||||
value = f"this->{self.field_name}"
|
||||
if result := self._encode_with_precomputed_tag(value):
|
||||
return result
|
||||
if self.force:
|
||||
return f"ProtoEncode::{self.encode_func}(pos, {self.number}, this->{self.field_name}, true);"
|
||||
return f"ProtoEncode::{self.encode_func}(pos, {self.number}, this->{self.field_name});"
|
||||
if result := self._encode_fixed32_with_precomputed_tag(value):
|
||||
return result
|
||||
return _encode_call(self.encode_func, str(self.number), value, force=self.force)
|
||||
|
||||
def encode_element(self, number: int, element: str) -> str:
|
||||
"""Encode one element of a repeated field; elements are always written."""
|
||||
return _encode_call(self.encode_func, str(number), element, force=True)
|
||||
|
||||
encode_func = None
|
||||
|
||||
@@ -605,7 +621,6 @@ class DoubleType(FixedSizeTypeMixin, TypeInfo):
|
||||
# Unsupported but defined for completeness
|
||||
cpp_type = "double"
|
||||
default_value = "0.0"
|
||||
decode_64bit = "value.as_double()"
|
||||
encode_func = "encode_double"
|
||||
wire_type = WireType.FIXED64 # Uses wire type 1 according to protobuf spec
|
||||
|
||||
@@ -631,10 +646,12 @@ class DoubleType(FixedSizeTypeMixin, TypeInfo):
|
||||
class FloatType(FixedSizeTypeMixin, TypeInfo):
|
||||
cpp_type = "float"
|
||||
default_value = "0.0f"
|
||||
decode_32bit = "value.as_float()"
|
||||
decode_expr = "value.as_float()"
|
||||
encode_func = "encode_float"
|
||||
wire_type = WireType.FIXED32 # Uses wire type 5
|
||||
|
||||
fixed32_value_template = "float_to_raw({value})"
|
||||
|
||||
def dump(self, name: str) -> str:
|
||||
o = f'snprintf(buffer, sizeof(buffer), "%g", {name});\n'
|
||||
o += "out.append(buffer);"
|
||||
@@ -658,7 +675,7 @@ class Int64Type(VarintTypeMixin, TypeInfo):
|
||||
cpp_type = "int64_t"
|
||||
_varint_max_bits = 64
|
||||
default_value = "0"
|
||||
decode_varint = "static_cast<int64_t>(value)"
|
||||
decode_expr = "static_cast<int64_t>(value.as_varint())"
|
||||
encode_func = "encode_int64"
|
||||
wire_type = WireType.VARINT # Uses wire type 0
|
||||
|
||||
@@ -679,7 +696,7 @@ class UInt64Type(VarintTypeMixin, TypeInfo):
|
||||
cpp_type = "uint64_t"
|
||||
_varint_max_bits = 64
|
||||
default_value = "0"
|
||||
decode_varint = "value"
|
||||
decode_expr = "value.as_varint()"
|
||||
encode_func = "encode_uint64"
|
||||
wire_type = WireType.VARINT # Uses wire type 0
|
||||
|
||||
@@ -697,11 +714,11 @@ class UInt64Type(VarintTypeMixin, TypeInfo):
|
||||
return self._get_simple_size_calculation(name, force, "uint64")
|
||||
|
||||
@property
|
||||
def RAW_ENCODE_MAP(self) -> dict[str, str]: # noqa: N802
|
||||
def RAW_ENCODE_MAP(self) -> dict[str, tuple[str, str]]: # noqa: N802
|
||||
if self.mac_address:
|
||||
return {
|
||||
**TypeInfo.RAW_ENCODE_MAP,
|
||||
"encode_uint64": "ProtoEncode::encode_varint_raw_48bit(pos, {value});",
|
||||
"encode_uint64": ("encode_varint_raw_48bit", "{value}"),
|
||||
}
|
||||
return TypeInfo.RAW_ENCODE_MAP
|
||||
|
||||
@@ -714,7 +731,7 @@ class Int32Type(VarintTypeMixin, TypeInfo):
|
||||
cpp_type = "int32_t"
|
||||
_varint_max_bits = 64 # int32 is sign-extended to 64 bits in protobuf
|
||||
default_value = "0"
|
||||
decode_varint = "static_cast<int32_t>(value)"
|
||||
decode_expr = "static_cast<int32_t>(value.as_varint())"
|
||||
encode_func = "encode_int32"
|
||||
wire_type = WireType.VARINT # Uses wire type 0
|
||||
|
||||
@@ -734,7 +751,6 @@ class Int32Type(VarintTypeMixin, TypeInfo):
|
||||
class Fixed64Type(FixedSizeTypeMixin, TypeInfo):
|
||||
cpp_type = "uint64_t"
|
||||
default_value = "0"
|
||||
decode_64bit = "value.as_fixed64()"
|
||||
encode_func = "encode_fixed64"
|
||||
wire_type = WireType.FIXED64 # Uses wire type 1
|
||||
|
||||
@@ -760,7 +776,7 @@ class Fixed64Type(FixedSizeTypeMixin, TypeInfo):
|
||||
class Fixed32Type(FixedSizeTypeMixin, TypeInfo):
|
||||
cpp_type = "uint32_t"
|
||||
default_value = "0"
|
||||
decode_32bit = "value.as_fixed32()"
|
||||
decode_expr = "value.as_fixed32()"
|
||||
encode_func = "encode_fixed32"
|
||||
wire_type = WireType.FIXED32 # Uses wire type 5
|
||||
|
||||
@@ -769,15 +785,7 @@ class Fixed32Type(FixedSizeTypeMixin, TypeInfo):
|
||||
o += "out.append(buffer);"
|
||||
return o
|
||||
|
||||
@property
|
||||
def encode_content(self) -> str:
|
||||
tag = self.calculate_tag()
|
||||
if self.force and tag < 128:
|
||||
# Emit combined tag+value write: precomputed tag + direct memcpy
|
||||
return f"ProtoEncode::write_tag_and_fixed32(pos, {tag}, this->{self.field_name});"
|
||||
if self.force:
|
||||
return f"ProtoEncode::{self.encode_func}(pos, {self.number}, this->{self.field_name}, true);"
|
||||
return f"ProtoEncode::{self.encode_func}(pos, {self.number}, this->{self.field_name});"
|
||||
fixed32_value_template = "{value}"
|
||||
|
||||
def get_size_calculation(self, name: str, force: bool = False) -> str:
|
||||
field_id_size = self.calculate_field_id_size()
|
||||
@@ -797,7 +805,7 @@ class BoolType(VarintTypeMixin, TypeInfo):
|
||||
_varint_max_bits = 1
|
||||
cpp_type = "bool"
|
||||
default_value = "false"
|
||||
decode_varint = "value != 0"
|
||||
decode_expr = "value.as_bool()"
|
||||
encode_func = "encode_bool"
|
||||
wire_type = WireType.VARINT # Uses wire type 0
|
||||
|
||||
@@ -817,7 +825,7 @@ class StringType(TypeInfo):
|
||||
default_value = ""
|
||||
reference_type = "std::string &"
|
||||
const_reference_type = "const std::string &"
|
||||
decode_length = "value.as_string()"
|
||||
decode_expr = "value.as_string()"
|
||||
encode_func = "encode_string"
|
||||
wire_type = WireType.LENGTH_DELIMITED # Uses wire type 2
|
||||
|
||||
@@ -851,9 +859,12 @@ class StringType(TypeInfo):
|
||||
f"this->{self.field_name}_ref_.size()",
|
||||
):
|
||||
return result
|
||||
if self.force:
|
||||
return f"ProtoEncode::encode_string(pos, {self.number}, this->{self.field_name}_ref_, true);"
|
||||
return f"ProtoEncode::encode_string(pos, {self.number}, this->{self.field_name}_ref_);"
|
||||
return _encode_call(
|
||||
"encode_string",
|
||||
str(self.number),
|
||||
f"this->{self.field_name}_ref_",
|
||||
force=self.force,
|
||||
)
|
||||
|
||||
def dump(self, name):
|
||||
# If name is 'it', this is a repeated field element - always use string
|
||||
@@ -929,6 +940,9 @@ class MessageType(TypeInfo):
|
||||
def can_use_dump_field(cls) -> bool:
|
||||
return False
|
||||
|
||||
def encode_element(self, number: int, element: str) -> str:
|
||||
return _encode_call("encode_sub_message", "buffer", str(number), element)
|
||||
|
||||
@property
|
||||
def cpp_type(self) -> str:
|
||||
return self._field.type_name[1:]
|
||||
@@ -951,15 +965,9 @@ class MessageType(TypeInfo):
|
||||
@property
|
||||
def encode_content(self) -> str:
|
||||
# Sub-message encoding needs buffer for backpatch/sync
|
||||
return f"ProtoEncode::{self.encode_func}(pos, buffer, {self.number}, this->{self.field_name});"
|
||||
|
||||
@property
|
||||
def decode_length(self) -> str:
|
||||
# Override to return None for message types because we can't use template-based
|
||||
# decoding when the specific message type isn't known at compile time.
|
||||
# Instead, we use the non-template decode_to_message() method which allows
|
||||
# runtime polymorphism through virtual function calls.
|
||||
return None
|
||||
return _encode_call(
|
||||
self.encode_func, "buffer", str(self.number), f"this->{self.field_name}"
|
||||
)
|
||||
|
||||
@property
|
||||
def public_content(self) -> list[str]:
|
||||
@@ -976,19 +984,14 @@ class MessageType(TypeInfo):
|
||||
)
|
||||
|
||||
@property
|
||||
def decode_length_content(self) -> str:
|
||||
# Custom decode that doesn't use templates
|
||||
def decode_content(self) -> str:
|
||||
body = f"value.decode_to_message(this->{self.field_name});"
|
||||
if self._track_presence:
|
||||
# decode_to_message() cannot report failure, so setting the flag
|
||||
# afterwards only documents intent; a status-returning decode could
|
||||
# gate it for real without touching callers.
|
||||
return (
|
||||
f"case {self.number}:\n"
|
||||
f" value.decode_to_message(this->{self.field_name});\n"
|
||||
f" this->has_{self.name} = true;\n"
|
||||
f" break;"
|
||||
)
|
||||
return f"case {self.number}: value.decode_to_message(this->{self.field_name}); break;"
|
||||
body += f"\nthis->has_{self.name} = true;"
|
||||
return self.decode_case(body)
|
||||
|
||||
def dump(self, name: str) -> str:
|
||||
return f"{name}.dump_to(out);"
|
||||
@@ -1027,7 +1030,7 @@ class BytesType(TypeInfo):
|
||||
reference_type = "std::string &"
|
||||
const_reference_type = "const std::string &"
|
||||
encode_func = "encode_bytes"
|
||||
decode_length = "value.as_string()"
|
||||
decode_expr = "value.as_string()"
|
||||
wire_type = WireType.LENGTH_DELIMITED # Uses wire type 2
|
||||
|
||||
@property
|
||||
@@ -1058,9 +1061,13 @@ class BytesType(TypeInfo):
|
||||
f"this->{self.field_name}_ptr_", f"this->{self.field_name}_len_"
|
||||
):
|
||||
return result
|
||||
if self.force:
|
||||
return f"ProtoEncode::encode_bytes(pos, {self.number}, this->{self.field_name}_ptr_, this->{self.field_name}_len_, true);"
|
||||
return f"ProtoEncode::encode_bytes(pos, {self.number}, this->{self.field_name}_ptr_, this->{self.field_name}_len_);"
|
||||
return _encode_call(
|
||||
"encode_bytes",
|
||||
str(self.number),
|
||||
f"this->{self.field_name}_ptr_",
|
||||
f"this->{self.field_name}_len_",
|
||||
force=self.force,
|
||||
)
|
||||
|
||||
def dump(self, name: str) -> str:
|
||||
ptr_dump = f"format_hex_pretty(this->{self.field_name}_ptr_, this->{self.field_name}_len_)"
|
||||
@@ -1133,11 +1140,6 @@ class PointerToBufferTypeBase(TypeInfo):
|
||||
super().__init__(field)
|
||||
self.array_size = 0
|
||||
|
||||
@property
|
||||
def decode_length(self) -> str | None:
|
||||
# This is handled in decode_length_content
|
||||
return None
|
||||
|
||||
@property
|
||||
def wire_type(self) -> WireType:
|
||||
"""Get the wire type for this field."""
|
||||
@@ -1170,17 +1172,20 @@ class PointerToBytesBufferType(PointerToBufferTypeBase):
|
||||
f"this->{self.field_name}", f"this->{self.field_name}_len"
|
||||
):
|
||||
return result
|
||||
if self.force:
|
||||
return f"ProtoEncode::encode_bytes(pos, {self.number}, this->{self.field_name}, this->{self.field_name}_len, true);"
|
||||
return f"ProtoEncode::encode_bytes(pos, {self.number}, this->{self.field_name}, this->{self.field_name}_len);"
|
||||
return _encode_call(
|
||||
"encode_bytes",
|
||||
str(self.number),
|
||||
f"this->{self.field_name}",
|
||||
f"this->{self.field_name}_len",
|
||||
force=self.force,
|
||||
)
|
||||
|
||||
@property
|
||||
def decode_length_content(self) -> str | None:
|
||||
return f"""case {self.number}: {{
|
||||
this->{self.field_name} = value.data();
|
||||
this->{self.field_name}_len = value.size();
|
||||
break;
|
||||
}}"""
|
||||
def decode_content(self) -> str:
|
||||
return self.decode_case(
|
||||
f"this->{self.field_name} = value.data();\n"
|
||||
f"this->{self.field_name}_len = value.size();",
|
||||
)
|
||||
|
||||
def dump(self, name: str) -> str:
|
||||
return (
|
||||
@@ -1224,24 +1229,26 @@ class PointerToStringBufferType(PointerToBufferTypeBase):
|
||||
if max_len is not None and max_len < 128 and self.force:
|
||||
tag = self.calculate_tag()
|
||||
if tag < 128:
|
||||
return f"ProtoEncode::encode_short_string_force(pos, {tag}, this->{self.field_name});"
|
||||
return _encode_call(
|
||||
"encode_short_string_force", str(tag), f"this->{self.field_name}"
|
||||
)
|
||||
if result := self._encode_bytes_with_precomputed_tag(
|
||||
f"this->{self.field_name}.c_str()",
|
||||
f"this->{self.field_name}.size()",
|
||||
):
|
||||
return result
|
||||
if self.force:
|
||||
return f"ProtoEncode::encode_string(pos, {self.number}, this->{self.field_name}, true);"
|
||||
return (
|
||||
f"ProtoEncode::encode_string(pos, {self.number}, this->{self.field_name});"
|
||||
return _encode_call(
|
||||
"encode_string",
|
||||
str(self.number),
|
||||
f"this->{self.field_name}",
|
||||
force=self.force,
|
||||
)
|
||||
|
||||
@property
|
||||
def decode_length_content(self) -> str | None:
|
||||
return f"""case {self.number}: {{
|
||||
this->{self.field_name} = StringRef(reinterpret_cast<const char *>(value.data()), value.size());
|
||||
break;
|
||||
}}"""
|
||||
def decode_content(self) -> str:
|
||||
return self.decode_case(
|
||||
f"this->{self.field_name} = StringRef(value.data(), value.size());",
|
||||
)
|
||||
|
||||
def dump(self, name: str) -> str:
|
||||
# Not used since we use dump_field, but required by abstract base class
|
||||
@@ -1310,14 +1317,13 @@ class PackedBufferTypeInfo(TypeInfo):
|
||||
]
|
||||
|
||||
@property
|
||||
def decode_length_content(self) -> str:
|
||||
def decode_content(self) -> str:
|
||||
"""Store pointer to buffer and calculate count of packed varints."""
|
||||
return f"""case {self.number}: {{
|
||||
this->{self.field_name}_data_ = value.data();
|
||||
this->{self.field_name}_length_ = value.size();
|
||||
this->{self.field_name}_count_ = count_packed_varints(value.data(), value.size());
|
||||
break;
|
||||
}}"""
|
||||
return self.decode_case(
|
||||
f"this->{self.field_name}_data_ = value.data();\n"
|
||||
f"this->{self.field_name}_length_ = value.size();\n"
|
||||
f"this->{self.field_name}_count_ = count_packed_varints(value.data(), value.size());",
|
||||
)
|
||||
|
||||
@property
|
||||
def encode_content(self) -> str:
|
||||
@@ -1402,17 +1408,11 @@ class FixedArrayBytesType(TypeInfo):
|
||||
]
|
||||
|
||||
@property
|
||||
def decode_length_content(self) -> str:
|
||||
o = f"case {self.number}: {{\n"
|
||||
o += " const std::string &data_str = value.as_string();\n"
|
||||
o += f" this->{self.field_name}_len = data_str.size();\n"
|
||||
o += f" if (this->{self.field_name}_len > {self.array_size}) {{\n"
|
||||
o += f" this->{self.field_name}_len = {self.array_size};\n"
|
||||
o += " }\n"
|
||||
o += f" memcpy(this->{self.field_name}, data_str.data(), this->{self.field_name}_len);\n"
|
||||
o += " break;\n"
|
||||
o += "}"
|
||||
return o
|
||||
def decode_content(self) -> str:
|
||||
return self.decode_case(
|
||||
f"this->{self.field_name}_len = std::min<size_t>(value.size(), {self.array_size});\n"
|
||||
f"memcpy(this->{self.field_name}, value.data(), this->{self.field_name}_len);",
|
||||
)
|
||||
|
||||
@property
|
||||
def encode_content(self) -> str:
|
||||
@@ -1421,9 +1421,13 @@ class FixedArrayBytesType(TypeInfo):
|
||||
f"this->{self.field_name}", f"this->{self.field_name}_len", max_len=max_len
|
||||
):
|
||||
return result
|
||||
if self.force:
|
||||
return f"ProtoEncode::encode_bytes(pos, {self.number}, this->{self.field_name}, this->{self.field_name}_len, true);"
|
||||
return f"ProtoEncode::encode_bytes(pos, {self.number}, this->{self.field_name}, this->{self.field_name}_len);"
|
||||
return _encode_call(
|
||||
"encode_bytes",
|
||||
str(self.number),
|
||||
f"this->{self.field_name}",
|
||||
f"this->{self.field_name}_len",
|
||||
force=self.force,
|
||||
)
|
||||
|
||||
def dump(self, name: str) -> str:
|
||||
return f"out.append(format_hex_pretty({name}, {name}_len));"
|
||||
@@ -1471,7 +1475,7 @@ class UInt32Type(VarintTypeMixin, TypeInfo):
|
||||
cpp_type = "uint32_t"
|
||||
_varint_max_bits = 32
|
||||
default_value = "0"
|
||||
decode_varint = "value"
|
||||
decode_expr = "value.as_varint()"
|
||||
encode_func = "encode_uint32"
|
||||
wire_type = WireType.VARINT # Uses wire type 0
|
||||
|
||||
@@ -1494,13 +1498,21 @@ class UInt32Type(VarintTypeMixin, TypeInfo):
|
||||
class EnumType(VarintTypeMixin, TypeInfo):
|
||||
_varint_max_bits = 32
|
||||
|
||||
def encode_element(self, number: int, element: str) -> str:
|
||||
return _encode_call(
|
||||
self.encode_func,
|
||||
str(number),
|
||||
f"static_cast<uint32_t>({element})",
|
||||
force=True,
|
||||
)
|
||||
|
||||
@property
|
||||
def cpp_type(self) -> str:
|
||||
return f"enums::{self._field.type_name[1:]}"
|
||||
|
||||
@property
|
||||
def decode_varint(self) -> str:
|
||||
return f"static_cast<{self.cpp_type}>(value)"
|
||||
def decode_expr(self) -> str:
|
||||
return f"static_cast<{self.cpp_type}>(value.as_varint())"
|
||||
|
||||
default_value = ""
|
||||
wire_type = WireType.VARINT # Uses wire type 0
|
||||
@@ -1520,9 +1532,9 @@ class EnumType(VarintTypeMixin, TypeInfo):
|
||||
@property
|
||||
def encode_content(self) -> str:
|
||||
value_expr = f"static_cast<uint32_t>(this->{self.field_name})"
|
||||
if self.force:
|
||||
return f"ProtoEncode::{self.encode_func}(pos, {self.number}, {value_expr}, true);"
|
||||
return f"ProtoEncode::{self.encode_func}(pos, {self.number}, {value_expr});"
|
||||
return _encode_call(
|
||||
self.encode_func, str(self.number), value_expr, force=self.force
|
||||
)
|
||||
|
||||
def dump(self, name: str) -> str:
|
||||
return f"out.append_p(proto_enum_to_string<{self.cpp_type}>({name}));"
|
||||
@@ -1547,7 +1559,7 @@ class EnumType(VarintTypeMixin, TypeInfo):
|
||||
class SFixed32Type(FixedSizeTypeMixin, TypeInfo):
|
||||
cpp_type = "int32_t"
|
||||
default_value = "0"
|
||||
decode_32bit = "value.as_sfixed32()"
|
||||
decode_expr = "value.as_sfixed32()"
|
||||
encode_func = "encode_sfixed32"
|
||||
wire_type = WireType.FIXED32 # Uses wire type 5
|
||||
|
||||
@@ -1573,7 +1585,6 @@ class SFixed32Type(FixedSizeTypeMixin, TypeInfo):
|
||||
class SFixed64Type(FixedSizeTypeMixin, TypeInfo):
|
||||
cpp_type = "int64_t"
|
||||
default_value = "0"
|
||||
decode_64bit = "value.as_sfixed64()"
|
||||
encode_func = "encode_sfixed64"
|
||||
wire_type = WireType.FIXED64 # Uses wire type 1
|
||||
|
||||
@@ -1600,7 +1611,7 @@ class SInt32Type(VarintTypeMixin, TypeInfo):
|
||||
cpp_type = "int32_t"
|
||||
_varint_max_bits = 32 # zigzag encoding keeps it 32-bit
|
||||
default_value = "0"
|
||||
decode_varint = "decode_zigzag32(static_cast<uint32_t>(value))"
|
||||
decode_expr = "decode_zigzag32(static_cast<uint32_t>(value.as_varint()))"
|
||||
encode_func = "encode_sint32"
|
||||
wire_type = WireType.VARINT # Uses wire type 0
|
||||
|
||||
@@ -1621,7 +1632,7 @@ class SInt64Type(VarintTypeMixin, TypeInfo):
|
||||
cpp_type = "int64_t"
|
||||
_varint_max_bits = 64
|
||||
default_value = "0"
|
||||
decode_varint = "decode_zigzag64(value)"
|
||||
decode_expr = "decode_zigzag64(value.as_varint())"
|
||||
encode_func = "encode_sint64"
|
||||
wire_type = WireType.VARINT # Uses wire type 0
|
||||
|
||||
@@ -1701,9 +1712,9 @@ def _generate_inline_encode_block(
|
||||
|
||||
lines = []
|
||||
lines.append(f"auto &sub_msg = {element};")
|
||||
lines.append(f"ProtoEncode::write_raw_byte(pos, {tag});")
|
||||
lines.append(_encode_call("write_raw_byte", str(tag)))
|
||||
lines.append("uint8_t *len_pos = pos;")
|
||||
lines.append("ProtoEncode::reserve_byte(pos);")
|
||||
lines.append(_encode_call("reserve_byte"))
|
||||
|
||||
# Generate inline field encoding for each sub-message field
|
||||
for field in sub_desc.field:
|
||||
@@ -1774,18 +1785,11 @@ class FixedArrayRepeatedType(TypeInfo):
|
||||
|
||||
def _encode_element(self, element: str) -> str:
|
||||
"""Helper to generate encode statement for a single element."""
|
||||
if isinstance(self._ti, EnumType):
|
||||
return f"ProtoEncode::{self._ti.encode_func}(pos, {self.number}, static_cast<uint32_t>({element}), true);"
|
||||
# Repeated message elements use encode_sub_message (force=true is default)
|
||||
if isinstance(self._ti, MessageType):
|
||||
if _is_inline_encode(self._ti.cpp_type):
|
||||
return _generate_inline_encode_block(
|
||||
self.number, self._ti.cpp_type, element
|
||||
)
|
||||
return f"ProtoEncode::encode_sub_message(pos, buffer, {self.number}, {element});"
|
||||
return (
|
||||
f"ProtoEncode::{self._ti.encode_func}(pos, {self.number}, {element}, true);"
|
||||
)
|
||||
if isinstance(self._ti, MessageType) and _is_inline_encode(self._ti.cpp_type):
|
||||
return _generate_inline_encode_block(
|
||||
self.number, self._ti.cpp_type, element
|
||||
)
|
||||
return self._ti.encode_element(self.number, element)
|
||||
|
||||
@property
|
||||
def cpp_type(self) -> str:
|
||||
@@ -2079,55 +2083,23 @@ class RepeatedTypeInfo(TypeInfo):
|
||||
return self._ti.wire_type
|
||||
|
||||
@property
|
||||
def decode_varint_content(self) -> str:
|
||||
# Pointer fields don't support decoding
|
||||
if self._use_pointer:
|
||||
return None
|
||||
content = self._ti.decode_varint
|
||||
if content is None:
|
||||
return None
|
||||
return (
|
||||
f"case {self.number}: this->{self.field_name}.push_back({content}); break;"
|
||||
)
|
||||
def decode_expr(self) -> str | None:
|
||||
return self._ti.decode_expr
|
||||
|
||||
def _decode_store(self, expr: str) -> str:
|
||||
return f"this->{self.field_name}.push_back({expr});"
|
||||
|
||||
@property
|
||||
def decode_length_content(self) -> str:
|
||||
def decode_content(self) -> str | None:
|
||||
# Pointer fields don't support decoding
|
||||
if self._use_pointer:
|
||||
return None
|
||||
content = self._ti.decode_length
|
||||
if content is None and isinstance(self._ti, MessageType):
|
||||
# Special handling for non-template message decoding
|
||||
return f"case {self.number}: this->{self.field_name}.emplace_back(); value.decode_to_message(this->{self.field_name}.back()); break;"
|
||||
if content is None:
|
||||
return None
|
||||
return (
|
||||
f"case {self.number}: this->{self.field_name}.push_back({content}); break;"
|
||||
)
|
||||
|
||||
@property
|
||||
def decode_32bit_content(self) -> str:
|
||||
# Pointer fields don't support decoding
|
||||
if self._use_pointer:
|
||||
return None
|
||||
content = self._ti.decode_32bit
|
||||
if content is None:
|
||||
return None
|
||||
return (
|
||||
f"case {self.number}: this->{self.field_name}.push_back({content}); break;"
|
||||
)
|
||||
|
||||
@property
|
||||
def decode_64bit_content(self) -> str:
|
||||
# Pointer fields don't support decoding
|
||||
if self._use_pointer:
|
||||
return None
|
||||
content = self._ti.decode_64bit
|
||||
if content is None:
|
||||
return None
|
||||
return (
|
||||
f"case {self.number}: this->{self.field_name}.push_back({content}); break;"
|
||||
)
|
||||
if isinstance(self._ti, MessageType):
|
||||
return self.decode_case(
|
||||
f"this->{self.field_name}.emplace_back();\n"
|
||||
f"value.decode_to_message(this->{self.field_name}.back());"
|
||||
)
|
||||
return super().decode_content
|
||||
|
||||
@property
|
||||
def _ti_is_bool(self) -> bool:
|
||||
@@ -2135,15 +2107,7 @@ class RepeatedTypeInfo(TypeInfo):
|
||||
return isinstance(self._ti, BoolType)
|
||||
|
||||
def _encode_element_call(self, element: str) -> str:
|
||||
"""Helper to generate encode call for a single element."""
|
||||
if isinstance(self._ti, EnumType):
|
||||
return f"ProtoEncode::{self._ti.encode_func}(pos, {self.number}, static_cast<uint32_t>({element}), true);"
|
||||
# Repeated message elements use encode_sub_message (force=true is default)
|
||||
if isinstance(self._ti, MessageType):
|
||||
return f"ProtoEncode::encode_sub_message(pos, buffer, {self.number}, {element});"
|
||||
return (
|
||||
f"ProtoEncode::{self._ti.encode_func}(pos, {self.number}, {element}, true);"
|
||||
)
|
||||
return self._ti.encode_element(self.number, element)
|
||||
|
||||
@property
|
||||
def encode_content(self) -> str:
|
||||
@@ -2152,7 +2116,7 @@ class RepeatedTypeInfo(TypeInfo):
|
||||
# Special handling for const char* elements (when container_no_template contains "const char")
|
||||
if "const char" in self._container_no_template:
|
||||
o = f"for (const char *it : *this->{self.field_name}) {{\n"
|
||||
o += f" ProtoEncode::{self._ti.encode_func}(pos, {self.number}, it, strlen(it), true);\n"
|
||||
o += f" {_encode_call(self._ti.encode_func, str(self.number), 'it', 'strlen(it)', force=True)}\n"
|
||||
else:
|
||||
o = f"for (const auto &it : *this->{self.field_name}) {{\n"
|
||||
o += f" {self._encode_element_call('it')}\n"
|
||||
@@ -2538,10 +2502,7 @@ def build_message_type(
|
||||
) -> tuple[str, str, str]:
|
||||
public_content: list[str] = []
|
||||
protected_content: list[str] = []
|
||||
decode_varint: list[str] = []
|
||||
decode_length: list[str] = []
|
||||
decode_32bit: list[str] = []
|
||||
decode_64bit: list[str] = []
|
||||
decode: list[str] = []
|
||||
encode: list[str] = []
|
||||
dump: list[str] = []
|
||||
size_calc: list[str] = []
|
||||
@@ -2670,22 +2631,8 @@ def build_message_type(
|
||||
if field.options.HasExtension(pb.field_ifdef):
|
||||
field_ifdef = field.options.Extensions[pb.field_ifdef]
|
||||
|
||||
if ti.decode_varint_content:
|
||||
decode_varint.extend(
|
||||
wrap_with_ifdef(ti.decode_varint_content, field_ifdef)
|
||||
)
|
||||
if ti.decode_length_content:
|
||||
decode_length.extend(
|
||||
wrap_with_ifdef(ti.decode_length_content, field_ifdef)
|
||||
)
|
||||
if ti.decode_32bit_content:
|
||||
decode_32bit.extend(
|
||||
wrap_with_ifdef(ti.decode_32bit_content, field_ifdef)
|
||||
)
|
||||
if ti.decode_64bit_content:
|
||||
decode_64bit.extend(
|
||||
wrap_with_ifdef(ti.decode_64bit_content, field_ifdef)
|
||||
)
|
||||
if case := ti.decode_content:
|
||||
decode.extend(wrap_with_ifdef(case, field_ifdef))
|
||||
if ti.dump_content:
|
||||
# Check for field_ifdef option for dump as well
|
||||
field_ifdef = None
|
||||
@@ -2695,49 +2642,15 @@ def build_message_type(
|
||||
dump.extend(wrap_with_ifdef(ti.dump_content, field_ifdef))
|
||||
|
||||
cpp = ""
|
||||
if decode_varint:
|
||||
o = f"bool {desc.name}::decode_varint(uint32_t field_id, proto_varint_value_t value) {{\n"
|
||||
o += " switch (field_id) {\n"
|
||||
o += indent("\n".join(decode_varint), " ") + "\n"
|
||||
o += " default: return false;\n"
|
||||
if decode:
|
||||
o = f"void {desc.name}::decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) {{\n"
|
||||
o += " const ProtoFieldValue value(data, scalar);\n"
|
||||
o += " switch (tag) {\n"
|
||||
o += indent("\n".join(decode), " ") + "\n"
|
||||
o += " }\n"
|
||||
o += " return true;\n"
|
||||
o += "}\n"
|
||||
cpp += o
|
||||
prot = "bool decode_varint(uint32_t field_id, proto_varint_value_t value) override;"
|
||||
protected_content.insert(0, prot)
|
||||
if decode_length:
|
||||
o = f"bool {desc.name}::decode_length(uint32_t field_id, ProtoLengthDelimited value) {{\n"
|
||||
o += " switch (field_id) {\n"
|
||||
o += indent("\n".join(decode_length), " ") + "\n"
|
||||
o += " default: return false;\n"
|
||||
o += " }\n"
|
||||
o += " return true;\n"
|
||||
o += "}\n"
|
||||
cpp += o
|
||||
prot = "bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override;"
|
||||
protected_content.insert(0, prot)
|
||||
if decode_32bit:
|
||||
o = f"bool {desc.name}::decode_32bit(uint32_t field_id, Proto32Bit value) {{\n"
|
||||
o += " switch (field_id) {\n"
|
||||
o += indent("\n".join(decode_32bit), " ") + "\n"
|
||||
o += " default: return false;\n"
|
||||
o += " }\n"
|
||||
o += " return true;\n"
|
||||
o += "}\n"
|
||||
cpp += o
|
||||
prot = "bool decode_32bit(uint32_t field_id, Proto32Bit value) override;"
|
||||
protected_content.insert(0, prot)
|
||||
if decode_64bit:
|
||||
o = f"bool {desc.name}::decode_64bit(uint32_t field_id, Proto64Bit value) {{\n"
|
||||
o += " switch (field_id) {\n"
|
||||
o += indent("\n".join(decode_64bit), " ") + "\n"
|
||||
o += " default: return false;\n"
|
||||
o += " }\n"
|
||||
o += " return true;\n"
|
||||
o += "}\n"
|
||||
cpp += o
|
||||
prot = "bool decode_64bit(uint32_t field_id, Proto64Bit value) override;"
|
||||
prot = "void decode_field(uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) override;"
|
||||
protected_content.insert(0, prot)
|
||||
|
||||
# Generate custom decode() override for messages with FixedVector fields
|
||||
@@ -2784,28 +2697,36 @@ def build_message_type(
|
||||
)
|
||||
for line in encode
|
||||
]
|
||||
o = f"{speed_attr}uint8_t *{desc.name}::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {{\n"
|
||||
o = f"{speed_attr}uint8_t *{desc.name}::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) {{\n"
|
||||
o += f" const auto &msg = *static_cast<const {desc.name} *>(self);\n"
|
||||
o += " uint8_t *__restrict__ pos = buffer.get_pos();\n"
|
||||
o += indent("\n".join(encode_debug)) + "\n"
|
||||
o += indent("\n".join(encode_debug)).replace("this->", "msg.") + "\n"
|
||||
o += " return pos;\n"
|
||||
o += "}\n"
|
||||
cpp += o
|
||||
prot = (
|
||||
"uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;"
|
||||
public_content.append(
|
||||
"static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM);"
|
||||
)
|
||||
public_content.append(
|
||||
"uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {\n"
|
||||
" return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG);\n"
|
||||
"}"
|
||||
)
|
||||
public_content.append(prot)
|
||||
# If no fields to encode or message doesn't need encoding, the default implementation in ProtoMessage will be used
|
||||
|
||||
# Add calculate_size method only if this message needs encoding and has fields
|
||||
if needs_encode and size_calc and not is_inline_only:
|
||||
o = f"{speed_attr}uint32_t {desc.name}::calculate_size() const {{\n"
|
||||
o = f"{speed_attr}uint32_t {desc.name}::calc_size_msg(const void *self) {{\n"
|
||||
o += f" const auto &msg = *static_cast<const {desc.name} *>(self);\n"
|
||||
o += " uint32_t size = 0;\n"
|
||||
o += indent("\n".join(size_calc)) + "\n"
|
||||
o += indent("\n".join(size_calc)).replace("this->", "msg.") + "\n"
|
||||
o += " return size;\n"
|
||||
o += "}\n"
|
||||
cpp += o
|
||||
prot = "uint32_t calculate_size() const;"
|
||||
public_content.append(prot)
|
||||
public_content.append("static uint32_t calc_size_msg(const void *self);")
|
||||
public_content.append(
|
||||
"uint32_t calculate_size() const { return calc_size_msg(this); }"
|
||||
)
|
||||
# If no fields to calculate size for or message doesn't need encoding, the default implementation in ProtoMessage will be used
|
||||
|
||||
# dump_to method declaration in header
|
||||
|
||||
+24
-137
@@ -50,7 +50,6 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from collections import Counter
|
||||
from collections.abc import Callable
|
||||
from enum import StrEnum
|
||||
from functools import cache
|
||||
import json
|
||||
@@ -532,58 +531,37 @@ ESP32_PLATFORMIO_TRIGGER_PATH_PREFIXES = ("esphome/platformio/",)
|
||||
# - esphome/build_gen/platformio.py -- the PlatformIO build generator
|
||||
# - script/test_build_components.py -- the harness the job invokes
|
||||
# - .github/workflows/ci.yml -- the job's own definition
|
||||
# Shared by every toolchain smoke-test job: the harness it invokes and the
|
||||
# workflow that defines it
|
||||
_SMOKE_HARNESS_TRIGGER_FILES = frozenset(
|
||||
ESP32_PLATFORMIO_TRIGGER_FILES = frozenset(
|
||||
{
|
||||
"esphome/build_gen/platformio.py",
|
||||
"script/test_build_components.py",
|
||||
".github/workflows/ci.yml",
|
||||
}
|
||||
)
|
||||
|
||||
ESP32_PLATFORMIO_TRIGGER_FILES = _SMOKE_HARNESS_TRIGGER_FILES | {
|
||||
"esphome/build_gen/platformio.py",
|
||||
# The pch machinery the strict smoke job polices, and the modules it
|
||||
# imports; the rest of build_helpers/ does not affect PlatformIO builds
|
||||
"esphome/build_helpers/pch.py",
|
||||
"esphome/build_helpers/ccache.py",
|
||||
"esphome/build_helpers/idedata.py",
|
||||
}
|
||||
|
||||
|
||||
def _path_or_file_trigger(
|
||||
files: list[str],
|
||||
trigger_files: frozenset[str],
|
||||
trigger_prefixes: tuple[str, ...],
|
||||
) -> bool:
|
||||
"""Whether any changed file matches the given infrastructure triggers."""
|
||||
return any(
|
||||
file in trigger_files or file.startswith(trigger_prefixes) for file in files
|
||||
)
|
||||
|
||||
|
||||
@cache
|
||||
def _cached_components_closure(files: tuple[str, ...]) -> frozenset[str]:
|
||||
"""Dependency closure of the changed components, from the changed files.
|
||||
|
||||
The walk is expensive and every toolchain smoke-test job asks for the
|
||||
same file list, so compute it once per run."""
|
||||
component_files = [f for f in files if filter_component_and_test_files(f)]
|
||||
return frozenset(get_components_with_dependencies(component_files, True))
|
||||
|
||||
|
||||
def _esp32_platformio_path_or_file_trigger(files: list[str]) -> bool:
|
||||
"""Whether any changed file is a PlatformIO infrastructure / harness trigger."""
|
||||
return _path_or_file_trigger(
|
||||
files, ESP32_PLATFORMIO_TRIGGER_FILES, ESP32_PLATFORMIO_TRIGGER_PATH_PREFIXES
|
||||
)
|
||||
for file in files:
|
||||
if file in ESP32_PLATFORMIO_TRIGGER_FILES:
|
||||
return True
|
||||
if any(
|
||||
file.startswith(prefix) for prefix in ESP32_PLATFORMIO_TRIGGER_PATH_PREFIXES
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _esp_idf_infra_changed(files: list[str]) -> bool:
|
||||
"""Whether any changed file is ESP-IDF build/runner infrastructure."""
|
||||
return _path_or_file_trigger(
|
||||
files, ESP_IDF_INFRA_TRIGGER_FILES, ESP_IDF_INFRA_TRIGGER_PATH_PREFIXES
|
||||
)
|
||||
for file in files:
|
||||
if file in ESP_IDF_INFRA_TRIGGER_FILES:
|
||||
return True
|
||||
if any(
|
||||
file.startswith(prefix) for prefix in ESP_IDF_INFRA_TRIGGER_PATH_PREFIXES
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def esp32_platformio_components_to_test(branch: str | None = None) -> list[str]:
|
||||
@@ -621,23 +599,15 @@ def esp32_platformio_components_to_test(branch: str | None = None) -> list[str]:
|
||||
Returns:
|
||||
Sorted list of component names to compile.
|
||||
"""
|
||||
return _toolchain_components_to_test(
|
||||
branch, ESP32_PLATFORMIO_TEST_COMPONENTS, _esp32_platformio_path_or_file_trigger
|
||||
)
|
||||
|
||||
|
||||
def _toolchain_components_to_test(
|
||||
branch: str | None,
|
||||
test_set: frozenset[str],
|
||||
infra_trigger: Callable[[list[str]], bool],
|
||||
) -> list[str]:
|
||||
"""The shared narrowing rule for the per-toolchain smoke-test jobs."""
|
||||
files = changed_files(branch)
|
||||
|
||||
if core_changed(files) or infra_trigger(files):
|
||||
return sorted(test_set)
|
||||
if core_changed(files) or _esp32_platformio_path_or_file_trigger(files):
|
||||
return sorted(ESP32_PLATFORMIO_TEST_COMPONENTS)
|
||||
|
||||
return sorted(test_set & _cached_components_closure(tuple(files)))
|
||||
component_files = [f for f in files if filter_component_and_test_files(f)]
|
||||
changed = get_components_with_dependencies(component_files, True)
|
||||
|
||||
return sorted(ESP32_PLATFORMIO_TEST_COMPONENTS & set(changed))
|
||||
|
||||
|
||||
def should_run_esp32_platformio(branch: str | None = None) -> bool:
|
||||
@@ -658,83 +628,6 @@ def should_run_esp32_platformio(branch: str | None = None) -> bool:
|
||||
return bool(esp32_platformio_components_to_test(branch))
|
||||
|
||||
|
||||
# The `--toolchain arduino` smoke-test set: covers the core, the bundled and
|
||||
# converted registry libraries, and the waveform path.
|
||||
ESP8266_NATIVE_TEST_COMPONENTS = frozenset(
|
||||
{
|
||||
"esp8266",
|
||||
"api",
|
||||
"web_server",
|
||||
"captive_portal",
|
||||
"mqtt",
|
||||
"esp8266_pwm",
|
||||
"neopixelbus",
|
||||
"bme280_i2c",
|
||||
"uart",
|
||||
}
|
||||
)
|
||||
|
||||
# Infrastructure whose changes always trigger the native ESP8266 compile
|
||||
# test. esphome/build_helpers/ holds the idedata and size-summary helpers
|
||||
# the backend shares with the native ESP-IDF build.
|
||||
ESP8266_NATIVE_TRIGGER_PATH_PREFIXES = (
|
||||
"esphome/arduino8266/",
|
||||
"esphome/arduino/",
|
||||
"esphome/build_helpers/",
|
||||
)
|
||||
# Shared library-conversion modules every native build imports; espidf-only
|
||||
# infra (build_gen/espidf.py) deliberately stays out of the esp8266 set.
|
||||
_NATIVE_SHARED_TRIGGER_FILES = frozenset(
|
||||
{
|
||||
"esphome/framework_helpers.py",
|
||||
"esphome/platformio/library.py",
|
||||
"esphome/platformio/extra_script.py",
|
||||
}
|
||||
)
|
||||
# Tripwire: the shared modules must stay in the ESP-IDF trigger set too
|
||||
# (now defined in clang_tidy_hash), or its smoke test silently skips them
|
||||
assert _NATIVE_SHARED_TRIGGER_FILES <= ESP_IDF_INFRA_TRIGGER_FILES
|
||||
ESP8266_NATIVE_TRIGGER_FILES = (
|
||||
_NATIVE_SHARED_TRIGGER_FILES
|
||||
| _SMOKE_HARNESS_TRIGGER_FILES
|
||||
| {
|
||||
"esphome/build_gen/arduino8266.py",
|
||||
"esphome/build_gen/build_tool.py",
|
||||
"esphome/components/esp8266/build_surgery.py",
|
||||
"esphome/components/esp8266/boards.py",
|
||||
"esphome/platformio/registry.py",
|
||||
# esp8266/__init__.py imports copy_ccache_script from it
|
||||
"esphome/platformio/toolchain.py",
|
||||
".github/actions/cache-arduino8266/action.yml",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _esp8266_native_path_or_file_trigger(files: list[str]) -> bool:
|
||||
"""Whether any changed file is native-ESP8266 infrastructure / harness."""
|
||||
# base_python_changed covers the top-level esphome/*.py modules the
|
||||
# native backend imports directly (framework_helpers, helpers, writer,
|
||||
# __main__); without it a change there would silently skip this job.
|
||||
# base_python_changed is deliberately broad (any top-level esphome/*.py)
|
||||
# as belt-and-braces while the backend is new; narrow it to the modules
|
||||
# the backend imports once the toolchain has soaked a few releases
|
||||
return base_python_changed(files) or _path_or_file_trigger(
|
||||
files, ESP8266_NATIVE_TRIGGER_FILES, ESP8266_NATIVE_TRIGGER_PATH_PREFIXES
|
||||
)
|
||||
|
||||
|
||||
def esp8266_native_components_to_test(branch: str | None = None) -> list[str]:
|
||||
"""Subset of ``ESP8266_NATIVE_TEST_COMPONENTS`` the job needs to compile.
|
||||
|
||||
Same narrowing logic as ``esp32_platformio_components_to_test``: the full
|
||||
list on core or infrastructure changes, otherwise the intersection with
|
||||
the changed-component dependency closure (empty list skips the job).
|
||||
"""
|
||||
return _toolchain_components_to_test(
|
||||
branch, ESP8266_NATIVE_TEST_COMPONENTS, _esp8266_native_path_or_file_trigger
|
||||
)
|
||||
|
||||
|
||||
def determine_cpp_unit_tests(
|
||||
branch: str | None = None,
|
||||
) -> tuple[bool, list[str]]:
|
||||
@@ -1333,8 +1226,6 @@ def main() -> None:
|
||||
run_device_builder = True
|
||||
esp32_platformio_components = sorted(ESP32_PLATFORMIO_TEST_COMPONENTS)
|
||||
run_esp32_platformio = True
|
||||
esp8266_native_components = sorted(ESP8266_NATIVE_TEST_COMPONENTS)
|
||||
run_esp8266_native = True
|
||||
else:
|
||||
integration_run_all, integration_test_files = determine_integration_tests(
|
||||
args.branch
|
||||
@@ -1346,8 +1237,6 @@ def main() -> None:
|
||||
run_device_builder = should_run_device_builder(args.branch)
|
||||
esp32_platformio_components = esp32_platformio_components_to_test(args.branch)
|
||||
run_esp32_platformio = bool(esp32_platformio_components)
|
||||
esp8266_native_components = esp8266_native_components_to_test(args.branch)
|
||||
run_esp8266_native = bool(esp8266_native_components)
|
||||
run_integration, integration_test_buckets = _compute_integration_test_buckets(
|
||||
integration_run_all, integration_test_files
|
||||
)
|
||||
@@ -1543,8 +1432,6 @@ def main() -> None:
|
||||
"device_builder": run_device_builder,
|
||||
"esp32_platformio": run_esp32_platformio,
|
||||
"esp32_platformio_components": ",".join(esp32_platformio_components),
|
||||
"esp8266_native": run_esp8266_native,
|
||||
"esp8266_native_components": ",".join(esp8266_native_components),
|
||||
"changed_components": changed_components,
|
||||
"changed_components_with_tests": changed_components_with_tests,
|
||||
"directly_changed_components_with_tests": list(directly_changed_with_tests),
|
||||
|
||||
@@ -14,31 +14,7 @@ top=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0
|
||||
[ -x "$top/venv/bin/python" ] && exit 0
|
||||
[ -x "$top/script/setup" ] || exit 0
|
||||
|
||||
# Every worktree shares the hooks directory of the checkout it was created
|
||||
# from, and the script/setup run below is the one from whichever branch was just
|
||||
# checked out. Older branches install their own pre-commit hook without checking
|
||||
# for a worktree: that moves the shared hook aside as pre-commit.legacy and
|
||||
# replaces it with one tied to this worktree's virtual environment, so commits
|
||||
# break in every checkout. To rule that out, the hooks directory is copied
|
||||
# before script/setup runs and put back exactly as it was afterwards, including
|
||||
# removing any file script/setup added.
|
||||
hooks=$(git rev-parse --path-format=absolute --git-path hooks 2>/dev/null) || exit 0
|
||||
snap=$(mktemp -d "$hooks/.post-checkout.XXXXXX") || exit 0
|
||||
cp -p "$hooks"/* "$snap"/ 2>/dev/null
|
||||
|
||||
# Clear VIRTUAL_ENV so a checkout made from a shell with an environment already
|
||||
# activated still gets its own, rather than having the active one repointed at
|
||||
# this working tree.
|
||||
env -u VIRTUAL_ENV "$top/script/setup"
|
||||
status=$?
|
||||
|
||||
for f in "$hooks"/*; do
|
||||
[ -e "$snap/${f##*/}" ] || rm -f "$f"
|
||||
done
|
||||
# Files are moved rather than copied so a hook that is still running, such as
|
||||
# this one, is swapped out atomically instead of being rewritten in place.
|
||||
for f in "$snap"/*; do
|
||||
cmp -s "$f" "$hooks/${f##*/}" 2>/dev/null || mv -f "$f" "$hooks/${f##*/}"
|
||||
done
|
||||
rm -rf "$snap"
|
||||
exit $status
|
||||
exec env -u VIRTUAL_ENV "$top/script/setup"
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Keep pre-commit hook revs in sync with the requirements files.
|
||||
|
||||
Dependabot only bumps the ``package==version`` pins in ``requirements*.txt``.
|
||||
Some of those tools are pinned a second time as hook ``rev`` values in
|
||||
``.pre-commit-config.yaml``. This script treats the requirements files as
|
||||
the source of truth and rewrites the revs to match, editing the config
|
||||
through yamlrocks so comments and layout survive.
|
||||
|
||||
Run without arguments to apply the changes in place, or with ``--check`` to
|
||||
only report drift (exit status 1 when anything is out of sync).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
import yamlrocks
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
PRECOMMIT_CONFIG = ".pre-commit-config.yaml"
|
||||
|
||||
|
||||
class SyncError(Exception):
|
||||
"""A pin could not be located in a requirements file or the config."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SyncTarget:
|
||||
"""A requirements pin and the pre-commit repo whose rev mirrors it."""
|
||||
|
||||
package: str
|
||||
requirements_file: str
|
||||
repo: str
|
||||
|
||||
|
||||
SYNC_TARGETS: tuple[SyncTarget, ...] = (
|
||||
SyncTarget(
|
||||
"ruff", "requirements_test.txt", "https://github.com/astral-sh/ruff-pre-commit"
|
||||
),
|
||||
SyncTarget("flake8", "requirements_test.txt", "https://github.com/PyCQA/flake8"),
|
||||
SyncTarget(
|
||||
"pyupgrade", "requirements_test.txt", "https://github.com/asottile/pyupgrade"
|
||||
),
|
||||
SyncTarget(
|
||||
"clang-format",
|
||||
"requirements_dev.txt",
|
||||
"https://github.com/pre-commit/mirrors-clang-format",
|
||||
),
|
||||
SyncTarget(
|
||||
"yamllint",
|
||||
"requirements_dev.txt",
|
||||
"https://github.com/adrienverge/yamllint.git",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def read_requirement_version(requirements: str, package: str) -> str | None:
|
||||
"""Return the ``==`` pin for ``package`` or None when it is not pinned."""
|
||||
pattern = re.compile(
|
||||
rf"^{re.escape(package)}==(?P<version>[^\s#]+)",
|
||||
re.MULTILINE | re.IGNORECASE,
|
||||
)
|
||||
match = pattern.search(requirements)
|
||||
return match.group("version") if match else None
|
||||
|
||||
|
||||
def find_repo_entry(doc: Any, repo: str) -> Any:
|
||||
"""Return the single ``- repo:`` block for ``repo`` in a pre-commit doc."""
|
||||
try:
|
||||
entries = [entry for entry in doc["repos"] if entry["repo"] == repo]
|
||||
except KeyError as err:
|
||||
raise SyncError(f"malformed pre-commit config, missing key {err}") from None
|
||||
if len(entries) != 1:
|
||||
raise SyncError(
|
||||
f"expected exactly one block for repo {repo}, found {len(entries)}"
|
||||
)
|
||||
return entries[0]
|
||||
|
||||
|
||||
def current_rev(entry: Any, repo: str) -> tuple[str, str]:
|
||||
"""Split the block's rev into its tag prefix (``v`` or empty) and version."""
|
||||
if "rev" not in entry:
|
||||
raise SyncError(f"repo {repo} has no rev")
|
||||
rev = entry["rev"]
|
||||
if not isinstance(rev, str):
|
||||
# A rev such as ``1.0`` parses as a number and cannot be compared or
|
||||
# rewritten safely; quote it in the config instead.
|
||||
raise SyncError(f"rev of repo {repo} is not a string: {rev!r}")
|
||||
prefix = "v" if rev.startswith("v") else ""
|
||||
return prefix, rev.removeprefix("v")
|
||||
|
||||
|
||||
def sync(root: Path, *, write: bool) -> list[str]:
|
||||
"""Bring every hook rev in line with its requirements pin.
|
||||
|
||||
Returns one description per rev that was (or, when ``write`` is False,
|
||||
would be) changed. Raises SyncError when a pin cannot be found, which
|
||||
means SYNC_TARGETS has gone stale and needs updating by hand.
|
||||
"""
|
||||
config_path = root / PRECOMMIT_CONFIG
|
||||
doc = yamlrocks.loads(config_path.read_bytes(), option=yamlrocks.OPT_ROUND_TRIP)
|
||||
requirements: dict[str, str] = {}
|
||||
changes: list[str] = []
|
||||
for target in SYNC_TARGETS:
|
||||
if target.requirements_file not in requirements:
|
||||
requirements[target.requirements_file] = (
|
||||
root / target.requirements_file
|
||||
).read_text()
|
||||
version = read_requirement_version(
|
||||
requirements[target.requirements_file], target.package
|
||||
)
|
||||
if version is None:
|
||||
raise SyncError(
|
||||
f"{target.requirements_file}: no '{target.package}==' pin found"
|
||||
)
|
||||
|
||||
entry = find_repo_entry(doc, target.repo)
|
||||
prefix, current = current_rev(entry, target.repo)
|
||||
if current == version:
|
||||
continue
|
||||
changes.append(f"{target.package}: {current} -> {version}")
|
||||
entry["rev"] = f"{prefix}{version}"
|
||||
|
||||
if changes and write:
|
||||
config_path.write_bytes(doc.to_yaml())
|
||||
return changes
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
parser.add_argument(
|
||||
"--check",
|
||||
action="store_true",
|
||||
help="report drift without modifying any file; exit 1 if out of sync",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--root",
|
||||
type=Path,
|
||||
default=REPO_ROOT,
|
||||
help="repository checkout to operate on (default: this checkout)",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
try:
|
||||
changes = sync(args.root, write=not args.check)
|
||||
except SyncError as err:
|
||||
print(f"error: {err}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
for change in changes:
|
||||
print(change)
|
||||
if args.check and changes:
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
sys.exit(main())
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -249,7 +249,7 @@ static APIBuffer build_infrared_rf_transmit_wire() {
|
||||
std::memcpy(bytes + len, packed, packed_len);
|
||||
len += packed_len;
|
||||
// field 6: modulation = 1 (non-zero so it's actually emitted and exercises
|
||||
// decode_varint for this field, matching the documented layout above).
|
||||
// decode_field for this field, matching the documented layout above).
|
||||
put_byte(0x30);
|
||||
put_varint(1);
|
||||
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
esphome:
|
||||
name: test-keyboard-no-label
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
spi:
|
||||
- id: spi_bus
|
||||
clk_pin: GPIO18
|
||||
mosi_pin: GPIO23
|
||||
|
||||
display:
|
||||
- platform: mipi_spi
|
||||
spi_id: spi_bus
|
||||
model: st7789v
|
||||
id: tft_display
|
||||
dimensions:
|
||||
width: 240
|
||||
height: 320
|
||||
cs_pin: GPIO22
|
||||
dc_pin: GPIO21
|
||||
auto_clear_enabled: false
|
||||
invert_colors: false
|
||||
update_interval: never
|
||||
|
||||
lvgl:
|
||||
displays: tft_display
|
||||
widgets:
|
||||
- keyboard:
|
||||
id: keyboard_widget
|
||||
@@ -1,34 +0,0 @@
|
||||
esphome:
|
||||
name: test-qrcode-no-label
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
spi:
|
||||
- id: spi_bus
|
||||
clk_pin: GPIO18
|
||||
mosi_pin: GPIO23
|
||||
|
||||
display:
|
||||
- platform: mipi_spi
|
||||
spi_id: spi_bus
|
||||
model: st7789v
|
||||
id: tft_display
|
||||
dimensions:
|
||||
width: 240
|
||||
height: 320
|
||||
cs_pin: GPIO22
|
||||
dc_pin: GPIO21
|
||||
auto_clear_enabled: false
|
||||
invert_colors: false
|
||||
update_interval: never
|
||||
|
||||
lvgl:
|
||||
displays: tft_display
|
||||
widgets:
|
||||
- qrcode:
|
||||
id: qr_widget
|
||||
size: 100
|
||||
text: "esphome.io"
|
||||
@@ -1,35 +0,0 @@
|
||||
esphome:
|
||||
name: test-tabview-no-label
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
spi:
|
||||
- id: spi_bus
|
||||
clk_pin: GPIO18
|
||||
mosi_pin: GPIO23
|
||||
|
||||
display:
|
||||
- platform: mipi_spi
|
||||
spi_id: spi_bus
|
||||
model: st7789v
|
||||
id: tft_display
|
||||
dimensions:
|
||||
width: 240
|
||||
height: 320
|
||||
cs_pin: GPIO22
|
||||
dc_pin: GPIO21
|
||||
auto_clear_enabled: false
|
||||
invert_colors: false
|
||||
update_interval: never
|
||||
|
||||
lvgl:
|
||||
displays: tft_display
|
||||
widgets:
|
||||
- tabview:
|
||||
id: tabview_widget
|
||||
tabs:
|
||||
- name: "Tab 1"
|
||||
id: tab_1
|
||||
@@ -1,32 +0,0 @@
|
||||
"""Widgets whose LVGL C implementation creates or references labels
|
||||
internally (tab titles, key legends, the QR canvas fallback) must declare
|
||||
the label dependency in ``get_uses()``. Otherwise a config that contains
|
||||
no ``label`` widget of its own compiles LVGL without ``LV_USE_LABEL`` and
|
||||
fails at C compile time with undefined ``lv_label_*`` symbols.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components.lvgl import defines as df
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"yaml_file",
|
||||
[
|
||||
"qrcode_no_label.yaml",
|
||||
"keyboard_no_label.yaml",
|
||||
"tabview_no_label.yaml",
|
||||
],
|
||||
)
|
||||
def test_label_less_config_enables_lv_use_label(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
yaml_file: str,
|
||||
) -> None:
|
||||
generate_main(component_config_path(yaml_file))
|
||||
assert "LV_USE_LABEL" in df.get_defines()
|
||||
@@ -59,7 +59,7 @@ static void verify_mac(uint64_t mac, size_t expected_bytes) {
|
||||
#ifdef ESPHOME_DEBUG_API
|
||||
uint8_t *proto_debug_end_ = api_buf.data() + api_buf.size();
|
||||
#endif
|
||||
ProtoEncode::encode_varint_raw_48bit(pos PROTO_ENCODE_DEBUG_ARG, mac);
|
||||
pos = ProtoEncode::encode_varint_raw_48bit(pos PROTO_ENCODE_DEBUG_ARG, mac);
|
||||
size_t new_len = pos - api_buf.data();
|
||||
|
||||
EXPECT_EQ(new_len, expected_bytes) << "mac=0x" << std::hex << mac << std::dec;
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
from tests.testing_helpers import ComponentManifestOverride
|
||||
|
||||
|
||||
def override_manifest(manifest: ComponentManifestOverride) -> None:
|
||||
manifest.dependencies = manifest.dependencies + ["sensor", "spi"]
|
||||
@@ -1,62 +0,0 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include "esphome/components/atm90e32/atm90e32.h"
|
||||
|
||||
namespace esphome::atm90e32::testing {
|
||||
|
||||
TEST(ATM90E32OffsetRegisterVerification, AcceptsExactSignedReadback) {
|
||||
EXPECT_TRUE(offset_register_value_matches(0x007B, 123));
|
||||
EXPECT_TRUE(offset_register_value_matches(0xFF85, -123));
|
||||
}
|
||||
|
||||
TEST(ATM90E32OffsetRegisterVerification, RejectsMismatchedReadback) {
|
||||
EXPECT_FALSE(offset_register_value_matches(0x007C, 123));
|
||||
EXPECT_FALSE(offset_register_value_matches(0xFF84, -123));
|
||||
}
|
||||
|
||||
TEST(ATM90E32OffsetRestoreState, ReportsVerifiedStoredValuesAsRestored) {
|
||||
const auto state = resolve_offset_restore_state(true, true, false);
|
||||
|
||||
EXPECT_TRUE(state.restored);
|
||||
EXPECT_TRUE(state.values_verified);
|
||||
}
|
||||
|
||||
TEST(ATM90E32OffsetRestoreState, ReportsVerifiedConfigFallbackAsNotRestored) {
|
||||
const auto state = resolve_offset_restore_state(true, false, true);
|
||||
|
||||
EXPECT_FALSE(state.restored);
|
||||
EXPECT_TRUE(state.values_verified);
|
||||
}
|
||||
|
||||
TEST(ATM90E32OffsetRestoreState, ReportsFailedConfigFallbackAsUnverified) {
|
||||
const auto state = resolve_offset_restore_state(true, false, false);
|
||||
|
||||
EXPECT_FALSE(state.restored);
|
||||
EXPECT_FALSE(state.values_verified);
|
||||
}
|
||||
|
||||
TEST(ATM90E32OffsetRestoreState, ReportsConfigWithoutStoredValuesAsNotRestored) {
|
||||
const auto state = resolve_offset_restore_state(false, true, false);
|
||||
|
||||
EXPECT_FALSE(state.restored);
|
||||
EXPECT_TRUE(state.values_verified);
|
||||
}
|
||||
|
||||
TEST(ATM90E32OffsetPersistence, RollsBackStoredValuesOrZeroSentinel) {
|
||||
const OffsetCalibration previous[3]{{1, -1}, {2, -2}, {3, -3}};
|
||||
OffsetCalibration rollback[3]{};
|
||||
|
||||
prepare_offset_rollback(previous, true, rollback);
|
||||
for (uint8_t phase = 0; phase < 3; phase++) {
|
||||
EXPECT_EQ(rollback[phase].first_offset, previous[phase].first_offset);
|
||||
EXPECT_EQ(rollback[phase].second_offset, previous[phase].second_offset);
|
||||
}
|
||||
|
||||
prepare_offset_rollback(previous, false, rollback);
|
||||
for (const auto &phase : rollback) {
|
||||
EXPECT_EQ(phase.first_offset, 0);
|
||||
EXPECT_EQ(phase.second_offset, 0);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace esphome::atm90e32::testing
|
||||
@@ -1,29 +0,0 @@
|
||||
# Tuya without any network component (no wifi/ethernet/api), as used on
|
||||
# serial-only or BLE-only Tuya MCU boards. Regression test for
|
||||
# https://github.com/esphome/esphome/issues/18942
|
||||
substitutions:
|
||||
status_pin: P6
|
||||
|
||||
packages:
|
||||
uart: !include ../../test_build_components/common/uart/bk72xx-ard.yaml
|
||||
|
||||
tuya:
|
||||
status_pin: ${status_pin}
|
||||
|
||||
binary_sensor:
|
||||
- platform: tuya
|
||||
id: tuya_presence
|
||||
sensor_datapoint: 101
|
||||
|
||||
sensor:
|
||||
- platform: tuya
|
||||
id: tuya_light_intensity
|
||||
sensor_datapoint: 103
|
||||
|
||||
number:
|
||||
- platform: tuya
|
||||
id: tuya_far_detection
|
||||
number_datapoint: 109
|
||||
min_value: 0
|
||||
max_value: 600
|
||||
step: 1
|
||||
@@ -0,0 +1,43 @@
|
||||
esphome:
|
||||
name: api-decode-wire-types-test
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
switch:
|
||||
- platform: template
|
||||
name: "Wire Switch"
|
||||
optimistic: true
|
||||
|
||||
output:
|
||||
- platform: template
|
||||
id: wire_dim
|
||||
type: float
|
||||
write_action:
|
||||
- lambda: ""
|
||||
|
||||
light:
|
||||
- platform: monochromatic
|
||||
name: "Wire Light"
|
||||
output: wire_dim
|
||||
default_transition_length: 0s
|
||||
effects:
|
||||
- pulse:
|
||||
name: Pulse
|
||||
|
||||
text:
|
||||
- platform: template
|
||||
name: "Wire Text"
|
||||
optimistic: true
|
||||
mode: text
|
||||
min_length: 0
|
||||
max_length: 255
|
||||
|
||||
number:
|
||||
- platform: template
|
||||
name: "Wire Number"
|
||||
optimistic: true
|
||||
min_value: -1000
|
||||
max_value: 1000
|
||||
step: 0.5
|
||||
@@ -0,0 +1,11 @@
|
||||
esphome:
|
||||
name: api-empty-message-test
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
switch:
|
||||
- platform: template
|
||||
name: "Empty Message Switch"
|
||||
optimistic: true
|
||||
@@ -0,0 +1,58 @@
|
||||
esphome:
|
||||
name: api-encode-boundaries-test
|
||||
# Top-level area fills DeviceInfoResponse.suggested_area (field 16, a two-byte tag)
|
||||
area:
|
||||
id: kitchen_area
|
||||
name: Kitchen
|
||||
on_boot:
|
||||
- sensor.template.publish:
|
||||
id: zero_then_value
|
||||
state: 0.0
|
||||
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
sensor:
|
||||
- platform: template
|
||||
name: "Zero Then Value"
|
||||
id: zero_then_value
|
||||
# Negative int32 takes the ten byte varint path
|
||||
accuracy_decimals: -2
|
||||
update_interval: never
|
||||
|
||||
text_sensor:
|
||||
- platform: template
|
||||
name: "Long Text"
|
||||
id: long_text
|
||||
update_interval: never
|
||||
|
||||
number:
|
||||
- platform: template
|
||||
name: "Negative Number"
|
||||
optimistic: true
|
||||
min_value: -1000
|
||||
max_value: 1000
|
||||
step: 0.5
|
||||
initial_value: -123.5
|
||||
|
||||
select:
|
||||
- platform: template
|
||||
name: "Long Option Select"
|
||||
optimistic: true
|
||||
options:
|
||||
- short
|
||||
- "option-with-a-name-long-enough-that-its-length-prefix-needs-two-varint-bytes-when-the-list-entities-response-is-encoded-xxxxxxxxxx"
|
||||
initial_option: short
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: "Publish Values"
|
||||
on_press:
|
||||
- sensor.template.publish:
|
||||
id: zero_then_value
|
||||
state: 12.5
|
||||
- text_sensor.template.publish:
|
||||
id: long_text
|
||||
state: !lambda return std::string(200, 'y');
|
||||
@@ -125,11 +125,12 @@ class RawApiClient:
|
||||
await self.read_until_frame(MESSAGE_TYPE_OF[api_pb2.HelloResponse])
|
||||
|
||||
async def send_message(self, msg: message.Message) -> None:
|
||||
await self.send_raw(MESSAGE_TYPE_OF[type(msg)], msg.SerializeToString())
|
||||
|
||||
async def send_raw(self, msg_type: int, payload: bytes) -> None:
|
||||
"""Send a frame with a hand built payload, for shapes protobuf will not serialize."""
|
||||
loop = asyncio.get_running_loop()
|
||||
await loop.sock_sendall(
|
||||
self._sock,
|
||||
encode_frame(MESSAGE_TYPE_OF[type(msg)], msg.SerializeToString()),
|
||||
)
|
||||
await loop.sock_sendall(self._sock, encode_frame(msg_type, payload))
|
||||
|
||||
async def read_until_frame(self, msg_type: int, timeout: float = 10.0) -> None:
|
||||
"""Read until at least one frame of msg_type has been received."""
|
||||
|
||||
@@ -57,6 +57,46 @@ async def wait_for_state(
|
||||
return await asyncio.wait_for(future, timeout=timeout)
|
||||
|
||||
|
||||
class StateWaiter:
|
||||
"""Route one state subscription to any number of predicate waits."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._waiters: list[
|
||||
tuple[Callable[[EntityState], bool], asyncio.Future[EntityState]]
|
||||
] = []
|
||||
|
||||
def on_state(self, state: EntityState) -> None:
|
||||
for predicate, future in self._waiters:
|
||||
if future.done():
|
||||
continue
|
||||
try:
|
||||
matched = predicate(state)
|
||||
except Exception as exc: # noqa: BLE001 the wait re-raises it, the callback must not die
|
||||
future.set_exception(exc)
|
||||
continue
|
||||
if matched:
|
||||
future.set_result(state)
|
||||
|
||||
async def expect(
|
||||
self,
|
||||
predicate: Callable[[EntityState], bool],
|
||||
timeout: float = 5.0,
|
||||
label: str | None = None,
|
||||
) -> EntityState:
|
||||
"""Wait for the next state matching ``predicate``; states seen before this call do not count."""
|
||||
entry = (predicate, asyncio.get_running_loop().create_future())
|
||||
self._waiters.append(entry)
|
||||
try:
|
||||
async with asyncio.timeout(timeout):
|
||||
return await entry[1]
|
||||
except TimeoutError:
|
||||
raise TimeoutError(
|
||||
f"no state matched {label or predicate} within {timeout}s"
|
||||
) from None
|
||||
finally:
|
||||
self._waiters.remove(entry)
|
||||
|
||||
|
||||
def find_entity[T: EntityInfo](
|
||||
entities: list[EntityInfo],
|
||||
object_id_substring: str,
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
"""decode_field() must take fields that match their declared wire type, drop the ones that do
|
||||
not, skip unknown fields, and handle two byte tags, varints and length prefixes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
import struct
|
||||
|
||||
from aioesphomeapi import (
|
||||
EntityState,
|
||||
LightState,
|
||||
NumberState,
|
||||
SwitchState,
|
||||
TextState,
|
||||
api_pb2,
|
||||
)
|
||||
import pytest
|
||||
|
||||
from .raw_api_client import MESSAGE_TYPE_OF, RawApiClient, encode_varint
|
||||
from .state_utils import InitialStateHelper, StateWaiter, require_entity
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
SWITCH_COMMAND = MESSAGE_TYPE_OF[api_pb2.SwitchCommandRequest]
|
||||
WIRE_VARINT, WIRE_LENGTH, WIRE_FIXED32 = 0, 2, 5
|
||||
|
||||
|
||||
def tag(field: int, wire_type: int) -> bytes:
|
||||
return encode_varint((field << 3) | wire_type)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_decode_wire_types(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
unused_tcp_port: int,
|
||||
) -> None:
|
||||
async with (
|
||||
run_compiled(yaml_config),
|
||||
api_client_connected() as client,
|
||||
RawApiClient(unused_tcp_port) as raw,
|
||||
):
|
||||
entities, _ = await client.list_entities_services()
|
||||
switch = require_entity(entities, "wire_switch")
|
||||
light = require_entity(entities, "wire_light")
|
||||
text = require_entity(entities, "wire_text")
|
||||
number = require_entity(entities, "wire_number")
|
||||
key = tag(1, WIRE_FIXED32) + struct.pack("<I", switch.key)
|
||||
on, off = tag(2, WIRE_VARINT) + b"\x01", tag(2, WIRE_VARINT) + b"\x00"
|
||||
|
||||
switch_states: list[bool] = []
|
||||
waiter = StateWaiter()
|
||||
|
||||
def on_state(state: EntityState) -> None:
|
||||
if isinstance(state, SwitchState) and state.key == switch.key:
|
||||
switch_states.append(state.state)
|
||||
waiter.on_state(state)
|
||||
|
||||
def switch_is(value: bool) -> Callable[[EntityState], bool]:
|
||||
return lambda s: (
|
||||
isinstance(s, SwitchState) and s.key == switch.key and s.state is value
|
||||
)
|
||||
|
||||
def number_is(value: float) -> Callable[[EntityState], bool]:
|
||||
return lambda s: (
|
||||
isinstance(s, NumberState) and s.key == number.key and s.state == value
|
||||
)
|
||||
|
||||
initial = InitialStateHelper(entities)
|
||||
client.subscribe_states(initial.on_state_wrapper(on_state))
|
||||
await initial.wait_for_initial_states()
|
||||
await raw.connect()
|
||||
|
||||
# A well formed command: fixed32 key, varint state
|
||||
await raw.send_raw(SWITCH_COMMAND, key + on)
|
||||
await waiter.expect(switch_is(True))
|
||||
await raw.send_raw(SWITCH_COMMAND, key + off)
|
||||
await waiter.expect(switch_is(False))
|
||||
|
||||
# The same field with the wrong wire type is dropped, and a varint key never matches an
|
||||
# entity; each of these would turn the switch on if the payload were read as a varint
|
||||
seen = len(switch_states)
|
||||
await raw.send_raw(SWITCH_COMMAND, key + tag(2, WIRE_LENGTH) + b"\x01\x01")
|
||||
await raw.send_raw(
|
||||
SWITCH_COMMAND, key + tag(2, WIRE_FIXED32) + b"\x01\x00\x00\x00"
|
||||
)
|
||||
await raw.send_raw(
|
||||
SWITCH_COMMAND, tag(1, WIRE_VARINT) + encode_varint(switch.key) + on
|
||||
)
|
||||
# Ordered on the raw socket itself: this frame cannot be parsed before the bad ones, so
|
||||
# the only switch state since the marker must be the one it produces
|
||||
await raw.send_raw(SWITCH_COMMAND, key + on)
|
||||
await waiter.expect(switch_is(True), label="switch on after wrong wire types")
|
||||
assert switch_states[seen:] == [True]
|
||||
await raw.send_raw(SWITCH_COMMAND, key + off)
|
||||
await waiter.expect(switch_is(False))
|
||||
|
||||
# Truncated bodies stop the decode loop without taking the connection down: a tag with its
|
||||
# continuation bit set and nothing after it, a length prefix past the end of the payload,
|
||||
# and a fixed32 with two of its four bytes
|
||||
seen = len(switch_states)
|
||||
await raw.send_raw(SWITCH_COMMAND, key + b"\x80")
|
||||
await raw.send_raw(SWITCH_COMMAND, key + tag(2, WIRE_LENGTH) + b"\x7f" + b"ab")
|
||||
await raw.send_raw(SWITCH_COMMAND, tag(1, WIRE_FIXED32) + b"\x01\x02")
|
||||
await raw.send_raw(SWITCH_COMMAND, key + on)
|
||||
await waiter.expect(switch_is(True), label="switch on after truncated frames")
|
||||
assert switch_states[seen:] == [True]
|
||||
await raw.send_raw(SWITCH_COMMAND, key + off)
|
||||
await waiter.expect(switch_is(False))
|
||||
|
||||
# A negative number goes through the fixed32 float path of a normal client
|
||||
client.number_command(number.key, -77.5)
|
||||
await waiter.expect(number_is(-77.5))
|
||||
|
||||
# An unknown field ahead of the known ones is skipped; field 200 needs a two byte tag
|
||||
await raw.send_raw(
|
||||
SWITCH_COMMAND, tag(200, WIRE_VARINT) + encode_varint(300) + key + on
|
||||
)
|
||||
await waiter.expect(switch_is(True))
|
||||
|
||||
# Two byte tags (effect fields 18 and 19) and a two byte varint (300 ms transition)
|
||||
client.light_command(
|
||||
light.key, state=True, brightness=0.5, transition_length=0.3, effect="Pulse"
|
||||
)
|
||||
await waiter.expect(
|
||||
lambda s: (
|
||||
isinstance(s, LightState) and s.key == light.key and s.effect == "Pulse"
|
||||
)
|
||||
)
|
||||
client.light_command(light.key, effect="None", state=False)
|
||||
await waiter.expect(
|
||||
lambda s: isinstance(s, LightState) and s.key == light.key and not s.state
|
||||
)
|
||||
|
||||
# A string whose length prefix needs two varint bytes
|
||||
long_text = "w" * 200
|
||||
client.text_command(text.key, long_text)
|
||||
await waiter.expect(
|
||||
lambda s: (
|
||||
isinstance(s, TextState) and s.key == text.key and s.state == long_text
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Messages without fields go through the shared ProtoMessage entry points on both directions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from aioesphomeapi import api_pb2
|
||||
import pytest
|
||||
|
||||
from .raw_api_client import MESSAGE_TYPE_OF, RawApiClient
|
||||
from .types import RunCompiledFunction
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_empty_message_roundtrip(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
unused_tcp_port: int,
|
||||
) -> None:
|
||||
async with run_compiled(yaml_config), RawApiClient(unused_tcp_port) as client:
|
||||
await client.connect()
|
||||
|
||||
# Field free request and reply on the plain send path
|
||||
await client.send_message(api_pb2.PingRequest())
|
||||
await client.read_until_frame(MESSAGE_TYPE_OF[api_pb2.PingResponse])
|
||||
|
||||
# Field free request answered by a message with fields, and a list that ends with
|
||||
# the field free ListEntitiesDoneResponse through the batching path
|
||||
await client.send_message(api_pb2.DeviceInfoRequest())
|
||||
await client.read_until_frame(MESSAGE_TYPE_OF[api_pb2.DeviceInfoResponse])
|
||||
await client.send_message(api_pb2.ListEntitiesRequest())
|
||||
await client.read_until_frame(MESSAGE_TYPE_OF[api_pb2.ListEntitiesDoneResponse])
|
||||
assert (
|
||||
client.frame_counts[MESSAGE_TYPE_OF[api_pb2.ListEntitiesSwitchResponse]]
|
||||
== 1
|
||||
)
|
||||
|
||||
await client.send_message(api_pb2.DisconnectRequest())
|
||||
await client.read_until_frame(MESSAGE_TYPE_OF[api_pb2.DisconnectResponse])
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Encode paths at their branch boundaries: zero skipped float, fixed32 state, negative int32,
|
||||
length prefixes of two varint bytes and two byte field tags."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from aioesphomeapi import (
|
||||
NumberState,
|
||||
SelectInfo,
|
||||
SensorInfo,
|
||||
SensorState,
|
||||
TextSensorState,
|
||||
)
|
||||
import pytest
|
||||
|
||||
from .state_utils import InitialStateHelper, StateWaiter, require_entity
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
LONG_OPTION = (
|
||||
"option-with-a-name-long-enough-that-its-length-prefix-needs-two-varint-bytes-"
|
||||
"when-the-list-entities-response-is-encoded-xxxxxxxxxx"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_encode_boundaries(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
async with run_compiled(yaml_config), api_client_connected() as client:
|
||||
device_info, (entities, _) = await asyncio.gather(
|
||||
client.device_info(), client.list_entities_services()
|
||||
)
|
||||
assert device_info.suggested_area == "Kitchen"
|
||||
|
||||
sensor = require_entity(entities, "zero_then_value", SensorInfo)
|
||||
assert sensor.accuracy_decimals == -2
|
||||
select = require_entity(entities, "long_option_select", SelectInfo)
|
||||
assert len(LONG_OPTION) >= 128
|
||||
assert select.options == ["short", LONG_OPTION]
|
||||
text = require_entity(entities, "long_text")
|
||||
number = require_entity(entities, "negative_number")
|
||||
button = require_entity(entities, "publish_values")
|
||||
|
||||
initial = InitialStateHelper(entities)
|
||||
waiter = StateWaiter()
|
||||
client.subscribe_states(initial.on_state_wrapper(waiter.on_state))
|
||||
await initial.wait_for_initial_states()
|
||||
|
||||
# A float of exactly zero is skipped on the wire and must still read as 0.0, not missing
|
||||
first = initial.initial_states[sensor.key]
|
||||
assert isinstance(first, SensorState)
|
||||
assert first.state == 0.0 and not first.missing_state
|
||||
first_number = initial.initial_states[number.key]
|
||||
assert isinstance(first_number, NumberState)
|
||||
assert first_number.state == -123.5
|
||||
|
||||
client.button_command(button.key)
|
||||
await asyncio.gather(
|
||||
waiter.expect(
|
||||
lambda s: (
|
||||
isinstance(s, SensorState)
|
||||
and s.key == sensor.key
|
||||
and s.state == 12.5
|
||||
),
|
||||
label="sensor 12.5",
|
||||
),
|
||||
waiter.expect(
|
||||
lambda s: (
|
||||
isinstance(s, TextSensorState)
|
||||
and s.key == text.key
|
||||
and s.state == "y" * 200
|
||||
),
|
||||
label="text 200 x y",
|
||||
),
|
||||
)
|
||||
@@ -78,17 +78,6 @@ def mock_esp32_platformio_components_to_test() -> Generator[Mock, None, None]:
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_esp8266_native_components_to_test() -> Generator[Mock, None, None]:
|
||||
"""Mock esp8266_native_components_to_test from determine_jobs.
|
||||
|
||||
main() drives both the ``esp8266_native`` boolean output and the
|
||||
``esp8266_native_components`` CSV from this one function.
|
||||
"""
|
||||
with patch.object(determine_jobs, "esp8266_native_components_to_test") as mock:
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_determine_cpp_unit_tests() -> Generator[Mock, None, None]:
|
||||
"""Mock determine_cpp_unit_tests from helpers."""
|
||||
@@ -117,7 +106,6 @@ def clear_determine_jobs_caches() -> None:
|
||||
"""Clear all cached functions before each test."""
|
||||
determine_jobs._is_clang_tidy_full_scan.cache_clear()
|
||||
determine_jobs._component_has_tests.cache_clear()
|
||||
determine_jobs._cached_components_closure.cache_clear()
|
||||
|
||||
|
||||
def test_main_all_tests_should_run(
|
||||
@@ -128,7 +116,6 @@ def test_main_all_tests_should_run(
|
||||
mock_should_run_import_time: Mock,
|
||||
mock_should_run_device_builder: Mock,
|
||||
mock_esp32_platformio_components_to_test: Mock,
|
||||
mock_esp8266_native_components_to_test: Mock,
|
||||
mock_changed_files: Mock,
|
||||
mock_determine_cpp_unit_tests: Mock,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
@@ -145,7 +132,6 @@ def test_main_all_tests_should_run(
|
||||
mock_should_run_import_time.return_value = True
|
||||
mock_should_run_device_builder.return_value = True
|
||||
mock_esp32_platformio_components_to_test.return_value = ["api", "esp32"]
|
||||
mock_esp8266_native_components_to_test.return_value = ["api", "logger"]
|
||||
mock_determine_cpp_unit_tests.return_value = (False, ["wifi", "api", "sensor"])
|
||||
|
||||
# Mock changed_files to return non-component files (to avoid memory impact)
|
||||
@@ -222,8 +208,6 @@ def test_main_all_tests_should_run(
|
||||
assert output["device_builder"] is True
|
||||
assert output["esp32_platformio"] is True
|
||||
assert output["esp32_platformio_components"] == "api,esp32"
|
||||
assert output["esp8266_native"] is True
|
||||
assert output["esp8266_native_components"] == "api,logger"
|
||||
assert output["changed_components"] == ["wifi", "api", "sensor"]
|
||||
# changed_components_with_tests will only include components that actually have test files
|
||||
assert "changed_components_with_tests" in output
|
||||
@@ -260,7 +244,6 @@ def test_main_no_tests_should_run(
|
||||
mock_should_run_import_time: Mock,
|
||||
mock_should_run_device_builder: Mock,
|
||||
mock_esp32_platformio_components_to_test: Mock,
|
||||
mock_esp8266_native_components_to_test: Mock,
|
||||
mock_changed_files: Mock,
|
||||
mock_determine_cpp_unit_tests: Mock,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
@@ -277,7 +260,6 @@ def test_main_no_tests_should_run(
|
||||
mock_should_run_import_time.return_value = False
|
||||
mock_should_run_device_builder.return_value = False
|
||||
mock_esp32_platformio_components_to_test.return_value = []
|
||||
mock_esp8266_native_components_to_test.return_value = []
|
||||
mock_determine_cpp_unit_tests.return_value = (False, [])
|
||||
|
||||
# Mock changed_files to return no component files
|
||||
@@ -320,8 +302,6 @@ def test_main_no_tests_should_run(
|
||||
assert output["device_builder"] is False
|
||||
assert output["esp32_platformio"] is False
|
||||
assert output["esp32_platformio_components"] == ""
|
||||
assert output["esp8266_native"] is False
|
||||
assert output["esp8266_native_components"] == ""
|
||||
assert output["changed_components"] == []
|
||||
assert output["changed_components_with_tests"] == []
|
||||
assert output["component_test_count"] == 0
|
||||
@@ -1013,10 +993,6 @@ _ESP32_PLATFORMIO_FULL_LIST_FILES = [
|
||||
# PlatformIO subsystem (path-prefix trigger) + build generator
|
||||
["esphome/platformio/runner.py"],
|
||||
["esphome/platformio/toolchain.py"],
|
||||
# The pch modules are standalone triggers, not the whole build_helpers/
|
||||
["esphome/build_helpers/pch.py"],
|
||||
["esphome/build_helpers/ccache.py"],
|
||||
["esphome/build_helpers/idedata.py"],
|
||||
["esphome/build_gen/platformio.py"],
|
||||
# Workflow / harness files
|
||||
["script/test_build_components.py"],
|
||||
@@ -1075,9 +1051,6 @@ def test_esp32_platformio_components_to_test_returns_full_list_on_infrastructure
|
||||
# Non-PlatformIO files in esphome/build_gen/ do NOT trigger the
|
||||
# full list -- only esphome/build_gen/platformio.py is a trigger.
|
||||
(["esphome/build_gen/espidf.py"], [], []),
|
||||
# build_helpers modules the pch does not import are not triggers.
|
||||
(["esphome/build_helpers/size_summary.py"], [], []),
|
||||
(["esphome/build_helpers/ninja.py"], [], []),
|
||||
# Docs / unrelated files -> empty.
|
||||
(["README.md"], [], []),
|
||||
([], [], []),
|
||||
@@ -3178,78 +3151,6 @@ def test_memory_impact_elf_layouts_are_found(tmp_path: Path) -> None:
|
||||
assert find_elf_path(build_path) == elf, f"{platform} ELF not found"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"changed",
|
||||
[
|
||||
"esphome/arduino8266/framework.py",
|
||||
"esphome/build_gen/arduino8266.py",
|
||||
"esphome/components/esp8266/build_surgery.py",
|
||||
# Shared modules the native build depends on
|
||||
"esphome/build_helpers/idedata.py",
|
||||
"esphome/platformio/library.py",
|
||||
# Top-level esphome/*.py modules the backend imports directly
|
||||
"esphome/framework_helpers.py",
|
||||
"esphome/writer.py",
|
||||
# esp8266/__init__.py imports copy_ccache_script from it
|
||||
"esphome/platformio/toolchain.py",
|
||||
# The composite cache action must not ship unexercised
|
||||
".github/actions/cache-arduino8266/action.yml",
|
||||
],
|
||||
)
|
||||
def test_esp8266_native_components_full_list_on_infra_change(changed: str) -> None:
|
||||
"""Native-ESP8266 infrastructure changes run the full test list."""
|
||||
with (
|
||||
patch.object(determine_jobs, "changed_files", return_value=[changed]),
|
||||
patch.object(
|
||||
determine_jobs,
|
||||
"get_components_with_dependencies",
|
||||
return_value=["wifi"],
|
||||
),
|
||||
):
|
||||
result = determine_jobs.esp8266_native_components_to_test()
|
||||
assert result == sorted(determine_jobs.ESP8266_NATIVE_TEST_COMPONENTS)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("changed_files", "dependency_closure", "expected"),
|
||||
[
|
||||
# Tested component changed -- narrow to the intersection.
|
||||
(
|
||||
["esphome/components/mqtt/mqtt_client.cpp"],
|
||||
["mqtt", "json"],
|
||||
["mqtt"],
|
||||
),
|
||||
# Components outside the test set return an empty list (job skipped).
|
||||
(
|
||||
["esphome/components/wifi/wifi_component.cpp"],
|
||||
["wifi", "network"],
|
||||
[],
|
||||
),
|
||||
# espidf infrastructure is not an esp8266-native trigger; the
|
||||
# native backend depends on esphome/build_helpers/ instead.
|
||||
(["esphome/build_gen/espidf.py"], [], []),
|
||||
(["esphome/espidf/toolchain.py"], [], []),
|
||||
(["README.md"], [], []),
|
||||
],
|
||||
)
|
||||
def test_esp8266_native_components_to_test_narrowing(
|
||||
changed_files: list[str],
|
||||
dependency_closure: list[str],
|
||||
expected: list[str],
|
||||
) -> None:
|
||||
"""Component changes narrow the native-ESP8266 test list."""
|
||||
with (
|
||||
patch.object(determine_jobs, "changed_files", return_value=changed_files),
|
||||
patch.object(
|
||||
determine_jobs,
|
||||
"get_components_with_dependencies",
|
||||
return_value=dependency_closure,
|
||||
),
|
||||
):
|
||||
result = determine_jobs.esp8266_native_components_to_test()
|
||||
assert result == expected
|
||||
|
||||
|
||||
def test_compute_integration_test_buckets_no_durations_full_fanout() -> None:
|
||||
"""Without recorded durations the fan-out stays at the maximum."""
|
||||
files = [f"tests/integration/test_{i:03d}.py" for i in range(15)]
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
"""Unit tests for script/sync_dependency_versions.py."""
|
||||
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
import yamlrocks
|
||||
|
||||
sys.path.insert(0, str((Path(__file__).parent / ".." / ".." / "script").resolve()))
|
||||
|
||||
import sync_dependency_versions as sync_mod # noqa: E402
|
||||
|
||||
PRECOMMIT = """\
|
||||
# See https://pre-commit.com for more information
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
# Ruff version.
|
||||
rev: v0.1.0
|
||||
hooks:
|
||||
- id: ruff
|
||||
- repo: https://github.com/PyCQA/flake8
|
||||
rev: 7.0.0
|
||||
hooks:
|
||||
- id: flake8
|
||||
- repo: https://github.com/asottile/pyupgrade
|
||||
rev: v3.0.0
|
||||
hooks:
|
||||
- id: pyupgrade
|
||||
- repo: https://github.com/pre-commit/mirrors-clang-format
|
||||
rev: v13.0.1
|
||||
hooks:
|
||||
- id: clang-format
|
||||
- repo: https://github.com/adrienverge/yamllint.git
|
||||
rev: v1.0.0
|
||||
hooks:
|
||||
- id: yamllint
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: pylint
|
||||
"""
|
||||
|
||||
REQ_TEST = """\
|
||||
pylint==4.0.8
|
||||
flake8==7.1.0
|
||||
ruff==0.2.0 # comment
|
||||
pyupgrade==3.0.0
|
||||
"""
|
||||
|
||||
REQ_DEV = """\
|
||||
clang-format==13.0.1
|
||||
yamllint==1.0.0
|
||||
"""
|
||||
|
||||
RUFF_REPO = "https://github.com/astral-sh/ruff-pre-commit"
|
||||
DUPLICATE_RUFF_BLOCK = f" - repo: {RUFF_REPO}\n rev: v0.3.0\n hooks: []\n"
|
||||
|
||||
EXPECTED_DRIFT = ["ruff: 0.1.0 -> 0.2.0", "flake8: 7.0.0 -> 7.1.0"]
|
||||
EXPECTED_PRECOMMIT = PRECOMMIT.replace("rev: v0.1.0", "rev: v0.2.0").replace(
|
||||
"rev: 7.0.0", "rev: 7.1.0"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def root(tmp_path: Path) -> Path:
|
||||
"""A fake checkout where ruff (v-prefixed) and flake8 (bare) have drifted."""
|
||||
(tmp_path / ".pre-commit-config.yaml").write_text(PRECOMMIT)
|
||||
(tmp_path / "requirements_test.txt").write_text(REQ_TEST)
|
||||
(tmp_path / "requirements_dev.txt").write_text(REQ_DEV)
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _load(text: str) -> object:
|
||||
return yamlrocks.loads(text.encode(), option=yamlrocks.OPT_ROUND_TRIP)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("requirements", "expected"),
|
||||
[
|
||||
("prek==0.5.1 # comment\n", "0.5.1"),
|
||||
("Prek==0.5.1\n", "0.5.1"),
|
||||
("other==1.0\nprek==0.5.1\n", "0.5.1"),
|
||||
("prek>=0.5.1\n", None),
|
||||
("prek-extra==0.5.1\n", None),
|
||||
("", None),
|
||||
],
|
||||
)
|
||||
def test_read_requirement_version(requirements: str, expected: str | None) -> None:
|
||||
assert sync_mod.read_requirement_version(requirements, "prek") == expected
|
||||
|
||||
|
||||
def test_find_repo_entry() -> None:
|
||||
entry = sync_mod.find_repo_entry(_load(PRECOMMIT), RUFF_REPO)
|
||||
assert entry["rev"] == "v0.1.0"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("text", "message"),
|
||||
[
|
||||
("hooks: []\n", "missing key 'repos'"),
|
||||
("repos:\n - rev: 1.0.0\n", "missing key 'repo'"),
|
||||
(PRECOMMIT + DUPLICATE_RUFF_BLOCK, "found 2"),
|
||||
("repos:\n - repo: other\n rev: 1.0.0\n", "found 0"),
|
||||
],
|
||||
)
|
||||
def test_find_repo_entry_errors(text: str, message: str) -> None:
|
||||
with pytest.raises(sync_mod.SyncError, match=message):
|
||||
sync_mod.find_repo_entry(_load(text), RUFF_REPO)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("rev", "expected"),
|
||||
[("v0.1.0", ("v", "0.1.0")), ("7.0.0", ("", "7.0.0")), ("'1.0'", ("", "1.0"))],
|
||||
)
|
||||
def test_current_rev(rev: str, expected: tuple[str, str]) -> None:
|
||||
doc = _load(f"repos:\n - repo: {RUFF_REPO}\n rev: {rev}\n")
|
||||
assert sync_mod.current_rev(doc["repos"][0], RUFF_REPO) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("block", "message"),
|
||||
[(" hooks: []\n", "has no rev"), (" rev: 1.0\n", "not a string: 1.0")],
|
||||
)
|
||||
def test_current_rev_errors(block: str, message: str) -> None:
|
||||
doc = _load(f"repos:\n - repo: {RUFF_REPO}\n{block}")
|
||||
with pytest.raises(sync_mod.SyncError, match=message):
|
||||
sync_mod.current_rev(doc["repos"][0], RUFF_REPO)
|
||||
|
||||
|
||||
def test_sync_reports_without_writing(root: Path) -> None:
|
||||
assert sync_mod.sync(root, write=False) == EXPECTED_DRIFT
|
||||
assert (root / ".pre-commit-config.yaml").read_text() == PRECOMMIT
|
||||
|
||||
|
||||
def test_sync_writes_keeps_layout_and_is_idempotent(root: Path) -> None:
|
||||
assert sync_mod.sync(root, write=True) == EXPECTED_DRIFT
|
||||
assert (root / ".pre-commit-config.yaml").read_text() == EXPECTED_PRECOMMIT
|
||||
assert sync_mod.sync(root, write=True) == []
|
||||
|
||||
|
||||
def test_sync_does_not_touch_a_config_that_matches(root: Path) -> None:
|
||||
(root / ".pre-commit-config.yaml").write_text(EXPECTED_PRECOMMIT)
|
||||
before = (root / ".pre-commit-config.yaml").stat().st_mtime_ns
|
||||
assert sync_mod.sync(root, write=True) == []
|
||||
assert (root / ".pre-commit-config.yaml").stat().st_mtime_ns == before
|
||||
|
||||
|
||||
def test_sync_missing_requirement_pin(root: Path) -> None:
|
||||
(root / "requirements_dev.txt").write_text("")
|
||||
with pytest.raises(sync_mod.SyncError, match="no 'clang-format==' pin"):
|
||||
sync_mod.sync(root, write=True)
|
||||
|
||||
|
||||
def test_sync_propagates_config_errors(root: Path) -> None:
|
||||
(root / ".pre-commit-config.yaml").write_text(PRECOMMIT + DUPLICATE_RUFF_BLOCK)
|
||||
with pytest.raises(sync_mod.SyncError, match="found 2"):
|
||||
sync_mod.sync(root, write=True)
|
||||
|
||||
|
||||
def test_main_check_reports_drift(
|
||||
root: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
assert sync_mod.main(["--check", "--root", str(root)]) == 1
|
||||
assert capsys.readouterr().out.splitlines() == EXPECTED_DRIFT
|
||||
assert (root / ".pre-commit-config.yaml").read_text() == PRECOMMIT
|
||||
|
||||
|
||||
def test_main_writes_then_check_is_clean(
|
||||
root: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
assert sync_mod.main(["--root", str(root)]) == 0
|
||||
assert capsys.readouterr().out.splitlines() == EXPECTED_DRIFT
|
||||
assert sync_mod.main(["--check", "--root", str(root)]) == 0
|
||||
assert capsys.readouterr().out == ""
|
||||
|
||||
|
||||
def test_main_reports_sync_error(
|
||||
root: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
(root / "requirements_dev.txt").write_text("")
|
||||
assert sync_mod.main(["--root", str(root)]) == 1
|
||||
assert (
|
||||
"error: requirements_dev.txt: no 'clang-format==' pin"
|
||||
in capsys.readouterr().err
|
||||
)
|
||||
|
||||
|
||||
def test_main_defaults_to_repo_root(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
seen: dict[str, object] = {}
|
||||
|
||||
def fake_sync(root: Path, *, write: bool) -> list[str]:
|
||||
seen["root"] = root
|
||||
seen["write"] = write
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(sync_mod, "sync", fake_sync)
|
||||
assert sync_mod.main([]) == 0
|
||||
assert seen == {"root": sync_mod.REPO_ROOT, "write": True}
|
||||
|
||||
|
||||
def test_repository_is_in_sync() -> None:
|
||||
"""The real checkout must match; a failure here means a rev has drifted.
|
||||
|
||||
Also proves every SYNC_TARGETS entry still resolves in the real files.
|
||||
"""
|
||||
assert sync_mod.sync(sync_mod.REPO_ROOT, write=False) == []
|
||||
|
||||
|
||||
def test_cli_entry_point(root: Path) -> None:
|
||||
"""Run the script the way the workflow does, as a subprocess."""
|
||||
script = Path(sync_mod.__file__)
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(script), "--check", "--root", str(root)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode == 1
|
||||
assert result.stdout.splitlines() == EXPECTED_DRIFT
|
||||
@@ -236,98 +236,3 @@ def test_run_grouped_test_closes_group_when_subprocess_raises(
|
||||
)
|
||||
|
||||
assert "::endgroup::" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_components_empty_match_fails_with_flag(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""Under --fail-on-no-tests, a real component filtered to a platform
|
||||
with no matching test file must not pass CI as a green zero-component
|
||||
compile."""
|
||||
rc = tbc.test_components(
|
||||
["logger"],
|
||||
"zz-none",
|
||||
"compile",
|
||||
False,
|
||||
enable_grouping=False,
|
||||
fail_on_no_tests=True,
|
||||
)
|
||||
assert rc == 1
|
||||
assert "No tests ran for requested pattern(s): logger" in (capsys.readouterr().out)
|
||||
|
||||
|
||||
def test_components_component_with_no_base_file_fails_with_flag(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A component whose fixture matches the platform but whose platform has
|
||||
no base file builds nothing; under the flag that silent zero fails by
|
||||
component name instead of hiding behind other components."""
|
||||
monkeypatch.setattr(tbc, "get_platform_base_files", lambda base_dir: {})
|
||||
rc = tbc.test_components(
|
||||
["logger"],
|
||||
"esp8266-ard",
|
||||
"compile",
|
||||
False,
|
||||
enable_grouping=False,
|
||||
fail_on_no_tests=True,
|
||||
)
|
||||
assert rc == 1
|
||||
assert "No tests ran for requested pattern(s): logger" in (capsys.readouterr().out)
|
||||
|
||||
|
||||
def test_components_blank_list_fails_with_flag(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""A fully blank component list must not slide into the baseline
|
||||
fallback and exit green under the flag."""
|
||||
rc = tbc.test_components(
|
||||
[""], "esp8266-ard", "compile", False, fail_on_no_tests=True
|
||||
)
|
||||
assert rc == 1
|
||||
assert "blank component list" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_components_wildcard_no_match_fails_with_flag(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""A wildcard matching nothing must not degrade to the synthetic
|
||||
baseline build and exit green under the flag."""
|
||||
rc = tbc.test_components(
|
||||
["zz_no_such*"],
|
||||
"esp8266-ard",
|
||||
"compile",
|
||||
False,
|
||||
enable_grouping=False,
|
||||
fail_on_no_tests=True,
|
||||
)
|
||||
assert rc == 1
|
||||
assert "No components found matching" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_components_empty_match_tolerated_without_flag() -> None:
|
||||
"""The esp32-ard smoke leg deliberately builds only the subset with a
|
||||
matching fixture; without the flag an empty match stays green."""
|
||||
assert (
|
||||
tbc.test_components(
|
||||
["logger"], "zz-none", "compile", False, enable_grouping=False
|
||||
)
|
||||
== 0
|
||||
)
|
||||
|
||||
|
||||
def test_components_unknown_component_fails_with_flag(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""A renamed smoke-test component must shrink coverage loudly, not fall
|
||||
into the reference-baseline build."""
|
||||
rc = tbc.test_components(
|
||||
["no_such_component_xyz"],
|
||||
"esp8266-ard",
|
||||
"compile",
|
||||
False,
|
||||
enable_grouping=False,
|
||||
fail_on_no_tests=True,
|
||||
)
|
||||
assert rc == 1
|
||||
assert "No components found matching" in capsys.readouterr().out
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -245,20 +244,3 @@ def test_copy_failure_leaves_no_partial_output(tmp_path: Path) -> None:
|
||||
):
|
||||
assert build_tool.main() == 1
|
||||
assert not dst.exists()
|
||||
|
||||
|
||||
def test_touch_creates_and_updates_stamp(tmp_path: Path) -> None:
|
||||
stamp = tmp_path / "esphome_pch.probe"
|
||||
assert build_tool._run_touch(str(stamp)) == 0
|
||||
assert stamp.is_file()
|
||||
os.utime(stamp, (1, 1))
|
||||
assert build_tool._run_touch(str(stamp)) == 0
|
||||
assert stamp.stat().st_mtime > 1
|
||||
|
||||
|
||||
def test_touch_reports_failure(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
missing_dir = tmp_path / "gone" / "stamp"
|
||||
assert build_tool._run_touch(str(missing_dir)) == 1
|
||||
assert "touch:" in capsys.readouterr().err
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user