mirror of
https://github.com/esphome/esphome.git
synced 2026-09-04 03:56:04 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91219375e1 |
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "d=\"${CLAUDE_PROJECT_DIR:-.}\"; [ -x \"$d/venv/bin/python\" ] || { mkdir -p \"$d/.temp\"; env -u VIRTUAL_ENV \"$d/script/setup\" >\"$d/.temp/setup.log\" 2>&1 || echo '{\"systemMessage\":\"script/setup failed; see .temp/setup.log\"}'; }",
|
||||
"statusMessage": "Setting up dev environment (script/setup)...",
|
||||
"timeout": 900
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
ARG BUILD_BASE_VERSION=2026.06.1
|
||||
ARG BUILD_BASE_VERSION=2025.04.0
|
||||
|
||||
|
||||
FROM ghcr.io/esphome/docker-base:debian-${BUILD_BASE_VERSION} AS base
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
// uncomment and edit the path in order to pass through local USB serial to the container
|
||||
// , "--device=/dev/ttyACM0"
|
||||
],
|
||||
"appPort": 6052,
|
||||
// if you are using avahi in the host device, uncomment these to allow the
|
||||
// devcontainer to find devices via mdns
|
||||
//"mounts": [
|
||||
@@ -40,11 +41,7 @@
|
||||
],
|
||||
"settings": {
|
||||
"python.languageServer": "Pylance",
|
||||
// Use the container's pre-provisioned venv (built by the Dockerfile, outside the
|
||||
// bind-mounted workspace) rather than a ./venv that may leak in from the host and
|
||||
// mismatch the container's Python. See .devcontainer/Dockerfile (esphome-venv).
|
||||
"python.defaultInterpreterPath": "/home/esphome/.local/esphome-venv/bin/python",
|
||||
"python.terminal.activateEnvironment": true,
|
||||
"python.pythonPath": "/usr/bin/python3",
|
||||
"pylint.args": [
|
||||
"--rcfile=${workspaceFolder}/pyproject.toml"
|
||||
],
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
# Normalize line endings to LF in the repository
|
||||
* text eol=lf
|
||||
*.png binary
|
||||
*.gif binary
|
||||
*.apng binary
|
||||
|
||||
@@ -8,7 +8,7 @@ contact_links:
|
||||
url: https://github.com/esphome/esphome-webserver/issues/new/choose
|
||||
about: Report an issue with the ESPHome web server.
|
||||
- name: Report an issue with the ESPHome Builder / Dashboard
|
||||
url: https://github.com/esphome/device-builder/issues/new/choose
|
||||
url: https://github.com/esphome/dashboard/issues/new/choose
|
||||
about: Report an issue with the ESPHome Builder / Dashboard.
|
||||
- name: Report an issue with the ESPHome API client
|
||||
url: https://github.com/esphome/aioesphomeapi/issues/new/choose
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
|
||||
- [ ] Bugfix (non-breaking change which fixes an issue)
|
||||
- [ ] New feature (non-breaking change which adds functionality)
|
||||
- [ ] New developer-facing feature (adds functionality for component developers; no end-user configuration change)
|
||||
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) — [policy](https://developers.esphome.io/contributing/code/#what-constitutes-a-c-breaking-change)
|
||||
- [ ] Developer breaking change (an API change that could break external components) — [policy](https://developers.esphome.io/contributing/code/#what-is-considered-public-c-api)
|
||||
- [ ] Undocumented C++ API change (removal or change of undocumented public methods that lambda users may depend on) — [policy](https://developers.esphome.io/contributing/code/#c-user-expectations)
|
||||
@@ -21,10 +20,6 @@
|
||||
|
||||
- esphome/esphome.io#<esphome.io PR number goes here>
|
||||
|
||||
**Pull request in [developers.esphome.io](https://github.com/esphome/developers.esphome.io) with developer documentation (if applicable):**
|
||||
|
||||
- esphome/developers.esphome.io#<developers.esphome.io PR number goes here>
|
||||
|
||||
## Test Environment
|
||||
|
||||
- [ ] ESP32
|
||||
|
||||
@@ -42,7 +42,7 @@ runs:
|
||||
|
||||
- name: Build and push to ghcr by digest
|
||||
id: build-ghcr
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
env:
|
||||
DOCKER_BUILD_SUMMARY: false
|
||||
DOCKER_BUILD_RECORD_UPLOAD: false
|
||||
@@ -67,7 +67,7 @@ runs:
|
||||
|
||||
- name: Build and push to dockerhub by digest
|
||||
id: build-dockerhub
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
env:
|
||||
DOCKER_BUILD_SUMMARY: false
|
||||
DOCKER_BUILD_RECORD_UPLOAD: false
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
name: Cache clang-tidy idedata
|
||||
description: >
|
||||
Cache the clang-tidy idedata and the headers it references under .temp
|
||||
(headers only, about 30MB per env). Run after restore-python and cache-esp-idf.
|
||||
inputs:
|
||||
environment:
|
||||
description: 'clang-tidy environment (e.g. esp32-idf-tidy).'
|
||||
required: true
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Compute cache key
|
||||
id: key
|
||||
shell: bash
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
[ -n "${{ inputs.environment }}" ] || { echo "::error::cache-clang-tidy-idedata: 'environment' input is empty"; exit 1; }
|
||||
hash=$(python -c 'import sys; sys.path.insert(0, "script"); from clang_tidy_hash import idedata_cache_hash; print(idedata_cache_hash("${{ inputs.environment }}"))')
|
||||
pyver=$(python -c 'import platform; print(platform.python_version())')
|
||||
# Generating idedata is what installs ESP-IDF; never skip it over a missing
|
||||
# install. This also skips the save, so a dev run that installs ESP-IDF
|
||||
# warms the idedata cache on the next run.
|
||||
if [ -d ~/.esphome-idf/frameworks ]; then
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "ESP-IDF install missing, not using the clang-tidy idedata cache"
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
echo "key=${{ runner.os }}-tidy-idedata-${{ inputs.environment }}-$hash-py$pyver" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "path<<EOF"
|
||||
printf '%s\n' '.temp/idedata-*.json' '.temp/idedata-*.hash'
|
||||
printf '.temp/**/*.%s\n' h hpp hh hxx inc inl ipp tpp
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
# Mirror cache-esp-idf: write on dev, restore-only on PRs. The post-step
|
||||
# save only runs when the job succeeded, so a failed generation is never saved.
|
||||
# Extend the extension list if a component ships extensionless headers.
|
||||
- name: Cache clang-tidy idedata (write on dev)
|
||||
if: (github.ref == 'refs/heads/dev' || contains(github.event.pull_request.labels.*.name, 'ci-cache-write')) && steps.key.outputs.skip != 'true'
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ${{ steps.key.outputs.path }}
|
||||
key: ${{ steps.key.outputs.key }}
|
||||
- name: Cache clang-tidy idedata (restore-only off dev)
|
||||
if: github.ref != 'refs/heads/dev' && !contains(github.event.pull_request.labels.*.name, 'ci-cache-write') && steps.key.outputs.skip != 'true'
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ${{ steps.key.outputs.path }}
|
||||
key: ${{ steps.key.outputs.key }}
|
||||
@@ -3,10 +3,8 @@ description: >
|
||||
Resolve the pinned ESP-IDF version and cache the native ESP-IDF install
|
||||
(toolchains + source) at ~/.esphome-idf. Every job that installs ESP-IDF
|
||||
natively (clang-tidy for IDF/Arduino and the component test batches) shares
|
||||
one cache, since the install is identical: ESPHOME_IDF_DEFAULT_TARGETS
|
||||
defaults to "all", and _get_configured_targets() in espidf/toolchain.py
|
||||
skips per-variant narrowing whenever CI is set, so all toolchains are
|
||||
present regardless of the chip a job builds.
|
||||
one cache, since the install is identical (ESPHOME_IDF_DEFAULT_TARGETS
|
||||
defaults to "all", so all toolchains are present regardless of the chip).
|
||||
Callers must set env ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf and have the
|
||||
Python venv already restored.
|
||||
inputs:
|
||||
@@ -26,9 +24,6 @@ runs:
|
||||
# The native-IDF version is pinned in code, not in any file that feeds the
|
||||
# other cache keys, so resolve it explicitly. Keying on it means the cache
|
||||
# invalidates on a version bump (actions/cache never overwrites a key).
|
||||
# Also key on the Python version: the cached IDF venv links to the
|
||||
# runner's toolcache interpreter and is reinstalled every run after a
|
||||
# runner image bump.
|
||||
id: version
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -39,32 +34,19 @@ runs:
|
||||
version=$(python -c 'from esphome.components.esp32 import ESP_IDF_FRAMEWORK_VERSION_LOOKUP as L; print(L["recommended"])')
|
||||
fi
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
echo "python-version=$(python -c 'import platform; print(platform.python_version())')" >> "$GITHUB_OUTPUT"
|
||||
# Mirror the adjacent PlatformIO cache: only dev-branch runs write the
|
||||
# shared cache (so it lives in the default-branch scope readable by all
|
||||
# PRs), and PRs are restore-only -- they never push multi-GB artifacts into
|
||||
# their own scope / the repo quota (e.g. on a version-bump PR). The
|
||||
# ci-cache-write label lets a PR write into its own scope to test the hit path;
|
||||
# that costs about 1GB of the repo cache quota per run, so remove it when done.
|
||||
# -slim: bump when prune-esp-idf changes what it removes; a key is never overwritten.
|
||||
# their own scope / the repo quota (e.g. on a version-bump PR).
|
||||
- name: Cache ESP-IDF install (write on dev)
|
||||
if: (github.ref == 'refs/heads/dev' || contains(github.event.pull_request.labels.*.name, 'ci-cache-write')) && inputs.restore-only != 'true'
|
||||
if: github.ref == 'refs/heads/dev' && inputs.restore-only != 'true'
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: ~/.esphome-idf
|
||||
key: ${{ runner.os }}-esphome-idf-${{ steps.version.outputs.version }}-py${{ steps.version.outputs.python-version }}-slim
|
||||
key: ${{ runner.os }}-esphome-idf-${{ steps.version.outputs.version }}
|
||||
- name: Cache ESP-IDF install (restore-only off dev)
|
||||
if: github.ref != 'refs/heads/dev' && !contains(github.event.pull_request.labels.*.name, 'ci-cache-write') || inputs.restore-only == 'true'
|
||||
if: github.ref != 'refs/heads/dev' || inputs.restore-only == 'true'
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: ~/.esphome-idf
|
||||
key: ${{ runner.os }}-esphome-idf-${{ steps.version.outputs.version }}-py${{ steps.version.outputs.python-version }}-slim
|
||||
# Install explicitly so the prune below sees the toolchains on a cache miss
|
||||
# too, instead of the install happening inside the first build step.
|
||||
- name: Install ESP-IDF
|
||||
shell: bash
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
python -c 'from esphome.espidf.framework import check_esp_idf_install; check_esp_idf_install("${{ steps.version.outputs.version }}")'
|
||||
- name: Prune ESP-IDF install
|
||||
uses: ./.github/actions/prune-esp-idf
|
||||
key: ${{ runner.os }}-esphome-idf-${{ steps.version.outputs.version }}
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
name: Cache nRF Connect SDK
|
||||
description: >
|
||||
Resolve the pinned sdk-nrf version and cache the native sdk-nrf install
|
||||
(west workspace, Zephyr SDK toolchain, python env) at ~/.esphome-sdk-nrf.
|
||||
Every job that installs sdk-nrf natively (the nrf52 clang-tidy job and,
|
||||
once the component tests build natively, their batches) shares one cache.
|
||||
Callers must set env ESPHOME_SDK_NRF_PREFIX: ~/.esphome-sdk-nrf and have
|
||||
the Python venv already restored.
|
||||
inputs:
|
||||
restore-only:
|
||||
description: >
|
||||
When "true", only restore -- never save the cache, even on dev. Use from
|
||||
jobs that may not produce a complete install (e.g. a component batch
|
||||
that fails mid-install), so a partial install is never written.
|
||||
default: "false"
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Resolve sdk-nrf and toolchain versions for cache key
|
||||
# Both versions are pinned in code, not in any file that feeds the
|
||||
# other cache keys, so resolve them explicitly. Keying on them means
|
||||
# the cache invalidates when either is bumped (actions/cache never
|
||||
# overwrites a key).
|
||||
id: version
|
||||
shell: bash
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
version=$(python -c '
|
||||
from esphome.components.nrf52 import RECOMMENDED_SDK_NRF_VERSION
|
||||
from esphome.components.nrf52.framework import TOOLCHAIN_VERSION
|
||||
print(f"{RECOMMENDED_SDK_NRF_VERSION}-{TOOLCHAIN_VERSION}")')
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
# Mirror cache-esp-idf: only dev-branch runs write the shared cache (so it
|
||||
# lives in the default-branch scope readable by all PRs); PRs are
|
||||
# restore-only and never push multi-GB artifacts into their own scope.
|
||||
- name: Cache nRF Connect SDK install (write on dev)
|
||||
if: github.ref == 'refs/heads/dev' && inputs.restore-only != 'true'
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ~/.esphome-sdk-nrf
|
||||
# yamllint disable-line rule:line-length
|
||||
key: ${{ runner.os }}-esphome-sdk-nrf-${{ steps.version.outputs.version }}-${{ hashFiles('esphome/components/nrf52/requirements.txt') }}
|
||||
- name: Cache nRF Connect SDK install (restore-only off dev)
|
||||
if: github.ref != 'refs/heads/dev' || inputs.restore-only == 'true'
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ~/.esphome-sdk-nrf
|
||||
# yamllint disable-line rule:line-length
|
||||
key: ${{ runner.os }}-esphome-sdk-nrf-${{ steps.version.outputs.version }}-${{ hashFiles('esphome/components/nrf52/requirements.txt') }}
|
||||
@@ -1,34 +0,0 @@
|
||||
name: Prune ESP-IDF install
|
||||
description: >
|
||||
Remove the picolibc sysroots (1.1GB of the 3.9GB install) from the native
|
||||
ESP-IDF toolchains; IDF 5.x links newlib. Skipped when an IDF 6 install is
|
||||
present, which links picolibc (see esp32/__init__.py).
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Prune picolibc
|
||||
shell: bash
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
prefix="${ESPHOME_ESP_IDF_PREFIX:-$HOME/.esphome-idf}"
|
||||
prefix="${prefix/#\~/$HOME}"
|
||||
for fw in "$prefix"/frameworks/*/; do
|
||||
case "$(basename "$fw")" in
|
||||
[6-9].*) echo "IDF $(basename "$fw") installed, keeping picolibc"; exit 0 ;;
|
||||
esac
|
||||
done
|
||||
n=0
|
||||
for dir in "$prefix"/tools/*-esp-elf/*/*-esp-elf/picolibc; do
|
||||
echo "Removing $dir ($(du -sh "$dir" | cut -f1))"
|
||||
rm -rf "$dir"
|
||||
n=$((n + 1))
|
||||
done
|
||||
# The marker rides along in the cache entry so a restored slim tree stays quiet.
|
||||
if [ "$n" -gt 0 ]; then
|
||||
touch "$prefix/.picolibc-pruned"
|
||||
elif [ -d "$prefix/tools" ] && [ ! -f "$prefix/.picolibc-pruned" ]; then
|
||||
echo "::warning::no picolibc sysroots matched under $prefix/tools"
|
||||
fi
|
||||
if [ -d "$prefix" ]; then
|
||||
du -sh "$prefix"
|
||||
fi
|
||||
@@ -17,12 +17,12 @@ runs:
|
||||
steps:
|
||||
- name: Set up Python ${{ inputs.python-version }}
|
||||
id: python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
with:
|
||||
python-version: ${{ inputs.python-version }}
|
||||
- name: Restore Python virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||
with:
|
||||
path: venv
|
||||
# yamllint disable-line rule:line-length
|
||||
@@ -32,12 +32,9 @@ runs:
|
||||
# detects the activated venv via ``VIRTUAL_ENV`` so the venv layout
|
||||
# downstream jobs rely on is preserved.
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pull request saves land in per-PR scopes nothing else can
|
||||
# reuse; dev pushes seed the shared copy instead.
|
||||
save-cache: ${{ github.event_name != 'pull_request' }}
|
||||
# Pin uv version so the action does not have to fetch the
|
||||
# manifest from raw.githubusercontent.com on every cache
|
||||
# miss; that fetch flakes on Windows runners.
|
||||
@@ -49,7 +46,7 @@ runs:
|
||||
python -m venv venv
|
||||
source venv/bin/activate
|
||||
python --version
|
||||
uv pip install -r requirements.txt -r requirements_dev.txt -r requirements_test.txt
|
||||
uv pip install -r requirements.txt -r requirements_test.txt
|
||||
uv pip install -e .
|
||||
- name: Create Python virtual environment
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true' && runner.os == 'Windows'
|
||||
@@ -58,5 +55,5 @@ runs:
|
||||
python -m venv venv
|
||||
source ./venv/Scripts/activate
|
||||
python --version
|
||||
uv pip install -r requirements.txt -r requirements_dev.txt -r requirements_test.txt
|
||||
uv pip install -r requirements.txt -r requirements_test.txt
|
||||
uv pip install -e .
|
||||
|
||||
@@ -13,7 +13,6 @@ module.exports = {
|
||||
'merging-to-release',
|
||||
'merging-to-beta',
|
||||
'chained-pr',
|
||||
'stacked-pr',
|
||||
'core',
|
||||
'small-pr',
|
||||
'medium-pr',
|
||||
@@ -23,13 +22,11 @@ module.exports = {
|
||||
'has-tests',
|
||||
'needs-tests',
|
||||
'needs-docs',
|
||||
'needs-developer-docs',
|
||||
'needs-codeowners',
|
||||
'too-big',
|
||||
'labeller-recheck',
|
||||
'bugfix',
|
||||
'new-feature',
|
||||
'new-feature-developer',
|
||||
'breaking-change',
|
||||
'developer-breaking-change',
|
||||
'undocumented-api-change',
|
||||
@@ -43,17 +40,5 @@ module.exports = {
|
||||
// Keep matching the old esphome-docs name during the transition period
|
||||
/https:\/\/github\.com\/esphome\/esphome-docs\/pull\/\d+/,
|
||||
/esphome\/esphome-docs#\d+/
|
||||
],
|
||||
|
||||
DEVELOPER_DOCS_PR_PATTERNS: [
|
||||
/https:\/\/github\.com\/esphome\/developers\.esphome\.io\/pull\/\d+/,
|
||||
/esphome\/developers\.esphome\.io#\d+/
|
||||
],
|
||||
|
||||
// Files whose developer-facing changes are documented via Python docstrings
|
||||
// only - developers.esphome.io has no reference page for them yet, so PRs
|
||||
// touching nothing but these files (and tests/) skip needs-developer-docs.
|
||||
DEV_DOCS_EXEMPT_FILES: [
|
||||
'esphome/config_validation.py'
|
||||
]
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const { DOCS_PR_PATTERNS, DEVELOPER_DOCS_PR_PATTERNS, DEV_DOCS_EXEMPT_FILES } = require('./constants');
|
||||
const { DOCS_PR_PATTERNS } = require('./constants');
|
||||
const {
|
||||
COMPONENT_REGEX,
|
||||
detectComponents,
|
||||
@@ -33,54 +33,16 @@ async function fetchPrFileContent(github, context, path) {
|
||||
}
|
||||
}
|
||||
|
||||
// Check whether a pull request is part of a GitHub stack.
|
||||
//
|
||||
// GitHub's stacked pull request feature adds a `stack` object to the pull
|
||||
// request resource. It is present on every pull request in the stack -
|
||||
// including the bottom one, whose base is already `dev` - and is absent
|
||||
// entirely on standalone pull requests.
|
||||
//
|
||||
// The `pull_request_target` webhook payload is not guaranteed to carry this
|
||||
// field, so fall back to asking the API when it is missing. Guessing wrong
|
||||
// here is costly: a stacked pull request mistaken for a manually chained one
|
||||
// gets a label that blocks merging.
|
||||
async function isStackedPr(github, context) {
|
||||
const pr = context.payload.pull_request;
|
||||
if (pr.stack != null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const { owner, repo } = context.repo;
|
||||
const { data } = await github.rest.pulls.get({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pr.number,
|
||||
});
|
||||
return data.stack != null;
|
||||
} catch (error) {
|
||||
// Treat an API failure as "not stacked" so a chained pull request still
|
||||
// gets its blocking label rather than silently slipping through.
|
||||
console.log('Failed to check stack membership:', error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy: Merge branch detection
|
||||
async function detectMergeBranch(github, context) {
|
||||
async function detectMergeBranch(context) {
|
||||
const labels = new Set();
|
||||
const baseRef = context.payload.pull_request.base.ref;
|
||||
const defaultBranch = context.payload.repository.default_branch;
|
||||
|
||||
if (baseRef === 'release') {
|
||||
labels.add('merging-to-release');
|
||||
} else if (baseRef === 'beta') {
|
||||
labels.add('merging-to-beta');
|
||||
} else if (await isStackedPr(github, context)) {
|
||||
// GitHub manages the merge order for a stack, so these are not blocked.
|
||||
labels.add('stacked-pr');
|
||||
} else if (baseRef !== defaultBranch) {
|
||||
// A chain built by hand: it must not merge until its base branch does.
|
||||
} else if (baseRef !== 'dev') {
|
||||
labels.add('chained-pr');
|
||||
}
|
||||
|
||||
@@ -283,7 +245,6 @@ async function detectPRTemplateCheckboxes(context) {
|
||||
const checkboxPatterns = [
|
||||
{ pattern: /- \[x\] Bugfix \(non-breaking change which fixes an issue\)/i, label: 'bugfix' },
|
||||
{ pattern: /- \[x\] New feature \(non-breaking change which adds functionality\)/i, label: 'new-feature' },
|
||||
{ pattern: /- \[x\] New developer-facing feature \(adds functionality for component developers; no end-user configuration change\)/i, label: 'new-feature-developer' },
|
||||
{ pattern: /- \[x\] Breaking change \(fix or feature that would cause existing functionality to not work as expected\)/i, label: 'breaking-change' },
|
||||
{ pattern: /- \[x\] Developer breaking change \(an API change that could break external components\)/i, label: 'developer-breaking-change' },
|
||||
{ pattern: /- \[x\] Undocumented C\+\+ API change \(removal or change of undocumented public methods that lambda users may depend on\)/i, label: 'undocumented-api-change' },
|
||||
@@ -394,14 +355,12 @@ async function detectRequirements(allLabels, prFiles, context, hasYamlLoadable)
|
||||
const labels = new Set();
|
||||
|
||||
// Check for missing tests
|
||||
if ((allLabels.has('new-component') || allLabels.has('new-platform') || allLabels.has('new-feature') || allLabels.has('new-feature-developer')) && !allLabels.has('has-tests')) {
|
||||
if ((allLabels.has('new-component') || allLabels.has('new-platform') || allLabels.has('new-feature')) && !allLabels.has('has-tests')) {
|
||||
labels.add('needs-tests');
|
||||
}
|
||||
|
||||
// Check for missing docs.
|
||||
// `new-feature` (PR-body checkbox) always counts. `new-feature-developer` is
|
||||
// deliberately excluded here: its docs live on developers.esphome.io and are
|
||||
// checked separately below. `new-component` / `new-platform`
|
||||
// `new-feature` (PR-body checkbox) always counts. `new-component` / `new-platform`
|
||||
// only count when at least one newly added file defines a top-level CONFIG_SCHEMA,
|
||||
// i.e. the new component/platform is actually loadable from YAML.
|
||||
const docsEligible =
|
||||
@@ -417,22 +376,6 @@ async function detectRequirements(allLabels, prFiles, context, hasYamlLoadable)
|
||||
}
|
||||
}
|
||||
|
||||
// Check for missing developer docs. `new-feature-developer` requires a
|
||||
// developers.esphome.io PR link, unless every changed file outside tests/ is
|
||||
// in DEV_DOCS_EXEMPT_FILES (core validators documented via docstrings only).
|
||||
if (allLabels.has('new-feature-developer')) {
|
||||
const prBody = context.payload.pull_request.body || '';
|
||||
const nonTestFiles = prFiles
|
||||
.map(file => file.filename)
|
||||
.filter(file => !file.startsWith('tests/'));
|
||||
const onlyExemptFiles = nonTestFiles.every(file => DEV_DOCS_EXEMPT_FILES.includes(file));
|
||||
const hasDevDocsLink = DEVELOPER_DOCS_PR_PATTERNS.some(pattern => pattern.test(prBody));
|
||||
|
||||
if (!onlyExemptFiles && !hasDevDocsLink) {
|
||||
labels.add('needs-developer-docs');
|
||||
}
|
||||
}
|
||||
|
||||
// Check for missing CODEOWNERS
|
||||
if (allLabels.has('new-component')) {
|
||||
const codeownersModified = prFiles.some(file =>
|
||||
|
||||
@@ -88,7 +88,7 @@ module.exports = async ({ github, context }) => {
|
||||
|
||||
// Early exit for release and beta branches only
|
||||
if (baseRef === 'release' || baseRef === 'beta') {
|
||||
const branchLabels = await detectMergeBranch(github, context);
|
||||
const branchLabels = await detectMergeBranch(context);
|
||||
const finalLabels = Array.from(branchLabels);
|
||||
|
||||
console.log('Computed labels (merge branch only):', finalLabels.join(', '));
|
||||
@@ -118,7 +118,7 @@ module.exports = async ({ github, context }) => {
|
||||
deprecatedResult,
|
||||
maintainerAccess
|
||||
] = await Promise.all([
|
||||
detectMergeBranch(github, context),
|
||||
detectMergeBranch(context),
|
||||
detectComponentPlatforms(changedFiles, apiData),
|
||||
detectNewComponents(github, context, prFiles),
|
||||
detectNewPlatforms(github, context, prFiles, apiData),
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
const { describe, it } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const {
|
||||
detectMergeBranch,
|
||||
detectNewPlatforms,
|
||||
detectNewComponents,
|
||||
detectPRSize,
|
||||
detectPRTemplateCheckboxes,
|
||||
detectRequirements,
|
||||
} = require('../detectors');
|
||||
const { MANAGED_LABELS } = require('../constants');
|
||||
const { detectNewPlatforms, detectNewComponents, detectPRSize } = require('../detectors');
|
||||
|
||||
// Minimal GitHub API mock — only repos.getContent is called by detectNewPlatforms/detectNewComponents
|
||||
// to check for CONFIG_SCHEMA in newly added files.
|
||||
@@ -37,122 +29,6 @@ const API_DATA = {
|
||||
const WITH_SCHEMA = 'CONFIG_SCHEMA = cv.Schema({})';
|
||||
const WITHOUT_SCHEMA = 'CODEOWNERS = ["@esphome/core"]';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// detectMergeBranch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Builds a fresh context for detectMergeBranch tests instead of mutating the
|
||||
// shared CONTEXT fixture above (which other describe blocks rely on).
|
||||
function makeMergeContext(baseRef, { stack, defaultBranch = 'dev' } = {}) {
|
||||
const pull_request = { number: 1, base: { ref: baseRef } };
|
||||
if (stack !== undefined) {
|
||||
pull_request.stack = stack;
|
||||
}
|
||||
return {
|
||||
repo: { owner: 'esphome', repo: 'esphome' },
|
||||
payload: { pull_request, repository: { default_branch: defaultBranch } }
|
||||
};
|
||||
}
|
||||
|
||||
// A GitHub API mock exposing only rest.pulls.get, with a call counter so
|
||||
// tests can assert whether the API fallback was actually invoked.
|
||||
function makeStackGithub({ stack = null, error = null } = {}) {
|
||||
const state = { calls: 0 };
|
||||
const github = {
|
||||
rest: {
|
||||
pulls: {
|
||||
get: async () => {
|
||||
state.calls++;
|
||||
if (error) throw error;
|
||||
return { data: { stack } };
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
return { github, state };
|
||||
}
|
||||
|
||||
const STACK_INFO = { base: { ref: 'dev' }, id: 71540, number: 17978, position: 3, size: 3 };
|
||||
|
||||
describe('detectMergeBranch', () => {
|
||||
it('base ref release adds merging-to-release only and never checks the stack', async () => {
|
||||
const { github, state } = makeStackGithub({ stack: STACK_INFO });
|
||||
const context = makeMergeContext('release', { stack: STACK_INFO });
|
||||
const labels = await detectMergeBranch(github, context);
|
||||
assert.deepEqual(Array.from(labels).sort(), ['merging-to-release']);
|
||||
assert.equal(state.calls, 0);
|
||||
});
|
||||
|
||||
it('base ref beta adds merging-to-beta only and never checks the stack', async () => {
|
||||
const { github, state } = makeStackGithub({ stack: STACK_INFO });
|
||||
const context = makeMergeContext('beta', { stack: STACK_INFO });
|
||||
const labels = await detectMergeBranch(github, context);
|
||||
assert.deepEqual(Array.from(labels).sort(), ['merging-to-beta']);
|
||||
assert.equal(state.calls, 0);
|
||||
});
|
||||
|
||||
it('stack present on the webhook payload adds stacked-pr without calling the API', async () => {
|
||||
const { github, state } = makeStackGithub();
|
||||
const context = makeMergeContext('feature-branch', { stack: STACK_INFO });
|
||||
const labels = await detectMergeBranch(github, context);
|
||||
assert.deepEqual(Array.from(labels).sort(), ['stacked-pr']);
|
||||
assert.equal(state.calls, 0);
|
||||
});
|
||||
|
||||
it('stack absent from payload falls back to the API and adds stacked-pr', async () => {
|
||||
const { github, state } = makeStackGithub({ stack: STACK_INFO });
|
||||
const context = makeMergeContext('feature-branch');
|
||||
const labels = await detectMergeBranch(github, context);
|
||||
assert.deepEqual(Array.from(labels).sort(), ['stacked-pr']);
|
||||
assert.equal(state.calls, 1);
|
||||
});
|
||||
|
||||
it('bottom of a stack (base ref dev, stack present) still adds stacked-pr', async () => {
|
||||
const { github, state } = makeStackGithub();
|
||||
const context = makeMergeContext('dev', { stack: STACK_INFO });
|
||||
const labels = await detectMergeBranch(github, context);
|
||||
assert.deepEqual(Array.from(labels).sort(), ['stacked-pr']);
|
||||
assert.equal(state.calls, 0);
|
||||
});
|
||||
|
||||
it('not stacked, base ref not dev adds chained-pr', async () => {
|
||||
const { github } = makeStackGithub({ stack: null });
|
||||
const context = makeMergeContext('feature-branch');
|
||||
const labels = await detectMergeBranch(github, context);
|
||||
assert.deepEqual(Array.from(labels).sort(), ['chained-pr']);
|
||||
});
|
||||
|
||||
it('not stacked, base ref dev adds no labels', async () => {
|
||||
const { github } = makeStackGithub({ stack: null });
|
||||
const context = makeMergeContext('dev');
|
||||
const labels = await detectMergeBranch(github, context);
|
||||
assert.deepEqual(Array.from(labels).sort(), []);
|
||||
});
|
||||
|
||||
it('a failed stack lookup falls back to not-stacked, so a feature-branch base adds chained-pr', async () => {
|
||||
const { github, state } = makeStackGithub({ error: new Error('API unavailable') });
|
||||
const context = makeMergeContext('feature-branch');
|
||||
const labels = await detectMergeBranch(github, context);
|
||||
assert.deepEqual(Array.from(labels).sort(), ['chained-pr']);
|
||||
assert.equal(state.calls, 1);
|
||||
});
|
||||
|
||||
it('base ref matches default branch adds no labels', async () => {
|
||||
const { github } = makeStackGithub({ stack: null });
|
||||
const context = makeMergeContext('other', { defaultBranch: 'other' });
|
||||
const labels = await detectMergeBranch(github, context);
|
||||
assert.deepEqual(Array.from(labels).sort(), []);
|
||||
});
|
||||
|
||||
it('base ref dev when the default branch is main adds chained-pr', async () => {
|
||||
const { github } = makeStackGithub({ stack: null });
|
||||
const context = makeMergeContext('dev', { defaultBranch: 'main' });
|
||||
const labels = await detectMergeBranch(github, context);
|
||||
assert.deepEqual(Array.from(labels).sort(), ['chained-pr']);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// detectNewPlatforms
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -270,125 +146,6 @@ describe('detectNewComponents', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// detectPRTemplateCheckboxes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const NEW_FEATURE_LINE = '- [x] New feature (non-breaking change which adds functionality)';
|
||||
const DEV_FEATURE_LINE = '- [x] New developer-facing feature (adds functionality for component developers; no end-user configuration change)';
|
||||
const DEV_FEATURE_LINE_UNTICKED = '- [ ] New developer-facing feature (adds functionality for component developers; no end-user configuration change)';
|
||||
|
||||
function makeBodyContext(body) {
|
||||
return { payload: { pull_request: { body } } };
|
||||
}
|
||||
|
||||
describe('detectPRTemplateCheckboxes', () => {
|
||||
it('ticked developer-facing feature checkbox adds new-feature-developer only', async () => {
|
||||
const labels = await detectPRTemplateCheckboxes(makeBodyContext(DEV_FEATURE_LINE));
|
||||
assert.ok(labels.has('new-feature-developer'));
|
||||
assert.ok(!labels.has('new-feature'));
|
||||
});
|
||||
|
||||
it('unticked developer-facing feature checkbox adds no label', async () => {
|
||||
const labels = await detectPRTemplateCheckboxes(makeBodyContext(DEV_FEATURE_LINE_UNTICKED));
|
||||
assert.ok(!labels.has('new-feature-developer'));
|
||||
});
|
||||
|
||||
it('ticked new feature checkbox does not add new-feature-developer', async () => {
|
||||
const labels = await detectPRTemplateCheckboxes(makeBodyContext(NEW_FEATURE_LINE));
|
||||
assert.ok(labels.has('new-feature'));
|
||||
assert.ok(!labels.has('new-feature-developer'));
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// detectRequirements
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('detectRequirements', () => {
|
||||
// PR body without any docs-PR link.
|
||||
const NO_DOCS_CONTEXT = makeBodyContext('Just a description, no docs link.');
|
||||
const USER_DOCS_CONTEXT = makeBodyContext('Docs: esphome/esphome.io#1234');
|
||||
const DEV_DOCS_CONTEXT = makeBodyContext('Docs: esphome/developers.esphome.io#1234');
|
||||
const DEV_DOCS_URL_CONTEXT = makeBodyContext('Docs: https://github.com/esphome/developers.esphome.io/pull/1234');
|
||||
|
||||
// File sets: a normal source change vs. one confined to the exempt core validators.
|
||||
const SOURCE_FILES = [
|
||||
{ filename: 'esphome/components/foo/foo.py' },
|
||||
{ filename: 'tests/components/foo/common.yaml' },
|
||||
];
|
||||
const VALIDATOR_FILES = [
|
||||
{ filename: 'esphome/config_validation.py' },
|
||||
{ filename: 'tests/unit_tests/test_config_validation.py' },
|
||||
];
|
||||
|
||||
it('new-feature-developer without has-tests adds needs-tests but not needs-docs', async () => {
|
||||
const labels = await detectRequirements(new Set(['new-feature-developer']), SOURCE_FILES, NO_DOCS_CONTEXT, false);
|
||||
assert.ok(labels.has('needs-tests'));
|
||||
assert.ok(!labels.has('needs-docs'));
|
||||
});
|
||||
|
||||
it('new-feature-developer with has-tests does not add needs-tests', async () => {
|
||||
const labels = await detectRequirements(new Set(['new-feature-developer', 'has-tests']), SOURCE_FILES, NO_DOCS_CONTEXT, false);
|
||||
assert.ok(!labels.has('needs-tests'));
|
||||
});
|
||||
|
||||
it('new-feature without a docs link still adds needs-docs', async () => {
|
||||
const labels = await detectRequirements(new Set(['new-feature', 'has-tests']), [], NO_DOCS_CONTEXT, false);
|
||||
assert.ok(labels.has('needs-docs'));
|
||||
});
|
||||
|
||||
it('new-feature-developer without a developer docs link adds needs-developer-docs', async () => {
|
||||
const labels = await detectRequirements(new Set(['new-feature-developer', 'has-tests']), SOURCE_FILES, NO_DOCS_CONTEXT, false);
|
||||
assert.ok(labels.has('needs-developer-docs'));
|
||||
});
|
||||
|
||||
it('a developers.esphome.io shorthand link satisfies needs-developer-docs', async () => {
|
||||
const labels = await detectRequirements(new Set(['new-feature-developer', 'has-tests']), SOURCE_FILES, DEV_DOCS_CONTEXT, false);
|
||||
assert.ok(!labels.has('needs-developer-docs'));
|
||||
});
|
||||
|
||||
it('a developers.esphome.io URL link satisfies needs-developer-docs', async () => {
|
||||
const labels = await detectRequirements(new Set(['new-feature-developer', 'has-tests']), SOURCE_FILES, DEV_DOCS_URL_CONTEXT, false);
|
||||
assert.ok(!labels.has('needs-developer-docs'));
|
||||
});
|
||||
|
||||
it('a user docs (esphome.io) link does not satisfy needs-developer-docs', async () => {
|
||||
const labels = await detectRequirements(new Set(['new-feature-developer', 'has-tests']), SOURCE_FILES, USER_DOCS_CONTEXT, false);
|
||||
assert.ok(labels.has('needs-developer-docs'));
|
||||
});
|
||||
|
||||
it('a developer docs link does not satisfy needs-docs for new-feature', async () => {
|
||||
const labels = await detectRequirements(new Set(['new-feature', 'has-tests']), [], DEV_DOCS_CONTEXT, false);
|
||||
assert.ok(labels.has('needs-docs'));
|
||||
});
|
||||
|
||||
it('changes confined to core validator files are exempt from needs-developer-docs', async () => {
|
||||
const labels = await detectRequirements(new Set(['new-feature-developer', 'has-tests']), VALIDATOR_FILES, NO_DOCS_CONTEXT, false);
|
||||
assert.ok(!labels.has('needs-developer-docs'));
|
||||
});
|
||||
|
||||
it('validator changes mixed with other source files are not exempt', async () => {
|
||||
const prFiles = [...VALIDATOR_FILES, { filename: 'esphome/components/foo/foo.py' }];
|
||||
const labels = await detectRequirements(new Set(['new-feature-developer', 'has-tests']), prFiles, NO_DOCS_CONTEXT, false);
|
||||
assert.ok(labels.has('needs-developer-docs'));
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MANAGED_LABELS
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('MANAGED_LABELS', () => {
|
||||
it('includes new-feature-developer so the workflow syncs it', () => {
|
||||
assert.ok(MANAGED_LABELS.includes('new-feature-developer'));
|
||||
});
|
||||
|
||||
it('includes needs-developer-docs so the workflow syncs it', () => {
|
||||
assert.ok(MANAGED_LABELS.includes('needs-developer-docs'));
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// detectPRSize
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
if: github.event.pull_request.state == 'open' && (github.event.action != 'labeled' || github.event.sender.type != 'Bot')
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
# Scope the minted App token to the minimum needed by auto-label-pr/*.js.
|
||||
permission-contents: read # repos.getContent for CODEOWNERS and file lookups in detectors.js
|
||||
permission-issues: write # listLabelsOnIssue, addLabels, removeLabel, list/createComment
|
||||
permission-pull-requests: write # pulls.get, pulls.listFiles, list/create/update/dismissReview
|
||||
permission-pull-requests: write # pulls.listFiles, list/create/update/dismissReview
|
||||
|
||||
- name: Auto Label PR
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
|
||||
@@ -21,52 +21,27 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
python-version: "3.11"
|
||||
- name: Set up uv
|
||||
# ``--system`` (below) installs into the setup-python interpreter;
|
||||
# no venv is created or restored by this workflow.
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pull-request-only workflow: a save could never be shared and
|
||||
# would only consume quota.
|
||||
save-cache: "false"
|
||||
# Pin uv version so the action does not have to fetch the
|
||||
# manifest from raw.githubusercontent.com on every cache
|
||||
# miss; that fetch flakes on Windows runners.
|
||||
version: "0.11.15"
|
||||
|
||||
- name: Install apt dependencies
|
||||
# PR-only workflow, so nothing on dev could seed a shared apt cache
|
||||
# entry; the cached apt action would save one copy per PR. Plain apt
|
||||
# with every call bounded: the apt.conf.d timeouts make a dead
|
||||
# mirror fail over in seconds, and timeout runs under sudo so it can
|
||||
# kill apt-get itself. Install without update first: image lists are
|
||||
# fresh, and the index refresh is what a congested mirror makes slow.
|
||||
timeout-minutes: 15
|
||||
run: |
|
||||
sudo tee /etc/apt/apt.conf.d/99ci-acquire-timeouts >/dev/null <<'EOF'
|
||||
Acquire::Retries "1";
|
||||
Acquire::http::Timeout "15";
|
||||
Acquire::https::Timeout "15";
|
||||
EOF
|
||||
# Common path: the image's package lists are fresh enough.
|
||||
if sudo DEBIAN_FRONTEND=noninteractive timeout -k 15 90 \
|
||||
apt-get install -y protobuf-compiler; then
|
||||
protoc --version
|
||||
exit 0
|
||||
fi
|
||||
# Rescue path: refresh the lists once with a generous bound; the
|
||||
# apt config already fails a stalled mirror over quickly.
|
||||
sudo DEBIAN_FRONTEND=noninteractive timeout -k 10 30 \
|
||||
dpkg --configure -a || true
|
||||
sudo timeout -k 15 300 apt-get update
|
||||
sudo DEBIAN_FRONTEND=noninteractive timeout -k 15 300 \
|
||||
apt-get install -y protobuf-compiler
|
||||
sudo apt update
|
||||
sudo apt-cache show protobuf-compiler
|
||||
sudo apt install -y protobuf-compiler
|
||||
protoc --version
|
||||
- name: Install python dependencies
|
||||
run: uv pip install --system aioesphomeapi -c requirements.txt -r requirements_dev.txt
|
||||
|
||||
@@ -61,23 +61,21 @@ jobs:
|
||||
tag: ${{ steps.tag.outputs.tag }}
|
||||
push: ${{ steps.tag.outputs.push }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
python-version: "3.11"
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
|
||||
- name: Determine tag and whether to push
|
||||
id: tag
|
||||
env:
|
||||
HEAD_REF: ${{ github.head_ref || github.ref_name }}
|
||||
run: |
|
||||
# Sanitize the branch name into a valid docker tag: replace invalid
|
||||
# characters, ensure the first character is valid (tags must start
|
||||
# with [A-Za-z0-9_]), and cap the length at 128 characters.
|
||||
branch="$HEAD_REF"
|
||||
branch="${{ github.head_ref || github.ref_name }}"
|
||||
tag="${branch//[^a-zA-Z0-9_.-]/-}"
|
||||
case "$tag" in
|
||||
[a-zA-Z0-9_]*) ;;
|
||||
@@ -98,7 +96,7 @@ jobs:
|
||||
|
||||
- name: Log in to the GitHub container registry
|
||||
if: steps.tag.outputs.push == 'true'
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -119,22 +117,16 @@ jobs:
|
||||
# pushed image) keeps it working for fork PRs, which never push to ghcr.io.
|
||||
- name: Export image for compile-test
|
||||
if: matrix.os == 'ubuntu-24.04' && matrix.build_type == 'docker'
|
||||
# zstd over gzip: docker save is on the critical path for every
|
||||
# compile-test job, and zstd -T0 is multithreaded (export 50s -> 9s).
|
||||
# docker load auto-detects the format; its time is layer extraction,
|
||||
# not decompression, so it is unchanged. shell: bash adds pipefail so
|
||||
# a failed docker save cannot upload a truncated artifact.
|
||||
shell: bash
|
||||
run: docker save "ghcr.io/esphome/esphome-amd64:${{ steps.tag.outputs.tag }}" | zstd -T0 -3 > compile-test-image.tar.zst
|
||||
run: docker save "ghcr.io/esphome/esphome-amd64:${{ steps.tag.outputs.tag }}" | gzip > compile-test-image.tar.gz
|
||||
|
||||
- name: Upload compile-test image artifact
|
||||
if: matrix.os == 'ubuntu-24.04' && matrix.build_type == 'docker'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
# The tar is already compressed, so upload it as-is. archive: false
|
||||
# skips the redundant zip and makes the file name the artifact name
|
||||
# (the `name` input is ignored in that mode).
|
||||
path: compile-test-image.tar.zst
|
||||
# The tar is already gzipped, so upload it as-is. archive: false skips
|
||||
# the redundant zip and makes the file name the artifact name (the
|
||||
# `name` input is ignored in that mode).
|
||||
path: compile-test-image.tar.gz
|
||||
retention-days: 1
|
||||
archive: false
|
||||
|
||||
@@ -153,16 +145,16 @@ jobs:
|
||||
- "ha-addon"
|
||||
- "docker"
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
python-version: "3.11"
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
|
||||
- name: Log in to the GitHub container registry
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -188,6 +180,8 @@ jobs:
|
||||
contents: read # actions/checkout to load the test configs
|
||||
strategy:
|
||||
fail-fast: false
|
||||
# Cap concurrency so this smoke test doesn't hog all the shared runners.
|
||||
max-parallel: 2
|
||||
matrix:
|
||||
# One entry per distinct toolchain. ESP32 variants (c3/c6/s2/s3/p4)
|
||||
# share a toolchain bundle, so esp32 is exercised on the base variant
|
||||
@@ -208,13 +202,13 @@ jobs:
|
||||
- nrf52
|
||||
- host
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- name: Download image artifact
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: compile-test-image.tar.zst
|
||||
name: compile-test-image.tar.gz
|
||||
- name: Load image
|
||||
run: docker load --input compile-test-image.tar.zst
|
||||
run: docker load --input compile-test-image.tar.gz
|
||||
- name: Compile ${{ matrix.id }}
|
||||
run: |
|
||||
docker run --rm \
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Run tests
|
||||
working-directory: .github/scripts/auto-label-pr
|
||||
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
|
||||
- name: Check out code from base repository
|
||||
if: steps.pr.outputs.skip != 'true'
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
# Always check out from the base repository (esphome/esphome), never from forks
|
||||
# Use the PR's target branch to ensure we run trusted code from the main repo
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
if: steps.pr.outputs.skip != 'true'
|
||||
uses: ./.github/actions/restore-python
|
||||
with:
|
||||
python-version: "3.12"
|
||||
python-version: "3.11"
|
||||
cache-key: ${{ hashFiles('.cache-key') }}
|
||||
|
||||
- name: Download memory analysis artifacts
|
||||
|
||||
+292
-529
File diff suppressed because it is too large
Load Diff
@@ -26,7 +26,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout base branch
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
sparse-checkout: |
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout base branch
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
|
||||
|
||||
@@ -52,11 +52,11 @@ jobs:
|
||||
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
@@ -84,6 +84,6 @@ jobs:
|
||||
exit 1
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
|
||||
@@ -14,4 +14,4 @@ jobs:
|
||||
permissions:
|
||||
issues: write # issues.lock on closed issues
|
||||
pull-requests: write # issues.lock on closed pull requests
|
||||
uses: esphome/workflows/.github/workflows/lock.yml@0fdd5e311b7e744069166696072a1a9cbc5fbeb6 # 2026.8.1
|
||||
uses: esphome/workflows/.github/workflows/lock.yml@025a1e6255610c498ed590403b7e510b69e474df # 2026.4.1
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
name: Validate PR title
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
---
|
||||
name: Nightly Dev Release
|
||||
|
||||
# Works out the dated dev tag and starts the release workflow with it, so that
|
||||
# the release run is named after the tag it builds. A workflow run name is
|
||||
# fixed when the run starts and cannot read a file or the current date.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 2 * * *"
|
||||
|
||||
permissions:
|
||||
contents: read # actions/checkout to read the version from esphome/const.py
|
||||
|
||||
jobs:
|
||||
trigger:
|
||||
name: Start release build
|
||||
if: github.repository == 'esphome/esphome'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read # actions/checkout to read the version from esphome/const.py
|
||||
actions: write # gh workflow run starts release.yml
|
||||
steps:
|
||||
- name: Check out the repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Start the release workflow
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
VERSION=$(sed -n -E "s/^__version__\s+=\s+\"(.+)\"$/\1/p" esphome/const.py)
|
||||
if [[ -z "$VERSION" ]]; then
|
||||
echo "::error::Could not read __version__ from esphome/const.py"
|
||||
exit 1
|
||||
fi
|
||||
TAG="${VERSION}$(date --utc '+%Y%m%d')"
|
||||
echo "Starting release build for ${TAG}"
|
||||
gh workflow run release.yml --ref "${GITHUB_REF_NAME}" --field tag="${TAG}"
|
||||
@@ -1,23 +1,12 @@
|
||||
---
|
||||
name: Publish Release
|
||||
|
||||
# Releases (production and beta) are named after the version they publish.
|
||||
# Dev builds are named after the dated dev tag, which is passed in by the
|
||||
# nightly workflow because a run name cannot compute it itself.
|
||||
run-name: ${{ github.event.inputs.tag || github.event.release.tag_name || format('Manual build ({0})', github.ref_name) }}
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: >-
|
||||
Tag to build. Only supported on dev, where the nightly workflow
|
||||
uses it. Leave empty to build the version from esphome/const.py
|
||||
with today's date appended.
|
||||
required: false
|
||||
default: ""
|
||||
release:
|
||||
types: [published]
|
||||
schedule:
|
||||
- cron: "0 2 * * *"
|
||||
|
||||
permissions:
|
||||
contents: read # actions/checkout for all jobs; deploy jobs add their own scopes when they need to write
|
||||
@@ -31,11 +20,9 @@ jobs:
|
||||
branch_build: ${{ steps.tag.outputs.branch_build }}
|
||||
deploy_env: ${{ steps.tag.outputs.deploy_env }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- name: Get tag
|
||||
id: tag
|
||||
env:
|
||||
INPUT_TAG: ${{ github.event.inputs.tag }}
|
||||
# yamllint disable rule:line-length
|
||||
run: |
|
||||
if [[ "${{ github.event_name }}" = "release" ]]; then
|
||||
@@ -47,23 +34,12 @@ jobs:
|
||||
ENVIRONMENT="production"
|
||||
fi
|
||||
else
|
||||
TAG=$(cat esphome/const.py | sed -n -E "s/^__version__\s+=\s+\"(.+)\"$/\1/p")
|
||||
today="$(date --utc '+%Y%m%d')"
|
||||
TAG="${TAG}${today}"
|
||||
BRANCH=${GITHUB_REF#refs/heads/}
|
||||
# The nightly workflow passes the finished tag so that the run name
|
||||
# matches what is built. Without it, work it out here.
|
||||
TAG="${INPUT_TAG}"
|
||||
if [[ -n "$TAG" && "$BRANCH" != "dev" ]]; then
|
||||
echo "::error::The tag input is only supported on dev. A build from ${BRANCH} has to use the tag worked out here, which carries the branch name, so that it cannot publish over the dev, beta, latest or stable images."
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "$TAG" ]]; then
|
||||
TAG=$(cat esphome/const.py | sed -n -E "s/^__version__\s+=\s+\"(.+)\"$/\1/p")
|
||||
today="$(date --utc '+%Y%m%d')"
|
||||
TAG="${TAG}${today}"
|
||||
if [[ "$BRANCH" != "dev" ]]; then
|
||||
TAG="${TAG}-${BRANCH}"
|
||||
fi
|
||||
fi
|
||||
if [[ "$BRANCH" != "dev" ]]; then
|
||||
TAG="${TAG}-${BRANCH}"
|
||||
BRANCH_BUILD="true"
|
||||
ENVIRONMENT=""
|
||||
else
|
||||
@@ -84,9 +60,9 @@ jobs:
|
||||
contents: read # actions/checkout to build the sdist/wheel
|
||||
id-token: write # OIDC token for PyPI Trusted Publishing (pypa/gh-action-pypi-publish)
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
with:
|
||||
python-version: "3.x"
|
||||
- name: Build
|
||||
@@ -94,7 +70,7 @@ jobs:
|
||||
pip3 install build
|
||||
python3 -m build
|
||||
- name: Publish
|
||||
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
|
||||
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
|
||||
with:
|
||||
skip-existing: true
|
||||
|
||||
@@ -116,22 +92,22 @@ jobs:
|
||||
os: "ubuntu-24.04-arm"
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
|
||||
- name: Log in to docker hub
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Log in to the GitHub container registry
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -192,7 +168,7 @@ jobs:
|
||||
- ghcr
|
||||
- dockerhub
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
@@ -202,17 +178,17 @@ jobs:
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
|
||||
- name: Log in to docker hub
|
||||
if: matrix.registry == 'dockerhub'
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Log in to the GitHub container registry
|
||||
if: matrix.registry == 'ghcr'
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
||||
+50
-35
@@ -6,46 +6,61 @@ on:
|
||||
- cron: "30 0 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
# The reusable workflow authenticates as the ESPHome GitHub App, so GITHUB_TOKEN
|
||||
# needs no permissions at all.
|
||||
permissions: {}
|
||||
permissions:
|
||||
issues: write # actions/stale labels, comments on, and closes stale issues
|
||||
pull-requests: write # actions/stale labels, comments on, and closes stale pull requests
|
||||
|
||||
concurrency:
|
||||
group: lock
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
if: github.repository_owner == 'esphome'
|
||||
# No GITHUB_TOKEN permissions: the reusable workflow mints an ESPHome
|
||||
# GitHub App token so the labels, comments and closures come from
|
||||
# esphome[bot] instead of github-actions[bot].
|
||||
uses: esphome/workflows/.github/workflows/stale.yml@a1c1485ab46ef41a84a6a9d8abd7fa4b7628fd70 # main
|
||||
secrets:
|
||||
ESPHOME_GITHUB_APP_PRIVATE_KEY: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
|
||||
with:
|
||||
# Live only on dev: a workflow_dispatch from any other branch is a dry run
|
||||
dry-run: ${{ github.ref != 'refs/heads/dev' }}
|
||||
days-before-stale: 90
|
||||
days-before-close: 7
|
||||
stale-label: stale
|
||||
exempt-label: not-stale
|
||||
ignored-users: esphbot,codecov-commenter
|
||||
stale-pr-message: >
|
||||
There hasn't been any activity on this pull request recently. This
|
||||
pull request has been automatically marked as stale because of that
|
||||
and will be closed if no further activity occurs within 7 days.
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Stale
|
||||
uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||
with:
|
||||
debug-only: ${{ github.ref != 'refs/heads/dev' }} # Dry-run when not run on dev branch
|
||||
remove-stale-when-updated: true
|
||||
operations-per-run: 400
|
||||
|
||||
If you are the author of this PR, please leave a comment if you want
|
||||
to keep it open. Also, please rebase your PR onto the latest dev
|
||||
branch to ensure that it's up to date with the latest changes.
|
||||
# The 90 day stale policy for PRs
|
||||
# - PRs
|
||||
# - No PRs marked as "not-stale"
|
||||
# - No Issues (see below)
|
||||
days-before-pr-stale: 90
|
||||
days-before-pr-close: 7
|
||||
stale-pr-label: "stale"
|
||||
exempt-pr-labels: "not-stale"
|
||||
stale-pr-message: >
|
||||
There hasn't been any activity on this pull request recently. This
|
||||
pull request has been automatically marked as stale because of that
|
||||
and will be closed if no further activity occurs within 7 days.
|
||||
|
||||
Thank you for your contribution!
|
||||
stale-issue-message: >
|
||||
There hasn't been any activity on this issue recently. Due to the
|
||||
high number of incoming GitHub notifications, we have to clean some
|
||||
of the old issues, as many of them have already been resolved with
|
||||
the latest updates.
|
||||
If you are the author of this PR, please leave a comment if you want
|
||||
to keep it open. Also, please rebase your PR onto the latest dev
|
||||
branch to ensure that it's up to date with the latest changes.
|
||||
|
||||
Please make sure to update to the latest ESPHome version and
|
||||
check if that solves the issue. Let us know if that works for you by
|
||||
adding a comment 👍
|
||||
Thank you for your contribution!
|
||||
|
||||
This issue has now been marked as stale and will be closed if no
|
||||
further activity occurs. Thank you for your contributions.
|
||||
# The 90 day stale policy for Issues
|
||||
# - Issues
|
||||
# - No Issues marked as "not-stale"
|
||||
# - No PRs (see above)
|
||||
days-before-issue-stale: 90
|
||||
days-before-issue-close: 7
|
||||
stale-issue-label: "stale"
|
||||
exempt-issue-labels: "not-stale"
|
||||
stale-issue-message: >
|
||||
There hasn't been any activity on this issue recently. Due to the
|
||||
high number of incoming GitHub notifications, we have to clean some
|
||||
of the old issues, as many of them have already been resolved with
|
||||
the latest updates.
|
||||
|
||||
Please make sure to update to the latest ESPHome version and
|
||||
check if that solves the issue. Let us know if that works for you by
|
||||
adding a comment 👍
|
||||
|
||||
This issue has now been marked as stale and will be closed if no
|
||||
further activity occurs. Thank you for your contributions.
|
||||
|
||||
@@ -5,7 +5,7 @@ on:
|
||||
types: [opened, reopened, labeled, unlabeled, synchronize]
|
||||
|
||||
permissions:
|
||||
pull-requests: read # issues.listLabelsOnIssue to detect blocking labels (needs-docs, needs-developer-docs, merge-after-release, chained-pr)
|
||||
pull-requests: read # issues.listLabelsOnIssue to detect blocking labels (needs-docs, merge-after-release, chained-pr)
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const blockingLabels = ['needs-docs', 'needs-developer-docs', 'merge-after-release', 'chained-pr'];
|
||||
const blockingLabels = ['needs-docs', 'merge-after-release', 'chained-pr'];
|
||||
const { data: labels } = await github.rest.issues.listLabelsOnIssue({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
|
||||
@@ -28,26 +28,26 @@ jobs:
|
||||
permission-pull-requests: write # pulls.create / pulls.update to open or refresh the sync PR
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Checkout Home Assistant
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
repository: home-assistant/core
|
||||
path: lib/home-assistant
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
with:
|
||||
python-version: "3.14"
|
||||
|
||||
- name: Set up uv
|
||||
# An order of magnitude faster than pip on cold boots, with its
|
||||
# own wheel cache. ``--system`` (below) installs into the
|
||||
# setup-python interpreter so subsequent ``prek`` /
|
||||
# setup-python interpreter so subsequent ``pre-commit`` /
|
||||
# ``script/run-in-env.py`` steps find the deps without a
|
||||
# ``uv run`` prefix.
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pin uv version so the action does not have to fetch the
|
||||
@@ -58,19 +58,19 @@ jobs:
|
||||
- name: Install Home Assistant
|
||||
run: |
|
||||
uv pip install --system -e lib/home-assistant
|
||||
uv pip install --system -r requirements.txt -r requirements_test.txt
|
||||
uv pip install --system -r requirements.txt -r requirements_test.txt pre-commit
|
||||
|
||||
- name: Sync
|
||||
run: |
|
||||
python ./script/sync-device_class.py
|
||||
|
||||
- name: Apply prek auto-fixes
|
||||
- name: Apply pre-commit auto-fixes
|
||||
# First pass: let formatters (ruff, end-of-file-fixer, etc.) modify
|
||||
# files. prek exits non-zero whenever a hook touches anything,
|
||||
# files. pre-commit exits non-zero whenever a hook touches anything,
|
||||
# which would otherwise abort the workflow before the auto-fixes
|
||||
# can flow into the sync PR.
|
||||
#
|
||||
# PREK_SKIP:
|
||||
# SKIP:
|
||||
# - no-commit-to-branch is a local guard against committing on
|
||||
# dev/release/beta; CI runs on dev by definition, and
|
||||
# peter-evans/create-pull-request creates the branch itself.
|
||||
@@ -79,25 +79,25 @@ jobs:
|
||||
# the runtime deps (HA + requirements*.txt); main CI already
|
||||
# gates pylint on real PRs.
|
||||
env:
|
||||
PREK_SKIP: pylint,no-commit-to-branch
|
||||
run: python script/run-in-env.py prek run --all-files || true
|
||||
SKIP: pylint,no-commit-to-branch
|
||||
run: python script/run-in-env.py pre-commit run --all-files || true
|
||||
|
||||
- name: Verify prek clean
|
||||
- name: Verify pre-commit clean
|
||||
# Second pass: re-run all hooks against the now-fixed tree.
|
||||
# Auto-fixers exit 0 (nothing to change); any remaining failure
|
||||
# from a check-only hook (flake8 / yamllint / ci-custom) is a
|
||||
# real issue and fails the workflow loudly. Same PREK_SKIP list as
|
||||
# real issue and fails the workflow loudly. Same SKIP list as
|
||||
# above for the same reasons.
|
||||
env:
|
||||
PREK_SKIP: pylint,no-commit-to-branch
|
||||
run: python script/run-in-env.py prek run --all-files
|
||||
SKIP: pylint,no-commit-to-branch
|
||||
run: python script/run-in-env.py pre-commit run --all-files
|
||||
|
||||
- name: Commit changes
|
||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
|
||||
with:
|
||||
commit-message: "Synchronise Device Classes from Home Assistant"
|
||||
committer: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com>
|
||||
author: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com>
|
||||
committer: esphomebot <esphome@openhomefoundation.org>
|
||||
author: esphomebot <esphome@openhomefoundation.org>
|
||||
branch: sync/device-classes
|
||||
delete-branch: true
|
||||
title: "Synchronise Device Classes from Home Assistant"
|
||||
|
||||
@@ -133,8 +133,6 @@ CTestTestfile.cmake
|
||||
.gcc-flags.json
|
||||
|
||||
config/
|
||||
# Test fixture config/ directories are tracked (the rule above is the dashboard dir)
|
||||
!tests/component_tests/**/config/
|
||||
tests/build/
|
||||
tests/.esphome/
|
||||
/.temp-clang-tidy.cpp
|
||||
|
||||
@@ -11,7 +11,7 @@ ci:
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
# Ruff version.
|
||||
rev: v0.16.3
|
||||
rev: v0.15.15
|
||||
hooks:
|
||||
# Run the linter.
|
||||
- id: ruff
|
||||
@@ -40,7 +40,7 @@ repos:
|
||||
rev: v3.21.2
|
||||
hooks:
|
||||
- id: pyupgrade
|
||||
args: [--py312-plus]
|
||||
args: [--py311-plus]
|
||||
- repo: https://github.com/adrienverge/yamllint.git
|
||||
rev: v1.37.1
|
||||
hooks:
|
||||
|
||||
@@ -9,7 +9,7 @@ This document provides essential context for AI models interacting with this pro
|
||||
|
||||
## 2. Core Technologies & Stack
|
||||
|
||||
* **Languages:** Python (>=3.12), C++ (gnu++20)
|
||||
* **Languages:** Python (>=3.11), C++ (gnu++20)
|
||||
* **Frameworks & Runtimes:** PlatformIO, Arduino, ESP-IDF.
|
||||
* **Build Systems:** PlatformIO is the primary build system. CMake is used as an alternative.
|
||||
* **Configuration:** YAML.
|
||||
@@ -57,12 +57,6 @@ This document provides essential context for AI models interacting with this pro
|
||||
- Function-local constants: `lower_snake_case`
|
||||
- Protected/private fields: `lower_snake_case_with_trailing_underscore_`
|
||||
- Favor descriptive names over abbreviations
|
||||
- Enumerator names: prefix every value of an `enum class` with the enum name converted to
|
||||
`UPPER_SNAKE_CASE` (e.g. `UARTFlushResult::UART_FLUSH_RESULT_SUCCESS`). Never use bare
|
||||
names like `SUCCESS`, `FAILURE`, `OK`, or `FAIL`: platform SDK headers define macros with
|
||||
these common names (for example the Realtek SDKs used by LibreTiny define
|
||||
`#define SUCCESS 0` in `basic_types.h`), and the preprocessor replaces the enumerator
|
||||
before the compiler sees it, breaking the build and clang-tidy on those platforms.
|
||||
|
||||
* **Python Idioms:**
|
||||
* **Assignment expressions (PEP 572):** Prefer the walrus operator (`:=`) wherever it removes a redundant lookup or a throwaway temporary. The most common case in component code is presence-checking a config key and then indexing it separately — fetch once with `.get()` and bind in the condition instead:
|
||||
@@ -197,14 +191,11 @@ This document provides essential context for AI models interacting with this pro
|
||||
my_component_ns = cg.esphome_ns.namespace("my_component")
|
||||
MyComponent = my_component_ns.class_("MyComponent", cg.Component)
|
||||
|
||||
CONFIG_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(MyComponent),
|
||||
cv.Required(CONF_KEY): cv.string,
|
||||
cv.Optional(CONF_PARAM, default=42): cv.int_,
|
||||
}
|
||||
).extend(cv.COMPONENT_SCHEMA)
|
||||
|
||||
CONFIG_SCHEMA = cv.Schema({
|
||||
cv.GenerateID(): cv.declare_id(MyComponent),
|
||||
cv.Required(CONF_KEY): cv.string,
|
||||
cv.Optional(CONF_PARAM, default=42): cv.int_,
|
||||
}).extend(cv.COMPONENT_SCHEMA)
|
||||
|
||||
async def to_code(config):
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
@@ -238,12 +229,7 @@ This document provides essential context for AI models interacting with this pro
|
||||
- **Sensor:**
|
||||
```python
|
||||
from esphome.components import sensor
|
||||
|
||||
CONFIG_SCHEMA = sensor.sensor_schema(MySensor).extend(
|
||||
cv.polling_component_schema("60s")
|
||||
)
|
||||
|
||||
|
||||
CONFIG_SCHEMA = sensor.sensor_schema(MySensor).extend(cv.polling_component_schema("60s"))
|
||||
async def to_code(config):
|
||||
var = await sensor.new_sensor(config)
|
||||
await cg.register_component(var, config)
|
||||
@@ -252,10 +238,7 @@ This document provides essential context for AI models interacting with this pro
|
||||
- **Binary Sensor:**
|
||||
```python
|
||||
from esphome.components import binary_sensor
|
||||
|
||||
CONFIG_SCHEMA = binary_sensor.binary_sensor_schema().extend({...})
|
||||
|
||||
|
||||
CONFIG_SCHEMA = binary_sensor.binary_sensor_schema().extend({ ... })
|
||||
async def to_code(config):
|
||||
var = await binary_sensor.new_binary_sensor(config)
|
||||
```
|
||||
@@ -263,10 +246,7 @@ This document provides essential context for AI models interacting with this pro
|
||||
- **Switch:**
|
||||
```python
|
||||
from esphome.components import switch
|
||||
|
||||
CONFIG_SCHEMA = switch.switch_schema().extend({...})
|
||||
|
||||
|
||||
CONFIG_SCHEMA = switch.switch_schema().extend({ ... })
|
||||
async def to_code(config):
|
||||
var = await switch.new_switch(config)
|
||||
```
|
||||
@@ -283,13 +263,10 @@ This document provides essential context for AI models interacting with this pro
|
||||
```python
|
||||
from esphome import automation
|
||||
|
||||
CONFIG_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(MyComponent),
|
||||
cv.Optional(CONF_ON_STATE): automation.validate_automation({}),
|
||||
}
|
||||
).extend(cv.COMPONENT_SCHEMA)
|
||||
|
||||
CONFIG_SCHEMA = cv.Schema({
|
||||
cv.GenerateID(): cv.declare_id(MyComponent),
|
||||
cv.Optional(CONF_ON_STATE): automation.validate_automation({}),
|
||||
}).extend(cv.COMPONENT_SCHEMA)
|
||||
|
||||
async def to_code(config):
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
@@ -339,14 +316,11 @@ This document provides essential context for AI models interacting with this pro
|
||||
```python
|
||||
TurnOnTrigger = my_ns.class_("TurnOnTrigger", automation.Trigger.template())
|
||||
|
||||
CONFIG_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_ON_TURN_ON): automation.validate_automation(
|
||||
{cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(TurnOnTrigger)}
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = cv.Schema({
|
||||
cv.Optional(CONF_ON_TURN_ON): automation.validate_automation(
|
||||
{cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(TurnOnTrigger)}
|
||||
),
|
||||
})
|
||||
|
||||
async def to_code(config):
|
||||
for conf in config.get(CONF_ON_TURN_ON, []):
|
||||
@@ -394,10 +368,7 @@ This document provides essential context for AI models interacting with this pro
|
||||
```
|
||||
Register with `@automation.register_condition("my_component.is_active", MyCondition, schema)`.
|
||||
|
||||
* **Type Hints:** Type-hint all function signatures, including test functions and config validators (e.g. `def validate_x(config: ConfigType) -> ConfigType:`, `def test_x() -> None:`). Import `ConfigType` from `esphome.types`.
|
||||
|
||||
* **Configuration Validation:**
|
||||
* **Reuse existing validators:** Before writing a custom validator, check for an existing one in `config_validation.py` and compose it in `cv.All(...)` rather than duplicating logic across components. For example, rename a config key with `cv.rename_key(CONF_OLD, CONF_NEW, removed_in="2026.6.0")`, and reject mutually-exclusive keys with `cv.has_at_most_one_key(...)` / `cv.has_exactly_one_key(...)`. See how `api` composes `cv.has_exactly_one_key` + `cv.rename_key`.
|
||||
* **Common Validators:** `cv.int_`, `cv.float_`, `cv.string`, `cv.boolean`, `cv.int_range(min=0, max=100)`, `cv.positive_int`, `cv.percentage`.
|
||||
* **Complex Validation:** `cv.All(cv.string, cv.Length(min=1, max=50))`, `cv.Any(cv.int_, cv.string)`.
|
||||
* **Platform-Specific:** `cv.only_on(["esp32", "esp8266"])`, `esp32.only_on_variant(...)`, `cv.only_on_esp32`, `cv.only_on_esp8266`, `cv.only_on_rp2040`.
|
||||
@@ -410,7 +381,6 @@ This document provides essential context for AI models interacting with this pro
|
||||
.extend(i2c.i2c_device_schema(0x48))
|
||||
.extend(spi.spi_device_schema(cs_pin_required=True))
|
||||
```
|
||||
* **Constants:** `esphome/const.py` is frozen — do not add new `CONF_` constants there. Define a component-local constant in the component's own `.py` (as with `CONF_PARAM` above); for a constant shared by multiple components, add it to `esphome/components/const/__init__.py`. CI (`lint_constants_usage`) fails if the same constant is defined in three or more component files. Constants used in core files (i.e. those not under `esphome/components`) may be added to `esphome/const.py` but will require adjustment to the CI validation check.
|
||||
|
||||
## 5. Key Files & Entrypoints
|
||||
|
||||
@@ -418,7 +388,7 @@ This document provides essential context for AI models interacting with this pro
|
||||
* **Configuration:**
|
||||
* `pyproject.toml`: Defines the Python project metadata and dependencies.
|
||||
* `platformio.ini`: Configures the PlatformIO build environments for different microcontrollers.
|
||||
* `.pre-commit-config.yaml`: Configures the lint and format hooks, run by `prek`.
|
||||
* `.pre-commit-config.yaml`: Configures the pre-commit hooks for linting and formatting.
|
||||
* **CI/CD Pipeline:** Defined in `.github/workflows`.
|
||||
* **Static Analysis & Development:**
|
||||
* `esphome/core/defines.h`: A comprehensive header file containing all `#define` directives that can be added by components using `cg.add_define()` in Python. This file is used exclusively for development, static analysis tools, and CI testing - it is not used during runtime compilation. When developing components that add new defines, they must be added to this file to ensure proper IDE support and static analysis coverage. The file includes feature flags, build configurations, and platform-specific defines that help static analyzers understand the complete codebase without needing to compile for specific platforms.
|
||||
@@ -426,7 +396,7 @@ This document provides essential context for AI models interacting with this pro
|
||||
## 6. Development & Testing Workflow
|
||||
|
||||
* **Local Development Environment:** Use the provided Docker container or create a Python virtual environment and install dependencies from `requirements_dev.txt`.
|
||||
* **Running Commands:** Use the `script/run-in-env.py` script to execute commands within the project's virtual environment. For example, to run the linter: `python3 script/run-in-env.py prek run`.
|
||||
* **Running Commands:** Use the `script/run-in-env.py` script to execute commands within the project's virtual environment. For example, to run the linter: `python3 script/run-in-env.py pre-commit run`.
|
||||
* **Testing:**
|
||||
* **Python:** Run unit tests with `pytest`.
|
||||
* **C++:** Use `clang-tidy` for static analysis.
|
||||
@@ -457,14 +427,13 @@ This document provides essential context for AI models interacting with this pro
|
||||
|
||||
When a PR's only edits to a component are `validate.*.yaml` files (no source changes, no `test.*.yaml` changes, and the component isn't pulled in as a dependency of another changed component), CI skips the compile stage for that component entirely and only runs config validation. This is decided in `script/determine-jobs.py` via `_component_change_is_validate_only` and surfaced as the `validate_only_components` output that the `test-build-components-split` job consumes.
|
||||
|
||||
* **Test Grouping with Packages:** Components that use shared bus packages can be grouped together in CI to reduce build count. **Never define buses (uart, i2c, spi, modbus) directly in test YAML files** — always use packages from `test_build_components/common/`.
|
||||
|
||||
All includes in test files must go through dict-style `packages:` so that batch grouping works correctly — the grouping scripts only understand dict-style packages. Never use list-style packages (`packages: [- !include ...]`) or top-level merge keys (`<<: !include common.yaml`). Bus packages are keyed by the bus name; the component's `common.yaml` is keyed by the component name (e.g. `cst328: !include common.yaml`):
|
||||
* **Test Grouping with Packages:** Components that use shared bus packages can be grouped together in CI to reduce build count. **Never define buses (uart, i2c, spi, modbus) directly in test YAML files** — always use packages from `test_build_components/common/`:
|
||||
```yaml
|
||||
# test.esp32-idf.yaml — everything included via named packages
|
||||
# test.esp32-idf.yaml — use packages for buses
|
||||
packages:
|
||||
uart: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml
|
||||
my_component: !include common.yaml
|
||||
|
||||
<<: !include common.yaml
|
||||
```
|
||||
```yaml
|
||||
# common.yaml — component config only, NO bus definitions
|
||||
@@ -499,9 +468,9 @@ This document provides essential context for AI models interacting with this pro
|
||||
1. **Fork & Branch:** Create a new branch based on the `dev` branch (always use `git checkout -b <branch-name> dev` to ensure you're branching from `dev`, not the currently checked out branch).
|
||||
2. **Make Changes:** Adhere to all coding conventions and patterns.
|
||||
3. **Test:** Create component tests for all supported platforms and run the full test suite locally.
|
||||
4. **Lint:** Run `prek` to ensure code is compliant.
|
||||
4. **Lint:** Run `pre-commit` to ensure code is compliant.
|
||||
5. **Commit:** Commit your changes. There is no strict format for commit messages.
|
||||
6. **Pull Request:** Submit a PR against the `dev` branch. The Pull Request title must start with a `[tag]` prefix. For component work, use the component name (e.g., `[display] Fix bug`, `[abc123] Add new component`); for changes to shared/core code that isn't tied to a single component, use `[core]` (e.g., `[core] Add validator`). Update documentation, examples, and add `CODEOWNERS` entries as needed. Pull requests should always be made using the `.github/PULL_REQUEST_TEMPLATE.md` template - fill out all sections completely without removing any parts of the template.
|
||||
6. **Pull Request:** Submit a PR against the `dev` branch. The Pull Request title should have a prefix of the component being worked on (e.g., `[display] Fix bug`, `[abc123] Add new component`). Update documentation, examples, and add `CODEOWNERS` entries as needed. Pull requests should always be made using the `.github/PULL_REQUEST_TEMPLATE.md` template - fill out all sections completely without removing any parts of the template.
|
||||
|
||||
* **Documentation Contributions:**
|
||||
* Documentation is hosted in the separate `esphome/esphome.io` repository.
|
||||
@@ -647,7 +616,6 @@ This document provides essential context for AI models interacting with this pro
|
||||
_component_state = []
|
||||
_use_feature = None
|
||||
|
||||
|
||||
def enable_feature():
|
||||
global _use_feature
|
||||
_use_feature = True
|
||||
@@ -667,24 +635,20 @@ This document provides essential context for AI models interacting with this pro
|
||||
|
||||
DOMAIN = "my_component"
|
||||
|
||||
|
||||
@dataclass
|
||||
class MyComponentData:
|
||||
feature_enabled: bool = False
|
||||
item_count: int = 0
|
||||
items: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def _get_data() -> MyComponentData:
|
||||
if DOMAIN not in CORE.data:
|
||||
CORE.data[DOMAIN] = MyComponentData()
|
||||
return CORE.data[DOMAIN]
|
||||
|
||||
|
||||
def request_feature() -> None:
|
||||
_get_data().feature_enabled = True
|
||||
|
||||
|
||||
def add_item(item: str) -> None:
|
||||
_get_data().items.append(item)
|
||||
```
|
||||
@@ -739,37 +703,9 @@ This document provides essential context for AI models interacting with this pro
|
||||
```
|
||||
|
||||
* **Deprecation Pattern (Python):**
|
||||
For a renamed config key, use the shared `cv.rename_key` validator with `removed_in` (and `component` for context) — it warns and auto-migrates:
|
||||
```python
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
cv.rename_key(
|
||||
CONF_OLD_KEY, CONF_NEW_KEY, removed_in="2026.6.0", component="my_component"
|
||||
),
|
||||
cv.Schema({ ... }),
|
||||
)
|
||||
```
|
||||
For other deprecations, warn manually during validation:
|
||||
```python
|
||||
# Remove before 2026.6.0
|
||||
if CONF_OLD_KEY in config:
|
||||
_LOGGER.warning(
|
||||
f"'{CONF_OLD_KEY}' deprecated, use '{CONF_NEW_KEY}'. Removed in 2026.6.0"
|
||||
)
|
||||
_LOGGER.warning(f"'{CONF_OLD_KEY}' deprecated, use '{CONF_NEW_KEY}'. Removed in 2026.6.0")
|
||||
config[CONF_NEW_KEY] = config.pop(CONF_OLD_KEY) # Auto-migrate
|
||||
```
|
||||
## 9. English Language
|
||||
|
||||
The project uses English for non-code content. When drafting documentation, code comments, commit messages,
|
||||
PR descriptions, and similar text, avoid technical jargon. Instead, express concepts in plain English,
|
||||
using standard technical terms only when required. Ensure the text is readily comprehensible to a wide
|
||||
audience, including non-native English speakers.
|
||||
|
||||
## 10. Code Comments
|
||||
|
||||
Code comments on individual lines should be used only where necessary to flag issues that may not be obvious
|
||||
on a simple reading of the code. Keep them short (e.g. 1 or 2 lines).
|
||||
|
||||
Function and method comment blocks may include more detail as required to make
|
||||
calling contracts clear and document parameter usage, but should still be kept concise.
|
||||
|
||||
Avoid redundancy and repetition; comments should never simply restate what the code already says.
|
||||
|
||||
+1
-27
@@ -69,16 +69,12 @@ esphome/components/bh1750/* @OttoWinter
|
||||
esphome/components/bh1900nux/* @B48D81EFCC
|
||||
esphome/components/binary_sensor/* @esphome/core
|
||||
esphome/components/bk72xx/* @kuba2k2
|
||||
esphome/components/bk72xx_ble/* @Bl00d-B0b
|
||||
esphome/components/bk72xx_ble_tracker/* @Bl00d-B0b
|
||||
esphome/components/bl0906/* @athom-tech @jesserockz @tarontop
|
||||
esphome/components/bl0939/* @ziceva
|
||||
esphome/components/bl0940/* @dan-s-github @tobias-
|
||||
esphome/components/bl0942/* @dbuezas @dwmw2
|
||||
esphome/components/ble_client/* @buxtronix @clydebarrow
|
||||
esphome/components/ble_device_base/* @Bl00d-B0b
|
||||
esphome/components/ble_nus/* @tomaszduda23
|
||||
esphome/components/bluetooth_connection/* @bdraco @jesserockz
|
||||
esphome/components/bluetooth_proxy/* @bdraco @jesserockz
|
||||
esphome/components/bm8563/* @abmantis
|
||||
esphome/components/bme280_base/* @esphome/core
|
||||
@@ -126,9 +122,7 @@ esphome/components/cover/* @esphome/core
|
||||
esphome/components/cs5460a/* @balrog-kun
|
||||
esphome/components/cse7761/* @berfenger
|
||||
esphome/components/cst226/* @clydebarrow
|
||||
esphome/components/cst328/* @latonita
|
||||
esphome/components/cst816/* @clydebarrow
|
||||
esphome/components/cst9220/* @clydebarrow
|
||||
esphome/components/ct_clamp/* @jesserockz
|
||||
esphome/components/current_based/* @djwmarcx
|
||||
esphome/components/dac7678/* @NickB1
|
||||
@@ -149,7 +143,6 @@ esphome/components/dlms_meter/* @latonita @PolarGoose @SimonFischer04 @Tomer27cz
|
||||
esphome/components/dps310/* @kbx81
|
||||
esphome/components/ds1307/* @badbadc0ffee
|
||||
esphome/components/ds2484/* @mrk-its
|
||||
esphome/components/ds248x/* @tomwellnitz
|
||||
esphome/components/dsmr/* @glmnet @PolarGoose
|
||||
esphome/components/duty_time/* @dudanov
|
||||
esphome/components/ee895/* @Stock-M
|
||||
@@ -192,7 +185,6 @@ esphome/components/ezo_pmp/* @carlos-sarmiento
|
||||
esphome/components/factory_reset/* @anatoly-savchenkov
|
||||
esphome/components/fastled_base/* @OttoWinter
|
||||
esphome/components/feedback/* @ianchi
|
||||
esphome/components/file/* @esphome/core
|
||||
esphome/components/fingerprint_grow/* @alexborro @loongyh @OnFreund
|
||||
esphome/components/font/* @clydebarrow @esphome/core
|
||||
esphome/components/fs3000/* @kahrendt
|
||||
@@ -214,7 +206,6 @@ esphome/components/gree/switch/* @nagyrobi
|
||||
esphome/components/grove_gas_mc_v2/* @YorkshireIoT
|
||||
esphome/components/grove_tb6612fng/* @max246
|
||||
esphome/components/growatt_solar/* @leeuwte
|
||||
esphome/components/gsl3670/* @clydebarrow
|
||||
esphome/components/gt911/* @clydebarrow @jesserockz
|
||||
esphome/components/haier/* @paveldn
|
||||
esphome/components/haier/binary_sensor/* @paveldn
|
||||
@@ -238,7 +229,6 @@ esphome/components/hlw8032/* @rici4kubicek
|
||||
esphome/components/hm3301/* @freekode
|
||||
esphome/components/hmac_md5/* @dwmw2
|
||||
esphome/components/hmac_sha256/* @dwmw2
|
||||
esphome/components/hoermann_hcp/* @zweckj
|
||||
esphome/components/homeassistant/* @esphome/core @OttoWinter
|
||||
esphome/components/homeassistant/number/* @landonr
|
||||
esphome/components/homeassistant/switch/* @Links2004
|
||||
@@ -276,7 +266,6 @@ esphome/components/integration/* @OttoWinter
|
||||
esphome/components/internal_temperature/* @Mat931
|
||||
esphome/components/interval/* @esphome/core
|
||||
esphome/components/ir_rf_proxy/* @kbx81
|
||||
esphome/components/it8951/* @koosoli @limengdu @Passific
|
||||
esphome/components/jsn_sr04t/* @Mafus1
|
||||
esphome/components/json/* @esphome/core
|
||||
esphome/components/kamstrup_kmp/* @cfeenstra1024
|
||||
@@ -290,7 +279,6 @@ esphome/components/ld2412/* @Rihan9
|
||||
esphome/components/ld2420/* @descipher
|
||||
esphome/components/ld2450/* @hareeshmu
|
||||
esphome/components/ld24xx/* @kbx81
|
||||
esphome/components/ld6002b/* @hepter
|
||||
esphome/components/ledc/* @OttoWinter
|
||||
esphome/components/libretiny/* @kuba2k2
|
||||
esphome/components/libretiny_pwm/* @kuba2k2
|
||||
@@ -298,8 +286,6 @@ esphome/components/light/* @esphome/core
|
||||
esphome/components/lightwaverf/* @max246
|
||||
esphome/components/lilygo_t5_47/touchscreen/* @jesserockz
|
||||
esphome/components/lm75b/* @beormund
|
||||
esphome/components/ln882h_ble/* @Bl00d-B0b
|
||||
esphome/components/ln882h_ble_tracker/* @Bl00d-B0b
|
||||
esphome/components/ln882x/* @lamauny
|
||||
esphome/components/lock/* @esphome/core
|
||||
esphome/components/logger/* @esphome/core
|
||||
@@ -350,12 +336,10 @@ esphome/components/mipi_spi/* @clydebarrow
|
||||
esphome/components/mitsubishi/* @RubyBailey
|
||||
esphome/components/mitsubishi_cn105/* @crnjan
|
||||
esphome/components/mixer/speaker/* @kahrendt
|
||||
esphome/components/mk2pvrouter/* @FredM67
|
||||
esphome/components/mlx90393/* @functionpointer
|
||||
esphome/components/mlx90614/* @jesserockz
|
||||
esphome/components/mmc5603/* @benhoff
|
||||
esphome/components/mmc5983/* @agoode
|
||||
esphome/components/modbus_client/* @exciton
|
||||
esphome/components/modbus_controller/* @martgras
|
||||
esphome/components/modbus_controller/binary_sensor/* @martgras
|
||||
esphome/components/modbus_controller/number/* @martgras
|
||||
@@ -382,7 +366,6 @@ esphome/components/nextion/switch/* @senexcrenshaw
|
||||
esphome/components/nextion/text_sensor/* @senexcrenshaw
|
||||
esphome/components/nfc/* @jesserockz @kbx81
|
||||
esphome/components/noblex/* @AGalfra
|
||||
esphome/components/noise/* @esphome/core
|
||||
esphome/components/npi19/* @bakerkj
|
||||
esphome/components/nrf52/* @tomaszduda23
|
||||
esphome/components/number/* @esphome/core
|
||||
@@ -402,7 +385,6 @@ esphome/components/pcm5122/* @remcom
|
||||
esphome/components/pi4ioe5v6408/* @jesserockz
|
||||
esphome/components/pid/* @OttoWinter
|
||||
esphome/components/pipsolar/* @andreashergert1984
|
||||
esphome/components/pixoo/* @jesserockz
|
||||
esphome/components/pm1006/* @habbie
|
||||
esphome/components/pm2005/* @andrewjswan
|
||||
esphome/components/pmsa003i/* @sjtrny
|
||||
@@ -418,12 +400,10 @@ esphome/components/pn7160_i2c/* @jesserockz @kbx81
|
||||
esphome/components/pn7160_spi/* @jesserockz @kbx81
|
||||
esphome/components/power_supply/* @esphome/core
|
||||
esphome/components/preferences/* @esphome/core
|
||||
esphome/components/provisioning/* @esphome/core
|
||||
esphome/components/psram/* @esphome/core
|
||||
esphome/components/pulse_meter/* @cstaahl @stevebaxter @TrentHouliston
|
||||
esphome/components/pvvx_mithermometer/* @pasiz
|
||||
esphome/components/pylontech/* @functionpointer
|
||||
esphome/components/qmi8658/* @clydebarrow
|
||||
esphome/components/qmp6988/* @andrewpc
|
||||
esphome/components/qr_code/* @wjtje
|
||||
esphome/components/qspi_dbi/* @clydebarrow
|
||||
@@ -441,11 +421,10 @@ esphome/components/rf_bridge/* @jesserockz
|
||||
esphome/components/rgbct/* @jesserockz
|
||||
esphome/components/ring_buffer/* @kahrendt
|
||||
esphome/components/router/speaker/* @kahrendt
|
||||
esphome/components/rp2/* @jesserockz
|
||||
esphome/components/rp2040/* @jesserockz
|
||||
esphome/components/rp2040_ble/* @bdraco
|
||||
esphome/components/rp2040_pio_led_strip/* @Papa-DMan
|
||||
esphome/components/rp2040_pwm/* @jesserockz
|
||||
esphome/components/rp2_ble_tracker/* @bdraco
|
||||
esphome/components/rpi_dpi_rgb/* @clydebarrow
|
||||
esphome/components/rtl87xx/* @kuba2k2
|
||||
esphome/components/rtttl/* @glmnet @ximex
|
||||
@@ -468,7 +447,6 @@ esphome/components/sen21231/* @shreyaskarnik
|
||||
esphome/components/sen5x/* @martgras
|
||||
esphome/components/sen6x/* @martgras @mebner86 @tuct
|
||||
esphome/components/sendspin/* @kahrendt
|
||||
esphome/components/sendspin/image/* @kahrendt
|
||||
esphome/components/sendspin/media_player/* @kahrendt
|
||||
esphome/components/sendspin/media_source/* @kahrendt
|
||||
esphome/components/sendspin/sensor/* @kahrendt
|
||||
@@ -477,7 +455,6 @@ esphome/components/sensirion_common/* @martgras
|
||||
esphome/components/sensor/* @esphome/core
|
||||
esphome/components/serial_proxy/* @kbx81
|
||||
esphome/components/sfa30/* @ghsensdev
|
||||
esphome/components/sfa40/* @NoQuarrel
|
||||
esphome/components/sgp40/* @SenexCrenshaw
|
||||
esphome/components/sgp4x/* @martgras @SenexCrenshaw
|
||||
esphome/components/sha256/* @esphome/core
|
||||
@@ -520,7 +497,6 @@ esphome/components/ssd1331_base/* @kbx81
|
||||
esphome/components/ssd1331_spi/* @kbx81
|
||||
esphome/components/ssd1351_base/* @kbx81
|
||||
esphome/components/ssd1351_spi/* @kbx81
|
||||
esphome/components/st7123/* @miniskipper
|
||||
esphome/components/st7567_base/* @latonita
|
||||
esphome/components/st7567_i2c/* @latonita
|
||||
esphome/components/st7567_spi/* @latonita
|
||||
@@ -578,7 +554,6 @@ esphome/components/tuya/select/* @bearpawmaxim
|
||||
esphome/components/tuya/sensor/* @jesserockz
|
||||
esphome/components/tuya/switch/* @jesserockz
|
||||
esphome/components/tuya/text_sensor/* @dentra
|
||||
esphome/components/tuya/water_heater/* @iago-veiga
|
||||
esphome/components/uart/* @esphome/core
|
||||
esphome/components/uart/button/* @ssieb
|
||||
esphome/components/uart/event/* @eoasmxd
|
||||
@@ -639,7 +614,6 @@ esphome/components/xpt2046/touchscreen/* @nielsnl68 @numo68
|
||||
esphome/components/xxtea/* @clydebarrow
|
||||
esphome/components/zephyr/* @tomaszduda23
|
||||
esphome/components/zephyr_mcumgr/ota/* @tomaszduda23
|
||||
esphome/components/zephyr_pwm/* @wiomoc
|
||||
esphome/components/zhlt01/* @cfeenstra1024
|
||||
esphome/components/zigbee/* @luar123 @tomaszduda23
|
||||
esphome/components/zio_ultrasonic/* @kahrendt
|
||||
|
||||
@@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome
|
||||
# could be handy for archiving the generated documentation or if some version
|
||||
# control system is used.
|
||||
|
||||
PROJECT_NUMBER = 2026.9.0-dev
|
||||
PROJECT_NUMBER = 2026.7.0-dev
|
||||
|
||||
# Using the PROJECT_BRIEF tag one can provide an optional one line description
|
||||
# for a project that appears at the top of each page and should give viewer a
|
||||
|
||||
@@ -6,4 +6,3 @@ recursive-include esphome *.cpp *.h *.tcc *.c
|
||||
recursive-include esphome *.py.script
|
||||
recursive-include esphome *.jinja
|
||||
recursive-include esphome LICENSE.txt
|
||||
recursive-include esphome requirements.txt
|
||||
|
||||
@@ -79,48 +79,6 @@ These *are* security bugs in this repo, and we want to hear about them privately
|
||||
- Flaws that weaken the device's API encryption (Noise), OTA, or web server auth
|
||||
below their documented guarantees.
|
||||
|
||||
## The web server is an open HTTP API by design
|
||||
|
||||
The `web_server` component exposes a plain HTTP interface for viewing and
|
||||
controlling entities, and, when the `web_server` OTA platform is enabled, for
|
||||
uploading firmware at `/update`. Its only access controls are the optional
|
||||
`web_server` `auth:` credentials and the network the device sits on.
|
||||
|
||||
When `auth:` is not configured, every endpoint is reachable by any client that
|
||||
can reach the device. This is intentional; enabling `web_server` without `auth:`
|
||||
is choosing an open control surface, in the same way that running native OTA
|
||||
without a password leaves OTA open. The API is documented and is meant to be
|
||||
called by other devices, scripts, and pages.
|
||||
|
||||
As defense-in-depth, the web server checks the `Origin` header on browser requests
|
||||
to its entity control and state endpoints: a request whose `Origin` does not match
|
||||
the address the device is served on is rejected, and the `allowed_origins` option
|
||||
widens that list. This blocks the common "confused deputy" (CSRF) case where a page
|
||||
the operator visits drives the device through their browser. It is **not** an
|
||||
authentication boundary: it only constrains browsers. Any client that omits the
|
||||
`Origin` header — `curl`, scripts, or other non-browser callers on the same
|
||||
network — reaches every endpoint exactly as before. The check also does not cover
|
||||
the web OTA `/update` endpoint. The device performs no CSRF-token or `Referer`
|
||||
validation. The following are therefore **not** vulnerabilities in this repository:
|
||||
|
||||
- Requests without an `Origin` header (for example `curl`) reaching the control
|
||||
endpoints, whether or not `web_server` `auth:` is set.
|
||||
- Requests from an origin the operator added to `allowed_origins`.
|
||||
- Cross-origin or CSRF firmware upload through the web OTA endpoint (`/update`) when
|
||||
web OTA is enabled without `web_server` `auth:`. The `/update` endpoint is not
|
||||
covered by the `Origin` check; this is the same exposure as running OTA without a
|
||||
password.
|
||||
|
||||
The supported defenses are `web_server` `auth:`, protecting OTA (a web password or
|
||||
a native OTA password), and keeping devices on a trusted, segmented network. See
|
||||
the security best practices guide linked above.
|
||||
|
||||
What remains in scope is bypassing `web_server` `auth:` when it *is* configured,
|
||||
and any memory-safety or protocol bug in the server reachable without credentials.
|
||||
|
||||
This section documents the current design and scope; it is not a judgment that the
|
||||
design is optimal or that it will not change.
|
||||
|
||||
## Explicitly out of scope
|
||||
|
||||
- Local attackers who already have shell access on the host that runs `esphome`.
|
||||
@@ -128,10 +86,6 @@ design is optimal or that it will not change.
|
||||
- Operator-supplied hostile YAML (covered above — config authoring is trusted).
|
||||
- Attacks that require an already-authenticated device peer (someone who already
|
||||
holds the API key / OTA / web credentials).
|
||||
- Access to the device web server or its web OTA endpoint by non-browser clients
|
||||
(those that send no `Origin` header). The web server is an open HTTP API by
|
||||
design (see above); browser cross-origin requests are blocked by default, but the
|
||||
real controls are `web_server` `auth:` and network isolation.
|
||||
- Anything in the dashboard / device-builder — report that in its own repository
|
||||
(linked at the top).
|
||||
- Deployments where the operator removed protections or exposed credentials. See
|
||||
|
||||
+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.13.1
|
||||
RUN uv pip install --no-cache-dir esphome-device-builder==1.0.19
|
||||
|
||||
RUN \
|
||||
platformio settings set enable_telemetry No \
|
||||
|
||||
@@ -21,11 +21,6 @@ export PLATFORMIO_PLATFORMS_DIR="${pio_cache_base}/platforms"
|
||||
export PLATFORMIO_PACKAGES_DIR="${pio_cache_base}/packages"
|
||||
export PLATFORMIO_CACHE_DIR="${pio_cache_base}/cache"
|
||||
|
||||
# Keep the native toolchain installs on the persistent cache root, not the
|
||||
# container's ephemeral user cache dir (re-downloaded on every restart).
|
||||
export ESPHOME_ESP_IDF_PREFIX="$(dirname "${pio_cache_base}")/idf"
|
||||
export ESPHOME_SDK_NRF_PREFIX="$(dirname "${pio_cache_base}")/sdk-nrf"
|
||||
|
||||
# If /build is mounted, use that as the build path
|
||||
# otherwise use path in /config (so that builds aren't lost on container restart)
|
||||
if [[ -d /build ]]; then
|
||||
|
||||
@@ -15,11 +15,6 @@ export PLATFORMIO_PLATFORMS_DIR="${pio_cache_base}/platforms"
|
||||
export PLATFORMIO_PACKAGES_DIR="${pio_cache_base}/packages"
|
||||
export PLATFORMIO_CACHE_DIR="${pio_cache_base}/cache"
|
||||
|
||||
# Keep the native toolchain installs on the persistent /data volume, not the
|
||||
# container's ephemeral user cache dir (wiped on every add-on update/restart).
|
||||
export ESPHOME_ESP_IDF_PREFIX=/data/cache/idf
|
||||
export ESPHOME_SDK_NRF_PREFIX=/data/cache/sdk-nrf
|
||||
|
||||
if bashio::config.true 'leave_front_door_open'; then
|
||||
export DISABLE_HA_AUTHENTICATION=true
|
||||
fi
|
||||
|
||||
@@ -2,6 +2,6 @@ esphome:
|
||||
name: docker-test-ln882x-arduino
|
||||
|
||||
ln882x:
|
||||
board: generic-ln882h
|
||||
board: generic-ln882hki
|
||||
|
||||
logger:
|
||||
|
||||
+155
-352
@@ -2,33 +2,39 @@
|
||||
import argparse
|
||||
from collections.abc import Callable
|
||||
from contextlib import suppress
|
||||
from datetime import datetime
|
||||
import functools
|
||||
import getpass
|
||||
import importlib
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Protocol
|
||||
from typing import Protocol
|
||||
|
||||
import argcomplete
|
||||
|
||||
# Note: Do not import modules from esphome.components here, as this would
|
||||
# cause them to be loaded before external components are processed, resulting
|
||||
# in the built-in version being used instead of the external component one.
|
||||
from esphome import const, platform_hooks
|
||||
from esphome import const
|
||||
import esphome.codegen as cg
|
||||
from esphome.config import iter_component_configs, read_config, strip_default_ids
|
||||
from esphome.const import (
|
||||
ALLOWED_NAME_CHARS,
|
||||
ARGUMENT_HELP_DEVICE,
|
||||
BUNDLE_EXTENSION,
|
||||
CONF_API,
|
||||
CONF_AUTH,
|
||||
CONF_BAUD_RATE,
|
||||
CONF_BROKER,
|
||||
CONF_DEASSERT_RTS_DTR,
|
||||
CONF_DISABLED,
|
||||
CONF_DISCOVER_IP,
|
||||
CONF_ESPHOME,
|
||||
CONF_LEVEL,
|
||||
CONF_LOG,
|
||||
CONF_LOG_TOPIC,
|
||||
CONF_LOGGER,
|
||||
CONF_MDNS,
|
||||
@@ -42,12 +48,10 @@ from esphome.const import (
|
||||
CONF_PORT,
|
||||
CONF_SUBSTITUTIONS,
|
||||
CONF_TOPIC,
|
||||
CONF_VERSION,
|
||||
CONF_USERNAME,
|
||||
CONF_WEB_SERVER,
|
||||
CONF_WIFI,
|
||||
ENV_NOGITIGNORE,
|
||||
KEY_ESP32,
|
||||
KEY_VARIANT,
|
||||
SECRETS_FILES,
|
||||
Toolchain,
|
||||
)
|
||||
@@ -55,7 +59,6 @@ from esphome.core import CORE, EsphomeError, coroutine
|
||||
from esphome.enum import StrEnum
|
||||
from esphome.helpers import get_bool_env, indent, is_ip_address
|
||||
from esphome.log import AnsiFore, color, setup_log
|
||||
from esphome.stacktrace import LogLineProcessor
|
||||
from esphome.types import ConfigType
|
||||
from esphome.upload_targets import PortType, get_port_type
|
||||
from esphome.util import (
|
||||
@@ -71,9 +74,6 @@ from esphome.util import (
|
||||
safe_print,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import threading
|
||||
|
||||
# Keep expensive imports (zeroconf, writer, yaml_util, etc.) out of this
|
||||
# module's top level. Every `esphome` invocation — including fast paths
|
||||
# like `esphome version` — pays the cost of what's imported here before
|
||||
@@ -225,9 +225,8 @@ def _discover_mac_suffix_devices() -> list[str] | None:
|
||||
|
||||
Returns:
|
||||
- ``None`` when discovery isn't applicable (``name_add_mac_suffix`` off,
|
||||
mDNS disabled, or ``CORE.address`` isn't a ``.local`` mDNS address).
|
||||
Callers should then fall back to whatever default OTA address they
|
||||
normally use.
|
||||
mDNS disabled, or ``CORE.address`` is already an IP). Callers should
|
||||
then fall back to whatever default OTA address they normally use.
|
||||
- ``[]`` when discovery ran but found nothing. Callers should NOT fall
|
||||
back to the base name: with ``name_add_mac_suffix`` enabled, the base
|
||||
name by definition doesn't exist on the network.
|
||||
@@ -237,7 +236,7 @@ def _discover_mac_suffix_devices() -> list[str] | None:
|
||||
``aioesphomeapi`` via :func:`_resolve_network_devices`) reuses the IPs we
|
||||
already have without opening a second Zeroconf client.
|
||||
"""
|
||||
if not (has_name_add_mac_suffix() and has_mdns() and has_mdns_address()):
|
||||
if not (has_name_add_mac_suffix() and has_mdns() and has_non_ip_address()):
|
||||
return None
|
||||
from esphome.zeroconf import discover_mdns_devices
|
||||
|
||||
@@ -276,8 +275,8 @@ def _unresolved_default_error(purpose: Purpose, defaults: list[str]) -> str:
|
||||
if purpose == Purpose.LOGGING and not has_api():
|
||||
return (
|
||||
"Cannot view logs over the network: no 'api:' component is "
|
||||
"configured. Add an 'api:' component, enable MQTT logging, add a "
|
||||
"'web_server:' component, or view logs over USB."
|
||||
"configured. Network log streaming requires the native API; add "
|
||||
"an 'api:' component, enable MQTT logging, or view logs over USB."
|
||||
)
|
||||
if purpose == Purpose.UPLOADING and not has_ota():
|
||||
return (
|
||||
@@ -317,12 +316,9 @@ def choose_upload_log_host(
|
||||
]
|
||||
resolved.append(choose_prompt(options, purpose=purpose))
|
||||
elif device == "OTA":
|
||||
# Logs can stream over a network transport via the native API
|
||||
# or the web_server HTTP SSE feed.
|
||||
network_logging = has_api() or has_web_server_logging()
|
||||
# ensure IP adresses are used first
|
||||
if is_ip_address(CORE.address) and (
|
||||
(purpose == Purpose.LOGGING and network_logging)
|
||||
(purpose == Purpose.LOGGING and has_api())
|
||||
or (purpose == Purpose.UPLOADING and has_ota())
|
||||
):
|
||||
resolved.extend(_resolve_with_cache(CORE.address, purpose))
|
||||
@@ -334,11 +330,7 @@ def choose_upload_log_host(
|
||||
if has_mqtt_logging():
|
||||
resolved.append("MQTT")
|
||||
|
||||
if (
|
||||
network_logging
|
||||
and has_non_ip_address()
|
||||
and has_resolvable_address()
|
||||
):
|
||||
if has_api() and has_non_ip_address() and has_resolvable_address():
|
||||
resolved.extend(_ota_hostnames_for_default(purpose))
|
||||
|
||||
elif purpose == Purpose.UPLOADING:
|
||||
@@ -362,7 +354,7 @@ def choose_upload_log_host(
|
||||
bootsel_permission_error = False
|
||||
if (
|
||||
purpose == Purpose.UPLOADING
|
||||
and CORE.is_rp2
|
||||
and CORE.is_rp2040
|
||||
and (picotool := _find_picotool()) is not None
|
||||
):
|
||||
bootsel = detect_rp2040_bootsel(picotool)
|
||||
@@ -400,7 +392,7 @@ def choose_upload_log_host(
|
||||
mqtt_config = CORE.config[CONF_MQTT]
|
||||
options.append((f"MQTT ({mqtt_config[CONF_BROKER]})", "MQTT"))
|
||||
|
||||
if has_api() or has_web_server_logging():
|
||||
if has_api():
|
||||
add_ota_options()
|
||||
|
||||
elif purpose == Purpose.UPLOADING and has_ota():
|
||||
@@ -409,7 +401,7 @@ def choose_upload_log_host(
|
||||
# Show helpful BOOTSEL instructions for RP2040 when no BOOTSEL device is found
|
||||
if (
|
||||
purpose == Purpose.UPLOADING
|
||||
and CORE.is_rp2
|
||||
and CORE.is_rp2040
|
||||
and not any(get_port_type(opt[1]) == PortType.BOOTSEL for opt in options)
|
||||
):
|
||||
if bootsel_permission_error:
|
||||
@@ -493,23 +485,10 @@ def has_web_server_ota() -> bool:
|
||||
)
|
||||
|
||||
|
||||
def has_web_server_logging() -> bool:
|
||||
"""Check if logs can be streamed over the web_server HTTP SSE endpoint.
|
||||
|
||||
The ``web_server`` component exposes a ``/events`` Server-Sent Events
|
||||
stream that carries ``event: log`` frames. This requires version 2+ (the
|
||||
v1 UI has no ``/events`` endpoint) and the ``log`` option enabled (default).
|
||||
"""
|
||||
web_conf = CORE.config.get(CONF_WEB_SERVER)
|
||||
if web_conf is None:
|
||||
return False
|
||||
if web_conf.get(CONF_VERSION, 2) == 1:
|
||||
return False
|
||||
return web_conf.get(CONF_LOG, True)
|
||||
|
||||
|
||||
def has_mqtt_ip_lookup() -> bool:
|
||||
"""Check if MQTT is available and IP lookup is supported."""
|
||||
from esphome.components.mqtt import CONF_DISCOVER_IP
|
||||
|
||||
if CONF_MQTT not in CORE.config:
|
||||
return False
|
||||
# Default Enabled
|
||||
@@ -524,22 +503,17 @@ def has_mdns() -> bool:
|
||||
|
||||
|
||||
def has_non_ip_address() -> bool:
|
||||
"""Check if ``CORE.address`` is set and is not an IP address."""
|
||||
"""Check if CORE.address is set and is not an IP address."""
|
||||
return CORE.address is not None and not is_ip_address(CORE.address)
|
||||
|
||||
|
||||
def has_mdns_address() -> bool:
|
||||
"""Check if ``CORE.address`` is a ``.local`` mDNS hostname."""
|
||||
return CORE.address is not None and CORE.address.endswith(".local")
|
||||
|
||||
|
||||
def has_ip_address() -> bool:
|
||||
"""Check if ``CORE.address`` is a valid IP address."""
|
||||
"""Check if CORE.address is a valid IP address."""
|
||||
return CORE.address is not None and is_ip_address(CORE.address)
|
||||
|
||||
|
||||
def has_resolvable_address() -> bool:
|
||||
"""Check if ``CORE.address`` is resolvable (via mDNS, DNS, or is an IP address)."""
|
||||
"""Check if CORE.address is resolvable (via mDNS, DNS, or is an IP address)."""
|
||||
# Any address (IP, mDNS hostname, or regular DNS hostname) is resolvable
|
||||
# The resolve_ip_address() function in helpers.py handles all types via AsyncResolver
|
||||
if CORE.address is None:
|
||||
@@ -558,7 +532,7 @@ def has_resolvable_address() -> bool:
|
||||
return True
|
||||
|
||||
# .local mDNS hostnames are only resolvable if mDNS is enabled
|
||||
return not has_mdns_address()
|
||||
return not CORE.address.endswith(".local")
|
||||
|
||||
|
||||
def has_name_add_mac_suffix() -> bool:
|
||||
@@ -570,48 +544,11 @@ def has_name_add_mac_suffix() -> bool:
|
||||
|
||||
|
||||
def mqtt_get_ip(
|
||||
config: ConfigType,
|
||||
username: str,
|
||||
password: str,
|
||||
client_id: str,
|
||||
stop_event: "threading.Event | None" = None,
|
||||
config: ConfigType, username: str, password: str, client_id: str
|
||||
) -> list[str]:
|
||||
from esphome import mqtt
|
||||
|
||||
return mqtt.get_esphome_device_ip(
|
||||
config, username, password, client_id, stop_event=stop_event
|
||||
)
|
||||
|
||||
|
||||
def _add_network_device(device: str, network_devices: list[str]) -> None:
|
||||
"""Append a device to the list, expanding it through ``CORE.address_cache``.
|
||||
|
||||
If the hostname is already in the address cache (e.g. populated by mDNS
|
||||
discovery), substitute the cached IPs so aioesphomeapi doesn't open its
|
||||
own Zeroconf to re-resolve it. Duplicates are dropped.
|
||||
"""
|
||||
if CORE.address_cache and (cached := CORE.address_cache.get_addresses(device)):
|
||||
network_devices.extend(addr for addr in cached if addr not in network_devices)
|
||||
elif device not in network_devices:
|
||||
network_devices.append(device)
|
||||
|
||||
|
||||
def _split_network_devices(devices: list[str]) -> tuple[list[str], bool]:
|
||||
"""Split the device list into direct addresses and an MQTT-lookup flag.
|
||||
|
||||
Direct addresses are expanded through ``CORE.address_cache`` and deduped
|
||||
the same way ``_resolve_network_devices`` does; MQTT/MQTTIP magic strings
|
||||
are not resolved, only reported via the returned bool so the caller can
|
||||
defer the broker lookup.
|
||||
"""
|
||||
network_devices: list[str] = []
|
||||
has_mqtt_lookup = False
|
||||
for device in devices:
|
||||
if get_port_type(device) in _MQTT_PORT_TYPES:
|
||||
has_mqtt_lookup = True
|
||||
else:
|
||||
_add_network_device(device, network_devices)
|
||||
return network_devices, has_mqtt_lookup
|
||||
return mqtt.get_esphome_device_ip(config, username, password, client_id)
|
||||
|
||||
|
||||
def _resolve_network_devices(
|
||||
@@ -644,47 +581,41 @@ def _resolve_network_devices(
|
||||
if port_type in _MQTT_PORT_TYPES:
|
||||
# Only resolve MQTT once, even if multiple MQTT entries
|
||||
if not mqtt_resolved:
|
||||
mqtt_ips = _mqtt_get_ip_or_warn(
|
||||
config, args.username, args.password, args.client_id
|
||||
)
|
||||
network_devices.extend(
|
||||
addr for addr in mqtt_ips if addr not in network_devices
|
||||
)
|
||||
try:
|
||||
mqtt_ips = mqtt_get_ip(
|
||||
config, args.username, args.password, args.client_id
|
||||
)
|
||||
# pylint can't infer mqtt_get_ip's return through its
|
||||
# lazy ``from esphome import mqtt`` import, so it flags
|
||||
# the genexpr below.
|
||||
network_devices.extend(
|
||||
addr
|
||||
for addr in mqtt_ips # pylint: disable=not-an-iterable
|
||||
if addr not in network_devices
|
||||
)
|
||||
except EsphomeError as err:
|
||||
_LOGGER.warning(
|
||||
"MQTT IP discovery failed (%s), will try other devices if available",
|
||||
err,
|
||||
)
|
||||
mqtt_resolved = True
|
||||
continue
|
||||
|
||||
_add_network_device(device, network_devices)
|
||||
# If the hostname is already in the address cache (e.g. populated by
|
||||
# mDNS discovery), substitute the cached IPs so aioesphomeapi doesn't
|
||||
# open its own Zeroconf to re-resolve it.
|
||||
if CORE.address_cache and (cached := CORE.address_cache.get_addresses(device)):
|
||||
network_devices.extend(
|
||||
addr for addr in cached if addr not in network_devices
|
||||
)
|
||||
elif device not in network_devices:
|
||||
# Regular network address or IP - add if not already present
|
||||
network_devices.append(device)
|
||||
|
||||
return network_devices
|
||||
|
||||
|
||||
def _mqtt_get_ip_or_warn(
|
||||
config: ConfigType,
|
||||
username: str,
|
||||
password: str,
|
||||
client_id: str,
|
||||
stop_event: "threading.Event | None" = None,
|
||||
) -> list[str]:
|
||||
"""Look up the device IP via MQTT, returning [] with a warning on failure.
|
||||
|
||||
This owns the failure policy for MQTT IP discovery on paths that have
|
||||
other addresses to fall back on: a broker problem must not abort the
|
||||
operation. Also used as the deferred resolver handed to ``run_logs``,
|
||||
where it runs in a worker thread.
|
||||
"""
|
||||
try:
|
||||
return mqtt_get_ip(config, username, password, client_id, stop_event=stop_event)
|
||||
except EsphomeError as err:
|
||||
_LOGGER.warning(
|
||||
"MQTT IP discovery failed (%s), will try other devices if available",
|
||||
err,
|
||||
)
|
||||
return []
|
||||
|
||||
|
||||
def run_miniterm(config: ConfigType, port: str, args) -> int:
|
||||
from datetime import datetime
|
||||
|
||||
from aioesphomeapi import LogParser
|
||||
import serial
|
||||
|
||||
@@ -697,9 +628,18 @@ def run_miniterm(config: ConfigType, port: str, args) -> int:
|
||||
return 1
|
||||
_LOGGER.info("Starting log output from %s with baud rate %s", port, baud_rate)
|
||||
|
||||
# Decoder resolution, crash isolation, and disable-after-failure
|
||||
# all live in LogLineProcessor, shared with the API log path.
|
||||
processor = LogLineProcessor(config, CORE.target_platform)
|
||||
process_stacktrace = None
|
||||
|
||||
try:
|
||||
module = importlib.import_module("esphome.components." + CORE.target_platform)
|
||||
process_stacktrace = module.process_stacktrace
|
||||
except (AttributeError, ImportError):
|
||||
_LOGGER.info(
|
||||
'Stacktrace analysis is unavailable: no compatible analyzer found for target platform "%s".',
|
||||
CORE.target_platform,
|
||||
)
|
||||
|
||||
backtrace_state = False
|
||||
ser = serial.Serial()
|
||||
ser.baudrate = baud_rate
|
||||
ser.port = port
|
||||
@@ -739,7 +679,11 @@ def run_miniterm(config: ConfigType, port: str, args) -> int:
|
||||
"utf8", "backslashreplace"
|
||||
)
|
||||
safe_print(parser.parse_line(line, time_str))
|
||||
processor.process_line(line)
|
||||
|
||||
if process_stacktrace is not None:
|
||||
backtrace_state = process_stacktrace(
|
||||
config, line, backtrace_state
|
||||
)
|
||||
except serial.SerialException:
|
||||
_LOGGER.error("Serial port closed!")
|
||||
return 0
|
||||
@@ -754,19 +698,15 @@ def run_miniterm(config: ConfigType, port: str, args) -> int:
|
||||
|
||||
|
||||
def _wrap_to_code(name, comp, yaml_util):
|
||||
import esphome.codegen as cg
|
||||
|
||||
coro = coroutine(comp.to_code)
|
||||
|
||||
@functools.wraps(comp.to_code)
|
||||
async def wrapped(conf):
|
||||
cg.add(cg.LineComment(f"{name}:"))
|
||||
if comp.config_schema is not None:
|
||||
# sort_keys: voluptuous fills defaults in set order, so an
|
||||
# unsorted dump would churn main.cpp and relink every run
|
||||
conf_str = yaml_util.dump(conf, sort_keys=True)
|
||||
conf_str = yaml_util.dump(conf)
|
||||
conf_str = conf_str.replace("//", "")
|
||||
# remove trailing \ to avoid multi-line comment warning
|
||||
# remove tailing \ to avoid multi-line comment warning
|
||||
conf_str = conf_str.replace("\\\n", "\n")
|
||||
cg.add(cg.LineComment(indent(conf_str)))
|
||||
await coro(conf)
|
||||
@@ -793,7 +733,6 @@ def write_cpp(config: ConfigType) -> int:
|
||||
|
||||
def generate_cpp_contents(config: ConfigType) -> None:
|
||||
from esphome import yaml_util
|
||||
from esphome.config import iter_component_configs
|
||||
|
||||
_LOGGER.info("Generating C++ source...")
|
||||
|
||||
@@ -831,13 +770,6 @@ def compile_program(args: ArgsProtocol, config: ConfigType) -> int:
|
||||
|
||||
check_placeholder_credentials(config)
|
||||
|
||||
# Keep this here, NOT in codegen: config-hash and --only-generate must keep
|
||||
# working on machines that cannot run the toolchain.
|
||||
if CORE.is_esp8266:
|
||||
from esphome.components.esp8266 import check_rosetta
|
||||
|
||||
check_rosetta()
|
||||
|
||||
# NOTE: "Build path:" format is parsed by script/ci_memory_impact_extract.py
|
||||
# If you change this format, update the regex in that script as well
|
||||
_LOGGER.info("Compiling app... Build path: %s", CORE.build_path)
|
||||
@@ -857,20 +789,7 @@ 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 IDEDATA_BEST_EFFORT_ERRORS
|
||||
|
||||
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)
|
||||
toolchain.get_idedata()
|
||||
else:
|
||||
from esphome.platformio import toolchain
|
||||
|
||||
@@ -999,10 +918,9 @@ def upload_using_esptool(
|
||||
|
||||
mcu = "esp8266"
|
||||
if CORE.is_esp32:
|
||||
# Same lookup as esp32.get_esp32_variant(), read directly so the
|
||||
# serial upload path does not import the esp32 package; both the
|
||||
# validator and the warm-cache apply_to_core populate this key.
|
||||
mcu = CORE.data[KEY_ESP32][KEY_VARIANT].lower()
|
||||
from esphome.components.esp32 import get_esp32_variant
|
||||
|
||||
mcu = get_esp32_variant().lower()
|
||||
|
||||
line_callbacks: list[Callable[[str], str | None]] = []
|
||||
if (
|
||||
@@ -1056,14 +974,12 @@ def upload_using_esptool(
|
||||
|
||||
|
||||
def upload_using_platformio(config: ConfigType, port: str) -> int:
|
||||
import shutil
|
||||
|
||||
from esphome.platformio import toolchain
|
||||
|
||||
# RP2040 platform-raspberrypi build recipe expects firmware.bin.signed for
|
||||
# the upload target, but 'nobuild' skips the build phase that creates it.
|
||||
# Create it here so the upload doesn't fail.
|
||||
if CORE.is_rp2:
|
||||
if CORE.is_rp2040:
|
||||
idedata = toolchain.get_idedata(config)
|
||||
build_dir = Path(idedata.firmware_elf_path).parent
|
||||
firmware_bin = build_dir / "firmware.bin"
|
||||
@@ -1095,8 +1011,6 @@ def upload_using_picotool(config: ConfigType) -> int:
|
||||
the mass storage copy approach that causes "disk not ejected properly"
|
||||
warnings on macOS.
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
from esphome.platformio import toolchain
|
||||
|
||||
idedata = toolchain.get_idedata(config)
|
||||
@@ -1203,8 +1117,6 @@ def check_permissions(port: str):
|
||||
"the USB cable can be used for data and is not a power-only cable."
|
||||
)
|
||||
if not (os.access(port, os.R_OK | os.W_OK)):
|
||||
import getpass
|
||||
|
||||
raise EsphomeError(
|
||||
"You do not have read or write permission on the selected serial port. "
|
||||
"To resolve this issue, you can add your user to the dialout group "
|
||||
@@ -1217,11 +1129,12 @@ def upload_program(
|
||||
config: ConfigType, args: ArgsProtocol, devices: list[str]
|
||||
) -> tuple[int, str | None]:
|
||||
host = devices[0]
|
||||
platform_upload = platform_hooks.get_platform_hook(
|
||||
CORE.target_platform, "upload_program"
|
||||
)
|
||||
if platform_upload is not None and platform_upload(config, args, host):
|
||||
return 0, host
|
||||
try:
|
||||
module = importlib.import_module("esphome.components." + CORE.target_platform)
|
||||
if module.upload_program(config, args, host):
|
||||
return 0, host
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
port_type = get_port_type(host)
|
||||
|
||||
@@ -1254,7 +1167,7 @@ def upload_program(
|
||||
if CORE.is_esp32 or CORE.is_esp8266:
|
||||
file = getattr(args, "file", None)
|
||||
exit_code = upload_using_esptool(config, host, file, args.upload_speed)
|
||||
elif CORE.is_rp2 or CORE.is_libretiny:
|
||||
elif CORE.is_rp2040 or CORE.is_libretiny:
|
||||
exit_code = upload_using_platformio(config, host)
|
||||
# else: Unknown target platform, exit_code remains 1
|
||||
|
||||
@@ -1372,23 +1285,25 @@ def _upload_via_native_api(
|
||||
def _upload_via_web_server(
|
||||
config: ConfigType, network_devices: list[str], binary: Path
|
||||
) -> tuple[int, str | None]:
|
||||
web_conf = config.get(CONF_WEB_SERVER)
|
||||
if not web_conf:
|
||||
raise EsphomeError(
|
||||
f"Cannot upload via web_server OTA: the {CONF_WEB_SERVER} component "
|
||||
f"is not configured."
|
||||
)
|
||||
|
||||
remote_port = int(web_conf[CONF_PORT])
|
||||
auth = web_conf.get(CONF_AUTH) or {}
|
||||
username = auth.get(CONF_USERNAME)
|
||||
password = auth.get(CONF_PASSWORD)
|
||||
|
||||
from esphome import web_server_ota
|
||||
from esphome.web_server_helpers import get_web_server_connection
|
||||
|
||||
remote_port, username, password = get_web_server_connection(config)
|
||||
return web_server_ota.run_ota(
|
||||
network_devices, remote_port, username, password, binary
|
||||
)
|
||||
|
||||
|
||||
def _show_logs_via_web_server(config: ConfigType, network_devices: list[str]) -> int:
|
||||
from esphome import web_server_logs
|
||||
from esphome.web_server_helpers import get_web_server_connection
|
||||
|
||||
port, username, password = get_web_server_connection(config)
|
||||
return web_server_logs.run_logs(network_devices, port, username, password)
|
||||
|
||||
|
||||
# Layout of esp_partition_info_t on flash. Each entry is 32 bytes, leading with a
|
||||
# 16-bit little-endian magic. ESP-IDF defines ESP_PARTITION_MAGIC = 0x50AA (stored as
|
||||
# bytes 0xAA, 0x50) for partition entries and ESP_PARTITION_MAGIC_MD5 = 0xEBEB for the
|
||||
@@ -1479,11 +1394,12 @@ def _should_subscribe_states(args: ArgsProtocol) -> bool:
|
||||
|
||||
|
||||
def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int | None:
|
||||
platform_show_logs = platform_hooks.get_platform_hook(
|
||||
CORE.target_platform, "show_logs"
|
||||
)
|
||||
if platform_show_logs is not None and platform_show_logs(config, args, devices):
|
||||
return 0
|
||||
try:
|
||||
module = importlib.import_module("esphome.components." + CORE.target_platform)
|
||||
if module.show_logs(config, args, devices):
|
||||
return 0
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
if "logger" not in config:
|
||||
raise EsphomeError("Logger is not configured!")
|
||||
@@ -1497,37 +1413,17 @@ def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int
|
||||
return run_miniterm(config, port, args)
|
||||
|
||||
# Check if we should use API for logging
|
||||
if has_api():
|
||||
network_devices, has_mqtt_lookup = _split_network_devices(devices)
|
||||
mqtt_resolver = None
|
||||
if has_mqtt_lookup:
|
||||
if network_devices:
|
||||
# Addresses are already known, so don't block startup on the
|
||||
# MQTT broker lookup; hand it to run_logs as a deferred
|
||||
# resolver that runs in the background and feeds discovered
|
||||
# addresses into the running log client, keeping MQTT as a
|
||||
# fallback for when the known addresses are stale (e.g. DHCP
|
||||
# reassigned the IP).
|
||||
mqtt_resolver = functools.partial(
|
||||
_mqtt_get_ip_or_warn,
|
||||
config,
|
||||
args.username,
|
||||
args.password,
|
||||
args.client_id,
|
||||
)
|
||||
else:
|
||||
# The MQTT lookup is the only way to find the device; resolve
|
||||
# it up front since the client needs an address to start with.
|
||||
network_devices = _resolve_network_devices(devices, config, args)
|
||||
if network_devices:
|
||||
from esphome.api_client import run_logs
|
||||
# Resolve MQTT magic strings to actual IP addresses
|
||||
if has_api() and (
|
||||
network_devices := _resolve_network_devices(devices, config, args)
|
||||
):
|
||||
from esphome.components.api.client import run_logs
|
||||
|
||||
return run_logs(
|
||||
config,
|
||||
network_devices,
|
||||
subscribe_states=_should_subscribe_states(args),
|
||||
mqtt_resolver=mqtt_resolver,
|
||||
)
|
||||
return run_logs(
|
||||
config,
|
||||
network_devices,
|
||||
subscribe_states=_should_subscribe_states(args),
|
||||
)
|
||||
|
||||
if port_type in (PortType.NETWORK, PortType.MQTT) and has_mqtt_logging():
|
||||
from esphome import mqtt
|
||||
@@ -1536,13 +1432,6 @@ def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int
|
||||
config, args.topic, args.username, args.password, args.client_id
|
||||
)
|
||||
|
||||
# Fall back to the web_server HTTP SSE log stream for devices that have
|
||||
# web_server: but no api: (the logging counterpart to web_server OTA).
|
||||
if has_web_server_logging() and (
|
||||
network_devices := _resolve_network_devices(devices, config, args)
|
||||
):
|
||||
return _show_logs_via_web_server(config, network_devices)
|
||||
|
||||
raise EsphomeError("No remote or local logging method configured (api/mqtt/logger)")
|
||||
|
||||
|
||||
@@ -1562,7 +1451,6 @@ def command_wizard(args: ArgsProtocol) -> int | None:
|
||||
|
||||
def command_config(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
from esphome import yaml_util
|
||||
from esphome.config import strip_default_ids
|
||||
|
||||
if getattr(args, "no_defaults", False):
|
||||
user_config = getattr(config, "user_config", None)
|
||||
@@ -1600,37 +1488,12 @@ _LEGACY_REDACTION_REMOVAL = "2026.12.0"
|
||||
|
||||
def _redact_with_legacy_fallback(output: str) -> str:
|
||||
unmarked: set[str] = set()
|
||||
# Track the top-level ``substitutions:`` block. Its keys are arbitrary
|
||||
# user-chosen names with no schema validator, so the ``cv.sensitive(...)``
|
||||
# migration named in the warning can't be applied to them. Their values are
|
||||
# still redacted, but emitting the (unactionable) deprecation warning would
|
||||
# only confuse users.
|
||||
in_substitutions = False
|
||||
|
||||
lines = output.split("\n")
|
||||
for i, line in enumerate(lines):
|
||||
# A non-indented, non-blank line is a top-level key that opens or
|
||||
# closes the substitutions block.
|
||||
if line and not line[0].isspace():
|
||||
in_substitutions = line.startswith(f"{CONF_SUBSTITUTIONS}:")
|
||||
m = _LEGACY_REDACTION_RE.search(line)
|
||||
if m is None:
|
||||
continue
|
||||
key = m.group("key")
|
||||
if not in_substitutions:
|
||||
# Public keys (e.g. wireguard's peer_public_key) are not secret;
|
||||
# redacting them and telling maintainers to mark them cv.sensitive
|
||||
# would be wrong on both counts. Substitution keys are user-named
|
||||
# with no schema behind them, so anything secret-shaped there
|
||||
# (public or not) stays conservatively redacted.
|
||||
if "public" in key.split("_"):
|
||||
continue
|
||||
unmarked.add(key)
|
||||
lines[i] = (
|
||||
f"{line[: m.start()]}{key}: "
|
||||
f"\\033[8m{m.group('val')}\\033[28m{line[m.end() :]}"
|
||||
)
|
||||
output = "\n".join(lines)
|
||||
def _replace(m: re.Match[str]) -> str:
|
||||
unmarked.add(m.group("key"))
|
||||
return f"{m.group('key')}: \\033[8m{m.group('val')}\\033[28m"
|
||||
|
||||
output = _LEGACY_REDACTION_RE.sub(_replace, output)
|
||||
for key in sorted(unmarked):
|
||||
_LOGGER.warning(
|
||||
"Field '%s' is being redacted by a legacy substring heuristic. "
|
||||
@@ -1761,7 +1624,7 @@ def command_run(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
|
||||
# After BOOTSEL upload, wait for a new serial port to appear
|
||||
# so it shows up in the log chooser
|
||||
if successful_device is None and CORE.is_rp2:
|
||||
if successful_device is None and CORE.is_rp2040:
|
||||
_wait_for_serial_port(known_ports=pre_upload_ports)
|
||||
# If exactly one new serial port appeared, use it directly
|
||||
serial_ports = get_serial_ports()
|
||||
@@ -1812,7 +1675,7 @@ def command_clean(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
|
||||
|
||||
def command_bundle(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
from esphome.bundle import ConfigBundleCreator
|
||||
from esphome.bundle import BUNDLE_EXTENSION, ConfigBundleCreator
|
||||
|
||||
creator = ConfigBundleCreator(config)
|
||||
|
||||
@@ -2047,7 +1910,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
new_name = args.name
|
||||
for c in new_name:
|
||||
if c not in ALLOWED_NAME_CHARS:
|
||||
safe_print(
|
||||
print(
|
||||
color(
|
||||
AnsiFore.BOLD_RED,
|
||||
f"'{c}' is an invalid character for names. Valid characters are: "
|
||||
@@ -2060,7 +1923,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
|
||||
yaml = yaml_util.load_yaml(CORE.config_path)
|
||||
if CONF_ESPHOME not in yaml or CONF_NAME not in yaml[CONF_ESPHOME]:
|
||||
safe_print(
|
||||
print(
|
||||
color(
|
||||
AnsiFore.BOLD_RED, "Complex YAML files cannot be automatically renamed."
|
||||
)
|
||||
@@ -2107,9 +1970,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
)
|
||||
> 1
|
||||
):
|
||||
safe_print(
|
||||
color(AnsiFore.BOLD_RED, "Too many matches in YAML to safely rename")
|
||||
)
|
||||
print(color(AnsiFore.BOLD_RED, "Too many matches in YAML to safely rename"))
|
||||
return 1
|
||||
|
||||
new_raw = re.sub(
|
||||
@@ -2127,7 +1988,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
# ``kitchen``; running ``esphome rename weird-file.yaml kitchen``
|
||||
# would otherwise just re-flash the same hostname).
|
||||
if new_name == old_name:
|
||||
safe_print(
|
||||
print(
|
||||
color(
|
||||
AnsiFore.BOLD_RED,
|
||||
f"'{new_name}' is already the device's name.",
|
||||
@@ -2137,7 +1998,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
|
||||
new_path: Path = CORE.config_dir / (new_name + ".yaml")
|
||||
if new_path.resolve() == CORE.config_path.resolve():
|
||||
safe_print(
|
||||
print(
|
||||
color(
|
||||
AnsiFore.BOLD_RED,
|
||||
f"'{new_name}' is already the device's name.",
|
||||
@@ -2145,7 +2006,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
)
|
||||
return 1
|
||||
if new_path.exists():
|
||||
safe_print(
|
||||
print(
|
||||
color(
|
||||
AnsiFore.BOLD_RED,
|
||||
f"Cannot rename: {new_path} already exists. "
|
||||
@@ -2153,7 +2014,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
)
|
||||
)
|
||||
return 1
|
||||
safe_print(
|
||||
print(
|
||||
f"Updating {color(AnsiFore.CYAN, str(CORE.config_path))} to {color(AnsiFore.CYAN, str(new_path))}"
|
||||
)
|
||||
print()
|
||||
@@ -2162,7 +2023,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
|
||||
rc = run_external_process(*ESPHOME_COMMAND, "config", str(new_path))
|
||||
if rc != 0:
|
||||
safe_print(color(AnsiFore.BOLD_RED, "Rename failed. Reverting changes."))
|
||||
print(color(AnsiFore.BOLD_RED, "Rename failed. Reverting changes."))
|
||||
new_path.unlink()
|
||||
return 1
|
||||
|
||||
@@ -2188,7 +2049,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
if CORE.config_path != new_path:
|
||||
CORE.config_path.unlink()
|
||||
|
||||
safe_print(color(AnsiFore.BOLD_GREEN, "SUCCESS"))
|
||||
print(color(AnsiFore.BOLD_GREEN, "SUCCESS"))
|
||||
print()
|
||||
return 0
|
||||
|
||||
@@ -2508,10 +2369,7 @@ def parse_args(argv):
|
||||
)
|
||||
|
||||
parser_clean_all = subparsers.add_parser(
|
||||
"clean-all",
|
||||
help="Clean all build and platform files, including machine-global "
|
||||
"toolchain caches shared by all configurations, so other projects will "
|
||||
"re-download them on next build.",
|
||||
"clean-all", help="Clean all build and platform files."
|
||||
)
|
||||
parser_clean_all.add_argument(
|
||||
"configuration", help="Your YAML file or configuration directory.", nargs="*"
|
||||
@@ -2599,12 +2457,7 @@ def parse_args(argv):
|
||||
# a deprecation warning).
|
||||
arguments = argv[1:]
|
||||
|
||||
# argcomplete only does anything when the shell-completion machinery
|
||||
# invokes us with _ARGCOMPLETE set; skip the import otherwise.
|
||||
if "_ARGCOMPLETE" in os.environ:
|
||||
import argcomplete
|
||||
|
||||
argcomplete.autocomplete(parser)
|
||||
argcomplete.autocomplete(parser)
|
||||
|
||||
if len(arguments) > 0 and arguments[0] in SIMPLE_CONFIG_ACTIONS:
|
||||
args, unknown_args = parser.parse_known_args(arguments)
|
||||
@@ -2615,49 +2468,6 @@ def parse_args(argv):
|
||||
return parser.parse_args(arguments)
|
||||
|
||||
|
||||
def _warn_if_source_tree_mismatch() -> None:
|
||||
"""Warn when the checkout the user is standing in is not the one being run.
|
||||
|
||||
An editable install records one absolute path, so a venv shared between git
|
||||
worktrees (or reused after a checkout is copied or renamed) keeps importing
|
||||
the tree it was installed from. Every command then silently runs, and
|
||||
compiles, sources the user is not looking at. Only fires inside a checkout,
|
||||
so ordinary installs never see it.
|
||||
"""
|
||||
try:
|
||||
cwd = Path.cwd()
|
||||
except OSError:
|
||||
return # working directory is gone; a diagnostic must not break startup
|
||||
for candidate in (cwd, *cwd.parents):
|
||||
if (candidate / "esphome" / "__main__.py").is_file():
|
||||
standing_in = candidate.resolve()
|
||||
break
|
||||
else:
|
||||
return # not inside a checkout; nothing to compare against
|
||||
|
||||
running = Path(__file__).resolve().parent.parent
|
||||
# Both sides are resolved, so on a case-sensitive filesystem this matches
|
||||
# plain equality. samefile() compares device and inode, which additionally
|
||||
# covers a case-insensitive filesystem (macOS) reaching one directory by
|
||||
# differently cased paths. Falls back to equality if either path is gone.
|
||||
try:
|
||||
same = standing_in.samefile(running)
|
||||
except OSError:
|
||||
same = standing_in == running
|
||||
if same:
|
||||
return
|
||||
|
||||
_LOGGER.warning(
|
||||
"Running ESPHome from a different checkout than the one you are in:\n"
|
||||
" running from: %s\n"
|
||||
" you are in: %s\n"
|
||||
"The installed esphome resolves to the first, so its sources are used.\n"
|
||||
"Run 'python -m esphome' from the second to use that one instead.",
|
||||
running,
|
||||
standing_in,
|
||||
)
|
||||
|
||||
|
||||
def run_esphome(argv):
|
||||
from esphome.address_cache import AddressCache
|
||||
|
||||
@@ -2676,7 +2486,6 @@ def run_esphome(argv):
|
||||
args.log_level = "CRITICAL"
|
||||
|
||||
setup_log(log_level=args.log_level)
|
||||
_warn_if_source_tree_mismatch()
|
||||
|
||||
if args.command in PRE_CONFIG_ACTIONS:
|
||||
try:
|
||||
@@ -2708,11 +2517,10 @@ def run_esphome(argv):
|
||||
return 0
|
||||
|
||||
# Bundle support: if the configuration is a .esphomebundle, extract it
|
||||
# and rewrite conf_path to the extracted YAML config. The suffix check
|
||||
# stays inline so the ordinary run never imports esphome.bundle.
|
||||
if conf_path.name.lower().endswith(BUNDLE_EXTENSION):
|
||||
from esphome.bundle import prepare_bundle_for_compile
|
||||
# and rewrite conf_path to the extracted YAML config.
|
||||
from esphome.bundle import is_bundle_path, prepare_bundle_for_compile
|
||||
|
||||
if is_bundle_path(conf_path):
|
||||
_LOGGER.info("Extracting config bundle %s...", conf_path)
|
||||
conf_path = prepare_bundle_for_compile(conf_path)
|
||||
# Update the argument so downstream code sees the extracted path
|
||||
@@ -2734,14 +2542,10 @@ def run_esphome(argv):
|
||||
# Skipped when -s overrides are passed, since the cache was written
|
||||
# against the previous substitution set.
|
||||
config: ConfigType | None = None
|
||||
cache_write_eligible = (
|
||||
cache_eligible = (
|
||||
args.command in ("upload", "logs") and not command_line_substitutions
|
||||
)
|
||||
# An explicit --toolchain must re-run the per-platform validators, so
|
||||
# gate only the cache read; the refresh below saves the result unless
|
||||
# the sidecar records a different toolchain.
|
||||
cache_read_eligible = cache_write_eligible and args.toolchain is None
|
||||
if cache_read_eligible:
|
||||
if cache_eligible:
|
||||
from esphome.compiled_config import load_compiled_config
|
||||
|
||||
config = load_compiled_config(conf_path)
|
||||
@@ -2751,32 +2555,31 @@ def run_esphome(argv):
|
||||
conf_path.name,
|
||||
)
|
||||
|
||||
cache_missed = config is None
|
||||
if cache_missed:
|
||||
from esphome.config import read_config
|
||||
|
||||
if config is None:
|
||||
config = read_config(
|
||||
command_line_substitutions,
|
||||
skip_external_update=skip_external,
|
||||
# Snapshot only needed by `esphome config --no-defaults`.
|
||||
snapshot_user_config=getattr(args, "no_defaults", False),
|
||||
)
|
||||
if config is None:
|
||||
return 2
|
||||
# Refresh the cache so the next upload/logs hits the fast path
|
||||
# instead of re-running read_config. Skip when the storage
|
||||
# sidecar is absent (no compile has run): the cache would
|
||||
# never be loaded back, so writing secrets to disk is wasted.
|
||||
if cache_eligible and config is not None:
|
||||
from esphome.compiled_config import save_compiled_config
|
||||
from esphome.storage_json import ext_storage_path
|
||||
|
||||
if ext_storage_path(conf_path.name).exists():
|
||||
save_compiled_config(config)
|
||||
if config is None:
|
||||
return 2
|
||||
CORE.config = config
|
||||
|
||||
# The cache fast path skips validation, and legacy sidecars lack the
|
||||
# toolchain field. Must run before the cache refresh below.
|
||||
# Fallback for platforms whose validators didn't set the toolchain
|
||||
# (only the esp32 component reads esp32.framework.toolchain). All
|
||||
# other platforms only support PlatformIO today.
|
||||
if CORE.toolchain is None:
|
||||
CORE.toolchain = Toolchain.PLATFORMIO
|
||||
|
||||
# Refresh the cache so the next upload/logs hits the fast path
|
||||
# instead of re-running read_config.
|
||||
if cache_write_eligible and cache_missed:
|
||||
from esphome.compiled_config import save_compiled_config_and_sidecar
|
||||
|
||||
save_compiled_config_and_sidecar(config)
|
||||
|
||||
if args.command not in POST_CONFIG_ACTIONS:
|
||||
safe_print(f"Unknown command {args.command}")
|
||||
return 1
|
||||
|
||||
@@ -20,7 +20,6 @@ from . import (
|
||||
RAM_SECTIONS,
|
||||
MemoryAnalyzer,
|
||||
)
|
||||
from .toolchain import find_elf_path, find_idedata_path, idedata_candidates
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import ComponentMemory
|
||||
@@ -760,25 +759,45 @@ def main():
|
||||
print(f"Error: {build_path} is not a directory", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
elf_path = find_elf_path(build_path)
|
||||
if not elf_path:
|
||||
print(f"Error: no firmware ELF found in {build_dir}", file=sys.stderr)
|
||||
# Find firmware.elf
|
||||
elf_file = None
|
||||
for elf_candidate in [
|
||||
build_path / "firmware.elf",
|
||||
build_path / ".pioenvs" / build_path.name / "firmware.elf",
|
||||
]:
|
||||
if elf_candidate.exists():
|
||||
elf_file = str(elf_candidate)
|
||||
break
|
||||
|
||||
if not elf_file:
|
||||
print(f"Error: firmware.elf not found in {build_dir}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
elf_file = str(elf_path)
|
||||
|
||||
# Find idedata.json - check current directory first, then home
|
||||
device_name = build_path.name
|
||||
idedata_candidates = [
|
||||
Path.cwd() / ".esphome" / "idedata" / f"{device_name}.json",
|
||||
Path.home() / ".esphome" / "idedata" / f"{device_name}.json",
|
||||
]
|
||||
|
||||
idedata = None
|
||||
if idedata_path := find_idedata_path(build_path):
|
||||
for idedata_path in idedata_candidates:
|
||||
if not idedata_path.exists():
|
||||
continue
|
||||
try:
|
||||
with idedata_path.open(encoding="utf-8") as f:
|
||||
raw_data = json.load(f)
|
||||
idedata = IDEData(raw_data)
|
||||
print(f"Loaded idedata from: {idedata_path}", file=sys.stderr)
|
||||
break
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
print(f"Warning: Failed to load idedata: {e}", file=sys.stderr)
|
||||
|
||||
if not idedata:
|
||||
searched = "\n ".join(str(p) for p in idedata_candidates(build_path))
|
||||
print(f"Warning: idedata not found, searched:\n {searched}", file=sys.stderr)
|
||||
print(
|
||||
f"Warning: idedata not found (searched {idedata_candidates[0]} and {idedata_candidates[1]})",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
analyzer = MemoryAnalyzerCLI(elf_file, idedata=idedata)
|
||||
analyzer.analyze()
|
||||
|
||||
@@ -8,7 +8,7 @@ memory-constrained platforms like ESP8266.
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import re
|
||||
@@ -65,7 +65,6 @@ class RamSymbol:
|
||||
size: int
|
||||
section: str
|
||||
demangled: str = "" # Demangled name, set after batch demangling
|
||||
aliases: list[str] = field(default_factory=list) # Other names at same address
|
||||
|
||||
|
||||
class RamStringsAnalyzer:
|
||||
@@ -236,11 +235,6 @@ class RamStringsAnalyzer:
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
return
|
||||
|
||||
# Track symbols by address so aliases (multiple names for the same
|
||||
# object, e.g. the newlib __lock___* mutexes that all alias one
|
||||
# StaticSemaphore_t) are reported once instead of once per name.
|
||||
symbols_by_addr: dict[int, RamSymbol] = {}
|
||||
|
||||
for line in output.split("\n"):
|
||||
parts = line.split()
|
||||
if len(parts) < 4:
|
||||
@@ -259,18 +253,6 @@ class RamStringsAnalyzer:
|
||||
if sym_type not in DATA_SYMBOL_TYPES:
|
||||
continue
|
||||
|
||||
if (existing := symbols_by_addr.get(addr)) is not None:
|
||||
# Prefer a global (uppercase type) name as the primary so
|
||||
# nm output order can't hide it behind a local alias.
|
||||
if sym_type.isupper() and existing.sym_type.islower():
|
||||
existing.aliases.append(existing.name)
|
||||
existing.name = name
|
||||
existing.sym_type = sym_type
|
||||
else:
|
||||
existing.aliases.append(name)
|
||||
existing.size = max(existing.size, size)
|
||||
continue
|
||||
|
||||
# Check if symbol is in a RAM section
|
||||
for section_name in self.ram_sections:
|
||||
if section_name not in self.sections:
|
||||
@@ -278,15 +260,15 @@ class RamStringsAnalyzer:
|
||||
|
||||
section = self.sections[section_name]
|
||||
if section.address <= addr < section.address + section.size:
|
||||
symbol = RamSymbol(
|
||||
name=name,
|
||||
sym_type=sym_type,
|
||||
address=addr,
|
||||
size=size,
|
||||
section=section_name,
|
||||
self.ram_symbols.append(
|
||||
RamSymbol(
|
||||
name=name,
|
||||
sym_type=sym_type,
|
||||
address=addr,
|
||||
size=size,
|
||||
section=section_name,
|
||||
)
|
||||
)
|
||||
symbols_by_addr[addr] = symbol
|
||||
self.ram_symbols.append(symbol)
|
||||
break
|
||||
|
||||
def _demangle_symbols(self) -> None:
|
||||
@@ -454,13 +436,7 @@ class RamStringsAnalyzer:
|
||||
for symbol in largest_symbols:
|
||||
# Use demangled name if available, otherwise raw name
|
||||
display_name = symbol.demangled or symbol.name
|
||||
# Truncate the name, not the alias note, so merged aliases stay
|
||||
# visible even for long demangled C++ names.
|
||||
alias_note = f" (+{len(symbol.aliases)} aliases)" if symbol.aliases else ""
|
||||
max_name_len = 49 - len(alias_note)
|
||||
if len(display_name) > max_name_len:
|
||||
display_name = display_name[:max_name_len]
|
||||
name_display = display_name + alias_note
|
||||
name_display = display_name[:49] if len(display_name) > 49 else display_name
|
||||
lines.append(
|
||||
f"{name_display:<50} {symbol.sym_type:<6} {symbol.size:>8} B {symbol.section}"
|
||||
)
|
||||
|
||||
@@ -23,78 +23,6 @@ TOOLCHAIN_PREFIXES = [
|
||||
]
|
||||
|
||||
|
||||
def find_elf_path(build_path: Path) -> Path | None:
|
||||
"""Locate the firmware ELF inside an ESPHome build directory.
|
||||
|
||||
The layout depends on the toolchain that produced the build, so try each
|
||||
known one in turn.
|
||||
|
||||
Args:
|
||||
build_path: Path to an ESPHome build directory
|
||||
|
||||
Returns:
|
||||
Path to the ELF file, or None if no known layout matches
|
||||
"""
|
||||
name = build_path.name
|
||||
for candidate in (
|
||||
# Native ESP-IDF: idf.py writes build/<name>.elf, which ESPHome copies
|
||||
# to build/firmware.elf (see espidf.toolchain.create_elf_copy)
|
||||
build_path / "build" / "firmware.elf",
|
||||
# PlatformIO
|
||||
build_path / "firmware.elf",
|
||||
build_path / ".pioenvs" / name / "firmware.elf",
|
||||
# LibreTiny uses raw_firmware.elf
|
||||
build_path / "raw_firmware.elf",
|
||||
build_path / ".pioenvs" / name / "raw_firmware.elf",
|
||||
# Zephyr (nRF52); the SDK nests the artifacts one level deeper from 2.9.2
|
||||
build_path / ".pioenvs" / name / "zephyr" / "zephyr" / "zephyr.elf",
|
||||
build_path / ".pioenvs" / name / "zephyr" / "zephyr.elf",
|
||||
):
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def idedata_candidates(build_path: Path) -> list[Path]:
|
||||
"""Return the idedata locations searched for a build directory, in order.
|
||||
|
||||
Exposed so a caller reporting "not found" can name the paths it tried
|
||||
without keeping its own copy of the list.
|
||||
|
||||
Args:
|
||||
build_path: Path to an ESPHome build directory
|
||||
|
||||
Returns:
|
||||
The candidate idedata JSON paths, most specific first
|
||||
"""
|
||||
name = build_path.name
|
||||
return [
|
||||
# In .pioenvs for test builds
|
||||
build_path / ".pioenvs" / name / "idedata.json",
|
||||
# Both toolchains cache it in the data dir, which holds this build dir:
|
||||
# <data_dir>/idedata/<name>.json next to <data_dir>/build/<name>
|
||||
build_path.parent.parent / "idedata" / f"{name}.json",
|
||||
# Regular builds, invoked from the config dir or from anywhere
|
||||
Path.cwd() / ".esphome" / "idedata" / f"{name}.json",
|
||||
Path.home() / ".esphome" / "idedata" / f"{name}.json",
|
||||
]
|
||||
|
||||
|
||||
def find_idedata_path(build_path: Path) -> Path | None:
|
||||
"""Locate the idedata JSON belonging to an ESPHome build directory.
|
||||
|
||||
Args:
|
||||
build_path: Path to an ESPHome build directory
|
||||
|
||||
Returns:
|
||||
Path to the idedata JSON, or None if it was not found
|
||||
"""
|
||||
for candidate in idedata_candidates(build_path):
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _find_in_platformio_packages(tool_name: str) -> str | None:
|
||||
"""Search for a tool in PlatformIO package directories.
|
||||
|
||||
|
||||
@@ -1,198 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from contextlib import suppress
|
||||
import logging
|
||||
import threading
|
||||
from typing import TYPE_CHECKING, Any
|
||||
import warnings
|
||||
|
||||
# Suppress protobuf version warnings
|
||||
with warnings.catch_warnings():
|
||||
warnings.filterwarnings(
|
||||
"ignore", category=UserWarning, message=".*Protobuf gencode version.*"
|
||||
)
|
||||
from aioesphomeapi import APIClient, parse_log_message
|
||||
from aioesphomeapi.log_runner import async_run
|
||||
|
||||
from esphome.const import CONF_ENCRYPTION, CONF_KEY, CONF_PORT, __version__
|
||||
from esphome.core import CORE
|
||||
from esphome.stacktrace import LogLineProcessor
|
||||
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
|
||||
)
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_run_logs(
|
||||
config: dict[str, Any],
|
||||
addresses: list[str],
|
||||
subscribe_states: bool = True,
|
||||
mqtt_resolver: Callable[[threading.Event], list[str]] | None = None,
|
||||
) -> None:
|
||||
"""Run the logs command in the event loop.
|
||||
|
||||
If ``mqtt_resolver`` is given, it is called in a worker thread (paho-mqtt
|
||||
has no asyncio support on Windows) concurrently with the connection
|
||||
attempts to ``addresses``, and any addresses it discovers are fed into
|
||||
the running client. It owns its own failure handling (returning [] when
|
||||
discovery fails) and must honor the ``threading.Event`` it is passed so
|
||||
teardown is not delayed by the lookup's wait window; the initial broker
|
||||
connect itself is only bounded by the socket timeout.
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
conf = config["api"]
|
||||
name = config["esphome"]["name"]
|
||||
port: int = int(conf[CONF_PORT])
|
||||
noise_psk: str | None = None
|
||||
if (encryption := conf.get(CONF_ENCRYPTION)) and (key := encryption.get(CONF_KEY)):
|
||||
noise_psk = key
|
||||
|
||||
_LOGGER.info(
|
||||
"Starting log output from %s using esphome API", " or ".join(addresses)
|
||||
)
|
||||
|
||||
cli = APIClient(
|
||||
addresses[0], # Primary address for compatibility
|
||||
port,
|
||||
"", # Password auth removed in 2026.1.0
|
||||
client_info=f"ESPHome Logs {__version__}",
|
||||
noise_psk=noise_psk,
|
||||
addresses=addresses, # Pass all addresses for automatic retry
|
||||
provide_time=False,
|
||||
)
|
||||
|
||||
# Decoder resolution policy lives in LogLineProcessor.
|
||||
processor = LogLineProcessor(config, CORE.target_platform)
|
||||
|
||||
mqtt_task: asyncio.Task[None] | None = None
|
||||
mqtt_stop_event = threading.Event()
|
||||
|
||||
def _cancel_mqtt_discovery() -> None:
|
||||
"""Stop the broker lookup once a connection has been established.
|
||||
|
||||
Its answer is only useful while still disconnected: after that it
|
||||
either duplicates the connected address or arrives too late to
|
||||
matter, so don't keep an idle broker session open for it.
|
||||
"""
|
||||
mqtt_stop_event.set()
|
||||
if mqtt_task is not None and not mqtt_task.done():
|
||||
mqtt_task.cancel()
|
||||
|
||||
async def _resolve_mqtt_addresses() -> None:
|
||||
"""Discover the device address via the MQTT broker in the background."""
|
||||
try:
|
||||
mqtt_ips = await asyncio.to_thread(mqtt_resolver, mqtt_stop_event)
|
||||
if not mqtt_ips:
|
||||
_LOGGER.debug(
|
||||
"MQTT discovery %s",
|
||||
"aborted" if mqtt_stop_event.is_set() else "found no addresses",
|
||||
)
|
||||
return
|
||||
if cli.add_addresses(mqtt_ips):
|
||||
_LOGGER.info("Discovered address(es) via MQTT: %s", ", ".join(mqtt_ips))
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"MQTT-discovered address(es) already known: %s", ", ".join(mqtt_ips)
|
||||
)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
# A background task failure would otherwise stay invisible for
|
||||
# the whole session and only re-raise at teardown
|
||||
_LOGGER.exception("MQTT address discovery failed")
|
||||
|
||||
def on_log(msg: SubscribeLogsResponse) -> None:
|
||||
"""Handle a new log message."""
|
||||
time_ = datetime.now().astimezone()
|
||||
message: bytes = msg.message
|
||||
text = message.decode("utf8", "backslashreplace")
|
||||
nanoseconds = time_.microsecond // 1000
|
||||
timestamp = (
|
||||
f"[{time_.hour:02}:{time_.minute:02}:{time_.second:02}.{nanoseconds:03}]"
|
||||
)
|
||||
for parsed_msg in parse_log_message(text, timestamp):
|
||||
# safe_print handles the dashboard \033 escaping and falls back
|
||||
# to backslashreplace encoding on stdouts that can't represent
|
||||
# the wifi signal-bar block characters (Windows redirected
|
||||
# cp1252 pipe).
|
||||
safe_print(parsed_msg)
|
||||
for raw_line in text.splitlines():
|
||||
processor.process_line(raw_line)
|
||||
|
||||
# Safe to fall back to plaintext here only for this diagnostics use
|
||||
# case: the stream is one-way from device to client, and this code
|
||||
# never accepts commands or acts on any message the device sends.
|
||||
# An on-path attacker could still both inject fabricated log lines
|
||||
# and passively read the device's log output (and any state data
|
||||
# delivered when subscribe_states is enabled), so this does lose
|
||||
# confidentiality as well as authentication/integrity. That tradeoff
|
||||
# is acceptable for operator-visible logs, which aioesphomeapi also
|
||||
# warns may come from an unverified device. Never mirror this opt-in
|
||||
# for any connection that sends data to the device or uses Home
|
||||
# Assistant actions.
|
||||
stop = await async_run(
|
||||
cli,
|
||||
on_log,
|
||||
name=name,
|
||||
subscribe_states=subscribe_states,
|
||||
allow_plaintext_fallback=True,
|
||||
# A top-level ``deep_sleep:`` block means the device is only awake
|
||||
# briefly; cap the reconnect backoff so a wake window is not missed.
|
||||
deep_sleep="deep_sleep" in config,
|
||||
on_connect=_cancel_mqtt_discovery if mqtt_resolver is not None else None,
|
||||
)
|
||||
try:
|
||||
# Don't start (or keep) the broker lookup if a connection already
|
||||
# succeeded; the stop event doubles as the not-needed-anymore latch
|
||||
# and get_esphome_device_ip returns immediately when it is set.
|
||||
if mqtt_resolver is not None and not mqtt_stop_event.is_set():
|
||||
mqtt_task = asyncio.create_task(_resolve_mqtt_addresses())
|
||||
await asyncio.Event().wait()
|
||||
finally:
|
||||
try:
|
||||
if mqtt_task is not None:
|
||||
# Unblock the worker thread first so it can't hold up
|
||||
# loop.shutdown_default_executor() for the full lookup timeout.
|
||||
mqtt_stop_event.set()
|
||||
# Give the worker a moment to exit through its own error
|
||||
# handling; cancelling first would race out a late failure.
|
||||
done, _ = await asyncio.wait([mqtt_task], timeout=1.0)
|
||||
if not done:
|
||||
mqtt_task.cancel()
|
||||
# return_exceptions keeps a CancelledError from the cancel()
|
||||
# above from re-raising here and jumping over the stop() below.
|
||||
# The task handles Exception itself, so only a BaseException
|
||||
# escape (e.g. SystemExit from the worker) can land here.
|
||||
(result,) = await asyncio.gather(mqtt_task, return_exceptions=True)
|
||||
if isinstance(result, BaseException) and not isinstance(
|
||||
result, asyncio.CancelledError
|
||||
):
|
||||
_LOGGER.error("MQTT address discovery failed", exc_info=result)
|
||||
finally:
|
||||
# Must run even if a second cancellation lands mid-cleanup above
|
||||
await stop()
|
||||
|
||||
|
||||
def run_logs(
|
||||
config: dict[str, Any],
|
||||
addresses: list[str],
|
||||
subscribe_states: bool = True,
|
||||
mqtt_resolver: Callable[[threading.Event], list[str]] | None = None,
|
||||
) -> None:
|
||||
"""Run the logs command."""
|
||||
with suppress(KeyboardInterrupt):
|
||||
asyncio.run(
|
||||
async_run_logs(
|
||||
config,
|
||||
addresses,
|
||||
subscribe_states=subscribe_states,
|
||||
mqtt_resolver=mqtt_resolver,
|
||||
)
|
||||
)
|
||||
@@ -1,531 +0,0 @@
|
||||
"""Arduino-core backend for the shared PlatformIO library converter.
|
||||
|
||||
Bundled names build straight from the framework tree; everything else goes
|
||||
through ``esphome.platformio.library``. Mirrors ``lib_ldf_mode=off``: each
|
||||
library builds its own archive; all include dirs join one global path.
|
||||
|
||||
Deviations from PlatformIO: flat-layout libraries get the recursive default
|
||||
source filter; ``dot_a_linkage`` is honored; bundled libraries never run a
|
||||
manifest ``extraScript``; manifest ``-I`` flags join the global include path;
|
||||
``precompiled``/``ldflags`` properties are refused by name.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
from esphome.core import CORE, EsphomeError, Library
|
||||
from esphome.helpers import walk_files
|
||||
from esphome.platformio.extra_script import apply_extra_script
|
||||
from esphome.platformio.library import (
|
||||
DEFAULT_BUILD_INCLUDE_DIR,
|
||||
DEFAULT_BUILD_SRC_FILTER,
|
||||
ESPHOME_DATA_KEY,
|
||||
ESPHOME_DATA_LINK_FLAGS_KEY,
|
||||
LIBRARY_HEADER_SUFFIXES,
|
||||
SRC_FILE_EXTENSIONS,
|
||||
ConvertedLibrary,
|
||||
IncompatiblePlatform,
|
||||
InvalidLibrary,
|
||||
LibraryBackend,
|
||||
_url_or_none,
|
||||
check_library_data,
|
||||
collect_filtered_files,
|
||||
convert_libraries,
|
||||
ensure_list,
|
||||
is_lib_ignored,
|
||||
lex_build_flags,
|
||||
lib_ignore_set,
|
||||
normalize_dependencies,
|
||||
parse_library_json,
|
||||
parse_library_properties,
|
||||
warn_properties_depends,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ArduinoLibrary:
|
||||
"""One resolved library, ready for the ninja generator."""
|
||||
|
||||
name: str
|
||||
sources: list[Path] = field(default_factory=list)
|
||||
include_dirs: list[Path] = field(default_factory=list)
|
||||
# Extra compile flags private to this library's own sources
|
||||
flags: list[str] = field(default_factory=list)
|
||||
# PlatformIO's build.libArchive / Arduino's dot_a_linkage: when False the
|
||||
# objects go to the linker directly (symbols nothing references survive)
|
||||
lib_archive: bool = True
|
||||
# Link inputs the library contributes (-L dirs / -l libs, e.g. from
|
||||
# precompiled vendor blobs) and -Wl, options for the firmware link
|
||||
link_dirs: list[Path] = field(default_factory=list)
|
||||
link_libs: list[str] = field(default_factory=list)
|
||||
link_flags: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
# Source-like suffixes the case-sensitive suffix map rejects
|
||||
_UNMAPPED_SOURCE_SUFFIXES = frozenset(
|
||||
{s.lower() for s in SRC_FILE_EXTENSIONS} | {".ino"}
|
||||
)
|
||||
|
||||
# Filename-plain names: an allowlist excludes separators, drive colons,
|
||||
# and dot-only names by shape
|
||||
_SAFE_LIBRARY_NAME_RE = re.compile(r"[A-Za-z0-9_][A-Za-z0-9_. +-]*\Z")
|
||||
|
||||
|
||||
def _is_safe_library_name(name: object) -> bool:
|
||||
"""Whether a name may be joined under the framework's libraries dir."""
|
||||
return isinstance(name, str) and _SAFE_LIBRARY_NAME_RE.fullmatch(name) is not None
|
||||
|
||||
|
||||
def _manifest_build(name: str, data: object) -> dict:
|
||||
"""The manifest's ``build`` section; malformed manifests fail by name."""
|
||||
build = data.get("build", {}) if isinstance(data, dict) else None
|
||||
if not isinstance(build, dict):
|
||||
raise EsphomeError(f"Library {name} has a malformed manifest")
|
||||
return build
|
||||
|
||||
|
||||
def _resolve_src_dir(name: str, read_path: Path, build: dict) -> str:
|
||||
"""Resolve PIO's source dir: manifest srcDir, else src/Src, else the root."""
|
||||
if "srcDir" not in build:
|
||||
return next((d for d in ("src", "Src") if (read_path / d).is_dir()), ".")
|
||||
# A declared srcDir (falsy included) that does not resolve is a manifest error
|
||||
src_dir = build["srcDir"]
|
||||
if not (isinstance(src_dir, str) and src_dir and (read_path / src_dir).is_dir()):
|
||||
raise EsphomeError(
|
||||
f"Library {name} declares srcDir {src_dir!r} which does not exist"
|
||||
)
|
||||
return src_dir
|
||||
|
||||
|
||||
def _reject_unsupported_link_fields(name: str, data: dict) -> None:
|
||||
# PIO honors these; ignoring them would fail at link with no stated
|
||||
# cause. Property values are strings, so "false" is not a declaration.
|
||||
precompiled = data.get("precompiled")
|
||||
if precompiled and str(precompiled).strip().lower() != "false":
|
||||
raise EsphomeError(
|
||||
f"Library {name} declares precompiled, which this backend does not support"
|
||||
)
|
||||
if data.get("ldflags"):
|
||||
raise EsphomeError(
|
||||
f"Library {name} declares ldflags, which this backend does not support"
|
||||
)
|
||||
|
||||
|
||||
def _resolve_lib_archive(name: str, data: dict, build: dict) -> bool:
|
||||
"""build.libArchive, else dot_a_linkage (an Arduino IDE property PIO
|
||||
ignores; a deliberate extra), else archive."""
|
||||
|
||||
# Strict parse: bool("false") is True
|
||||
def _parse(key: str, raw: object) -> bool:
|
||||
if isinstance(raw, bool):
|
||||
return raw
|
||||
value = str(raw).strip().lower()
|
||||
if value in ("true", "false"):
|
||||
return value == "true"
|
||||
raise EsphomeError(f"Library {name} has a malformed {key} value {raw!r}")
|
||||
|
||||
if "libArchive" in build:
|
||||
return _parse("libArchive", build["libArchive"])
|
||||
if "dot_a_linkage" in data:
|
||||
return _parse("dot_a_linkage", data["dot_a_linkage"])
|
||||
return True
|
||||
|
||||
|
||||
def _classify_build_flags(
|
||||
name: str, read_path: Path, lib: ArduinoLibrary, flag_tokens: list[str]
|
||||
) -> list[str]:
|
||||
"""Route the lexed build.flags into the library's flag lists.
|
||||
|
||||
Returns the ``-I`` arguments for the include-dir resolution.
|
||||
"""
|
||||
include_flags: list[str] = []
|
||||
for tok in flag_tokens:
|
||||
if tok.startswith("-I"):
|
||||
include_flags.append(tok[2:])
|
||||
elif tok.startswith("-L"):
|
||||
link_dir = (read_path / tok[2:]).resolve()
|
||||
if not link_dir.is_dir():
|
||||
# Kept (the linker ignores missing -L dirs); the warning
|
||||
# names the culprit before a bare "cannot find -lfoo"
|
||||
_LOGGER.warning(
|
||||
"Library %s declares library dir %s which does not exist",
|
||||
name,
|
||||
tok[2:],
|
||||
)
|
||||
lib.link_dirs.append(link_dir)
|
||||
elif tok.startswith("-l"):
|
||||
lib.link_libs.append(tok[2:])
|
||||
elif tok.startswith("-Wl,"):
|
||||
lib.link_flags.append(tok)
|
||||
else:
|
||||
lib.flags.append(tok)
|
||||
return include_flags
|
||||
|
||||
|
||||
def _resolve_include_dirs(
|
||||
name: str,
|
||||
read_path: Path,
|
||||
lib: ArduinoLibrary,
|
||||
build: dict,
|
||||
src_dir: str,
|
||||
include_flags: list[str],
|
||||
) -> None:
|
||||
include_dir = build.get("includeDir", DEFAULT_BUILD_INCLUDE_DIR)
|
||||
if not isinstance(include_dir, str):
|
||||
raise EsphomeError(f"Library {name} has a malformed includeDir")
|
||||
for d, explicit in [
|
||||
(include_dir, "includeDir" in build),
|
||||
(src_dir, False), # _resolve_src_dir already validated it
|
||||
*((flag, True) for flag in include_flags),
|
||||
]:
|
||||
if (path := (read_path / d)).is_dir():
|
||||
lib.include_dirs.append(path.resolve())
|
||||
elif explicit:
|
||||
# Warn-and-drop (unlike srcDir): a missing include dir is
|
||||
# harmless until a header is needed, and the compile names it
|
||||
_LOGGER.warning(
|
||||
"Library %s declares include dir %s which does not exist", name, d
|
||||
)
|
||||
|
||||
|
||||
def _collect_lib_sources(
|
||||
name: str,
|
||||
read_path: Path,
|
||||
lib: ArduinoLibrary,
|
||||
src_dir: str,
|
||||
src_filter: list[str],
|
||||
) -> None:
|
||||
sources: list[Path] = []
|
||||
dropped: list[str] = []
|
||||
saw_header = False
|
||||
for f in collect_filtered_files(read_path / src_dir, src_filter):
|
||||
path = Path(f)
|
||||
suffix = path.suffix
|
||||
if suffix in SRC_FILE_EXTENSIONS:
|
||||
# resolve() per file: srcFilter patterns may escape src_dir
|
||||
sources.append(path.resolve())
|
||||
elif suffix.lower() in _UNMAPPED_SOURCE_SUFFIXES:
|
||||
# A source-like suffix the case-sensitive map rejects (.CPP,
|
||||
# .ino) is a dropped compilation unit; headers fall through
|
||||
dropped.append(path.name)
|
||||
elif suffix.lower() in LIBRARY_HEADER_SUFFIXES:
|
||||
saw_header = True
|
||||
lib.sources = sorted(sources)
|
||||
if dropped:
|
||||
_LOGGER.warning(
|
||||
"Library %s: %d file(s) with unmapped source suffixes are not compiled: %s",
|
||||
name,
|
||||
len(dropped),
|
||||
", ".join(sorted(dropped)),
|
||||
)
|
||||
if not lib.sources and not saw_header:
|
||||
# Matched headers mean header-only; a filter matching nothing is
|
||||
# a manifest/tree problem (a truly empty tree raises elsewhere)
|
||||
_LOGGER.warning("Library %s: no source files matched", name)
|
||||
|
||||
|
||||
def _library_info(name: str, read_path: Path, data: dict) -> ArduinoLibrary:
|
||||
"""Resolve one library's sources, include dirs, and flags (PIO semantics)."""
|
||||
build = _manifest_build(name, data)
|
||||
_reject_unsupported_link_fields(name, data)
|
||||
src_dir = _resolve_src_dir(name, read_path, build)
|
||||
src_filter = ensure_list(build.get("srcFilter", DEFAULT_BUILD_SRC_FILTER))
|
||||
if not all(isinstance(entry, str) for entry in src_filter):
|
||||
raise EsphomeError(f"Library {name} has a malformed srcFilter")
|
||||
lib = ArduinoLibrary(name=name, lib_archive=_resolve_lib_archive(name, data, build))
|
||||
# PlatformIO shell-lexes each build.flags entry
|
||||
include_flags = _classify_build_flags(
|
||||
name, read_path, lib, lex_build_flags(build.get("flags", []), f"library {name}")
|
||||
)
|
||||
_resolve_include_dirs(name, read_path, lib, build, src_dir, include_flags)
|
||||
_collect_lib_sources(name, read_path, lib, src_dir, src_filter)
|
||||
return lib
|
||||
|
||||
|
||||
def _bundled_library(framework_path: Path, name: str) -> ArduinoLibrary:
|
||||
"""A library bundled with the Arduino core, read from the framework tree.
|
||||
|
||||
``library.json`` wins over ``library.properties`` when both exist, as in
|
||||
PlatformIO's LibBuilderFactory; only the JSON manifest can carry a
|
||||
``build`` section (srcDir, srcFilter, flags).
|
||||
"""
|
||||
lib_dir = framework_path / "libraries" / name
|
||||
manifest_json = lib_dir / "library.json"
|
||||
if manifest_json.is_file():
|
||||
try:
|
||||
data = parse_library_json(manifest_json)
|
||||
except ValueError as err: # JSONDecodeError
|
||||
raise EsphomeError(
|
||||
f"Bundled library {name} has a corrupt library.json ({err}); "
|
||||
"the framework install may be incomplete (run 'esphome clean-all')"
|
||||
) from err
|
||||
elif (manifest := lib_dir / "library.properties").is_file():
|
||||
data = parse_library_properties(manifest)
|
||||
else:
|
||||
# Debug, not warning: the legacy manifest-less layout is legal and
|
||||
# the 3.1.2 core ships one such library (FSTools), so a warning
|
||||
# would be unactionable noise on every build using it
|
||||
_LOGGER.debug("Bundled library %s has no manifest; using defaults", name)
|
||||
data = {}
|
||||
if isinstance(data, dict):
|
||||
# Bundled manifest deps are never walked; make the skip visible
|
||||
if data.get("dependencies"):
|
||||
_LOGGER.warning(
|
||||
"Bundled library %s declares dependencies, which are not "
|
||||
"resolved automatically; add them with add_library() if needed",
|
||||
name,
|
||||
)
|
||||
warn_properties_depends(name, data)
|
||||
build = data.get("build")
|
||||
if isinstance(build, dict) and build.get("extraScript"):
|
||||
# Scripts only run on the converted path; building without
|
||||
# the script's flags would miscompile
|
||||
raise EsphomeError(
|
||||
f"Bundled library {name} declares an extraScript, which is "
|
||||
"not run for bundled libraries"
|
||||
)
|
||||
lib = _library_info(name, lib_dir, data)
|
||||
_assert_tree_has_code(
|
||||
name,
|
||||
lib_dir,
|
||||
"the framework install may be incomplete (run 'esphome clean-all')",
|
||||
)
|
||||
return lib
|
||||
|
||||
|
||||
def _assert_tree_has_code(name: str, root: Path, hint: str) -> None:
|
||||
"""An empty or half-extracted tree can never link; fail by name (a
|
||||
warning would scroll away and resurface as undefined symbols)."""
|
||||
if not any(
|
||||
Path(p).suffix in SRC_FILE_EXTENSIONS
|
||||
or Path(p).suffix.lower() in LIBRARY_HEADER_SUFFIXES
|
||||
for p in walk_files(root)
|
||||
):
|
||||
raise EsphomeError(f"Library {name} has no sources or headers; {hint}")
|
||||
|
||||
|
||||
def _external_short_name(name: str) -> str:
|
||||
"""The short library name of a requested spec.
|
||||
|
||||
"owner/Name" and plain names take the last path segment; "Name=<url>"
|
||||
takes the declared name. Git tails (".git", "#ref") are stripped like
|
||||
the walk's URL normalization; the comparand is a manifest dependency
|
||||
name, never a spec.
|
||||
"""
|
||||
head, sep, tail = name.partition("=")
|
||||
if sep and "://" in tail:
|
||||
return head
|
||||
short = name.rsplit("/", maxsplit=1)[-1]
|
||||
return short.partition("#")[0].removesuffix(".git")
|
||||
|
||||
|
||||
def _check_unfulfilled_provides(
|
||||
provided_requests: set[str], satisfied: set[str], still_requested: set[str]
|
||||
) -> None:
|
||||
"""Fail by name when a walk-skipped dependency was never added.
|
||||
|
||||
An unfulfilled provides() promise only surfaces as undefined symbols
|
||||
at link. The walk records across re-resolutions, so a name no final
|
||||
manifest still requests is stale state, never a failure.
|
||||
"""
|
||||
if missing := sorted((provided_requests & still_requested) - satisfied):
|
||||
raise EsphomeError(
|
||||
"provides() skipped these dependencies but nothing added them: "
|
||||
f"{', '.join(missing)}; the build is missing libraries"
|
||||
)
|
||||
|
||||
|
||||
def resolve_libraries(
|
||||
framework_path: Path, *, pio_platform: str, board_mcu: str, cache_key: str
|
||||
) -> list[ArduinoLibrary]:
|
||||
"""Resolve every ``cg.add_library()`` entry into an :class:`ArduinoLibrary`.
|
||||
|
||||
``pio_platform``/``board_mcu`` filter manifests the way PlatformIO would
|
||||
for that core (e.g. ``espressif8266``/``esp8266``); ``cache_key`` keys the
|
||||
shared converter's download cache.
|
||||
|
||||
The returned list is not topologically sorted, so the caller must link
|
||||
the archives inside one ``--start-group``/``--end-group`` pair (the
|
||||
bundled-first grouping is incidental).
|
||||
"""
|
||||
bundled: list[ArduinoLibrary] = []
|
||||
external: list[Library] = []
|
||||
# PlatformIO's lib_ignore covers framework-bundled libraries too; the
|
||||
# shared converter only filters the registry/git ones.
|
||||
lib_ignore = lib_ignore_set()
|
||||
# Exact directory names keep membership case-sensitive everywhere
|
||||
# (an is_dir() probe would match "wire" on macOS/Windows and build
|
||||
# the bundled Wire twice)
|
||||
libraries_dir = framework_path / "libraries"
|
||||
if not libraries_dir.is_dir():
|
||||
# A registry fallback would fail later with a misleading
|
||||
# package-not-found error per bundled name
|
||||
raise EsphomeError(
|
||||
f"{libraries_dir} is missing; the framework install may be "
|
||||
"incomplete (run 'esphome clean-all')"
|
||||
)
|
||||
bundled_dir_names = frozenset(p.name for p in libraries_dir.iterdir() if p.is_dir())
|
||||
|
||||
def _provided(name: object) -> bool:
|
||||
return _is_safe_library_name(name) and name in bundled_dir_names
|
||||
|
||||
for library in CORE.platformio_libraries.values():
|
||||
if is_lib_ignored(library.name, lib_ignore):
|
||||
continue
|
||||
# Bundled only for a bare name with a matching framework dir; pinned
|
||||
# or unmatched names resolve from the registry, as under PlatformIO.
|
||||
if not library.repository and not library.version and _provided(library.name):
|
||||
# Bundled manifest deps are not walked; _bundled_library warns
|
||||
bundled.append(_bundled_library(framework_path, library.name))
|
||||
else:
|
||||
external.append(library)
|
||||
|
||||
converted: list[ArduinoLibrary] = []
|
||||
bundled_names = {lib.name for lib in bundled}
|
||||
converted_manifest_names: set[str] = set()
|
||||
# Bundled candidates skipped on purpose (platform filter); the
|
||||
# provides() reconciliation must count them as satisfied
|
||||
knowingly_skipped: set[str] = set()
|
||||
# Dependency names of the manifests actually emitted; a walk recording
|
||||
# for a since-re-resolved manifest must not fail the reconciliation
|
||||
final_dep_names: set[str] = set()
|
||||
# Ordered set of bundled dependency names to add once conversion is done
|
||||
pending_bundled: dict[str, None] = {}
|
||||
# Deps matching a separately-requested external are already in the build
|
||||
# (a duplicate archive means duplicate-symbol link errors)
|
||||
external_short_names = {
|
||||
_external_short_name(lib.name) for lib in external if lib.name
|
||||
}
|
||||
|
||||
def _add_bundled_dependencies(component: ConvertedLibrary) -> None:
|
||||
# A version-less bare name ("Hash") is a core-bundled library the
|
||||
# shared converter cannot resolve from the registry
|
||||
for dep in normalize_dependencies(
|
||||
component.data.get("dependencies"), component.name
|
||||
):
|
||||
# normalize_dependencies guarantees a non-empty str name
|
||||
name = dep["name"]
|
||||
final_dep_names.add(name)
|
||||
if "/" in name:
|
||||
owner, _, pkg = name.partition("/")
|
||||
if _is_safe_library_name(owner) and _is_safe_library_name(pkg):
|
||||
# Owner-qualified; the converter resolves it from the registry
|
||||
continue
|
||||
if not _is_safe_library_name(name):
|
||||
# The name becomes a path component; never join a traversal
|
||||
_LOGGER.warning(
|
||||
"Ignoring malformed dependency entry %r of library %s",
|
||||
dep,
|
||||
component.name,
|
||||
)
|
||||
continue
|
||||
if name in external_short_names:
|
||||
if _provided(name):
|
||||
# A bundled copy is suppressed; a coincidental name
|
||||
# collision would surface as link errors
|
||||
_LOGGER.warning(
|
||||
"Dependency %s of %s is assumed satisfied by a "
|
||||
"requested external library; the bundled copy is "
|
||||
"not added",
|
||||
name,
|
||||
component.name,
|
||||
)
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"Dependency %s of %s assumed satisfied by a requested "
|
||||
"external library",
|
||||
name,
|
||||
component.name,
|
||||
)
|
||||
continue
|
||||
if name in bundled_names or is_lib_ignored(name, lib_ignore):
|
||||
continue
|
||||
if _url_or_none(dep.get("version")) is not None:
|
||||
# A URL names one specific source; never add the bundled copy
|
||||
continue
|
||||
if dep.get("owner") or not _provided(name):
|
||||
# Only owner-less framework-tree names take the bundled
|
||||
# copy (PIO's process_dependencies); the walk reports drops
|
||||
continue
|
||||
try:
|
||||
# framework=None: the walk already warned for non-platform
|
||||
# causes; debug keeps one fault from warning twice (pinned
|
||||
# by test_nonplatform_rejection_warns_once_through_real_converter)
|
||||
check_library_data(dep, pio_platform, None)
|
||||
except IncompatiblePlatform as err:
|
||||
# A knowing skip (platform filter), not a broken promise
|
||||
knowingly_skipped.add(name)
|
||||
_LOGGER.debug("Skip bundled candidate %s: %s", name, err)
|
||||
continue
|
||||
except InvalidLibrary as err:
|
||||
# Malformed manifest data never counts as satisfied; the
|
||||
# walk owns the warning (see the warns-once test above)
|
||||
_LOGGER.debug("Skip malformed bundled candidate %s: %s", name, err)
|
||||
continue
|
||||
# Deferred: a later manifest name may satisfy this
|
||||
pending_bundled.setdefault(name)
|
||||
|
||||
def _emit(component: ConvertedLibrary) -> None:
|
||||
apply_extra_script(
|
||||
component, board_mcu=lambda: board_mcu, pio_platform=pio_platform
|
||||
)
|
||||
_assert_tree_has_code(
|
||||
component.get_require_name(),
|
||||
component.source_dir,
|
||||
"the download may be incomplete (run 'esphome clean-all')",
|
||||
)
|
||||
if isinstance(manifest_name := component.data.get("name"), str):
|
||||
converted_manifest_names.add(manifest_name)
|
||||
lib = _library_info(
|
||||
component.get_require_name(), component.source_dir, component.data
|
||||
)
|
||||
# Extra-script LINKFLAGS travel outside build.flags; dropping
|
||||
# them would link wrong with no stated cause
|
||||
lib.link_flags.extend(
|
||||
component.data.get(ESPHOME_DATA_KEY, {}).get(
|
||||
ESPHOME_DATA_LINK_FLAGS_KEY, []
|
||||
)
|
||||
)
|
||||
converted.append(lib)
|
||||
_add_bundled_dependencies(component)
|
||||
|
||||
backend = LibraryBackend(
|
||||
platform=pio_platform,
|
||||
framework="arduino",
|
||||
emit=_emit,
|
||||
cache_key=cache_key,
|
||||
# The walk must not resolve bundled names from the registry;
|
||||
# _add_bundled_dependencies adds them after emit
|
||||
provides=_provided,
|
||||
)
|
||||
if external:
|
||||
convert_libraries(external, backend)
|
||||
for name in pending_bundled:
|
||||
if name in converted_manifest_names:
|
||||
# The converted library is this one; the bundled copy would
|
||||
# double the archive. Warn like the external_short_names twin.
|
||||
_LOGGER.warning(
|
||||
"Dependency %s is assumed satisfied by a converted library's "
|
||||
"manifest name; the bundled copy is not added",
|
||||
name,
|
||||
)
|
||||
continue
|
||||
bundled_names.add(name)
|
||||
bundled.append(_bundled_library(framework_path, name))
|
||||
|
||||
_check_unfulfilled_provides(
|
||||
backend.provided_requests,
|
||||
bundled_names
|
||||
| converted_manifest_names
|
||||
| external_short_names
|
||||
| knowingly_skipped,
|
||||
final_dep_names,
|
||||
)
|
||||
|
||||
return bundled + converted
|
||||
@@ -1,9 +0,0 @@
|
||||
"""Native (PlatformIO-free) build support for the ESP8266 Arduino core.
|
||||
|
||||
This package downloads the Arduino ESP8266 core and the xtensa-lx106
|
||||
toolchain, generates a ninja build for them plus the ESPHome sources, and
|
||||
drives the build directly — the ESP8266 equivalent of ``esphome.espidf``.
|
||||
|
||||
Deliberately importable without the esp8266 component to avoid circular
|
||||
imports; the component wires these modules in via lazy imports.
|
||||
"""
|
||||
@@ -1,164 +0,0 @@
|
||||
"""Download and install the Arduino ESP8266 core, toolchain, and ninja.
|
||||
|
||||
Artifacts land in a machine-global cache (shared across projects, like the
|
||||
ESP-IDF install in ``esphome.espidf.framework``):
|
||||
|
||||
<cache>/arduino8266/frameworks/<version>/ framework-arduinoespressif8266
|
||||
<cache>/arduino8266/toolchains/<version>/ toolchain-xtensa (gcc 10.3)
|
||||
|
||||
Packages come from the PlatformIO registry (identical bits to the PlatformIO
|
||||
backend); ``ESPHOME_ARDUINO8266_*_MIRRORS`` overrides the URLs. ninja comes
|
||||
from PATH or the ninja PyPI wheel.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
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.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_package, prefetch_packages
|
||||
|
||||
FRAMEWORK_PACKAGE = "framework-arduinoespressif8266"
|
||||
TOOLCHAIN_PACKAGE = "toolchain-xtensa"
|
||||
# gcc 10.3, the toolchain Arduino core 3.x builds with; the build
|
||||
# generator's compile flags are tuned to it.
|
||||
TOOLCHAIN_VERSION = "2.100300.220621"
|
||||
|
||||
ESPHOME_ARDUINO8266_FRAMEWORK_MIRRORS = str_to_lst_of_str(
|
||||
os.environ.get("ESPHOME_ARDUINO8266_FRAMEWORK_MIRRORS", "")
|
||||
)
|
||||
ESPHOME_ARDUINO8266_TOOLCHAIN_MIRRORS = str_to_lst_of_str(
|
||||
os.environ.get("ESPHOME_ARDUINO8266_TOOLCHAIN_MIRRORS", "")
|
||||
)
|
||||
|
||||
|
||||
def get_arduino8266_tools_path() -> Path:
|
||||
# Machine-global so all projects share one install; see
|
||||
# espidf.framework.get_idf_tools_path for the location rationale.
|
||||
return tools_cache_path(*ARDUINO8266_TOOLS_CACHE)
|
||||
|
||||
|
||||
# 3.1.1 rather than 3.1.0: the registry has no package for 3.1.0, and the
|
||||
# encoder below cannot name 3.0.0/3.0.1 either (see its docstring)
|
||||
MIN_FRAMEWORK_VERSION = Version(3, 1, 1)
|
||||
|
||||
|
||||
def framework_package_version(ver: Version) -> str:
|
||||
"""Map an Arduino core version to its registry package version (3.1.2 ->
|
||||
3.30102.0; the leading 3 is the package major).
|
||||
|
||||
Exact registry names only for cores > 2.6.2 and >= 3.0.2; callers floor
|
||||
at MIN_FRAMEWORK_VERSION.
|
||||
"""
|
||||
if ver.major > 3:
|
||||
raise EsphomeError(
|
||||
f"Arduino core {ver} is not supported yet; "
|
||||
"the newest known core series is 3.x"
|
||||
)
|
||||
if ver <= Version(2, 6, 2):
|
||||
# Cores <= 2.6.2 use the older 1.x/2.x package-major encodings (same
|
||||
# boundary as _format_framework_arduino_version's era guard)
|
||||
raise EsphomeError(
|
||||
f"Arduino core {ver} uses an older package encoding than this "
|
||||
"helper implements (newer than 2.6.2)"
|
||||
)
|
||||
return f"3.{ver.major}{ver.minor:02d}{ver.patch:02d}.0"
|
||||
|
||||
|
||||
def get_framework_path(package_version: str) -> Path:
|
||||
return get_arduino8266_tools_path() / "frameworks" / package_version
|
||||
|
||||
|
||||
def get_toolchain_path() -> Path:
|
||||
return get_arduino8266_tools_path() / "toolchains" / TOOLCHAIN_VERSION
|
||||
|
||||
|
||||
class InstalledPaths(NamedTuple):
|
||||
"""Locations of the installed framework, toolchain, and ninja binary."""
|
||||
|
||||
framework: Path
|
||||
toolchain: Path
|
||||
ninja: Path
|
||||
|
||||
|
||||
def check_and_install(framework_version: Version) -> InstalledPaths:
|
||||
"""Ensure framework, toolchain, and ninja are installed; return their paths."""
|
||||
if framework_version < MIN_FRAMEWORK_VERSION:
|
||||
# Config validation enforces this too; keep the module honest when
|
||||
# called directly.
|
||||
raise EsphomeError(
|
||||
f"The native toolchain requires the Arduino core "
|
||||
f">= {MIN_FRAMEWORK_VERSION}, got {framework_version}"
|
||||
)
|
||||
# Probe the cheap local dependency before ~110 MB of downloads
|
||||
ninja_path = find_ninja()
|
||||
package_version = framework_package_version(framework_version)
|
||||
framework_path = get_framework_path(package_version)
|
||||
downloads_dir = get_arduino8266_tools_path() / "downloads"
|
||||
toolchain_path = get_toolchain_path()
|
||||
# One spec per package: the prefetch and the installs must agree
|
||||
specs = (
|
||||
(
|
||||
FRAMEWORK_PACKAGE,
|
||||
package_version,
|
||||
framework_path,
|
||||
ESPHOME_ARDUINO8266_FRAMEWORK_MIRRORS,
|
||||
("cores/esp8266", "tools/sdk", "libraries"),
|
||||
),
|
||||
(
|
||||
TOOLCHAIN_PACKAGE,
|
||||
TOOLCHAIN_VERSION,
|
||||
toolchain_path,
|
||||
ESPHOME_ARDUINO8266_TOOLCHAIN_MIRRORS,
|
||||
# xtensa-lx106-elf pins the target: every gcc package has a bin/
|
||||
("bin", "xtensa-lx106-elf"),
|
||||
),
|
||||
)
|
||||
# Fetch both archives at once; the installs below verify and extract
|
||||
prefetch_packages([spec[:4] for spec in 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
|
||||
)
|
||||
|
||||
|
||||
def toolchain_tool(toolchain_path: Path, name: str) -> Path:
|
||||
"""Path to one toolchain tool (gcc, g++, ar, size, addr2line, ...).
|
||||
|
||||
The single owner of the ``bin/xtensa-lx106-elf-<name>`` layout and the
|
||||
Windows suffix, so a toolchain package bump touches one spot.
|
||||
"""
|
||||
suffix = ".exe" if os.name == "nt" else ""
|
||||
return toolchain_path / "bin" / f"xtensa-lx106-elf-{name}{suffix}"
|
||||
|
||||
|
||||
def get_build_env(toolchain_path: Path, ccache: str | None) -> dict[str, str]:
|
||||
env = os.environ.copy()
|
||||
# Drop empty entries: a trailing separator from an absent PATH would
|
||||
# make the shell search the current directory for tools
|
||||
parts = [
|
||||
str(toolchain_path / "bin"),
|
||||
*filter(None, env.get("PATH", "").split(os.pathsep)),
|
||||
]
|
||||
env["PATH"] = os.pathsep.join(parts)
|
||||
env.update(ccache_env(ccache))
|
||||
return env
|
||||
|
||||
|
||||
def ccache_env(ccache: str | None) -> dict[str, str]:
|
||||
"""Return ccache settings for the build subprocess (not os.environ).
|
||||
|
||||
``ccache`` is the pre-resolved binary (resolve_ccache_path), or None
|
||||
when disabled. Values the user already set in the environment are
|
||||
respected.
|
||||
"""
|
||||
if ccache is None:
|
||||
return {}
|
||||
return ccache_defaults_env(get_arduino8266_tools_path() / "ccache")
|
||||
+23
-113
@@ -11,136 +11,46 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable
|
||||
from itertools import count
|
||||
import logging
|
||||
import threading
|
||||
from typing import cast
|
||||
from typing import Generic, TypeVar
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# How long the orphan watcher waits for an abandoned coroutine before giving
|
||||
# up, so a hung operation does not park a watcher thread forever.
|
||||
ORPHAN_WAIT_TIMEOUT = 300.0
|
||||
_T = TypeVar("_T")
|
||||
|
||||
|
||||
_runner_ids = count(1)
|
||||
|
||||
|
||||
class AsyncDispatchTimeout(TimeoutError):
|
||||
"""The caller stopped waiting; the coroutine was abandoned.
|
||||
|
||||
A subclass so callers can tell the dispatcher's own expiry apart from a
|
||||
``TimeoutError`` raised inside the coroutine, while existing
|
||||
``except TimeoutError`` handlers keep working.
|
||||
"""
|
||||
|
||||
|
||||
class AsyncThreadRunner[T](threading.Thread):
|
||||
class AsyncThreadRunner(threading.Thread, Generic[_T]):
|
||||
"""Run an async coroutine in a daemon thread and expose its result.
|
||||
|
||||
``event`` is always set, even when the coroutine crashes, so waiters
|
||||
never hang; ``completed`` distinguishes a delivered result (even a
|
||||
legitimate ``None``) from a coroutine that never finished. Prefer
|
||||
:func:`run_async`; use this class directly only when a failure should
|
||||
degrade to a default value instead of raising.
|
||||
The runner catches all exceptions from the coroutine and stores them in
|
||||
``exception`` so ``event`` is always set — this prevents callers waiting
|
||||
on ``event`` from hanging forever when the coroutine crashes.
|
||||
|
||||
Typical usage::
|
||||
|
||||
runner = AsyncThreadRunner(lambda: my_coro(arg))
|
||||
runner.start()
|
||||
if not runner.event.wait(timeout=5.0):
|
||||
... # timed out
|
||||
if runner.exception is not None:
|
||||
raise runner.exception
|
||||
result = runner.result
|
||||
"""
|
||||
|
||||
def __init__(self, coro_factory: Callable[[], Awaitable[T]]) -> None:
|
||||
super().__init__(daemon=True, name=f"async-thread-runner-{next(_runner_ids)}")
|
||||
def __init__(self, coro_factory: Callable[[], Awaitable[_T]]) -> None:
|
||||
super().__init__(daemon=True)
|
||||
self._coro_factory = coro_factory
|
||||
self.result: T | None = None
|
||||
self.result: _T | None = None
|
||||
self.exception: BaseException | None = None
|
||||
self.completed = False
|
||||
self.event = threading.Event()
|
||||
|
||||
async def _runner(self) -> None:
|
||||
try:
|
||||
self.result = await self._coro_factory()
|
||||
# Distinguishes a delivered result from "never ran", since None
|
||||
# is a valid result value.
|
||||
self.completed = True
|
||||
except BaseException as exc: # noqa: BLE001 # pylint: disable=broad-except
|
||||
# Capture everything, including BaseException — otherwise a
|
||||
# cancellation or SystemExit would leave ``exception`` unset and
|
||||
# waiters would mistake the empty ``result`` for success.
|
||||
except Exception as exc: # noqa: BLE001 # pylint: disable=broad-except
|
||||
# Capture all exceptions so ``event`` is always set — otherwise a
|
||||
# crash would hang the waiter forever.
|
||||
self.exception = exc
|
||||
finally:
|
||||
self.event.set()
|
||||
|
||||
def run(self) -> None:
|
||||
try:
|
||||
asyncio.run(self._runner())
|
||||
except BaseException as exc: # noqa: BLE001 # pylint: disable=broad-except
|
||||
# asyncio.run itself can fail before _runner executes (e.g. loop
|
||||
# creation under fd exhaustion); record it so waiters never hang.
|
||||
# A failure during loop cleanup after the coroutine completed
|
||||
# must not clobber the delivered result, hence the guard.
|
||||
if self.exception is None and not self.completed:
|
||||
self.exception = exc
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"Event loop teardown failed after outcome recorded",
|
||||
exc_info=True,
|
||||
)
|
||||
finally:
|
||||
self.event.set()
|
||||
|
||||
|
||||
def run_async[T](
|
||||
coro_factory: Callable[[], Awaitable[T]],
|
||||
timeout: float | None = None,
|
||||
on_orphan: Callable[[T], None] | None = None,
|
||||
) -> T:
|
||||
"""Run a coroutine in a daemon-thread event loop and return its result.
|
||||
|
||||
Raises :class:`AsyncDispatchTimeout` if the coroutine does not finish
|
||||
within ``timeout`` seconds; the thread is abandoned and exits with the
|
||||
interpreter. If the abandoned coroutine later produces a result,
|
||||
``on_orphan`` (if given) is called with it so resources such as a
|
||||
connected socket can be released; delivery is best effort and bounded
|
||||
by ``ORPHAN_WAIT_TIMEOUT``.
|
||||
"""
|
||||
runner: AsyncThreadRunner[T] = AsyncThreadRunner(coro_factory)
|
||||
runner.start()
|
||||
if not runner.event.wait(timeout):
|
||||
|
||||
def _cleanup() -> None:
|
||||
if not runner.event.wait(ORPHAN_WAIT_TIMEOUT):
|
||||
# The one state where a resource can genuinely leak; leave
|
||||
# a trace so a recurring hang is attributable.
|
||||
_LOGGER.info(
|
||||
"Orphan watcher gave up after %.0fs; a late result may leak",
|
||||
ORPHAN_WAIT_TIMEOUT,
|
||||
)
|
||||
return
|
||||
if not runner.completed:
|
||||
# The only place an abandoned thread's real error surfaces;
|
||||
# without it a late failure hides behind the TimeoutError.
|
||||
# INFO, not DEBUG: it fires at most once per abandoned
|
||||
# operation and the cause may not reproduce on a rerun.
|
||||
_LOGGER.info(
|
||||
"Abandoned async operation failed",
|
||||
exc_info=runner.exception,
|
||||
)
|
||||
return
|
||||
if (result := runner.result) is None:
|
||||
return
|
||||
if on_orphan is None:
|
||||
_LOGGER.debug("Discarding late result; no on_orphan handler")
|
||||
return
|
||||
try:
|
||||
on_orphan(result)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
# INFO, not DEBUG: a failed release means a real leak, and
|
||||
# it fires at most once per abandoned operation.
|
||||
_LOGGER.info("Error releasing orphaned result", exc_info=True)
|
||||
|
||||
threading.Thread(
|
||||
target=_cleanup, daemon=True, name="async-orphan-cleanup"
|
||||
).start()
|
||||
raise AsyncDispatchTimeout("Timed out waiting for async operation")
|
||||
if (exc := runner.exception) is not None:
|
||||
raise exc
|
||||
if not runner.completed:
|
||||
raise RuntimeError("Async operation finished without a result or an exception")
|
||||
return cast("T", runner.result)
|
||||
asyncio.run(self._runner())
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
"""Tiny cross-platform build steps invoked from the generated ninja file.
|
||||
|
||||
Plain script (not ``python -m``): it runs from ninja with whatever Python
|
||||
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
|
||||
|
||||
The ar rspfile carries one object path per line (the generating rule must
|
||||
use ``$in_newline``, never ``$in``).
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def _read_rspfile(rspfile: str) -> list[str]:
|
||||
r"""The object paths listed in ``rspfile``, unquoted.
|
||||
|
||||
GNU ar treats backslashes in response files as escapes (corrupts
|
||||
Windows paths), so the caller expands the list into argv; strip the
|
||||
simple surrounding quote ninja adds to special paths, then undo
|
||||
ninja's POSIX escape for an embedded quote ('a'\\''b.o' -> a'b.o).
|
||||
"""
|
||||
return [
|
||||
line[1:-1].replace("'\\''", "'")
|
||||
if len(line) >= 2 and line[0] == line[-1] and line[0] in "'\""
|
||||
else line
|
||||
for line in Path(rspfile).read_text(encoding="utf-8").splitlines()
|
||||
if line
|
||||
]
|
||||
|
||||
|
||||
def _run_ar(ar: str, archive: str, rspfile: str) -> int:
|
||||
# Remove first: ``ar rcs`` replaces members but never drops ones whose
|
||||
# source was removed from the build, which would leak stale objects.
|
||||
Path(archive).unlink(missing_ok=True)
|
||||
objects = _read_rspfile(rspfile)
|
||||
if not objects:
|
||||
# An empty archive would "succeed" here and fail far away at link
|
||||
print(f"ar: no objects listed in {rspfile} for {archive}", file=sys.stderr)
|
||||
return 1
|
||||
# Batch by argv length: expanding the rspfile gives back the Windows
|
||||
# 32767-char command-line limit it existed to avoid. "rcs" creates,
|
||||
# "qs" appends; the s keeps the symbol index explicit on every ar.
|
||||
op = "rcs"
|
||||
ok = False
|
||||
try:
|
||||
while objects:
|
||||
batch = [objects.pop(0)]
|
||||
batch_len = len(batch[0])
|
||||
while objects and batch_len + len(objects[0]) < 25000:
|
||||
batch_len += len(objects[0]) + 1
|
||||
batch.append(objects.pop(0))
|
||||
rc = subprocess.run(
|
||||
[ar, op, archive, *batch], check=False, close_fds=False
|
||||
).returncode
|
||||
if rc != 0:
|
||||
return rc
|
||||
op = "qs"
|
||||
ok = True
|
||||
return 0
|
||||
finally:
|
||||
if not ok:
|
||||
# Any failure (bad exit, missing ar binary, interrupt) must not
|
||||
# leave a truncated archive behind
|
||||
Path(archive).unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _run_copy(src: str, dst: str) -> int:
|
||||
try:
|
||||
shutil.copyfile(src, dst)
|
||||
except OSError as err:
|
||||
# Never leave a partially written output (e.g. a firmware image);
|
||||
# SameFileError means dst IS src, where unlinking destroys the input
|
||||
if not isinstance(err, shutil.SameFileError):
|
||||
Path(dst).unlink(missing_ok=True)
|
||||
print(f"copy: {src} -> {dst} 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)}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
mode = sys.argv[1] if len(sys.argv) > 1 else ""
|
||||
if entry := _MODES.get(mode):
|
||||
handler, argc = entry
|
||||
args = sys.argv[2:]
|
||||
if len(args) != argc:
|
||||
print(
|
||||
f"build_tool {mode}: expected {argc} arguments, got {len(args)}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
return handler(*args)
|
||||
print(f"unknown build_tool mode: {mode}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
sys.exit(main())
|
||||
+35
-107
@@ -1,27 +1,14 @@
|
||||
"""ESP-IDF direct build generator for ESPHome."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from esphome.components.esp32 import (
|
||||
get_esp32_variant,
|
||||
get_excluded_builtin_components,
|
||||
get_managed_component_require_names,
|
||||
idf_version,
|
||||
)
|
||||
from esphome.components.esp32 import get_esp32_variant, idf_version
|
||||
import esphome.config_validation as cv
|
||||
from esphome.core import CORE
|
||||
from esphome.espidf import variant_to_idf_target
|
||||
from esphome.framework_helpers import (
|
||||
get_project_compile_flags,
|
||||
get_project_cxx_compile_flags,
|
||||
get_project_link_flags,
|
||||
)
|
||||
from esphome.framework_helpers import get_project_compile_flags, get_project_link_flags
|
||||
from esphome.helpers import mkdir_p, write_file_if_changed
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Replaces the IDF default C++ standard (-std=gnu++2b appended to
|
||||
# CXX_COMPILE_OPTIONS by project.cmake's __build_init) with the one set via
|
||||
# cg.set_cpp_standard(). Emitted between include(project.cmake) and project(),
|
||||
@@ -35,12 +22,11 @@ idf_build_set_property(CXX_COMPILE_OPTIONS "${{esphome_cxx_compile_options}}")""
|
||||
|
||||
|
||||
def get_available_components() -> list[str] | None:
|
||||
"""List the built-in ESP-IDF components from ``project_description.json``.
|
||||
"""Get list of built-in ESP-IDF components from project_description.json.
|
||||
|
||||
Only components below its ``idf_path/components`` count, which leaves out
|
||||
``src``, IDF-managed components, converted PIO libs and project local
|
||||
ones such as the Arduino ``component_stubs``. Returns ``None`` if the
|
||||
build dir or ``project_description.json`` isn't ready yet.
|
||||
Excludes ``src``, IDF-managed components (``managed_components/``), and
|
||||
converted PIO libs (``pio_components/``). Returns ``None`` if the build
|
||||
dir or ``project_description.json`` isn't ready yet.
|
||||
"""
|
||||
if CORE.build_path is None:
|
||||
return None
|
||||
@@ -51,44 +37,41 @@ def get_available_components() -> list[str] | None:
|
||||
try:
|
||||
with project_desc.open(encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
root = (Path(data["idf_path"]) / "components").resolve()
|
||||
result = [
|
||||
name
|
||||
for name, info in data.get("build_component_info", {}).items()
|
||||
if (comp_dir := info.get("dir"))
|
||||
and Path(comp_dir).resolve().is_relative_to(root)
|
||||
]
|
||||
except (json.JSONDecodeError, KeyError, OSError) as err:
|
||||
_LOGGER.debug("Could not read %s: %s", project_desc, err)
|
||||
|
||||
component_info = data.get("build_component_info", {})
|
||||
|
||||
result = []
|
||||
for name, info in component_info.items():
|
||||
# Exclude our own src component
|
||||
if name == "src":
|
||||
continue
|
||||
|
||||
# Exclude IDF-managed and converted-PIO components (external).
|
||||
comp_dir = info.get("dir", "")
|
||||
if "managed_components" in comp_dir or "pio_components" in comp_dir:
|
||||
continue
|
||||
|
||||
result.append(name)
|
||||
|
||||
return result
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return None
|
||||
if not result:
|
||||
_LOGGER.warning("No ESP-IDF components found under %s", root)
|
||||
return result
|
||||
|
||||
|
||||
def has_discovered_components() -> bool:
|
||||
"""Check if a previous configure discovered any built-in components."""
|
||||
return bool(get_available_components())
|
||||
"""Check if we have discovered components from a previous configure."""
|
||||
return get_available_components() is not None
|
||||
|
||||
|
||||
def _cmake_quote(value: str) -> str:
|
||||
"""Quote a cmake arg value for a set() line. add_cmake_arg rejects
|
||||
whitespace, quotes, and '$', so only backslashes need escaping."""
|
||||
escaped = value.replace("\\", "\\\\")
|
||||
return f'"{escaped}"'
|
||||
|
||||
|
||||
def get_project_cmakelists(
|
||||
minimal: bool = False, builtin_components: list[str] | None = None
|
||||
) -> str:
|
||||
def get_project_cmakelists(minimal: bool = False) -> str:
|
||||
"""Generate the top-level CMakeLists.txt for ESP-IDF project.
|
||||
|
||||
When ``minimal`` is true, omit ``ESPHOME_PROJECT_BUILTIN_COMPONENTS``
|
||||
since ``project_description.json`` may be stale on the first write.
|
||||
``builtin_components`` supplies the discovered list (from the cache)
|
||||
instead of reading it from ``project_description.json``.
|
||||
"""
|
||||
idf_target = variant_to_idf_target(get_esp32_variant())
|
||||
# Get IDF target from ESP32 variant (e.g., ESP32S3 -> esp32s3)
|
||||
variant = get_esp32_variant()
|
||||
idf_target = variant.lower().replace("-", "")
|
||||
|
||||
# esp_idf_size 2.x (bundled with IDF >=6.0) made NG the default and
|
||||
# removed the --ng flag; on 1.x (IDF 5.5) --ng is required to get
|
||||
@@ -108,29 +91,12 @@ def get_project_cmakelists(
|
||||
for flag in project_compile_opts
|
||||
)
|
||||
|
||||
# Flags registered via cg.add_cxx_build_flag() go on CXX_COMPILE_OPTIONS
|
||||
# (not COMPILE_OPTIONS) because GCC warns when a C++-only flag such as
|
||||
# -Wno-volatile is passed on a C compile.
|
||||
cxx_compile_options = "\n".join(
|
||||
f'idf_build_set_property(CXX_COMPILE_OPTIONS "{flag}" APPEND)'
|
||||
for flag in get_project_cxx_compile_flags()
|
||||
)
|
||||
|
||||
cpp_standard_options = (
|
||||
CPP_STANDARD_TEMPLATE.format(standard=CORE.cpp_standard)
|
||||
if CORE.cpp_standard
|
||||
else ""
|
||||
)
|
||||
|
||||
# CMake variables registered via cg.add_cmake_arg(). Emitted before
|
||||
# include(project.cmake) so values like EXCLUDE_COMPONENTS are already
|
||||
# set when project.cmake seeds the component list, and on minimal
|
||||
# (discovery) writes too so excluded components never register.
|
||||
cmake_args = "\n".join(
|
||||
f"set({name} {_cmake_quote(value)})"
|
||||
for name, value in sorted(CORE.cmake_args.items())
|
||||
)
|
||||
|
||||
# Per-project list exposed as a CMake variable so converted PIO libs
|
||||
# can reference ${ESPHOME_PROJECT_MANAGED_COMPONENTS} without baking
|
||||
# project-specific names into their cached CMakeLists.
|
||||
@@ -141,6 +107,8 @@ def get_project_cmakelists(
|
||||
# runs as a separate CMake script invocation that doesn't load the
|
||||
# project's top-level CMakeLists; without this, ${ESPHOME_PROJECT_
|
||||
# MANAGED_COMPONENTS} in a converted-lib REQUIRES expands to empty).
|
||||
from esphome.components.esp32 import get_managed_component_require_names
|
||||
|
||||
managed_components_property = "\n".join(
|
||||
f"idf_build_set_property(ESPHOME_PROJECT_MANAGED_COMPONENTS {name} APPEND)"
|
||||
for name in get_managed_component_require_names()
|
||||
@@ -151,24 +119,12 @@ def get_project_cmakelists(
|
||||
# component's REQUIRES including real IDF components). Referenced by
|
||||
# src/CMakeLists and by each converted PIO lib's CMakeLists. Skipped
|
||||
# on minimal writes because project_description.json may be stale.
|
||||
# Excluded components are dropped here as well: a stale
|
||||
# project_description.json from a build without exclusions may still
|
||||
# list them, and requiring an excluded component pulls it back into
|
||||
# the build (IDF requirement expansion overrides EXCLUDE_COMPONENTS).
|
||||
# Derived from the EXCLUDE_COMPONENTS cmake arg emitted above so the
|
||||
# two can never disagree within one generated file.
|
||||
builtin_components_property = (
|
||||
""
|
||||
if minimal
|
||||
else "\n".join(
|
||||
f"idf_build_set_property(ESPHOME_PROJECT_BUILTIN_COMPONENTS {name} APPEND)"
|
||||
for name in sorted(
|
||||
set(
|
||||
builtin_components
|
||||
if builtin_components is not None
|
||||
else get_available_components() or []
|
||||
).difference(CORE.cmake_args.get("EXCLUDE_COMPONENTS", "").split(";"))
|
||||
)
|
||||
for name in sorted(get_available_components() or [])
|
||||
)
|
||||
)
|
||||
|
||||
@@ -195,14 +151,10 @@ set(CMAKE_NINJA_FORCE_RESPONSE_FILE 1)
|
||||
set(IDF_TARGET {idf_target})
|
||||
set(EXTRA_COMPONENT_DIRS ${{CMAKE_SOURCE_DIR}}/src)
|
||||
|
||||
{cmake_args}
|
||||
|
||||
include($ENV{{IDF_PATH}}/tools/cmake/project.cmake)
|
||||
|
||||
{cpp_standard_options}
|
||||
|
||||
{cxx_compile_options}
|
||||
|
||||
{extra_compile_options}
|
||||
|
||||
{managed_components_property}
|
||||
@@ -244,27 +196,15 @@ def get_component_cmakelists() -> str:
|
||||
if(CMAKE_SCRIPT_MODE_FILE)
|
||||
file(GLOB_RECURSE app_sources
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.cpp"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.cc"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.cxx"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.c++"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.c"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cpp"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cc"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cxx"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.c++"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.c"
|
||||
)
|
||||
else()
|
||||
file(GLOB_RECURSE app_sources CONFIGURE_DEPENDS
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.cpp"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.cc"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.cxx"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.c++"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.c"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cpp"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cc"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cxx"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.c++"
|
||||
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.c"
|
||||
)
|
||||
endif()
|
||||
@@ -282,9 +222,7 @@ target_link_options(${{COMPONENT_LIB}} PUBLIC
|
||||
"""
|
||||
|
||||
|
||||
def write_project(
|
||||
minimal: bool = False, builtin_components: list[str] | None = None
|
||||
) -> None:
|
||||
def write_project(minimal: bool = False) -> None:
|
||||
"""Write ESP-IDF project files."""
|
||||
mkdir_p(CORE.build_path)
|
||||
mkdir_p(CORE.relative_src_path())
|
||||
@@ -292,7 +230,7 @@ def write_project(
|
||||
# Write top-level CMakeLists.txt
|
||||
write_file_if_changed(
|
||||
CORE.relative_build_path("CMakeLists.txt"),
|
||||
get_project_cmakelists(minimal=minimal, builtin_components=builtin_components),
|
||||
get_project_cmakelists(minimal=minimal),
|
||||
)
|
||||
|
||||
# Write component CMakeLists.txt in src/
|
||||
@@ -300,13 +238,3 @@ def write_project(
|
||||
CORE.relative_src_path("CMakeLists.txt"),
|
||||
get_component_cmakelists(),
|
||||
)
|
||||
|
||||
# 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
|
||||
# config gains mqtt) requires a fresh discovery pass before the
|
||||
# ESPHOME_PROJECT_BUILTIN_COMPONENTS property can list it.
|
||||
write_file_if_changed(
|
||||
CORE.relative_build_path("exclude_components.esphomeinternal"),
|
||||
";".join(get_excluded_builtin_components()),
|
||||
)
|
||||
|
||||
@@ -63,17 +63,6 @@ def get_ini_content():
|
||||
# Add extra script for C++ flags
|
||||
CORE.add_platformio_option("extra_scripts", [f"pre:{CXX_FLAGS_FILE_NAME}"])
|
||||
|
||||
# Add CMake args. A user-supplied value (str or list) is deliberately
|
||||
# replaced; this option was always overwritten at FINAL priority.
|
||||
if CORE.cmake_args:
|
||||
CORE.add_platformio_option(
|
||||
"board_build.cmake_extra_args",
|
||||
" ".join(
|
||||
f"-D{name}={value}" for name, value in sorted(CORE.cmake_args.items())
|
||||
),
|
||||
replace=True,
|
||||
)
|
||||
|
||||
content = "[platformio]\n"
|
||||
content += f"description = ESPHome {__version__}\n"
|
||||
|
||||
@@ -119,6 +108,7 @@ Import("env")
|
||||
def write_cxx_flags_script() -> None:
|
||||
path = CORE.relative_build_path(CXX_FLAGS_FILE_NAME)
|
||||
contents = CXX_FLAGS_FILE_CONTENTS
|
||||
for flag in sorted(CORE.cxx_build_flags):
|
||||
contents += f'env.Append(CXXFLAGS=["{flag}"])\n'
|
||||
if not CORE.is_host:
|
||||
contents += 'env.Append(CXXFLAGS=["-Wno-volatile"])'
|
||||
contents += "\n"
|
||||
write_file_if_changed(path, contents)
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
"""Build helpers shared by the native (non-PlatformIO) toolchains."""
|
||||
@@ -1,92 +0,0 @@
|
||||
"""Shared ccache policy for build backends: env-knob parsing, binary
|
||||
resolution, and default ``CCACHE_*`` values."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from esphome.framework_helpers import strip_win_long_path_prefix, tool_version_runs
|
||||
from esphome.helpers import FALSY_ENV_STRINGS, TRUTHY_ENV_STRINGS
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _ccache_runs(ccache: str) -> bool:
|
||||
"""Return True when the ``ccache`` found on PATH actually runs."""
|
||||
return tool_version_runs(
|
||||
ccache,
|
||||
"Ignoring ccache at %s because it failed to run; compiling without ccache",
|
||||
)
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
raw = os.environ.get(name)
|
||||
if raw is None:
|
||||
return None
|
||||
lowered = raw.strip().lower()
|
||||
if not lowered:
|
||||
# ENV KNOB= (Docker/CI) has always read as a disable
|
||||
return False
|
||||
if lowered in TRUTHY_ENV_STRINGS:
|
||||
return True
|
||||
if lowered in FALSY_ENV_STRINGS:
|
||||
return False
|
||||
_LOGGER.warning("Ignoring unrecognized %s=%r; use 1 or 0", name, raw)
|
||||
return None
|
||||
|
||||
|
||||
def resolve_ccache_path() -> str | None:
|
||||
"""The ccache binary to wrap compiles with, or None when disabled.
|
||||
|
||||
An explicit ``ESPHOME_CCACHE_ENABLE=1`` skips the runnability probe; the
|
||||
Windows extended-length prefix is stripped before probing (#18399).
|
||||
"""
|
||||
import shutil
|
||||
|
||||
explicit = parse_enable_env("ESPHOME_CCACHE_ENABLE")
|
||||
if explicit is False:
|
||||
return None
|
||||
ccache = shutil.which("ccache")
|
||||
if ccache is None:
|
||||
if explicit:
|
||||
_LOGGER.warning(
|
||||
"ESPHOME_CCACHE_ENABLE is set but no ccache binary is on PATH; "
|
||||
"compiling without ccache"
|
||||
)
|
||||
return None
|
||||
ccache = strip_win_long_path_prefix(ccache)
|
||||
if not explicit and not _ccache_runs(ccache):
|
||||
return None
|
||||
return ccache
|
||||
|
||||
|
||||
def ccache_defaults_env(cache_dir: Path) -> dict[str, str]:
|
||||
"""Default ``CCACHE_*`` values for a build subprocess (not os.environ).
|
||||
|
||||
Values the user already set in the environment are respected. Depend
|
||||
mode is on: both native backends emit depfiles (-MMD / CMake), which
|
||||
keeps cache-miss overhead low.
|
||||
"""
|
||||
from esphome.core import CORE
|
||||
|
||||
# An unset build_path means the env was built before preload; fail loudly
|
||||
# rather than silently drop CCACHE_BASEDIR.
|
||||
if CORE.build_path is None:
|
||||
raise ValueError(
|
||||
"CORE.build_path must be set before constructing the build environment"
|
||||
)
|
||||
defaults = {
|
||||
"CCACHE_DIR": str(cache_dir),
|
||||
"CCACHE_NOHASHDIR": "true",
|
||||
"CCACHE_DEPEND": "1",
|
||||
"CCACHE_BASEDIR": str(Path(CORE.build_path).resolve()),
|
||||
}
|
||||
return {k: v for k, v in defaults.items() if k not in os.environ}
|
||||
@@ -1,380 +0,0 @@
|
||||
"""Derive idedata from a native (non-PlatformIO) build's ``compile_commands.json``.
|
||||
|
||||
PlatformIO exposes a curated ``pio run -t idedata`` JSON; the native
|
||||
toolchains have no such command, but each build produces a
|
||||
``compile_commands.json`` (CMAKE_EXPORT_COMPILE_COMMANDS for ESP-IDF, ninja's
|
||||
compdb tool otherwise). This module turns that file into the same fields
|
||||
consumers (IDE integration, clang-tidy) expect:
|
||||
|
||||
{cc_path, cxx_path, cxx_flags, defines, includes: {build, toolchain}}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shlex
|
||||
import subprocess
|
||||
|
||||
from esphome.core import EsphomeError
|
||||
from esphome.helpers import write_file
|
||||
|
||||
# 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 = (
|
||||
EsphomeError,
|
||||
LookupError,
|
||||
OSError,
|
||||
RuntimeError,
|
||||
ValueError,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# 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_SUFFIXES, ".c", ".o", ".S", ".s")
|
||||
# Path marker identifying an ESPHome source translation unit.
|
||||
_ESPHOME_SRC_MARKER = "/src/esphome/"
|
||||
|
||||
|
||||
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_SUFFIXES
|
||||
)
|
||||
|
||||
|
||||
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``.
|
||||
ESP-IDF's compile_commands.json there mixes two backslash conventions in one
|
||||
string: literal path separators in the compiler path (``C:\Users\...g++.exe``,
|
||||
no quote follows) and shell quote-escaping in -D defines (``-DVER=\"1.2.3\"``).
|
||||
Only the real Windows parser — where a backslash escapes solely a following
|
||||
quote — handles both, and it is the exact tokenizer the compiler is launched
|
||||
with. ``shlex`` cannot: POSIX mode eats the path separators, and disabling
|
||||
its escape mangles the defines.
|
||||
"""
|
||||
if os.name != "nt":
|
||||
return shlex.split(command)
|
||||
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
|
||||
# CommandLineToArgvW("") returns the current process name, not []; guard it
|
||||
# so an empty response file tokenizes the same as it would via shlex.
|
||||
if not command.strip():
|
||||
return []
|
||||
|
||||
CommandLineToArgvW = ctypes.windll.shell32.CommandLineToArgvW
|
||||
CommandLineToArgvW.argtypes = [wintypes.LPCWSTR, ctypes.POINTER(ctypes.c_int)]
|
||||
CommandLineToArgvW.restype = ctypes.POINTER(wintypes.LPWSTR)
|
||||
argc = ctypes.c_int()
|
||||
argv = CommandLineToArgvW(command, ctypes.byref(argc))
|
||||
if not argv: # pragma: no cover
|
||||
raise ctypes.WinError()
|
||||
try:
|
||||
return [argv[i] for i in range(argc.value)]
|
||||
finally:
|
||||
ctypes.windll.kernel32.LocalFree(argv)
|
||||
|
||||
|
||||
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
|
||||
inside them (e.g. ``-mlongcalls``) can be filtered downstream; left as
|
||||
``@file`` clang would read them and choke.
|
||||
"""
|
||||
out: list[str] = []
|
||||
for tok in tokens:
|
||||
if tok.startswith("@"):
|
||||
rf = Path(tok[1:])
|
||||
if not rf.is_absolute():
|
||||
rf = directory / rf
|
||||
try:
|
||||
out.extend(
|
||||
_expand_response_files(
|
||||
_split_command(rf.read_text(encoding="utf-8")), directory
|
||||
)
|
||||
)
|
||||
continue
|
||||
except OSError as err:
|
||||
# Keep the literal token if the file can't be read, but log it
|
||||
# so the (otherwise opaque) downstream clang failure is traceable.
|
||||
_LOGGER.warning("Could not read response file %s: %s", rf, err)
|
||||
out.append(tok)
|
||||
return out
|
||||
|
||||
|
||||
def _pick_entry(entries: list[dict]) -> dict:
|
||||
"""Pick a representative ESPHome C++ TU; all share the same component
|
||||
flags/defines."""
|
||||
for entry in entries:
|
||||
if _is_esphome_src(entry["file"]):
|
||||
return entry
|
||||
for entry in entries:
|
||||
if entry["file"].endswith(_CXX_SUFFIXES):
|
||||
return entry
|
||||
raise ValueError("no C++ translation unit found in compile_commands.json")
|
||||
|
||||
|
||||
# Compiler launchers that may prefix a compile command; a closed launcher
|
||||
# denylist beats enumerating compiler names, an open set.
|
||||
_LAUNCHER_STEMS = frozenset({"ccache", "sccache", "distcc", "icecc", "buildcache"})
|
||||
|
||||
|
||||
def _is_launcher(token: str) -> bool:
|
||||
return Path(token).stem.lower() in _LAUNCHER_STEMS
|
||||
|
||||
|
||||
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)
|
||||
|
||||
def _include(raw: str) -> str:
|
||||
# Resolve against the entry's ``directory`` so cached idedata works
|
||||
# from any cwd; emit forward slashes to match the JSON's own entries
|
||||
raw = raw.strip()
|
||||
if raw and not Path(raw).is_absolute():
|
||||
raw = os.path.normpath(directory / raw)
|
||||
return raw.replace("\\", "/")
|
||||
|
||||
# A launcher-wrapped command ("ccache g++ ...") names the compiler second
|
||||
if launcher is not None and tokens[:1] == [launcher]:
|
||||
tokens = tokens[1:]
|
||||
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("-"):
|
||||
# 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])
|
||||
tokens = tokens[1:]
|
||||
# token0 is the compiler path; the rest of the command already uses forward
|
||||
# slashes on Windows, so normalize it too for a consistent idedata file.
|
||||
cxx_path = tokens[0].replace("\\", "/")
|
||||
# Enforced here so no caller can record ccache as the compiler
|
||||
reject_launcher_compiler(cxx_path)
|
||||
defines: list[str] = []
|
||||
includes: list[str] = []
|
||||
cxx_flags: 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.startswith("-D"):
|
||||
# ``.strip()`` handles tokens like ``-D CONFIGURED=1`` (a single
|
||||
# quoted arg with a space after -D) that some flags arrive as.
|
||||
defines.append(tok[2:].strip() if len(tok) > 2 else next(it, "").strip())
|
||||
elif tok.startswith("-I"):
|
||||
includes.append(_include(tok[2:] if len(tok) > 2 else next(it, "")))
|
||||
elif tok == "-isystem":
|
||||
includes.append(_include(next(it, "")))
|
||||
elif tok.startswith("-isystem"):
|
||||
includes.append(_include(tok[len("-isystem") :]))
|
||||
elif tok in ("-MT", "-MF", "-MQ"):
|
||||
next(it, None) # dependency-file flag + its argument
|
||||
elif tok.startswith(("-MD", "-MMD", "-MP", "-MM")):
|
||||
pass # dependency-generation flags, no argument
|
||||
elif tok.endswith(_INPUT_FILE_SUFFIXES):
|
||||
pass # input/output files
|
||||
else:
|
||||
cxx_flags.append(tok)
|
||||
return cxx_path, defines, includes, cxx_flags
|
||||
|
||||
|
||||
def get_toolchain_includes(cxx_path: str) -> list[str]:
|
||||
"""Query the compiler for its builtin ``#include <...>`` search dirs."""
|
||||
result = subprocess.run(
|
||||
[cxx_path, "-E", "-x", "c++", "-", "-v"],
|
||||
input="",
|
||||
text=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
check=False,
|
||||
close_fds=False,
|
||||
)
|
||||
includes: list[str] = []
|
||||
capture = False
|
||||
for line in result.stderr.splitlines():
|
||||
if "#include <...> search starts here:" in line:
|
||||
capture = True
|
||||
continue
|
||||
if "End of search list." in line:
|
||||
break
|
||||
if capture:
|
||||
includes.append(line.strip())
|
||||
if result.returncode != 0 or not includes:
|
||||
raise RuntimeError(
|
||||
f"Could not query builtin include dirs from {cxx_path} "
|
||||
f"(return code {result.returncode}); stderr:\n{result.stderr.strip()}"
|
||||
)
|
||||
return includes
|
||||
|
||||
|
||||
def _cc_path_from_cxx(cxx_path: str) -> str:
|
||||
"""Derive the C compiler path from the C++ compiler path.
|
||||
|
||||
compile_commands.json only names the C++ compiler, but consumers reach the
|
||||
rest of the toolchain (objdump, readelf, addr2line) by rewriting the tail of
|
||||
``cc_path``, so they need the ``gcc``-suffixed name.
|
||||
"""
|
||||
stem, suffix = (
|
||||
(cxx_path[: -len(".exe")], ".exe")
|
||||
if cxx_path.endswith(".exe")
|
||||
else (cxx_path, "")
|
||||
)
|
||||
# Rewrite the program name only when it is g++ itself, or a toolchain
|
||||
# prefixed one such as xtensa-esp32-elf-g++ -> xtensa-esp32-elf-gcc.
|
||||
# Requiring a separator before the "g++" keeps names that merely end in
|
||||
# those three characters intact: "clang++" must not become "clangcc".
|
||||
head = stem[: -len("g++")]
|
||||
if stem.endswith("g++") and (not head or head.endswith(("-", "/", "\\"))):
|
||||
stem = f"{head}gcc"
|
||||
return f"{stem}{suffix}"
|
||||
|
||||
|
||||
def _cache_usable(cached: object) -> bool:
|
||||
"""Check a cached idedata dict against the guarantees of the write path.
|
||||
|
||||
Caches written by older versions predate the launcher rejection and the
|
||||
include-union shape; serving one would bypass both. The dict check also
|
||||
keeps "in" from substring-matching a bare JSON string.
|
||||
"""
|
||||
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):
|
||||
return False
|
||||
includes = cached.get("includes")
|
||||
return isinstance(includes, dict) and isinstance(includes.get("build"), list)
|
||||
|
||||
|
||||
def load_or_build_idedata(
|
||||
compile_commands: Path,
|
||||
elf_path: Path,
|
||||
cache: Path,
|
||||
launcher: str | None = None,
|
||||
) -> dict | None:
|
||||
"""Return idedata for a compile_commands.json build, cached on mtime.
|
||||
|
||||
Shared by the native ESP-IDF and ESP8266 Arduino toolchains. Returns None
|
||||
when the compile DB doesn't exist yet (nothing was built). ``launcher``
|
||||
is the compiler-launcher path (ccache) the build was generated with, if
|
||||
any; commands in the compile DB are prefixed with it.
|
||||
"""
|
||||
if not compile_commands.is_file():
|
||||
_LOGGER.debug("No %s yet; skipping idedata generation", compile_commands)
|
||||
return None
|
||||
|
||||
if cache.is_file() and cache.stat().st_mtime >= compile_commands.stat().st_mtime:
|
||||
try:
|
||||
cached = json.loads(cache.read_text(encoding="utf-8"))
|
||||
except (ValueError, OSError) as err:
|
||||
# A recurring cause (interrupted write, disk full) would otherwise
|
||||
# look like unexplained slow builds
|
||||
_LOGGER.warning("Discarding unreadable idedata cache %s: %s", cache, err)
|
||||
else:
|
||||
if _cache_usable(cached):
|
||||
# Re-stamp so a relocated build dir cannot serve a stale ELF path
|
||||
cached["prog_path"] = str(elf_path)
|
||||
return cached
|
||||
_LOGGER.debug("Regenerating idedata: cache %s fails validation", cache)
|
||||
|
||||
data = idedata_from_build(compile_commands, launcher)
|
||||
data["prog_path"] = str(elf_path)
|
||||
cache.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Atomic so a crash mid-write cannot leave a truncated cache
|
||||
write_file(cache, json.dumps(data, indent=2) + "\n")
|
||||
return data
|
||||
|
||||
|
||||
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):
|
||||
raise EsphomeError(
|
||||
f"compile_commands.json names the launcher {cxx_path} as the "
|
||||
"compiler; the compile database is unusable"
|
||||
)
|
||||
|
||||
|
||||
def idedata_from_build(compile_commands: Path, launcher: str | None = None) -> dict:
|
||||
"""Parse compile_commands.json into the idedata fields consumers expect.
|
||||
|
||||
A single compile entry only carries the include set its own translation
|
||||
unit was built with (per-component under ESP-IDF), but consumers
|
||||
(clang-tidy) analyze ESPHome headers that transitively pull in other
|
||||
components. So take cxx_path / cxx_flags / defines from a representative
|
||||
ESPHome TU, but union the include dirs across all ESPHome TUs to get a
|
||||
project-wide superset (as PlatformIO's idedata provides).
|
||||
"""
|
||||
entries = json.loads(Path(compile_commands).read_text(encoding="utf-8"))
|
||||
if not isinstance(entries, list) or not all(isinstance(e, dict) for e in entries):
|
||||
# A TypeError here would escape IDEDATA_BEST_EFFORT_ERRORS
|
||||
raise EsphomeError(f"{compile_commands} is not a compile-command list")
|
||||
|
||||
representative = _pick_entry(entries)
|
||||
cxx_path, defines, rep_includes, cxx_flags = parse_entry(representative, launcher)
|
||||
|
||||
# Seed with the representative's includes so it is not parsed twice
|
||||
has_esphome_tu = _is_esphome_src(representative["file"])
|
||||
build_includes: dict[str, None] = dict.fromkeys(
|
||||
rep_includes if has_esphome_tu else ()
|
||||
)
|
||||
|
||||
def _shape(entry: dict) -> str:
|
||||
# directory + command minus TU-specific paths: same shape means the
|
||||
# same include set, so tokenize once per shape. Response-file
|
||||
# commands never dedupe (the .rsp contents differ per object)
|
||||
command = entry["command"]
|
||||
directory = entry.get("directory", "")
|
||||
if "@" in command:
|
||||
return f"unique:{directory}|{entry.get('output') or command}"
|
||||
stripped = command.replace(entry.get("file", ""), "").replace(
|
||||
entry.get("output", ""), ""
|
||||
)
|
||||
return f"{directory}|{stripped}"
|
||||
|
||||
seen_shapes = {_shape(representative)}
|
||||
for entry in entries:
|
||||
if entry is representative or not _is_esphome_src(entry["file"]):
|
||||
continue
|
||||
has_esphome_tu = True
|
||||
if (shape := _shape(entry)) in seen_shapes:
|
||||
_LOGGER.debug("Include union: %s shares a command shape", entry["file"])
|
||||
continue
|
||||
seen_shapes.add(shape)
|
||||
for inc in parse_entry(entry, launcher)[2]:
|
||||
build_includes.setdefault(inc, None)
|
||||
|
||||
if not has_esphome_tu:
|
||||
# An arbitrary fallback TU breaks clang-tidy/IDE consumers, and a
|
||||
# warning would be cached into permanence; call sites downgrade this
|
||||
raise EsphomeError(
|
||||
f"No ESPHome translation unit found in {compile_commands}; "
|
||||
"refusing to cache unusable idedata"
|
||||
)
|
||||
|
||||
return {
|
||||
"cc_path": _cc_path_from_cxx(cxx_path),
|
||||
"cxx_path": cxx_path,
|
||||
"cxx_flags": cxx_flags,
|
||||
"defines": defines,
|
||||
"includes": {
|
||||
"build": list(build_includes),
|
||||
"toolchain": get_toolchain_includes(cxx_path),
|
||||
},
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
"""Platform-neutral helpers for ninja-driven native builds."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
|
||||
from esphome.core import EsphomeError
|
||||
from esphome.framework_helpers import strip_win_long_path_prefix, tool_version_runs
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _ninja_runs(binary: str) -> bool:
|
||||
"""Whether the ninja found on PATH actually runs (see tool_version_runs)."""
|
||||
return tool_version_runs(
|
||||
binary,
|
||||
"Ignoring ninja at %s because it failed to run; "
|
||||
"falling back to the bundled wheel",
|
||||
)
|
||||
|
||||
|
||||
def find_ninja() -> Path:
|
||||
"""Locate the ninja binary: a runnable PATH hit first, else the ninja
|
||||
PyPI wheel."""
|
||||
if binary := shutil.which("ninja"):
|
||||
binary = strip_win_long_path_prefix(binary)
|
||||
if _ninja_runs(binary):
|
||||
return Path(binary)
|
||||
import_error: ImportError | None = None
|
||||
try:
|
||||
import ninja
|
||||
except ImportError as err:
|
||||
import_error = err
|
||||
wheel_binary = None
|
||||
else:
|
||||
wheel_binary = Path(ninja.BIN_DIR) / (
|
||||
"ninja.exe" if os.name == "nt" else "ninja"
|
||||
)
|
||||
if wheel_binary is None or not wheel_binary.is_file():
|
||||
raise EsphomeError(
|
||||
"ninja not found on PATH or in the ninja package; reinstall the "
|
||||
"esphome Python environment"
|
||||
) from import_error
|
||||
return wheel_binary
|
||||
|
||||
|
||||
def escape(value: Path | str) -> str:
|
||||
"""Escape a path or token for a ninja file."""
|
||||
return str(value).replace("$", "$$").replace(":", "$:").replace(" ", "$ ")
|
||||
|
||||
|
||||
def quote_arg(tok: str) -> str:
|
||||
"""Quote with the CreateProcess argv rule (as ``subprocess.list2cmdline``):
|
||||
backslash runs double only before a quote. Windows-only; ``$`` must
|
||||
already be doubled for ninja.
|
||||
"""
|
||||
quoted = re.sub(r'(\\*)"', lambda m: m.group(1) * 2 + '\\"', tok)
|
||||
quoted = re.sub(r"(\\+)\Z", lambda m: m.group(1) * 2, quoted)
|
||||
return f'"{quoted}"'
|
||||
|
||||
|
||||
# Force-quote any token containing a character outside the shlex.quote-style
|
||||
# safe set: ninja hands POSIX commands to /bin/sh -c, so bare (, ;, <, *, `
|
||||
# and friends would be re-parsed as shell syntax.
|
||||
_NEEDS_QUOTE = re.compile(r"[^\w@%+=:,./-]")
|
||||
|
||||
|
||||
def shell_token(tok: str, force: bool = False) -> str:
|
||||
"""Re-quote a lexed token for the platform shell; ``force`` always quotes.
|
||||
|
||||
Single quotes on POSIX (/bin/sh), the argv rule on Windows
|
||||
(CreateProcess). ``$`` is doubled first because ninja expands it before
|
||||
the command reaches the shell.
|
||||
"""
|
||||
tok = tok.replace("$", "$$") # ninja would expand a bare $ to nothing
|
||||
if not (force or not tok or _NEEDS_QUOTE.search(tok)):
|
||||
return tok
|
||||
# An empty token must become '' / "" or it vanishes from the argv
|
||||
if os.name == "nt":
|
||||
return quote_arg(tok)
|
||||
# shlex.quote's rule; inlined because the $-doubled token must not be
|
||||
# re-examined for safe characters
|
||||
return "'" + tok.replace("'", "'\"'\"'") + "'"
|
||||
|
||||
|
||||
def quote_path(value: Path | str) -> str:
|
||||
"""Force-quote a path for the ninja command line (shell/CreateProcess)."""
|
||||
return shell_token(str(value), force=True)
|
||||
@@ -1,24 +0,0 @@
|
||||
"""The PlatformIO-format size bar shared by the native toolchains."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def format_bar(used: int, total: int) -> str:
|
||||
"""Match PlatformIO's ``_format_availale_bytes`` (sic, pioupload.py) exactly."""
|
||||
pct_raw = used / total if total else 0
|
||||
blocks = 10
|
||||
filled = min(int(round(blocks * pct_raw)), blocks)
|
||||
progress = "=" * filled
|
||||
return (
|
||||
f"[{progress:<{blocks}}] {pct_raw: 6.1%} "
|
||||
f"(used {used:d} bytes from {total:d} bytes)"
|
||||
)
|
||||
|
||||
|
||||
def print_size_line(label: str, used: int, total: int) -> None:
|
||||
"""One PlatformIO-format summary line (``RAM``/``Flash``).
|
||||
|
||||
The label padding is part of the format: ``script/ci_memory_impact_extract.py``
|
||||
matches these lines verbatim.
|
||||
"""
|
||||
print(f"{label + ':':<7}{format_bar(used, total)}")
|
||||
@@ -1,36 +0,0 @@
|
||||
"""Machine-global tools cache location shared by the native backends."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def tools_cache_path(env_var: str, subdir: str) -> Path:
|
||||
"""A backend's machine-global tools directory, with an env override.
|
||||
|
||||
A blank/whitespace override is treated as unset: ``Path("")`` resolves
|
||||
to the CWD, which ``clean-all`` would then delete.
|
||||
"""
|
||||
import platformdirs
|
||||
|
||||
from esphome.helpers import get_str_env
|
||||
|
||||
if prefix := get_str_env(env_var, "").strip():
|
||||
# resolve(): symlinked prefixes otherwise trip idf.py's
|
||||
# venv-mismatch warning on every build
|
||||
return Path(prefix).expanduser().resolve()
|
||||
# appauthor=False keeps the Windows path short (no vendor segment);
|
||||
# deep IDF trees run into MAX_PATH otherwise
|
||||
return (
|
||||
Path(platformdirs.user_cache_dir("esphome", appauthor=False)) / subdir
|
||||
).resolve()
|
||||
|
||||
|
||||
# (env override, cache subdir) per native backend. writer.clean_all wipes
|
||||
# every entry via tools_cache_path, so listing a cache here is the single
|
||||
# step that registers it for removal; the backends' own path getters use
|
||||
# the same named pairs so the two cannot drift.
|
||||
IDF_TOOLS_CACHE = ("ESPHOME_ESP_IDF_PREFIX", "idf")
|
||||
SDK_NRF_TOOLS_CACHE = ("ESPHOME_SDK_NRF_PREFIX", "sdk-nrf")
|
||||
ARDUINO8266_TOOLS_CACHE = ("ESPHOME_ARDUINO8266_PREFIX", "arduino8266")
|
||||
TOOLS_CACHE_SPECS = (IDF_TOOLS_CACHE, SDK_NRF_TOOLS_CACHE, ARDUINO8266_TOOLS_CACHE)
|
||||
+9
-172
@@ -7,12 +7,12 @@ and compiled directly: ``esphome compile my_device.esphomebundle.tar.gz``
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path, PurePath, PurePosixPath, PureWindowsPath
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import tarfile
|
||||
@@ -20,7 +20,6 @@ from typing import Any
|
||||
|
||||
from esphome import const, yaml_util
|
||||
from esphome.const import (
|
||||
BUNDLE_EXTENSION,
|
||||
CONF_ESPHOME,
|
||||
CONF_EXTERNAL_COMPONENTS,
|
||||
CONF_INCLUDES,
|
||||
@@ -30,12 +29,10 @@ from esphome.const import (
|
||||
CONF_TYPE,
|
||||
)
|
||||
from esphome.core import CORE, EsphomeError
|
||||
from esphome.util import filter_yaml_files
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DOMAIN = "bundle"
|
||||
|
||||
BUNDLE_EXTENSION = ".esphomebundle.tar.gz"
|
||||
MANIFEST_FILENAME = "manifest.json"
|
||||
CURRENT_MANIFEST_VERSION = 1
|
||||
MAX_DECOMPRESSED_SIZE = 500 * 1024 * 1024 # 500 MB
|
||||
@@ -52,7 +49,6 @@ class ManifestKey(StrEnum):
|
||||
MANIFEST_VERSION = "manifest_version"
|
||||
ESPHOME_VERSION = "esphome_version"
|
||||
CONFIG_FILENAME = "config_filename"
|
||||
CONFIG_DIR = "config_dir"
|
||||
FILES = "files"
|
||||
HAS_SECRETS = "has_secrets"
|
||||
|
||||
@@ -124,153 +120,6 @@ def _find_used_secret_keys(yaml_files: list[Path]) -> set[str]:
|
||||
return keys
|
||||
|
||||
|
||||
@dataclass
|
||||
class BundleData:
|
||||
"""Files components asked to include, keyed under DOMAIN in CORE.data."""
|
||||
|
||||
extra_files: list[Path] = field(default_factory=list)
|
||||
# Directories whose YAML files are scanned for !secret references but
|
||||
# never bundled, e.g. git package checkouts the builder re-fetches.
|
||||
secret_scan_dirs: set[Path] = field(default_factory=set)
|
||||
# Original config dir parsed from an extracted bundle's manifest.json,
|
||||
# kept in the path flavor of the machine the bundle was created on.
|
||||
# The checked flag makes the manifest lookup happen at most once per run;
|
||||
# CORE.data is cleared between runs.
|
||||
original_config_dir: PurePath | None = None
|
||||
original_config_dir_checked: bool = False
|
||||
|
||||
|
||||
def _get_data() -> BundleData:
|
||||
if DOMAIN not in CORE.data:
|
||||
CORE.data[DOMAIN] = BundleData()
|
||||
return CORE.data[DOMAIN]
|
||||
|
||||
|
||||
def add_bundle_file(path: Path) -> None:
|
||||
"""Register a file that a bundle must include.
|
||||
|
||||
Bundle discovery walks the validated config, so it only finds files the config
|
||||
names. Components call this during validation for files it cannot see, such as a
|
||||
file that is referenced from inside another file.
|
||||
|
||||
A relative path is taken as relative to the config directory. Files outside the
|
||||
config directory are skipped when the bundle is built.
|
||||
"""
|
||||
_get_data().extra_files.append(CORE.relative_config_path(path))
|
||||
|
||||
|
||||
def add_secret_scan_dir(path: Path) -> None:
|
||||
"""Register a directory to scan for ``!secret`` references when bundling.
|
||||
|
||||
The directory's files are not added to the bundle. Components call this
|
||||
for YAML the build consumes without bundling it — such as git-fetched
|
||||
packages, which the builder re-fetches — so the secrets those files
|
||||
reference are still shipped in the filtered secrets file.
|
||||
|
||||
A relative path is taken as relative to the config directory.
|
||||
"""
|
||||
if not path.is_absolute():
|
||||
path = CORE.relative_config_path(path)
|
||||
_get_data().secret_scan_dirs.add(path)
|
||||
|
||||
|
||||
def _secret_scan_yaml_files() -> list[Path]:
|
||||
"""Return the YAML files inside registered secret-scan directories."""
|
||||
return filter_yaml_files(
|
||||
f
|
||||
for scan_dir in _get_data().secret_scan_dirs
|
||||
for f in yaml_util.find_files(scan_dir, "*")
|
||||
)
|
||||
|
||||
|
||||
# Windows paths start with a drive letter or contain backslashes; POSIX
|
||||
# paths do neither in practice, so this is how the flavor of a recorded
|
||||
# path string is recognized on any host.
|
||||
_WINDOWS_DRIVE_RE = re.compile(r"^[A-Za-z]:")
|
||||
|
||||
|
||||
def _path_flavor(value: str) -> type[PurePath]:
|
||||
"""Pick the pure path class matching the flavor ``value`` was written in."""
|
||||
if "\\" in value or _WINDOWS_DRIVE_RE.match(value):
|
||||
return PureWindowsPath
|
||||
return PurePosixPath
|
||||
|
||||
|
||||
def _load_original_config_dir() -> PurePath | None:
|
||||
"""Read the original config dir from an extracted bundle's manifest.
|
||||
|
||||
Returns None when the current config dir is not an extracted bundle or
|
||||
the manifest does not record the original config dir.
|
||||
"""
|
||||
manifest_path = CORE.config_dir / MANIFEST_FILENAME
|
||||
try:
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
except FileNotFoundError:
|
||||
# The common case: this config dir is not an extracted bundle.
|
||||
return None
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as err:
|
||||
# A manifest.json is present but unreadable or malformed. Say so
|
||||
# instead of letting it look identical to "not a bundle".
|
||||
_LOGGER.warning("Bundle: ignoring unreadable %s: %s", manifest_path, err)
|
||||
return None
|
||||
if not isinstance(manifest, dict):
|
||||
return None
|
||||
# A manifest.json in the config dir does not have to be ours. Only trust
|
||||
# one that looks like a bundle manifest for exactly this config file.
|
||||
version = manifest.get(ManifestKey.MANIFEST_VERSION)
|
||||
if not isinstance(version, int) or version < 1:
|
||||
return None
|
||||
if manifest.get(ManifestKey.CONFIG_FILENAME) != CORE.config_path.name:
|
||||
return None
|
||||
config_dir = manifest.get(ManifestKey.CONFIG_DIR)
|
||||
if not isinstance(config_dir, str) or not config_dir:
|
||||
return None
|
||||
return _path_flavor(config_dir)(config_dir)
|
||||
|
||||
|
||||
def remap_bundle_path(value: str) -> Path | None:
|
||||
"""Remap an absolute path from the machine a bundle was created on.
|
||||
|
||||
A bundled config may reference files by absolute path. The referenced
|
||||
files ship inside the bundle at their config-relative locations, but the
|
||||
YAML text is copied verbatim, so after extraction on another machine the
|
||||
absolute reference points at a path that only existed on the creating
|
||||
machine. The bundle manifest records that machine's config dir; when
|
||||
``value`` names a path that lived under it, return the corresponding
|
||||
file next to the extracted config.
|
||||
|
||||
``value`` is the raw path string from the config. It is parsed with the
|
||||
original machine's path flavor, so a bundle created on Windows remaps on
|
||||
a POSIX build server and vice versa.
|
||||
|
||||
Returns None when not compiling an extracted bundle, when ``value`` was
|
||||
not under the original config dir, or when the bundle does not contain
|
||||
the file.
|
||||
"""
|
||||
data = _get_data()
|
||||
if not data.original_config_dir_checked:
|
||||
data.original_config_dir_checked = True
|
||||
data.original_config_dir = _load_original_config_dir()
|
||||
original_dir = data.original_config_dir
|
||||
if original_dir is None:
|
||||
return None
|
||||
path = type(original_dir)(value)
|
||||
if not path.is_absolute():
|
||||
return None
|
||||
try:
|
||||
rel = path.relative_to(original_dir)
|
||||
except ValueError:
|
||||
return None
|
||||
# relative_to is lexical, so ".." segments survive it. Refuse them: the
|
||||
# remapped file must land strictly inside the extracted config tree.
|
||||
if ".." in rel.parts:
|
||||
return None
|
||||
remapped = CORE.relative_config_path(Path(*rel.parts))
|
||||
if not remapped.exists():
|
||||
return None
|
||||
return remapped
|
||||
|
||||
|
||||
@dataclass
|
||||
class BundleFile:
|
||||
"""A file to include in the bundle."""
|
||||
@@ -297,7 +146,6 @@ class BundleManifest:
|
||||
config_filename: str
|
||||
files: list[str]
|
||||
has_secrets: bool
|
||||
config_dir: str | None = None
|
||||
|
||||
|
||||
class ConfigBundleCreator:
|
||||
@@ -338,7 +186,6 @@ class ConfigBundleCreator:
|
||||
yaml_sources = [
|
||||
bf.source for bf in files if bf.source.suffix in (".yaml", ".yml")
|
||||
]
|
||||
yaml_sources.extend(_secret_scan_yaml_files())
|
||||
used_secret_keys = _find_used_secret_keys(yaml_sources)
|
||||
filtered_secrets = self._build_filtered_secrets(used_secret_keys)
|
||||
|
||||
@@ -423,13 +270,6 @@ class ConfigBundleCreator:
|
||||
"""
|
||||
discovered = yaml_util.discover_user_yaml_files(self._config_path)
|
||||
self._secrets_paths.update(discovered.secrets)
|
||||
# A !secret inside a file this re-parse does not reach (for example
|
||||
# a git-fetched package the builder re-fetches) still resolves
|
||||
# against the config-dir secrets.yaml at build time, so always
|
||||
# consider that file; filtering no-ops when no key matches.
|
||||
default_secrets = self._config_dir / yaml_util.SECRET_YAML
|
||||
if default_secrets.is_file():
|
||||
self._secrets_paths.add(default_secrets.resolve())
|
||||
config_resolved = self._config_path.resolve()
|
||||
for fpath in discovered.files:
|
||||
if fpath == config_resolved:
|
||||
@@ -446,18 +286,13 @@ class ConfigBundleCreator:
|
||||
with known file extensions are also resolved and checked.
|
||||
|
||||
Core ESPHome concepts that use relative paths or directories
|
||||
are handled explicitly. Files the config does not name at all are
|
||||
registered by their component with add_bundle_file().
|
||||
are handled explicitly.
|
||||
"""
|
||||
config = self._config
|
||||
|
||||
# Generic walk: find all file paths in the validated config
|
||||
self._walk_config_for_files(config)
|
||||
|
||||
# Files registered by components during validation
|
||||
for extra_file in _get_data().extra_files:
|
||||
self._add_file(extra_file)
|
||||
|
||||
# --- Core ESPHome concepts needing explicit handling ---
|
||||
|
||||
# esphome.includes / includes_c - can be relative paths and directories
|
||||
@@ -570,7 +405,6 @@ class ConfigBundleCreator:
|
||||
ManifestKey.MANIFEST_VERSION: CURRENT_MANIFEST_VERSION,
|
||||
ManifestKey.ESPHOME_VERSION: const.__version__,
|
||||
ManifestKey.CONFIG_FILENAME: self._config_path.name,
|
||||
ManifestKey.CONFIG_DIR: str(self._config_dir),
|
||||
ManifestKey.FILES: [f.path for f in files],
|
||||
ManifestKey.HAS_SECRETS: has_secrets,
|
||||
}
|
||||
@@ -655,14 +489,12 @@ def read_bundle_manifest(bundle_path: Path) -> BundleManifest:
|
||||
except tarfile.TarError as err:
|
||||
raise EsphomeError(f"Failed to read bundle: {err}") from err
|
||||
|
||||
config_dir = manifest.get(ManifestKey.CONFIG_DIR)
|
||||
return BundleManifest(
|
||||
manifest_version=manifest[ManifestKey.MANIFEST_VERSION],
|
||||
esphome_version=manifest.get(ManifestKey.ESPHOME_VERSION, "unknown"),
|
||||
config_filename=manifest[ManifestKey.CONFIG_FILENAME],
|
||||
files=manifest.get(ManifestKey.FILES, []),
|
||||
has_secrets=manifest.get(ManifestKey.HAS_SECRETS, False),
|
||||
config_dir=config_dir if isinstance(config_dir, str) else None,
|
||||
)
|
||||
|
||||
|
||||
@@ -755,6 +587,11 @@ def _validate_tar_members(tar: tarfile.TarFile, target_dir: Path) -> None:
|
||||
)
|
||||
|
||||
|
||||
def is_bundle_path(path: Path) -> bool:
|
||||
"""Check if a path looks like a bundle file."""
|
||||
return path.name.lower().endswith(BUNDLE_EXTENSION)
|
||||
|
||||
|
||||
def _add_bytes_to_tar(tar: tarfile.TarFile, name: str, data: bytes) -> None:
|
||||
"""Add in-memory bytes to a tar archive with deterministic metadata."""
|
||||
info = tarfile.TarInfo(name=name)
|
||||
|
||||
@@ -25,8 +25,6 @@ from esphome.cpp_generator import ( # noqa: F401
|
||||
add,
|
||||
add_build_flag,
|
||||
add_build_unflag,
|
||||
add_cmake_arg,
|
||||
add_cxx_build_flag,
|
||||
add_define,
|
||||
add_global,
|
||||
add_library,
|
||||
@@ -50,13 +48,10 @@ from esphome.cpp_helpers import ( # noqa: F401
|
||||
build_registry_entry,
|
||||
build_registry_list,
|
||||
extract_registry_entry_config,
|
||||
get_slot_count,
|
||||
gpio_pin_expression,
|
||||
past_safe_mode,
|
||||
register_component,
|
||||
register_parented,
|
||||
set_setup_priority,
|
||||
slot_counter,
|
||||
)
|
||||
from esphome.cpp_types import ( # noqa: F401
|
||||
NAN,
|
||||
|
||||
+43
-186
@@ -1,193 +1,26 @@
|
||||
"""Validated-config cache for the upload/logs fast path.
|
||||
|
||||
compile dumps the validated config to <data_dir>/storage/<file>.validated.json;
|
||||
compile dumps the validated config to <data_dir>/storage/<file>.validated.yaml;
|
||||
the next upload/logs for that YAML reuses it instead of running the full
|
||||
read_config pipeline. The cache is deliberately lossy: only ``!lambda``
|
||||
bodies survive typed (``Lambda``); IDs, time periods, MAC/IP addresses,
|
||||
paths, UUIDs and enums store the same string form the YAML dumper
|
||||
produced for them. JSON additionally coerces non-str dict keys to
|
||||
strings; validated configs only use string keys (every schema key
|
||||
validator is ``cv.string``). mtime gates staleness.
|
||||
read_config pipeline. YAML round-trip (yaml_util.dump/load_yaml) keeps
|
||||
!lambda/!include/IDs/paths intact; mtime gates staleness.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from esphome.const import __version__ as ESPHOME_VERSION
|
||||
from esphome.core import CORE, EsphomeError, Lambda
|
||||
from esphome.core import CORE
|
||||
from esphome.helpers import write_file
|
||||
from esphome.storage_json import StorageJSON, ext_storage_path, storage_path
|
||||
from esphome.storage_json import StorageJSON, ext_storage_path
|
||||
from esphome.types import ConfigType
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Bump when the on-disk shape changes; a mismatched version falls back
|
||||
# to read_config. The envelope also stamps the writing esphome version:
|
||||
# after an upgrade the cache holds the previous release's validation, so
|
||||
# it falls back once and the re-save self-heals.
|
||||
_CACHE_VERSION = 1
|
||||
_LAMBDA_KEY = "__esphome_lambda__"
|
||||
|
||||
|
||||
def compiled_config_path(config_filename: str) -> Path:
|
||||
"""Path to the cached validated config alongside the storage sidecar."""
|
||||
return CORE.data_dir / "storage" / f"{config_filename}.validated.json"
|
||||
|
||||
|
||||
def save_compiled_config(config: ConfigType) -> None:
|
||||
"""Write the validated-config cache. Always-write so mtime stays fresh.
|
||||
|
||||
Mode 0600 because config validation resolved !secret inline.
|
||||
Failures are non-fatal: the fast path falls back to read_config.
|
||||
"""
|
||||
try:
|
||||
# The legacy YAML cache holds inline-resolved secrets and nothing
|
||||
# reads it anymore; drop it even when the write below fails. A
|
||||
# failed removal leaves resolved secrets on disk, so it warns.
|
||||
try:
|
||||
_legacy_compiled_config_path(CORE.config_filename).unlink(missing_ok=True)
|
||||
except OSError as err:
|
||||
_LOGGER.warning(
|
||||
"Could not remove the legacy validated-config cache: %s", err
|
||||
)
|
||||
rendered = json.dumps(
|
||||
{"v": _CACHE_VERSION, "esphome": ESPHOME_VERSION, "config": config},
|
||||
separators=(",", ":"),
|
||||
default=_json_default,
|
||||
)
|
||||
write_file(compiled_config_path(CORE.config_filename), rendered, private=True)
|
||||
except TypeError as err:
|
||||
# Structural, not transient: this config can never cache (e.g. a
|
||||
# non-basic dict key), so every upload/logs pays the slow path.
|
||||
_LOGGER.warning("Cannot cache the validated config: %s", err)
|
||||
except Exception as err: # noqa: BLE001 # pylint: disable=broad-except
|
||||
# Likely persistent (permissions, full disk): every upload/logs
|
||||
# pays the slow path until it clears, so surface it.
|
||||
_LOGGER.warning("Skipping compiled config cache write: %s", err)
|
||||
|
||||
|
||||
def save_compiled_config_and_sidecar(config: ConfigType) -> None:
|
||||
"""Refresh the cache from the upload/logs fallback (CORE.config must be set).
|
||||
|
||||
The cache is only written when a complete sidecar is on disk:
|
||||
load_compiled_config can't use it otherwise, and it holds resolved
|
||||
secrets.
|
||||
"""
|
||||
if _refresh_sidecar():
|
||||
save_compiled_config(config)
|
||||
|
||||
|
||||
def _refresh_sidecar() -> bool:
|
||||
"""Ensure a complete sidecar is on disk; True when one is.
|
||||
|
||||
Writes one (without claiming a build) when missing or wizard-only.
|
||||
Failures are non-fatal; the next upload/logs pays the slow path again.
|
||||
"""
|
||||
try:
|
||||
path = storage_path()
|
||||
try:
|
||||
old = StorageJSON.load_strict(path)
|
||||
except Exception as err: # noqa: BLE001 # pylint: disable=broad-except
|
||||
# Present but unreadable: it may hold a real build's metadata,
|
||||
# and a fresh rewrite would also stop the next compile from
|
||||
# cleaning a possibly incoherent build tree.
|
||||
_LOGGER.warning(
|
||||
"Not caching: storage sidecar %s is unreadable (%s)", path, err
|
||||
)
|
||||
return False
|
||||
if old is not None and old.can_apply_to_core():
|
||||
if (
|
||||
old.toolchain is not None
|
||||
and CORE.toolchain is not None
|
||||
and old.toolchain != CORE.toolchain.value
|
||||
):
|
||||
# Platforms normalize toolchain-sensitive keys differently;
|
||||
# never cache a config validated under a different toolchain
|
||||
# than the compile's
|
||||
_LOGGER.debug(
|
||||
"Not caching: config validated with toolchain %r but the "
|
||||
"last compile used %r",
|
||||
CORE.toolchain.value,
|
||||
old.toolchain,
|
||||
)
|
||||
return False
|
||||
# Compile-written; nothing to refresh.
|
||||
return True
|
||||
if CORE.build_path is not None and CORE.build_path.exists():
|
||||
# An unvalidated build tree: its absent or mismatched sidecar
|
||||
# is what makes the next compile wipe it, so don't vouch for
|
||||
# a build this run never saw.
|
||||
_LOGGER.warning(
|
||||
"Not caching: build tree %s has no matching sidecar; "
|
||||
"'esphome compile' will settle it",
|
||||
CORE.build_path,
|
||||
)
|
||||
return False
|
||||
new = StorageJSON.from_esphome_core(CORE, old, claim_build=False)
|
||||
if not new.can_apply_to_core():
|
||||
_LOGGER.warning("Not caching: rebuilt storage sidecar is still incomplete")
|
||||
return False
|
||||
new.save(path)
|
||||
return True
|
||||
except (OSError, EsphomeError) as err:
|
||||
# write_file wraps OSError into EsphomeError. Persistent
|
||||
# (unwritable storage dir), so surface that every upload/logs
|
||||
# pays the slow path.
|
||||
_LOGGER.warning("Could not refresh the storage sidecar: %s", err)
|
||||
except Exception: # noqa: BLE001 # pylint: disable=broad-except
|
||||
# A structural bug; keep the traceback so it isn't mistaken
|
||||
# for the I/O failure above.
|
||||
_LOGGER.warning(
|
||||
"Unexpected error refreshing the storage sidecar", exc_info=True
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def load_compiled_config(conf_path: Path) -> ConfigType | None:
|
||||
"""Load the cached validated config and apply storage metadata to CORE.
|
||||
|
||||
Returns None (caller falls back to read_config) when the cache is
|
||||
missing, older than the source YAML, unparseable, a different cache
|
||||
version, or the sidecar is incomplete. The loaded config carries no
|
||||
source ranges; callers must not feed it into read_config/write_cpp.
|
||||
"""
|
||||
cache_path = compiled_config_path(conf_path.name)
|
||||
if not _cache_is_fresh(cache_path, conf_path):
|
||||
return None
|
||||
|
||||
try:
|
||||
envelope = json.loads(
|
||||
cache_path.read_text(encoding="utf-8"), object_hook=_decode_object
|
||||
)
|
||||
except (OSError, ValueError) as err:
|
||||
_LOGGER.debug("Ignoring unreadable compiled config cache: %s", err)
|
||||
return None
|
||||
|
||||
if (
|
||||
not isinstance(envelope, dict)
|
||||
or envelope.get("v") != _CACHE_VERSION
|
||||
or envelope.get("esphome") != ESPHOME_VERSION
|
||||
or not isinstance(config := envelope.get("config"), dict)
|
||||
):
|
||||
_LOGGER.debug("Ignoring compiled config cache with a foreign envelope")
|
||||
return None
|
||||
|
||||
storage = StorageJSON.load(ext_storage_path(conf_path.name))
|
||||
if storage is None or not storage.can_apply_to_core():
|
||||
_LOGGER.debug("Ignoring compiled config cache: sidecar missing or incomplete")
|
||||
return None
|
||||
storage.apply_to_core()
|
||||
return config
|
||||
|
||||
|
||||
# Remove before 2027.8: by then every maintained install has saved the
|
||||
# JSON cache at least once and dropped its legacy YAML file.
|
||||
def _legacy_compiled_config_path(config_filename: str) -> Path:
|
||||
"""Path of the pre-JSON YAML cache; only ever removed."""
|
||||
return CORE.data_dir / "storage" / f"{config_filename}.validated.yaml"
|
||||
|
||||
|
||||
@@ -199,21 +32,45 @@ def _cache_is_fresh(cache_path: Path, source_path: Path) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _json_default(value: Any) -> Any:
|
||||
"""Mirror ESPHomeDumper's representers: Lambda stays typed, the rest
|
||||
stringify (IDs, time periods, MAC/IP addresses, paths, UUIDs, enums).
|
||||
def save_compiled_config(config: ConfigType) -> None:
|
||||
"""Write the validated-config cache. Always-write so mtime stays fresh.
|
||||
|
||||
IncludeFile/Extend/Remove have no JSON mirror and would stringify
|
||||
wrong, but none survive validation (config.py's packages merge and
|
||||
the substitution pass consume them) so no guard is spent on them.
|
||||
Mode 0600 because show_secrets=True resolves !secret inline.
|
||||
Failures are non-fatal: the fast path falls back to read_config.
|
||||
"""
|
||||
if isinstance(value, Lambda):
|
||||
return {_LAMBDA_KEY: value.value}
|
||||
return str(value)
|
||||
from esphome import yaml_util
|
||||
|
||||
try:
|
||||
rendered = yaml_util.dump(config, show_secrets=True)
|
||||
write_file(compiled_config_path(CORE.config_filename), rendered, private=True)
|
||||
except Exception as err: # noqa: BLE001 # pylint: disable=broad-except
|
||||
_LOGGER.debug("Skipping compiled config cache write: %s", err)
|
||||
|
||||
|
||||
def _decode_object(obj: dict[str, Any]) -> Any:
|
||||
"""Revive the Lambda sentinel; every other mapping passes through."""
|
||||
if len(obj) == 1 and isinstance(value := obj.get(_LAMBDA_KEY), str):
|
||||
return Lambda(value)
|
||||
return obj
|
||||
def load_compiled_config(conf_path: Path) -> ConfigType | None:
|
||||
"""Load the cached validated config and apply storage metadata to CORE.
|
||||
|
||||
Returns None (caller falls back to read_config) when the cache is
|
||||
missing, older than the source YAML, unparseable, or the sidecar
|
||||
is incomplete.
|
||||
"""
|
||||
cache_path = compiled_config_path(conf_path.name)
|
||||
if not _cache_is_fresh(cache_path, conf_path):
|
||||
return None
|
||||
|
||||
from esphome import yaml_util
|
||||
|
||||
try:
|
||||
config = yaml_util.load_yaml(cache_path, clear_secrets=False)
|
||||
except Exception: # noqa: BLE001 # pylint: disable=broad-except
|
||||
return None
|
||||
|
||||
storage = StorageJSON.load(ext_storage_path(conf_path.name))
|
||||
if storage is None:
|
||||
return None
|
||||
# apply_to_core assumes a real compile wrote the sidecar; wizard-only
|
||||
# sidecars leave both of these unset and can't drive upload/logs.
|
||||
if not storage.core_platform and not storage.target_platform:
|
||||
return None
|
||||
storage.apply_to_core()
|
||||
return config
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
"""Component alias registry.
|
||||
|
||||
Generated by script/build_alias_registry.py - do not edit manually.
|
||||
See the component-alias section of esphome/loader.py.
|
||||
"""
|
||||
|
||||
# alias -> (canonical component, removal version or None)
|
||||
COMPONENT_ALIASES: dict[str, tuple[str, str | None]] = {
|
||||
"rp2040": ("rp2", "2027.7.0"),
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
# Importing `esphome.loader` here installs the component-alias
|
||||
# ``sys.meta_path`` finder before any submodule lookup runs. Without this,
|
||||
# `from esphome.components import <legacy_alias>` from a fresh interpreter
|
||||
# can race the finder install and raise ImportError, since the legacy
|
||||
# alias dir no longer exists on disk.
|
||||
from esphome import loader as _loader # noqa: F401
|
||||
|
||||
@@ -25,7 +25,7 @@ void A01nyubComponent::check_buffer_() {
|
||||
if (this->buffer_[3] == checksum) {
|
||||
float distance = (this->buffer_[1] << 8) + this->buffer_[2];
|
||||
if (distance > 280) {
|
||||
float meters = distance / 1000.0f;
|
||||
float meters = distance / 1000.0;
|
||||
ESP_LOGV(TAG, "Distance from sensor: %f mm, %f m", distance, meters);
|
||||
this->publish_state(meters);
|
||||
} else {
|
||||
|
||||
@@ -6,7 +6,6 @@ from esphome.const import (
|
||||
STATE_CLASS_MEASUREMENT,
|
||||
UNIT_METER,
|
||||
)
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@MrSuicideParrot"]
|
||||
DEPENDENCIES = ["uart"]
|
||||
@@ -36,7 +35,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema(
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
async def to_code(config):
|
||||
var = await sensor.new_sensor(config)
|
||||
await cg.register_component(var, config)
|
||||
await uart.register_uart_device(var, config)
|
||||
|
||||
@@ -6,7 +6,6 @@ from esphome.const import (
|
||||
STATE_CLASS_MEASUREMENT,
|
||||
UNIT_MILLIMETER,
|
||||
)
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@TH-Braemer"]
|
||||
DEPENDENCIES = ["uart"]
|
||||
@@ -36,7 +35,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema(
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
async def to_code(config):
|
||||
var = await sensor.new_sensor(config)
|
||||
await cg.register_component(var, config)
|
||||
await uart.register_uart_device(var, config)
|
||||
|
||||
@@ -3,7 +3,6 @@ import esphome.codegen as cg
|
||||
from esphome.components import stepper
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_DIR_PIN, CONF_ID, CONF_SLEEP_PIN, CONF_STEP_PIN
|
||||
from esphome.types import ConfigType
|
||||
|
||||
a4988_ns = cg.esphome_ns.namespace("a4988")
|
||||
A4988 = a4988_ns.class_("A4988", stepper.Stepper, cg.Component)
|
||||
@@ -18,7 +17,7 @@ CONFIG_SCHEMA = stepper.STEPPER_SCHEMA.extend(
|
||||
).extend(cv.COMPONENT_SCHEMA)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
async def to_code(config):
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
await stepper.register_stepper(var, config)
|
||||
|
||||
@@ -9,7 +9,6 @@ from esphome.const import (
|
||||
STATE_CLASS_MEASUREMENT,
|
||||
UNIT_GRAMS_PER_CUBIC_METER,
|
||||
)
|
||||
from esphome.types import ConfigType
|
||||
|
||||
absolute_humidity_ns = cg.esphome_ns.namespace("absolute_humidity")
|
||||
AbsoluteHumidityComponent = absolute_humidity_ns.class_(
|
||||
@@ -44,7 +43,7 @@ CONFIG_SCHEMA = (
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
async def to_code(config):
|
||||
var = await sensor.new_sensor(config)
|
||||
await cg.register_component(var, config)
|
||||
|
||||
|
||||
@@ -216,7 +216,7 @@ void AcDimmer::setup() {
|
||||
}
|
||||
|
||||
void AcDimmer::write_state(float state) {
|
||||
state = std::acos(1 - (2 * state)) / std::numbers::pi_v<float>; // RMS power compensation
|
||||
state = std::acos(1 - (2 * state)) / std::numbers::pi; // RMS power compensation
|
||||
auto new_value = static_cast<uint16_t>(roundf(state * 65535));
|
||||
if (new_value != 0 && this->store_.value == 0)
|
||||
this->store_.init_cycle = this->init_with_half_cycle_;
|
||||
|
||||
@@ -4,7 +4,6 @@ from esphome.components import output
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID, CONF_METHOD, CONF_MIN_POWER
|
||||
from esphome.core import CORE
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@glmnet"]
|
||||
|
||||
@@ -49,13 +48,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
if CORE.is_esp32:
|
||||
from esphome.components.esp32 import include_builtin_idf_component
|
||||
|
||||
# Re-enable the gptimer driver (excluded by default to save compile time)
|
||||
include_builtin_idf_component("esp_driver_gptimer")
|
||||
|
||||
async def to_code(config):
|
||||
if CORE.is_esp8266:
|
||||
# ac_dimmer uses setTimer1Callback which requires the waveform generator
|
||||
from esphome.components.esp8266.const import require_waveform
|
||||
|
||||
@@ -4,9 +4,6 @@ from esphome.components.light.effects import register_addressable_effect
|
||||
from esphome.components.light.types import AddressableLightEffect
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_NAME, CONF_UART_ID
|
||||
from esphome.core import ID
|
||||
from esphome.cpp_generator import MockObj
|
||||
from esphome.types import ConfigType
|
||||
|
||||
DEPENDENCIES = ["uart"]
|
||||
|
||||
@@ -24,7 +21,7 @@ CONFIG_SCHEMA = cv.Schema({})
|
||||
"Adalight",
|
||||
{cv.GenerateID(CONF_UART_ID): cv.use_id(uart.UARTComponent)},
|
||||
)
|
||||
async def adalight_light_effect_to_code(config: ConfigType, effect_id: ID) -> MockObj:
|
||||
async def adalight_light_effect_to_code(config, effect_id):
|
||||
effect = cg.new_Pvariable(effect_id, config[CONF_NAME])
|
||||
await uart.register_uart_device(effect, config)
|
||||
return effect
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from typing import Any
|
||||
|
||||
from esphome import pins
|
||||
import esphome.codegen as cg
|
||||
from esphome.components.esp32 import (
|
||||
@@ -18,7 +16,6 @@ from esphome.components.esp32 import (
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ANALOG, CONF_INPUT, CONF_NUMBER, PLATFORM_ESP8266
|
||||
from esphome.core import CORE
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@esphome/core"]
|
||||
|
||||
@@ -228,15 +225,14 @@ ESP32_VARIANT_ADC2_PIN_TO_CHANNEL = {
|
||||
}
|
||||
|
||||
|
||||
def validate_adc_pin(value: Any) -> ConfigType | str:
|
||||
def validate_adc_pin(value):
|
||||
if str(value).upper() == "VCC":
|
||||
if CORE.is_rp2:
|
||||
if CORE.is_rp2040:
|
||||
return pins.internal_gpio_input_pin_schema(29)
|
||||
return cv.only_on([PLATFORM_ESP8266])("VCC")
|
||||
|
||||
# Deprecated in favour of the `internal_temperature` platform, remove before 2027.2.0
|
||||
if str(value).upper() == "TEMPERATURE":
|
||||
return cv.only_on_rp2("TEMPERATURE")
|
||||
return cv.only_on_rp2040("TEMPERATURE")
|
||||
|
||||
if CORE.is_esp32:
|
||||
conf = pins.internal_gpio_input_pin_schema(value)
|
||||
@@ -265,11 +261,11 @@ def validate_adc_pin(value: Any) -> ConfigType | str:
|
||||
raise cv.Invalid("ESP8266: Only pin A0 (GPIO17) supports ADC")
|
||||
return conf
|
||||
|
||||
if CORE.is_rp2:
|
||||
if CORE.is_rp2040:
|
||||
conf = pins.internal_gpio_input_pin_schema(value)
|
||||
number = conf[CONF_NUMBER]
|
||||
if number not in (26, 27, 28, 29):
|
||||
raise cv.Invalid("RP2: Only pins 26, 27, 28 and 29 support ADC")
|
||||
raise cv.Invalid("RP2040: Only pins 26, 27, 28 and 29 support ADC")
|
||||
return conf
|
||||
|
||||
if CORE.is_libretiny:
|
||||
|
||||
@@ -123,9 +123,9 @@ class ADCSensor final : public sensor::Sensor, public PollingComponent, public v
|
||||
void set_autorange(bool autorange) { this->autorange_ = autorange; }
|
||||
#endif // USE_ESP32
|
||||
|
||||
#ifdef USE_RP2
|
||||
#ifdef USE_RP2040
|
||||
void set_is_temperature() { this->is_temperature_ = true; }
|
||||
#endif // USE_RP2
|
||||
#endif // USE_RP2040
|
||||
|
||||
protected:
|
||||
uint8_t sample_count_{1};
|
||||
@@ -152,9 +152,9 @@ class ADCSensor final : public sensor::Sensor, public PollingComponent, public v
|
||||
static adc_oneshot_unit_handle_t shared_adc_handles[2];
|
||||
#endif // USE_ESP32
|
||||
|
||||
#ifdef USE_RP2
|
||||
#ifdef USE_RP2040
|
||||
bool is_temperature_{false};
|
||||
#endif // USE_RP2
|
||||
#endif // USE_RP2040
|
||||
|
||||
#ifdef USE_ZEPHYR
|
||||
const struct adc_dt_spec *channel_ = nullptr;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
namespace esphome::adc {
|
||||
|
||||
static const char *const TAG = "adc";
|
||||
static const char *const TAG = "adc.common";
|
||||
|
||||
const LogString *sampling_mode_to_str(SamplingMode mode) {
|
||||
switch (mode) {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
namespace esphome::adc {
|
||||
|
||||
static const char *const TAG = "adc";
|
||||
static const char *const TAG = "adc.esp32";
|
||||
|
||||
adc_oneshot_unit_handle_t ADCSensor::shared_adc_handles[2] = {nullptr, nullptr};
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ ADC_MODE(ADC_VCC)
|
||||
|
||||
namespace esphome::adc {
|
||||
|
||||
static const char *const TAG = "adc";
|
||||
static const char *const TAG = "adc.esp8266";
|
||||
|
||||
void ADCSensor::setup() {
|
||||
#ifndef USE_ADC_SENSOR_VCC
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
namespace esphome::adc {
|
||||
|
||||
static const char *const TAG = "adc";
|
||||
static const char *const TAG = "adc.libretiny";
|
||||
|
||||
void ADCSensor::setup() {
|
||||
#ifndef USE_ADC_SENSOR_VCC
|
||||
|
||||
+4
-23
@@ -1,4 +1,4 @@
|
||||
#ifdef USE_RP2
|
||||
#ifdef USE_RP2040
|
||||
|
||||
#include "adc_sensor.h"
|
||||
#include "esphome/core/log.h"
|
||||
@@ -17,26 +17,7 @@
|
||||
|
||||
namespace esphome::adc {
|
||||
|
||||
static const char *const TAG = "adc";
|
||||
|
||||
// The on-die temperature sensor sits on the last ADC channel: input 4 on RP2040
|
||||
// and RP2350A, but input 8 on RP2350B, which has eight external channels rather
|
||||
// than four.
|
||||
//
|
||||
// This deliberately does not use the SDK's ADC_TEMPERATURE_CHANNEL_NUM. That
|
||||
// derives from NUM_ADC_CHANNELS, which <pico.h> settles from a board header, and
|
||||
// arduino-pico supplies a fixed B-die one for every RP2350 build. The real die
|
||||
// is only declared later, by the variant's pins_arduino.h, so the SDK constant
|
||||
// reads 8 on A-die boards. PICO_RP2350A itself is correct by the time this file
|
||||
// is compiled, on both arduino-pico and pico-sdk builds.
|
||||
#if defined(PICO_RP2350) && !defined(PICO_RP2350A)
|
||||
#error "PICO_RP2350A is not defined, so the RP2350 die is unknown and the temperature ADC channel cannot be chosen"
|
||||
#endif
|
||||
#if defined(PICO_RP2350) && !PICO_RP2350A
|
||||
static constexpr uint8_t TEMPERATURE_ADC_INPUT = 8;
|
||||
#else
|
||||
static constexpr uint8_t TEMPERATURE_ADC_INPUT = 4;
|
||||
#endif
|
||||
static const char *const TAG = "adc.rp2040";
|
||||
|
||||
void ADCSensor::setup() {
|
||||
static bool initialized = false;
|
||||
@@ -71,7 +52,7 @@ float ADCSensor::sample() {
|
||||
if (this->is_temperature_) {
|
||||
adc_set_temp_sensor_enabled(true);
|
||||
delay(1);
|
||||
adc_select_input(TEMPERATURE_ADC_INPUT);
|
||||
adc_select_input(4);
|
||||
|
||||
for (uint8_t sample = 0; sample < this->sample_count_; sample++) {
|
||||
raw = adc_read();
|
||||
@@ -121,4 +102,4 @@ float ADCSensor::sample() {
|
||||
|
||||
} // namespace esphome::adc
|
||||
|
||||
#endif // USE_RP2
|
||||
#endif // USE_RP2040
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
namespace esphome::adc {
|
||||
|
||||
static const char *const TAG = "adc";
|
||||
static const char *const TAG = "adc.zephyr";
|
||||
|
||||
void ADCSensor::setup() {
|
||||
if (!adc_is_ready_dt(this->channel_)) {
|
||||
|
||||
@@ -10,8 +10,8 @@ from esphome.components.esp32 import (
|
||||
from esphome.components.nrf52.const import AIN_TO_GPIO, EXTRA_ADC
|
||||
from esphome.components.zephyr import (
|
||||
zephyr_add_overlay,
|
||||
zephyr_add_overlay_builder,
|
||||
zephyr_add_prj_conf,
|
||||
zephyr_add_user,
|
||||
)
|
||||
from esphome.config_helpers import filter_source_files_from_platform
|
||||
import esphome.config_validation as cv
|
||||
@@ -52,7 +52,7 @@ _attenuation = cv.enum(ATTENUATION_MODES, lower=True)
|
||||
_sampling_mode = cv.enum(SAMPLING_MODES, lower=True)
|
||||
|
||||
|
||||
def validate_config(config: ConfigType) -> ConfigType:
|
||||
def validate_config(config):
|
||||
if config[CONF_RAW] and config.get(CONF_ATTENUATION, None) == "auto":
|
||||
raise cv.Invalid("Automatic attenuation cannot be used when raw output is set")
|
||||
|
||||
@@ -67,13 +67,6 @@ def validate_config(config: ConfigType) -> ConfigType:
|
||||
# Alter value here so `config` command prints the recommended change
|
||||
config[CONF_ATTENUATION] = _attenuation("12db")
|
||||
|
||||
# Remove before 2027.2.0
|
||||
if config[CONF_PIN] == "TEMPERATURE":
|
||||
_LOGGER.warning(
|
||||
"[adc] `pin: TEMPERATURE` is deprecated, use the `internal_temperature` "
|
||||
"sensor platform instead. Will be removed in 2027.2.0"
|
||||
)
|
||||
|
||||
return config
|
||||
|
||||
|
||||
@@ -120,19 +113,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
CONF_ADC_CHANNEL_ID = "adc_channel_id"
|
||||
|
||||
|
||||
def _overlay_io_channels() -> str:
|
||||
channel_count = CORE.data[CONF_ADC_CHANNEL_ID]
|
||||
entries = ", ".join(f"<&adc {channel_id}>" for channel_id in range(channel_count))
|
||||
return f"""
|
||||
/ {{
|
||||
zephyr,user {{
|
||||
io-channels = {entries};
|
||||
}};
|
||||
}};
|
||||
"""
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
async def to_code(config):
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
await sensor.register_sensor(var, config)
|
||||
@@ -140,7 +121,6 @@ async def to_code(config: ConfigType) -> None:
|
||||
if config[CONF_PIN] == "VCC":
|
||||
cg.add_define("USE_ADC_SENSOR_VCC")
|
||||
elif config[CONF_PIN] == "TEMPERATURE":
|
||||
# Remove before 2027.2.0
|
||||
cg.add(var.set_is_temperature())
|
||||
elif not CORE.is_nrf52 or config[CONF_PIN][CONF_NUMBER] not in EXTRA_ADC:
|
||||
pin = await cg.gpio_pin_expression(config[CONF_PIN])
|
||||
@@ -193,8 +173,9 @@ async def to_code(config: ConfigType) -> None:
|
||||
if isinstance(pin_number, int):
|
||||
GPIO_TO_AIN = {v: k for k, v in AIN_TO_GPIO.items()}
|
||||
pin_number = GPIO_TO_AIN[pin_number]
|
||||
zephyr_add_overlay_builder(_overlay_io_channels)
|
||||
zephyr_add_overlay(f"""
|
||||
zephyr_add_user("io-channels", f"<&adc {channel_id}>")
|
||||
zephyr_add_overlay(
|
||||
f"""
|
||||
&adc {{
|
||||
#address-cells = <1>;
|
||||
#size-cells = <0>;
|
||||
@@ -209,7 +190,8 @@ async def to_code(config: ConfigType) -> None:
|
||||
zephyr,oversampling = <8>;
|
||||
}};
|
||||
}};
|
||||
""")
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
FILTER_SOURCE_FILES = filter_source_files_from_platform(
|
||||
@@ -219,7 +201,7 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
},
|
||||
"adc_sensor_esp8266.cpp": {PlatformFramework.ESP8266_ARDUINO},
|
||||
"adc_sensor_rp2.cpp": {PlatformFramework.RP2_ARDUINO},
|
||||
"adc_sensor_rp2040.cpp": {PlatformFramework.RP2040_ARDUINO},
|
||||
"adc_sensor_libretiny.cpp": {
|
||||
PlatformFramework.BK72XX_ARDUINO,
|
||||
PlatformFramework.RTL87XX_ARDUINO,
|
||||
|
||||
@@ -2,7 +2,6 @@ import esphome.codegen as cg
|
||||
from esphome.components import spi
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID
|
||||
from esphome.types import ConfigType
|
||||
|
||||
DEPENDENCIES = ["spi"]
|
||||
MULTI_CONF = True
|
||||
@@ -18,7 +17,7 @@ CONFIG_SCHEMA = cv.Schema(
|
||||
).extend(spi.spi_device_schema(cs_pin_required=True))
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
async def to_code(config):
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
await spi.register_spi_device(var, config)
|
||||
|
||||
@@ -2,7 +2,6 @@ import esphome.codegen as cg
|
||||
from esphome.components import sensor, voltage_sampler
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_CHANNEL, CONF_ID
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import ADC128S102, adc128s102_ns
|
||||
|
||||
@@ -29,7 +28,7 @@ CONFIG_SCHEMA = (
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
async def to_code(config):
|
||||
var = cg.new_Pvariable(
|
||||
config[CONF_ID],
|
||||
config[CONF_CHANNEL],
|
||||
|
||||
@@ -11,7 +11,6 @@ from esphome.const import (
|
||||
CONF_UPDATE_INTERVAL,
|
||||
CONF_WIDTH,
|
||||
)
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@justfalter"]
|
||||
|
||||
@@ -39,7 +38,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
async def to_code(config):
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
wrapped_light = await cg.get_variable(config[CONF_ADDRESSABLE_LIGHT_ID])
|
||||
cg.add(var.set_width(config[CONF_WIDTH]))
|
||||
|
||||
@@ -36,7 +36,6 @@ from esphome.const import (
|
||||
UNIT_WATT,
|
||||
UNIT_WATT_HOURS,
|
||||
)
|
||||
from esphome.cpp_generator import MockObj
|
||||
from esphome.types import ConfigType
|
||||
|
||||
DEPENDENCIES = ["i2c"]
|
||||
@@ -244,7 +243,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
)
|
||||
|
||||
|
||||
async def neutral_channel(config: ConfigType) -> MockObj:
|
||||
async def neutral_channel(config):
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
|
||||
current = config[CONF_CURRENT]
|
||||
@@ -258,7 +257,7 @@ async def neutral_channel(config: ConfigType) -> MockObj:
|
||||
return var
|
||||
|
||||
|
||||
async def power_channel(config: ConfigType) -> MockObj:
|
||||
async def power_channel(config):
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
|
||||
for sensor_type in POWER_SENSOR_TYPES:
|
||||
@@ -281,7 +280,7 @@ async def power_channel(config: ConfigType) -> MockObj:
|
||||
return var
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
async def to_code(config):
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
await i2c.register_i2c_device(var, config)
|
||||
|
||||
@@ -23,8 +23,6 @@ from esphome.const import (
|
||||
UNIT_VOLT_AMPS_REACTIVE,
|
||||
UNIT_WATT,
|
||||
)
|
||||
from esphome.cpp_generator import MockObj
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@angelnu"]
|
||||
|
||||
@@ -165,7 +163,7 @@ ADE7953_CONFIG_SCHEMA = cv.Schema(
|
||||
).extend(cv.polling_component_schema("60s"))
|
||||
|
||||
|
||||
async def register_ade7953(var: MockObj, config: ConfigType) -> None:
|
||||
async def register_ade7953(var, config):
|
||||
await cg.register_component(var, config)
|
||||
|
||||
if irq_pin_config := config.get(CONF_IRQ_PIN):
|
||||
|
||||
@@ -2,7 +2,6 @@ import esphome.codegen as cg
|
||||
from esphome.components import ade7953_base, i2c
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID
|
||||
from esphome.types import ConfigType
|
||||
|
||||
DEPENDENCIES = ["i2c"]
|
||||
AUTO_LOAD = ["ade7953_base"]
|
||||
@@ -21,7 +20,7 @@ CONFIG_SCHEMA = (
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
async def to_code(config):
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await i2c.register_i2c_device(var, config)
|
||||
await ade7953_base.register_ade7953(var, config)
|
||||
|
||||
@@ -2,7 +2,6 @@ import esphome.codegen as cg
|
||||
from esphome.components import ade7953_base, spi
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID
|
||||
from esphome.types import ConfigType
|
||||
|
||||
DEPENDENCIES = ["spi"]
|
||||
AUTO_LOAD = ["ade7953_base"]
|
||||
@@ -21,7 +20,7 @@ CONFIG_SCHEMA = (
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
async def to_code(config):
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await spi.register_spi_device(var, config)
|
||||
await ade7953_base.register_ade7953(var, config)
|
||||
|
||||
@@ -2,7 +2,6 @@ import esphome.codegen as cg
|
||||
from esphome.components import i2c
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID
|
||||
from esphome.types import ConfigType
|
||||
|
||||
DEPENDENCIES = ["i2c"]
|
||||
MULTI_CONF = True
|
||||
@@ -25,7 +24,7 @@ CONFIG_SCHEMA = (
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
async def to_code(config):
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
await i2c.register_i2c_device(var, config)
|
||||
|
||||
@@ -11,7 +11,6 @@ from esphome.const import (
|
||||
STATE_CLASS_MEASUREMENT,
|
||||
UNIT_VOLT,
|
||||
)
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import CONF_ADS1115_ID, ADS1115Component, ads1115_ns
|
||||
|
||||
@@ -87,7 +86,7 @@ CONFIG_SCHEMA = (
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
async def to_code(config):
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await sensor.register_sensor(var, config)
|
||||
await cg.register_component(var, config)
|
||||
|
||||
@@ -2,7 +2,6 @@ import esphome.codegen as cg
|
||||
from esphome.components import spi
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@solomondg1"]
|
||||
DEPENDENCIES = ["spi"]
|
||||
@@ -24,7 +23,7 @@ CONFIG_SCHEMA = (
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
async def to_code(config):
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
await spi.register_spi_device(var, config)
|
||||
|
||||
@@ -11,7 +11,6 @@ from esphome.const import (
|
||||
UNIT_CELSIUS,
|
||||
UNIT_VOLT,
|
||||
)
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import ADS1118, CONF_ADS1118_ID, ads1118_ns
|
||||
|
||||
@@ -87,7 +86,7 @@ CONFIG_SCHEMA = cv.typed_schema(
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
async def to_code(config):
|
||||
var = await sensor.new_sensor(config)
|
||||
await cg.register_component(var, config)
|
||||
await cg.register_parented(var, config[CONF_ADS1118_ID])
|
||||
|
||||
@@ -17,9 +17,6 @@ from esphome.const import (
|
||||
UNIT_OHM,
|
||||
UNIT_PARTS_PER_BILLION,
|
||||
)
|
||||
from esphome.core import ID
|
||||
from esphome.cpp_generator import MockObj, TemplateArgsType
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CONF_RESISTANCE = "resistance"
|
||||
|
||||
@@ -65,7 +62,7 @@ CONFIG_SCHEMA = (
|
||||
FINAL_VALIDATE_SCHEMA = i2c.final_validate_device_schema("ags10", max_frequency="15khz")
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
async def to_code(config):
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
await i2c.register_i2c_device(var, config)
|
||||
@@ -97,12 +94,7 @@ AGS10_NEW_I2C_ADDRESS_SCHEMA = cv.maybe_simple_value(
|
||||
AGS10_NEW_I2C_ADDRESS_SCHEMA,
|
||||
synchronous=True,
|
||||
)
|
||||
async def ags10newi2caddress_to_code(
|
||||
config: ConfigType,
|
||||
action_id: ID,
|
||||
template_arg: cg.TemplateArguments,
|
||||
args: TemplateArgsType,
|
||||
) -> MockObj:
|
||||
async def ags10newi2caddress_to_code(config, action_id, template_arg, args):
|
||||
var = cg.new_Pvariable(action_id, template_arg)
|
||||
await cg.register_parented(var, config[CONF_ID])
|
||||
address = await cg.templatable(config[CONF_ADDRESS], args, cg.uint8)
|
||||
@@ -134,12 +126,7 @@ AGS10_SET_ZERO_POINT_SCHEMA = cv.Schema(
|
||||
AGS10_SET_ZERO_POINT_SCHEMA,
|
||||
synchronous=True,
|
||||
)
|
||||
async def ags10setzeropoint_to_code(
|
||||
config: ConfigType,
|
||||
action_id: ID,
|
||||
template_arg: cg.TemplateArguments,
|
||||
args: TemplateArgsType,
|
||||
) -> MockObj:
|
||||
async def ags10setzeropoint_to_code(config, action_id, template_arg, args):
|
||||
var = cg.new_Pvariable(action_id, template_arg)
|
||||
await cg.register_parented(var, config[CONF_ID])
|
||||
mode = await cg.templatable(
|
||||
|
||||
@@ -12,7 +12,6 @@ from esphome.const import (
|
||||
UNIT_CELSIUS,
|
||||
UNIT_PERCENT,
|
||||
)
|
||||
from esphome.types import ConfigType
|
||||
|
||||
DEPENDENCIES = ["i2c"]
|
||||
|
||||
@@ -51,7 +50,7 @@ CONFIG_SCHEMA = (
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
async def to_code(config):
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
await i2c.register_i2c_device(var, config)
|
||||
|
||||
@@ -4,9 +4,6 @@ from esphome.components import i2c
|
||||
from esphome.components.audio_dac import AudioDac
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID, CONF_MODE
|
||||
from esphome.core import ID
|
||||
from esphome.cpp_generator import MockObj, TemplateArgsType
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@kbx81"]
|
||||
DEPENDENCIES = ["i2c"]
|
||||
@@ -42,12 +39,7 @@ SET_AUTO_MUTE_ACTION_SCHEMA = cv.maybe_simple_value(
|
||||
SET_AUTO_MUTE_ACTION_SCHEMA,
|
||||
synchronous=True,
|
||||
)
|
||||
async def aic3204_set_volume_to_code(
|
||||
config: ConfigType,
|
||||
action_id: ID,
|
||||
template_arg: cg.TemplateArguments,
|
||||
args: TemplateArgsType,
|
||||
) -> MockObj:
|
||||
async def aic3204_set_volume_to_code(config, action_id, template_arg, args):
|
||||
paren = await cg.get_variable(config[CONF_ID])
|
||||
var = cg.new_Pvariable(action_id, template_arg, paren)
|
||||
|
||||
@@ -57,7 +49,7 @@ async def aic3204_set_volume_to_code(
|
||||
return var
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
async def to_code(config):
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
await i2c.register_i2c_device(var, config)
|
||||
|
||||
@@ -1,27 +1,23 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import ble_device_base
|
||||
from esphome.components import esp32_ble_tracker
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID
|
||||
from esphome.types import ConfigType
|
||||
|
||||
AUTO_LOAD = ["ble_device_base"]
|
||||
DEPENDENCIES = ["esp32_ble_tracker"]
|
||||
CODEOWNERS = ["@jeromelaban"]
|
||||
|
||||
airthings_ble_ns = cg.esphome_ns.namespace("airthings_ble")
|
||||
AirthingsListener = airthings_ble_ns.class_(
|
||||
"AirthingsListener", ble_device_base.ESPBTDeviceListener
|
||||
"AirthingsListener", esp32_ble_tracker.ESPBTDeviceListener
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
ble_device_base.rename_legacy_hub_id("airthings_ble"),
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(AirthingsListener),
|
||||
}
|
||||
).extend(ble_device_base.BLE_DEVICE_SCHEMA),
|
||||
)
|
||||
CONFIG_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(AirthingsListener),
|
||||
}
|
||||
).extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
async def to_code(config):
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await ble_device_base.register_ble_device(var, config)
|
||||
await esp32_ble_tracker.register_ble_device(var, config)
|
||||
|
||||
@@ -2,13 +2,15 @@
|
||||
#include "esphome/core/log.h"
|
||||
#include <cinttypes>
|
||||
|
||||
#ifdef USE_ESP32
|
||||
|
||||
namespace esphome::airthings_ble {
|
||||
|
||||
static const char *const TAG = "airthings_ble";
|
||||
|
||||
bool AirthingsListener::parse_device(const ble_device_base::ESPBTDevice &device) {
|
||||
bool AirthingsListener::parse_device(const esp32_ble_tracker::ESPBTDevice &device) {
|
||||
for (auto &it : device.get_manufacturer_datas()) {
|
||||
if (it.uuid == ble_device_base::ESPBTUUID::from_uint32(0x0334)) {
|
||||
if (it.uuid == esp32_ble_tracker::ESPBTUUID::from_uint32(0x0334)) {
|
||||
if (it.data.size() < 4)
|
||||
continue;
|
||||
|
||||
@@ -27,3 +29,5 @@ bool AirthingsListener::parse_device(const ble_device_base::ESPBTDevice &device)
|
||||
}
|
||||
|
||||
} // namespace esphome::airthings_ble
|
||||
|
||||
#endif
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
#pragma once
|
||||
|
||||
#ifdef USE_ESP32
|
||||
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/components/ble_device_base/ble_device.h"
|
||||
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
|
||||
|
||||
namespace esphome::airthings_ble {
|
||||
|
||||
class AirthingsListener final : public ble_device_base::ESPBTDeviceListener {
|
||||
class AirthingsListener final : public esp32_ble_tracker::ESPBTDeviceListener {
|
||||
public:
|
||||
bool parse_device(const ble_device_base::ESPBTDevice &device) override;
|
||||
bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override;
|
||||
};
|
||||
|
||||
} // namespace esphome::airthings_ble
|
||||
|
||||
#endif
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user