Compare commits

...
Author SHA1 Message Date
Jesse HillsandGitHub 6f8dbb6fbc Merge pull request #18527 from esphome/bump-2026.8.0
2026.8.0
2026-08-20 14:32:15 +12:00
J. Nick KostonandJesse Hills ca97c86d65 [ci] Install requirements_dev.txt when the venv cache misses (#18502) 2026-08-20 13:12:33 +12:00
Jesse Hills 828eac90f3 Bump version to 2026.8.0 2026-08-20 11:51:59 +12:00
J. Nick KostonandJesse Hills d9359a70c1 [tests] Keep PlatformIO libdeps per xdist worker to stop a compile race (#18524) 2026-08-20 11:51:57 +12:00
Jesse HillsandGitHub e75a7a61fa Merge pull request #18523 from esphome/bump-2026.8.0b6
2026.8.0b6
2026-08-20 10:32:26 +12:00
J. Nick KostonandJesse Hills c455991962 [ci] Key PlatformIO cache on the Python version so a runner image bump does not serve a broken LibreTiny venv (#18512) 2026-08-20 09:38:44 +12:00
Jesse Hills f735dcadc0 Bump version to 2026.8.0b6 2026-08-20 09:33:05 +12:00
J. Nick KostonandJesse Hills 78a65eabdc [ci] Stop jobs hanging on apt by restoring the cached apt action and bounding raw apt calls (#18518) 2026-08-20 09:33:03 +12:00
b3fda9973e [image] Restore defaults:/files: support for platform entries (#18032)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-08-20 09:33:03 +12:00
esphome[bot]andJesse Hills 4a85c98285 Bump bundled esphome-device-builder to 1.12.0 (#18514) 2026-08-20 09:33:03 +12:00
esphome[bot]andJesse Hills 2c92a2498e Bump bundled esphome-device-builder to 1.11.5 (#18507) 2026-08-20 09:33:03 +12:00
esphome[bot]andJesse Hills 74e22b5ad7 Bump bundled esphome-device-builder to 1.11.4 (#18506) 2026-08-20 09:33:03 +12:00
esphome[bot]andJesse Hills e9e77d02a0 Bump bundled esphome-device-builder to 1.11.3 (#18505) 2026-08-20 09:33:03 +12:00
Jonathan SwobodaandJesse Hills 7418fcce8d [ci] Stop persisting the integration test ccache (#18504) 2026-08-20 09:33:03 +12:00
Jonathan SwobodaandJesse Hills b768e2a1ce [esp32] Fix ESP32-P4 bootloop on rev3 (v3.x) chips when only variant is set (#18500) 2026-08-20 09:33:03 +12:00
J. Nick KostonandJesse Hills 6084314cc9 [vscode] Report the origin of an unexpected exception during validation (#18494) 2026-08-20 09:33:03 +12:00
J. Nick KostonandJesse Hills 2df953f3d7 [platformio] Give the ccache wrapper a cmd.exe safe path (#18495) 2026-08-20 09:33:03 +12:00
10e592fa3a [modbus] CRC scan all unknown function codes (#18483)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-20 09:33:03 +12:00
J. Nick KostonandJesse Hills a99a8f364e [api] Bump noise-c to 0.1.21 (#18484) 2026-08-20 09:33:03 +12:00
J. Nick KostonandJesse Hills 200a1644a5 [ci] Fail the benchmark job when the C++ benchmark build fails (#18480) 2026-08-20 09:33:03 +12:00
J. Nick KostonandJesse Hills 9daae377fc [api] Bump noise-c to 0.1.20 (#18482) 2026-08-20 09:33:02 +12:00
Jesse HillsandGitHub f414a07bcd Merge pull request #18481 from esphome/bump-2026.8.0b5
2026.8.0b5
2026-08-19 08:01:14 +12:00
Jesse Hills d1391c2b10 Bump version to 2026.8.0b5 2026-08-18 16:04:49 +12:00
J. Nick KostonandJesse Hills 8b888f31e0 [gpio_expander][pcf8574][pca9554][tca9555][pca6416a][pi4ioe5v6408][mcp23016][mcp23xxx_base] Reject unsupported interrupt_pin options (inverted, allow_other_uses) (#18472) 2026-08-18 16:04:47 +12:00
Jesse Hills 6a247dfe91 [light] Replace rgb_order/is_rgbw/is_wrgb with channel_colors (#18474) 2026-08-18 16:01:54 +12:00
J. Nick KostonandJesse Hills 482869fbbe [socket] Fix multi-second TCP stalls on ESP8266 by yielding to the SYS context (#18455) 2026-08-18 14:27:54 +12:00
esphome[bot]andJesse Hills 4dea147386 Bump bundled esphome-device-builder to 1.11.2 (#18477) 2026-08-18 14:27:54 +12:00
J. Nick KostonandJesse Hills 1fd6337254 [api] Bump noise-c to 0.1.19 (#18473) 2026-08-18 14:27:54 +12:00
esphome[bot]andJesse Hills 4ce6d59484 Bump bundled esphome-device-builder to 1.11.1 (#18475) 2026-08-18 14:27:54 +12:00
J. Nick KostonandJesse Hills 014cc19902 [api] Bump noise-c to 0.1.18 (#18451) 2026-08-18 14:27:54 +12:00
esphome[bot]Jesse Hillsesphome[bot] <115708604+esphome[bot]@users.noreply.github.com>
b9041566ea Bump aioesphomeapi from 45.10.2 to 45.10.3 (#18433)
Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com>
2026-08-18 14:27:54 +12:00
J. Nick KostonandJesse Hills 3a403c40d5 [ld2420] Drop the setup priority override so setup runs after the UART bus (#18428) 2026-08-18 14:27:54 +12:00
esphome[bot]andJesse Hills 096e71bd67 Bump aioesphomeapi from 45.10.1 to 45.10.2 (#18357) 2026-08-18 14:27:54 +12:00
Jesse HillsandGitHub 443d8f1f28 Merge pull request #18424 from esphome/bump-2026.8.0b4
2026.8.0b4
2026-08-17 11:33:27 +12:00
Jesse Hills 1ec21a2245 Bump version to 2026.8.0b4 2026-08-17 10:22:41 +12:00
J. Nick KostonandJesse Hills bb7d4c3630 [esp32] Split crash handler addr2line hint per core (#18418) 2026-08-17 10:22:39 +12:00
J. Nick KostonandJesse Hills f42fe9af29 [core] Skip redundant ESP8266 main loop wake posts from ISR context (#18416) 2026-08-17 10:22:39 +12:00
J. Nick KostonandJesse Hills 0bc2d71370 [bk72xx_ble] Fail early with a clear error on non BLE 5.x SoCs (#18406) 2026-08-17 10:22:39 +12:00
luar123andJesse Hills 594c12b3d9 [zigbee] bump esp-zigbee-sdk to 2.0.4 (#18415) 2026-08-17 10:22:39 +12:00
Jonathan SwobodaandJesse Hills bca72e9b6d [sensor] Pass NaN through the delta filter again (#18400) 2026-08-17 10:22:39 +12:00
J. Nick KostonandJesse Hills ce09504c92 [platformio] Skip ccache when the binary on PATH fails to run (#18407) 2026-08-17 10:22:39 +12:00
esphome[bot]andJesse Hills dda4566b9e Bump bundled esphome-device-builder to 1.11.0 (#18403) 2026-08-17 10:22:39 +12:00
Samuel SiebandJesse Hills 46a5665a66 [rotary_encoder] account for min and max value when resetting (#18197) 2026-08-17 10:22:39 +12:00
esphome[bot]andJesse Hills 9161f74bb1 Bump bundled esphome-device-builder to 1.10.0 (#18389) 2026-08-17 10:22:39 +12:00
Jesse HillsandGitHub 9d7997f55a Merge pull request #18368 from esphome/bump-2026.8.0b3
2026.8.0b3
2026-08-14 22:49:40 +12:00
Jesse Hills 4db47de556 Bump version to 2026.8.0b3 2026-08-14 17:36:56 +12:00
Karl BeeckenandJesse Hills c8de632764 [core] fix PYTHONPATH leak (#18360) 2026-08-14 17:36:54 +12:00
J. Nick KostonandJesse Hills b794b7b1d1 [core] Restore cv.parse_esphome_version as a deprecated helper (#18366) 2026-08-14 17:36:54 +12:00
J. Nick KostonandJesse Hills add18d4e35 [core] Partially revert "Hash entity keys from the raw name to fix collisions" (#18361) 2026-08-14 17:36:54 +12:00
J. Nick KostonandJesse Hills 02c1810c3a [core] Load component aliases from a generated registry (#18335) 2026-08-14 17:36:54 +12:00
J. Nick KostonandJesse Hills 4f3153375a [ota] Retry uploads that fail from network errors (#18332) 2026-08-14 17:36:54 +12:00
J. Nick KostonandJesse Hills 1c3a67b5e8 [wifi] Fix ESP8266 crash in cnx_node_search when lwIP transmits after disconnect (#18333) 2026-08-14 17:36:54 +12:00
J. Nick KostonandJesse Hills 236ff33a09 [esp32_ble] Silence spurious warnings for local key GAP events (#18359) 2026-08-14 17:36:54 +12:00
J. Nick KostonandJesse Hills 7c07fb48c5 [esp32_ble_tracker] Fix missed BLE advertisements with WiFi on ESP-IDF 5.5.5 (#18356) 2026-08-14 17:36:54 +12:00
J. Nick KostonandJesse Hills d72bab79d7 [web_server_base] Stop deleting the web server on captive portal teardown (#18324) 2026-08-14 17:36:54 +12:00
Jesse HillsandGitHub 9c3407cfdd Merge pull request #18334 from esphome/bump-2026.8.0b2
2026.8.0b2
2026-08-13 14:06:28 +12:00
Jesse Hills d3e27054f6 Bump version to 2026.8.0b2 2026-08-13 13:30:47 +12:00
J. Nick KostonandJesse Hills bd58b5c8b3 [core] Retry framework downloads on transient network errors (#18330) 2026-08-13 13:30:46 +12:00
J. Nick KostonandJesse Hills c1a326f32e [core] Don't block logs startup on MQTT IP discovery when addresses are known (#18313) 2026-08-13 13:30:46 +12:00
J. Nick KostonandJesse Hills a14ea0e8fa [ld2420] Fix out-of-bounds read when device reports unknown command error (#18322) 2026-08-13 13:30:46 +12:00
esphome[bot]andJesse Hills 48d6368ff9 Bump bundled esphome-device-builder to 1.9.6 (#18328) 2026-08-13 13:30:46 +12:00
Kevin AhrendtandJesse Hills 83cff59fdd [sendspin] Bump sendspin-cpp to v0.7.2 (#18316) 2026-08-13 13:30:46 +12:00
esphome[bot]andJesse Hills 89489b1f0d Bump aioesphomeapi from 45.10.0 to 45.10.1 (#18318) 2026-08-13 13:30:46 +12:00
J. Nick KostonandJesse Hills 7569a7b5ce [usb_uart] Fix uint32_t format specifier warning in pl2303 (#18310) 2026-08-13 13:30:45 +12:00
Jesse HillsandGitHub ec6b4263a2 Merge pull request #18314 from esphome/bump-2026.8.0b1
2026.8.0b1
2026-08-13 00:28:40 +12:00
Jesse Hills 3e4661fe1e Bump version to 2026.8.0b1 2026-08-12 22:53:42 +12:00
J. Nick KostonandGitHub 22153be4cd [core] Add esphome logs over web_server HTTP SSE (#17110) 2026-08-12 22:00:07 +12:00
3f490fe1ed [internal_temperature] Read the RP2 on-die sensor directly instead of via the Arduino API (#18262)
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-08-12 21:42:05 +12:00
J. Nick KostonandGitHub 58a42fe5c2 [core] Batch remote file downloads during config validation (#18069) 2026-08-12 18:44:35 +12:00
Josef ZweckGitHubJ. Nick Kostonpre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
25c0c2c97b [hoermann_hcp] Add garage light control (#18190)
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-08-12 06:10:23 +00:00
J. Nick KostonandGitHub 622942482c [rp2] Size the lwIP segment pool and heap for concurrent senders (#18257) 2026-08-12 17:30:17 +12:00
201 changed files with 9528 additions and 2622 deletions
+2 -2
View File
@@ -49,7 +49,7 @@ runs:
python -m venv venv
source venv/bin/activate
python --version
uv pip install -r requirements.txt -r requirements_test.txt
uv pip install -r requirements.txt -r requirements_dev.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 +58,5 @@ runs:
python -m venv venv
source ./venv/Scripts/activate
python --version
uv pip install -r requirements.txt -r requirements_test.txt
uv pip install -r requirements.txt -r requirements_dev.txt -r requirements_test.txt
uv pip install -e .
+25 -3
View File
@@ -41,10 +41,32 @@ jobs:
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 apt update
sudo apt-cache show protobuf-compiler
sudo apt install -y protobuf-compiler
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
protoc --version
- name: Install python dependencies
run: uv pip install --system aioesphomeapi -c requirements.txt -r requirements_dev.txt
+97 -37
View File
@@ -68,6 +68,22 @@ jobs:
uv pip install -r requirements.txt -r requirements_dev.txt -r requirements_test.txt
uv pip install -e .
seed-apt-cache:
name: Seed apt package cache
runs-on: ubuntu-24.04
# PR-branch cache saves are invisible to other PRs, so dev/beta/release
# pushes seed the one shared entry PR jobs restore. The key is derived
# only from the package list and version; keep both identical in every
# step that restores it. In ci-status needs so a broken seed fails dev.
if: github.event_name == 'push'
timeout-minutes: 10
steps:
- name: Install apt packages (cached)
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
with:
packages: libsdl2-dev ccache
version: 1.1
determine-jobs:
name: Determine which jobs to run
runs-on: ubuntu-24.04
@@ -179,6 +195,7 @@ jobs:
. venv/bin/activate
script/ci-custom.py
script/build_codeowners.py --check
script/build_alias_registry.py --check
script/build_language_schema.py --check
script/generate-esp32-boards.py --check
script/generate-rp2-boards.py --check
@@ -322,7 +339,8 @@ jobs:
integration-tests:
name: Run integration tests (${{ matrix.bucket.name }})
runs-on: ubuntu-latest
# Must match seed-apt-cache's image: the apt cache key has no OS in it.
runs-on: ubuntu-24.04
needs:
- common
- determine-jobs
@@ -334,24 +352,16 @@ jobs:
steps:
- name: Check out code from GitHub
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install ccache
# Speeds up the host compiles: tests in a bucket compile overlapping
# component sets, so later tests reuse earlier tests' objects.
run: |
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends ccache
- name: Restore ccache (restore-only)
# esphome stores the PlatformIO ccache under the machine-global cache
# dir (see _ccache_env() in esphome/platformio/toolchain.py). The
# bucket-name prefix prefers a same-bucket seed; the bare prefix falls
# back to any seed when the bucket layout differs from dev.
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
- name: Install apt packages (cached)
# ccache speeds up the host compiles. A cache hit never touches apt
# (mirror outages cannot hang the job); the timeout bounds the cold
# path. Packages and version must match seed-apt-cache exactly;
# libsdl2-dev is unused here and carried only for cache-key parity.
timeout-minutes: 10
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
with:
path: ~/.cache/esphome/platformio-ccache
key: integration-ccache-${{ matrix.bucket.name }}-${{ github.sha }}
restore-keys: |
integration-ccache-${{ matrix.bucket.name }}-
integration-ccache-
packages: libsdl2-dev ccache
version: 1.1
- name: Set up Python 3.13
id: python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
@@ -400,14 +410,6 @@ jobs:
# esphome stores the PlatformIO ccache under the machine-global cache
# dir (see _ccache_env() in esphome/platformio/toolchain.py).
run: CCACHE_DIR="$HOME/.cache/esphome/platformio-ccache" ccache -s
- name: Save ccache
# Pull request saves land in per-PR scopes nothing else can reuse;
# dev pushes seed the shared copy instead.
if: github.event_name != 'pull_request'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/esphome/platformio-ccache
key: integration-ccache-${{ matrix.bucket.name }}-${{ github.sha }}
import-time:
name: Check import esphome.__main__ time
@@ -440,6 +442,7 @@ jobs:
benchmarks:
name: Run CodSpeed benchmarks
runs-on: ubuntu-24.04
timeout-minutes: 30
needs:
- common
- determine-jobs
@@ -459,12 +462,58 @@ jobs:
- name: Build benchmarks
id: build
run: |
# pipefail: without it a failed build is masked by the grep/cut
# pipeline below, leaving BINARY empty and silently dropping every
# C++ benchmark from the run while the job still reports success.
set -o pipefail
. venv/bin/activate
export BENCHMARK_LIB_CONFIG=$(python script/setup_codspeed_lib.py)
# --build-only prints BUILD_BINARY=<path> to stdout
BINARY=$(script/cpp_benchmark.py --all --build-only | grep '^BUILD_BINARY=' | tail -1 | cut -d= -f2-)
BENCHMARK_LIB_CONFIG=$(python script/setup_codspeed_lib.py)
export BENCHMARK_LIB_CONFIG
# --build-only prints BUILD_BINARY=<path> to stdout; the grep is
# non-fatal so a missing marker reaches the check below instead of
# tripping errexit at this assignment
BINARY=$(script/cpp_benchmark.py --all --build-only | { grep '^BUILD_BINARY=' || true; } | tail -1 | cut -d= -f2-)
if [ -z "$BINARY" ]; then
echo "::error::Benchmark build did not report a binary path"
exit 1
fi
echo "binary=$BINARY" >> $GITHUB_OUTPUT
- name: Bound apt fetches and pre-install libc6-dbg
# The CodSpeed runner installs valgrind + libc6-dbg via its own
# unbounded apt-get update; per-invocation apt options cannot reach
# it. The apt.conf.d timeouts below bound every later apt call in
# this job, the runner's included. Pre-installing libc6-dbg lets the
# runner skip apt once its valgrind cache is restored (it checks
# ``dpkg -s libc6-dbg``, so the cache action's unregistered restores
# would not count). Install without update first: image lists are
# fresh, and the index refresh is what a congested mirror makes
# slow. Best effort; the job timeout is the last backstop.
timeout-minutes: 15
continue-on-error: true
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
if dpkg -s libc6-dbg >/dev/null 2>&1; then
echo "libc6-dbg already installed"
exit 0
fi
# Common path: the image's package lists are fresh enough.
if sudo DEBIAN_FRONTEND=noninteractive timeout -k 15 90 \
apt-get install -y libc6-dbg; then
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 libc6-dbg
- name: Run CodSpeed benchmarks
uses: CodSpeedHQ/action@4296e51e7041e24dadb86d1d6e8b9320d223dbe8 # v5.0.3
with:
@@ -549,24 +598,29 @@ jobs:
fetch-depth: 2
- name: Restore Python
id: restore-python
uses: ./.github/actions/restore-python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
# Key on the exact Python version as well: LibreTiny creates a venv under
# ~/.platformio/penv whose interpreter is a symlink into the runner's
# hosted toolcache, so a cache saved on an older runner image breaks once
# a new image ships a newer patch release and drops the old interpreter.
- name: Cache platformio
if: github.ref == 'refs/heads/dev' && matrix.pio_cache_key
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.platformio
key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }}
key: platformio-${{ matrix.pio_cache_key }}-${{ steps.restore-python.outputs.python-version }}-${{ hashFiles('platformio.ini') }}
- name: Cache platformio
if: github.ref != 'refs/heads/dev' && matrix.pio_cache_key
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.platformio
key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }}
key: platformio-${{ matrix.pio_cache_key }}-${{ steps.restore-python.outputs.python-version }}-${{ hashFiles('platformio.ini') }}
- name: Cache ESP-IDF install
if: matrix.cache_idf
@@ -883,12 +937,17 @@ jobs:
- name: List components
run: echo ${{ matrix.batch.components }}
- name: Install apt packages
# Not cached: this job is pull-request-only, so a cache save could
# never be shared and would only consume quota.
run: |
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends libsdl2-dev ccache
- name: Install apt packages (cached)
# A cache hit (seeded on dev by seed-apt-cache) never touches apt,
# so mirror outages cannot hang this PR-only job; the timeout bounds
# the cold path. Packages and version must match seed-apt-cache
# exactly. The action has no --no-install-recommends; same package
# set this job used before #17463.
timeout-minutes: 10
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
with:
packages: libsdl2-dev ccache
version: 1.1
- name: Check out code from GitHub
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -1423,6 +1482,7 @@ jobs:
# this check.
needs:
- common
- seed-apt-cache
- determine-jobs
- ci-custom
- pylint
+1 -1
View File
@@ -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.8.0-dev
PROJECT_NUMBER = 2026.8.0
# 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
+1 -1
View File
@@ -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.9.5
RUN uv pip install --no-cache-dir esphome-device-builder==1.12.0
RUN \
platformio settings set enable_telemetry No \
+150 -59
View File
@@ -10,7 +10,7 @@ from pathlib import Path
import re
import sys
import time
from typing import Protocol
from typing import TYPE_CHECKING, Protocol
# Note: Do not import modules from esphome.components here, as this would
# cause them to be loaded before external components are processed, resulting
@@ -21,7 +21,6 @@ from esphome.const import (
ARGUMENT_HELP_DEVICE,
BUNDLE_EXTENSION,
CONF_API,
CONF_AUTH,
CONF_BAUD_RATE,
CONF_BROKER,
CONF_DEASSERT_RTS_DTR,
@@ -29,6 +28,7 @@ from esphome.const import (
CONF_DISCOVER_IP,
CONF_ESPHOME,
CONF_LEVEL,
CONF_LOG,
CONF_LOG_TOPIC,
CONF_LOGGER,
CONF_MDNS,
@@ -42,7 +42,7 @@ from esphome.const import (
CONF_PORT,
CONF_SUBSTITUTIONS,
CONF_TOPIC,
CONF_USERNAME,
CONF_VERSION,
CONF_WEB_SERVER,
CONF_WIFI,
ENV_NOGITIGNORE,
@@ -71,6 +71,9 @@ 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
@@ -273,8 +276,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. Network log streaming requires the native API; add "
"an 'api:' component, enable MQTT logging, or view logs over USB."
"configured. Add an 'api:' component, enable MQTT logging, add a "
"'web_server:' component, or view logs over USB."
)
if purpose == Purpose.UPLOADING and not has_ota():
return (
@@ -314,9 +317,12 @@ 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 has_api())
(purpose == Purpose.LOGGING and network_logging)
or (purpose == Purpose.UPLOADING and has_ota())
):
resolved.extend(_resolve_with_cache(CORE.address, purpose))
@@ -328,7 +334,11 @@ def choose_upload_log_host(
if has_mqtt_logging():
resolved.append("MQTT")
if has_api() and has_non_ip_address() and has_resolvable_address():
if (
network_logging
and has_non_ip_address()
and has_resolvable_address()
):
resolved.extend(_ota_hostnames_for_default(purpose))
elif purpose == Purpose.UPLOADING:
@@ -390,7 +400,7 @@ def choose_upload_log_host(
mqtt_config = CORE.config[CONF_MQTT]
options.append((f"MQTT ({mqtt_config[CONF_BROKER]})", "MQTT"))
if has_api():
if has_api() or has_web_server_logging():
add_ota_options()
elif purpose == Purpose.UPLOADING and has_ota():
@@ -483,6 +493,21 @@ 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."""
if CONF_MQTT not in CORE.config:
@@ -545,11 +570,48 @@ def has_name_add_mac_suffix() -> bool:
def mqtt_get_ip(
config: ConfigType, username: str, password: str, client_id: str
config: ConfigType,
username: str,
password: str,
client_id: str,
stop_event: "threading.Event | None" = None,
) -> list[str]:
from esphome import mqtt
return mqtt.get_esphome_device_ip(config, username, password, client_id)
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
def _resolve_network_devices(
@@ -582,40 +644,44 @@ def _resolve_network_devices(
if port_type in _MQTT_PORT_TYPES:
# Only resolve MQTT once, even if multiple MQTT entries
if not mqtt_resolved:
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_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
)
mqtt_resolved = True
continue
# 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)
_add_network_device(device, network_devices)
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
@@ -1291,25 +1357,23 @@ 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
@@ -1418,17 +1482,37 @@ 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
# Resolve MQTT magic strings to actual IP addresses
if has_api() and (
network_devices := _resolve_network_devices(devices, config, args)
):
from esphome.api_client import run_logs
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
return run_logs(
config,
network_devices,
subscribe_states=_should_subscribe_states(args),
)
return run_logs(
config,
network_devices,
subscribe_states=_should_subscribe_states(args),
mqtt_resolver=mqtt_resolver,
)
if port_type in (PortType.NETWORK, PortType.MQTT) and has_mqtt_logging():
from esphome import mqtt
@@ -1437,6 +1521,13 @@ 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)")
+84 -3
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import asyncio
from contextlib import suppress
import logging
import threading
from typing import TYPE_CHECKING, Any
import warnings
@@ -20,6 +21,8 @@ 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
)
@@ -32,8 +35,18 @@ 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."""
"""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"]
@@ -60,6 +73,41 @@ async def async_run_logs(
# 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()
@@ -98,20 +146,53 @@ async def async_run_logs(
# 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:
await stop()
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)
async_run_logs(
config,
addresses,
subscribe_states=subscribe_states,
mqtt_resolver=mqtt_resolver,
)
)
+10
View File
@@ -0,0 +1,10 @@
"""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"),
}
+5
View File
@@ -13,8 +13,13 @@
import esphome.components.image as espImage
import esphome.config_validation as cv
from . import image as animation_image
from .image import ANIMATION_CONFIG_SCHEMA, setup_animation
# The deprecated top-level `animation:` shim gets the same batched
# downloads as the `image:` platform form.
PREFETCH_FILES = animation_image.PREFETCH_FILES
AUTO_LOAD = ["image", "file"]
CODEOWNERS = ["@syndlex"]
DEPENDENCIES = ["display"]
+5
View File
@@ -1,6 +1,7 @@
from esphome import automation
import esphome.codegen as cg
from esphome.components.const import CONF_LOOP
from esphome.components.file import image as file_image
from esphome.components.file.image import image_schema, write_image
from esphome.components.image import Image_, validate_settings
import esphome.config_validation as cv
@@ -8,6 +9,10 @@ from esphome.const import CONF_ID, CONF_REPEAT
from esphome.types import ConfigType
CODEOWNERS = ["@syndlex"]
# The animation platform shares the file platform's remote file handling,
# including its batch-download hook.
PREFETCH_FILES = file_image.PREFETCH_FILES
AUTO_LOAD = ["file"]
DEPENDENCIES = ["display"]
+1 -1
View File
@@ -497,7 +497,7 @@ async def to_code(config: ConfigType) -> None:
# and plaintext disabled. Only a factory reset can remove it.
cg.add_define("USE_API_PLAINTEXT")
cg.add_define("USE_API_NOISE")
cg.add_library("esphome/noise-c", "0.1.11")
cg.add_library("esphome/noise-c", "0.1.21")
# Enable optimized memzero/memcmp in libsodium instead of volatile byte loops
cg.add_build_flag("-DHAVE_WEAK_SYMBOLS=1")
cg.add_build_flag("-DHAVE_INLINE_ASM=1")
+3 -3
View File
@@ -448,7 +448,7 @@ void APIConnection::on_disconnect_response() {
uint16_t APIConnection::fill_and_encode_entity_state(EntityBase *entity, StateResponseProtoMessage &msg,
CalculateSizeFn size_fn, MessageEncodeFn encode_fn,
APIConnection *conn, uint32_t remaining_size) {
msg.key = entity->get_entity_key();
msg.key = entity->get_object_id_hash();
#ifdef USE_DEVICES
msg.device_id = entity->get_device_id();
#endif
@@ -459,7 +459,7 @@ uint16_t APIConnection::fill_and_encode_entity_info(EntityBase *entity, InfoResp
CalculateSizeFn size_fn, MessageEncodeFn encode_fn,
APIConnection *conn, uint32_t remaining_size) {
// Set common fields that are shared by all entity types
msg.key = entity->get_entity_key();
msg.key = entity->get_object_id_hash();
if (entity->has_own_name()) {
msg.name = entity->get_name();
@@ -1149,7 +1149,7 @@ void APIConnection::try_send_camera_image_() {
bool done = this->image_reader_->available() == to_send;
CameraImageResponse msg;
msg.key = camera::Camera::instance()->get_entity_key();
msg.key = camera::Camera::instance()->get_object_id_hash();
msg.set_data(this->image_reader_->peek_data_buffer(), to_send);
msg.done = done;
#ifdef USE_DEVICES
+2 -2
View File
@@ -149,7 +149,7 @@ class APIFrameHelper {
// holding data too long waiting for Nagle's timer causes buffer exhaustion
// and dropped messages.
//
// ESP32 (TCP_SND_BUF=4×MSS+) / RP2040 (8×MSS) / LibreTiny (4×MSS): 4 logs per cycle
// ESP32 (TCP_SND_BUF=4×MSS+) / RP2040 (4×MSS) / LibreTiny (4×MSS): 4 logs per cycle
// ESP8266 (2×MSS): 3 logs per cycle (tightest buffers)
//
// Flow (ESP32/RP2040/LT): Log 1 (Nagle on) -> Log 2 -> Log 3 -> Log 4 (NODELAY, flush)
@@ -312,7 +312,7 @@ class APIFrameHelper {
// Values 1..LOG_NAGLE_COUNT count log messages in the current Nagle batch.
// After LOG_NAGLE_COUNT logs, we flush by re-enabling NODELAY and resetting to 0.
// ESP8266 has the tightest TCP send buffer (2×MSS) and needs conservative batching.
// ESP32 (4×MSS+), RP2040 (8×MSS), and LibreTiny (4×MSS) can coalesce more.
// ESP32 (4×MSS+), RP2040 (4×MSS), and LibreTiny (4×MSS) can coalesce more.
#ifdef USE_ESP8266
static constexpr uint8_t LOG_NAGLE_COUNT = 2;
#else
@@ -300,46 +300,12 @@ void BekenSPILEDStripLightOutput::write_state(light::LightState *state) {
}
light::ESPColorView BekenSPILEDStripLightOutput::get_view_internal(int32_t index) const {
int32_t r = 0, g = 0, b = 0;
switch (this->rgb_order_) {
case ORDER_RGB:
r = 0;
g = 1;
b = 2;
break;
case ORDER_RBG:
r = 0;
g = 2;
b = 1;
break;
case ORDER_GRB:
r = 1;
g = 0;
b = 2;
break;
case ORDER_GBR:
r = 2;
g = 0;
b = 1;
break;
case ORDER_BGR:
r = 2;
g = 1;
b = 0;
break;
case ORDER_BRG:
r = 1;
g = 2;
b = 0;
break;
}
uint8_t multiplier = this->is_rgbw_ || this->is_wrgb_ ? 4 : 3;
uint8_t white = this->is_wrgb_ ? 0 : 3;
return {this->buf_ + (index * multiplier) + r + this->is_wrgb_,
this->buf_ + (index * multiplier) + g + this->is_wrgb_,
this->buf_ + (index * multiplier) + b + this->is_wrgb_,
this->is_rgbw_ || this->is_wrgb_ ? this->buf_ + (index * multiplier) + white : nullptr,
const light::ChannelColors &colors = this->channel_colors_;
uint8_t *led = this->buf_ + (index * colors.bytes_per_led());
return {led + colors.r,
led + colors.g,
led + colors.b,
colors.has_white() ? led + colors.w : nullptr,
&this->effect_data_[index],
&this->correction_};
}
@@ -349,35 +315,12 @@ void BekenSPILEDStripLightOutput::dump_config() {
"Beken SPI LED Strip:\n"
" Pin: %u",
this->pin_);
const char *rgb_order;
switch (this->rgb_order_) {
case ORDER_RGB:
rgb_order = "RGB";
break;
case ORDER_RBG:
rgb_order = "RBG";
break;
case ORDER_GRB:
rgb_order = "GRB";
break;
case ORDER_GBR:
rgb_order = "GBR";
break;
case ORDER_BGR:
rgb_order = "BGR";
break;
case ORDER_BRG:
rgb_order = "BRG";
break;
default:
rgb_order = "UNKNOWN";
break;
}
char channel_colors[5];
ESP_LOGCONFIG(TAG,
" RGB Order: %s\n"
" Channel colors: %s\n"
" Max refresh rate: %" PRIu32 "\n"
" Number of LEDs: %u",
rgb_order, this->max_refresh_rate_.value_or(0), this->num_leds_);
this->channel_colors_.to_string(channel_colors), this->max_refresh_rate_.value_or(0), this->num_leds_);
}
float BekenSPILEDStripLightOutput::get_setup_priority() const { return setup_priority::HARDWARE; }
@@ -3,6 +3,7 @@
#ifdef USE_BK72XX
#include "esphome/components/light/addressable_light.h"
#include "esphome/components/light/channel_colors.h"
#include "esphome/components/light/light_output.h"
#include "esphome/core/color.h"
#include "esphome/core/component.h"
@@ -10,15 +11,6 @@
namespace esphome::beken_spi_led_strip {
enum RGBOrder : uint8_t {
ORDER_RGB,
ORDER_RBG,
ORDER_GRB,
ORDER_GBR,
ORDER_BGR,
ORDER_BRG,
};
class BekenSPILEDStripLightOutput final : public light::AddressableLight {
public:
void setup() override;
@@ -28,7 +20,7 @@ class BekenSPILEDStripLightOutput final : public light::AddressableLight {
int32_t size() const override { return this->num_leds_; }
light::LightTraits get_traits() override {
auto traits = light::LightTraits();
if (this->is_rgbw_ || this->is_wrgb_) {
if (this->channel_colors_.has_white()) {
traits.set_supported_color_modes({light::ColorMode::RGB_WHITE, light::ColorMode::WHITE});
} else {
traits.set_supported_color_modes({light::ColorMode::RGB});
@@ -38,16 +30,13 @@ class BekenSPILEDStripLightOutput final : public light::AddressableLight {
void set_pin(uint8_t pin) { this->pin_ = pin; }
void set_num_leds(uint16_t num_leds) { this->num_leds_ = num_leds; }
void set_is_rgbw(bool is_rgbw) { this->is_rgbw_ = is_rgbw; }
void set_is_wrgb(bool is_wrgb) { this->is_wrgb_ = is_wrgb; }
void set_channel_colors(light::ChannelColors channel_colors) { this->channel_colors_ = channel_colors; }
/// Set a maximum refresh rate in µs as some lights do not like being updated too often.
void set_max_refresh_rate(uint32_t interval_us) { this->max_refresh_rate_ = interval_us; }
void set_led_params(uint8_t bit0, uint8_t bit1, uint32_t spi_frequency);
void set_rgb_order(RGBOrder rgb_order) { this->rgb_order_ = rgb_order; }
void clear_effect_data() override {
for (int i = 0; i < this->size(); i++)
this->effect_data_[i] = 0;
@@ -58,7 +47,7 @@ class BekenSPILEDStripLightOutput final : public light::AddressableLight {
protected:
light::ESPColorView get_view_internal(int32_t index) const override;
size_t get_buffer_size_() const { return this->num_leds_ * (this->is_rgbw_ || this->is_wrgb_ ? 4 : 3); }
size_t get_buffer_size_() const { return this->num_leds_ * this->channel_colors_.bytes_per_led(); }
uint8_t *buf_{nullptr};
uint8_t *effect_data_{nullptr};
@@ -66,13 +55,11 @@ class BekenSPILEDStripLightOutput final : public light::AddressableLight {
uint8_t pin_;
uint16_t num_leds_;
bool is_rgbw_;
bool is_wrgb_;
uint32_t spi_frequency_{6666666};
uint8_t bit0_{0xE0};
uint8_t bit1_{0xFC};
RGBOrder rgb_order_;
light::ChannelColors channel_colors_{0, 1, 2, light::ChannelColors::NO_WHITE};
uint32_t last_refresh_{0};
optional<uint32_t> max_refresh_rate_{};
+17 -24
View File
@@ -3,6 +3,7 @@ from dataclasses import dataclass
from esphome import pins
import esphome.codegen as cg
from esphome.components import libretiny, light
from esphome.components.const import CONF_CHANNEL_COLORS, CONF_IS_WRGB
import esphome.config_validation as cv
from esphome.const import (
CONF_CHIPSET,
@@ -13,6 +14,7 @@ from esphome.const import (
CONF_PIN,
CONF_RGB_ORDER,
)
from esphome.types import ConfigType
CODEOWNERS = ["@Mat931"]
DEPENDENCIES = ["libretiny"]
@@ -22,17 +24,6 @@ BekenSPILEDStripLightOutput = beken_spi_led_strip_ns.class_(
"BekenSPILEDStripLightOutput", light.AddressableLight
)
RGBOrder = beken_spi_led_strip_ns.enum("RGBOrder")
RGB_ORDERS = {
"RGB": RGBOrder.ORDER_RGB,
"RBG": RGBOrder.ORDER_RBG,
"GRB": RGBOrder.ORDER_GRB,
"GBR": RGBOrder.ORDER_GBR,
"BGR": RGBOrder.ORDER_BGR,
"BRG": RGBOrder.ORDER_BRG,
}
@dataclass
class LEDStripTimings:
@@ -57,8 +48,6 @@ CHIPSETS = {
}
CONF_IS_WRGB = "is_wrgb"
SUPPORTED_PINS = {
libretiny.const.FAMILY_BK7231N: [16],
libretiny.const.FAMILY_BK7231T: [16],
@@ -79,10 +68,9 @@ def _validate_pin(value):
return value
def _validate_num_leds(value):
max_num_leds = 165 # 170
if value[CONF_IS_RGBW] or value[CONF_IS_WRGB]:
max_num_leds = 123 # 127
def _validate_num_leds(value: ConfigType) -> ConfigType:
# A white channel makes each LED one byte wider, so fewer of them fit in the DMA buffer.
max_num_leds = 123 if "W" in value[CONF_CHANNEL_COLORS] else 165 # 127 / 170
if value[CONF_NUM_LEDS] > max_num_leds:
raise cv.Invalid(
f"The maximum number of LEDs for this configuration is {max_num_leds}.",
@@ -99,18 +87,23 @@ CONFIG_SCHEMA = cv.All(
pins.internal_gpio_output_pin_number, _validate_pin
),
cv.Required(CONF_NUM_LEDS): cv.positive_not_null_int,
cv.Required(CONF_RGB_ORDER): cv.enum(RGB_ORDERS, upper=True),
cv.Optional(CONF_CHANNEL_COLORS): light.validate_channel_colors,
# Deprecated in favour of CONF_CHANNEL_COLORS, remove in 2027.3.0
cv.Optional(CONF_RGB_ORDER): cv.one_of(*light.RGB_ORDERS, upper=True),
cv.Optional(CONF_IS_RGBW): cv.boolean,
cv.Optional(CONF_IS_WRGB): cv.boolean,
cv.Optional(CONF_MAX_REFRESH_RATE): cv.positive_time_period_microseconds,
cv.Required(CONF_CHIPSET): cv.one_of(*CHIPSETS, upper=True),
cv.Optional(CONF_IS_RGBW, default=False): cv.boolean,
cv.Optional(CONF_IS_WRGB, default=False): cv.boolean,
}
),
light.migrate_channel_colors(
removed_in="2027.3.0", component="beken_spi_led_strip"
),
_validate_num_leds,
)
async def to_code(config):
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_OUTPUT_ID])
await light.register_light(var, config)
await cg.register_component(var, config)
@@ -130,6 +123,6 @@ async def to_code(config):
)
)
cg.add(var.set_rgb_order(config[CONF_RGB_ORDER]))
cg.add(var.set_is_rgbw(config[CONF_IS_RGBW]))
cg.add(var.set_is_wrgb(config[CONF_IS_WRGB]))
cg.add(
var.set_channel_colors(light.channel_colors_struct(config[CONF_CHANNEL_COLORS]))
)
+38 -6
View File
@@ -5,11 +5,11 @@ bring-up and the controller BLE address. Consumers (bk72xx_ble_tracker) build
on this component and contain no SDK calls of their own.
Supported SoCs (BLE 5.x): BK7231N/BK7236 (BLE 5.1), BK7238/BK7252N/BK7253
(BLE 5.2), and any future BLE-5.x SoC. Capability is detected at compile time,
not by a chip list: the C++ guards on `__has_include("ble_api.h")` — the Beken
BLE 5.x public API header, which the LibreTiny beken-72xx builder ships only
for BLE-5.x SoCs. BK7231T/BK7251/BK7271 (BLE 4.2) and BK7231Q (no BLE) fail
with a clear #error.
(BLE 5.2), and any future BLE-5.x SoC. Known non-5.x families are rejected in
to_code; unknown families are capability-checked at compile time via
`__has_include("app_ble.h")`, a header only on the BLE 5.x include path
(ble_api.h ships for every SoC, so it cannot be the probe). A non-5.x build
fails with a clear #error.
No framework patch is needed: the LibreTiny beken-72xx builder already compiles
and links the BLE 5.x stack (CFG_SUPPORT_BLE=1 + CFG_BLE_VERSION=BLE_VERSION_5_x;
@@ -21,9 +21,16 @@ import logging
import esphome.codegen as cg
from esphome.components import libretiny
from esphome.components.libretiny.const import FAMILY_BK7231N, FAMILY_BK7238
from esphome.components.libretiny.const import (
FAMILY_BK7231N,
FAMILY_BK7231Q,
FAMILY_BK7231T,
FAMILY_BK7238,
FAMILY_BK7251,
)
import esphome.config_validation as cv
from esphome.const import CONF_ENABLE_ON_BOOT, CONF_ID
from esphome.core import EsphomeError
from esphome.types import ConfigType
DEPENDENCIES = ["bk72xx"]
@@ -50,7 +57,32 @@ CONFIG_SCHEMA = cv.Schema(
request_scan_listener_slot = cg.slot_counter("BK72XX_BLE_SCAN_LISTENER_COUNT")
def _unsupported_family_message(family: str) -> str | None:
if family in (FAMILY_BK7231T, FAMILY_BK7251):
return (
f"bk72xx_ble does not support {family}: this SoC has the Beken BLE 4.2 "
"stack; a BLE 5.x SoC such as BK7231N or BK7238 is required"
)
if family == FAMILY_BK7231Q:
return "bk72xx_ble does not support BK7231Q: this SoC has no BLE"
return None
def _final_validate(config: ConfigType) -> ConfigType:
# Warn only: a hard error here would break the validate-only CI fixtures,
# which run on a BLE 4.2 board. The hard error is raised at codegen.
if msg := _unsupported_family_message(libretiny.get_libretiny_family()):
_LOGGER.warning("%s (this configuration cannot compile)", msg)
return config
FINAL_VALIDATE_SCHEMA = _final_validate
async def to_code(config: ConfigType) -> None:
if msg := _unsupported_family_message(libretiny.get_libretiny_family()):
raise EsphomeError(msg)
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
+2 -2
View File
@@ -10,7 +10,7 @@
#ifdef USE_BK72XX_BLE
// Same SDK gate as bk72xx_ble.cpp (which carries the explanatory #error).
#if !defined(CLANG_TIDY) && __has_include("ble_api.h")
#if !defined(CLANG_TIDY) && __has_include("ble_api.h") && __has_include("app_ble.h")
extern "C" {
#include "app_ble.h" // app_ble_env, app_ble_run, app_ble_reset, actv_state_t,
@@ -115,5 +115,5 @@ BdkOpResult bdk_scan_release(uint8_t activity_idx, bool created, int *err_out) {
} // namespace esphome::bk72xx_ble
#endif // !CLANG_TIDY && ble_api.h
#endif // !CLANG_TIDY && ble_api.h && app_ble.h
#endif // USE_BK72XX_BLE
+13 -9
View File
@@ -34,22 +34,26 @@
// ---------------------------------------------------------------------------
// SDK-capability gate (not a chip allowlist).
// This component drives the Beken BLE *5.x* controller via its public API,
// `ble_api.h`, which the LibreTiny beken-72xx builder ships only for the
// BLE-5.x SoCs (it selects the `ble_pub` 5.x stack from CFG_BLE_VERSION; the
// 4.2 SoCs build a different, older API with no ble_api.h). Gate on the header
// itself so any BLE-5.x Beken chip — present or future — is supported without a
// hard-coded list, and a non-5.x build fails here with a clear message instead
// of a cryptic "ble_api.h: No such file or directory".
// This component drives the Beken BLE *5.x* controller. `ble_api.h` cannot be
// the probe: it ships for every SoC (driver/include) and merely switches on
// CFG_BLE_VERSION internally. `app_ble.h` is on the include path only when the
// LibreTiny beken-72xx builder selects a 5.x stack, so gating on it supports
// any BLE-5.x chip — present or future — without a hard-coded list, and a
// non-5.x build fails here with a clear message instead of a cryptic
// "app_ble.h: No such file or directory".
// ---------------------------------------------------------------------------
#if defined(CLANG_TIDY)
// The clang-tidy environment does not carry the full Beken BDK BLE 5.x API
// (its ble_api.h variant lacks parts of the 5.x surface), so there is nothing
// accurate to analyze the SDK calls against — skip the file under analysis.
#define BK72XX_BLE_NO_SDK
#elif !__has_include("ble_api.h")
#elif !__has_include("ble_api.h") || !__has_include("app_ble.h")
// Also skip the SDK body: #error does not stop the preprocessor, and on a 4.2
// SoC ble_api.h exists, so without the guard the 5.x symbols would fail one by
// one and bury this message.
#define BK72XX_BLE_NO_SDK
#error \
"bk72xx_ble requires a BLE 5.x Beken SDK (ble_api.h). Supported SoCs: BK7231N/BK7236 (BLE 5.1) and BK7238/BK7252N/BK7253 (BLE 5.2). BK7231T/BK7251/BK7271 (BLE 4.2) and BK7231Q (no BLE) are not supported."
"bk72xx_ble requires a BLE 5.x Beken SDK (app_ble.h). Supported SoCs: BK7231N/BK7236 (BLE 5.1) and BK7238/BK7252N/BK7253 (BLE 5.2). BK7231T/BK7251/BK7271 (BLE 4.2) and BK7231Q (no BLE) are not supported."
#endif
#ifndef BK72XX_BLE_NO_SDK
+14 -6
View File
@@ -37,7 +37,7 @@ from esphome.const import (
CONF_INTERVAL,
KEY_TARGET_PLATFORM,
)
from esphome.core import CORE, ID, KEY_CORE
from esphome.core import CORE, ID, KEY_CORE, TimePeriod
from esphome.types import ConfigType
CODEOWNERS = ["@Bl00d-B0b"]
@@ -243,19 +243,27 @@ def validate_scan_parameters(config: ConfigType) -> ConfigType:
return config
# The historical scan window default shared by the trackers that do not pin
# their own; also the fallback for esp32's conditional default.
DEFAULT_SCAN_WINDOW = "30ms"
def scan_parameters_schema(
interval_default: str,
*,
window_default: str = "30ms",
window_default: str | Callable[[], TimePeriod] = DEFAULT_SCAN_WINDOW,
) -> cv.All:
"""Build the scan_parameters value schema shared by all BLE trackers.
interval_default and window_default are per chip (e.g. esp32 320/30 ms,
bk72xx/rp2 100/30 ms — the reference scan rates of the respective stacks;
LN882H's SDK recommends 100/50 ms). The `active` option (default on) is
unconditional: active scanning is part of the tracker contract — every
current proxy client assumes it, so a passive-only tracker must not share
this schema.
LN882H's SDK recommends 100/50 ms). window_default may also be a zero-arg
callable evaluated per validation when the user omits the key (esp32 uses
this to record that the window was defaulted, so a later validation step
can adjust it once sibling keys are resolved). The `active` option
(default on) is unconditional: active scanning is part of the tracker
contract — every current proxy client assumes it, so a passive-only
tracker must not share this schema.
"""
schema = {
cv.Optional(CONF_DURATION, default="5min"): cv.positive_time_period_seconds,
+45 -19
View File
@@ -1,4 +1,3 @@
import hashlib
from pathlib import Path
from esphome import core, external_files
@@ -12,6 +11,8 @@ from esphome.const import (
CONF_SAMPLE_RATE,
CONF_TEMPERATURE_OFFSET,
)
from esphome.external_files import RemoteFile
from esphome.types import ConfigType
CODEOWNERS = ["@neffs", "@kbx81"]
CONFLICTS_WITH = ["bme680_bsec"]
@@ -74,11 +75,7 @@ VOLTAGE_FILE_NAME = {
def _compute_local_file_path(url: str) -> Path:
h = hashlib.new("sha256")
h.update(url.encode())
key = h.hexdigest()[:8]
base_dir = external_files.compute_local_file_dir(DOMAIN)
return base_dir / key
return external_files.compute_local_file_path(DOMAIN, url)
def _compute_url(config: dict) -> str:
@@ -105,6 +102,42 @@ def download_bme68x_blob(config):
return config
# Shared by the schema and the prefetch hook so they cannot drift.
_MODEL_VALIDATOR = cv.one_of(*MODEL_OPTIONS, lower=True)
_ALGORITHM_OUTPUT_VALIDATOR = cv.enum(ALGORITHM_OUTPUT_OPTIONS, lower=True)
# Key -> (validator, default) for the defaulted options that select the blob.
_BLOB_OPTIONS = {
CONF_OPERATING_AGE: (cv.enum(OPERATING_AGE_OPTIONS, lower=True), "28d"),
CONF_SAMPLE_RATE: (cv.enum(SAMPLE_RATE_OPTIONS, upper=True), "LP"),
CONF_SUPPLY_VOLTAGE: (cv.enum(VOLTAGE_OPTIONS, upper=True), "3.3V"),
}
def _extract_blob_ref(entry: ConfigType) -> RemoteFile | None:
"""Raw entry to its BSEC2 blob; None when a value is unrecognized.
Applies the schema defaults and validators read-only; skipped entries
are left to the schema validator.
"""
try:
spec = {
key: validator(str(entry.get(key, default))) # pylint: disable=not-callable
for key, (validator, default) in _BLOB_OPTIONS.items()
}
spec[CONF_MODEL] = _MODEL_VALIDATOR(str(entry.get(CONF_MODEL, "")))
if (algorithm_output := entry.get(CONF_ALGORITHM_OUTPUT)) is not None:
spec[CONF_ALGORITHM_OUTPUT] = _ALGORITHM_OUTPUT_VALIDATOR(
str(algorithm_output)
)
except cv.Invalid:
return None
url = _compute_url(spec)
return RemoteFile(url, _compute_local_file_path(url))
PREFETCH_FILES = external_files.single_stage_prefetch(_extract_blob_ref)
def validate_bme68x(config):
if CONF_ALGORITHM_OUTPUT not in config:
return config
@@ -128,19 +161,12 @@ CONFIG_SCHEMA_BASE = (
{
cv.GenerateID(): cv.declare_id(BME68xBSEC2Component),
cv.GenerateID(CONF_RAW_DATA_ID): cv.declare_id(cg.uint8),
cv.Required(CONF_MODEL): cv.one_of(*MODEL_OPTIONS, lower=True),
cv.Optional(CONF_ALGORITHM_OUTPUT): cv.enum(
ALGORITHM_OUTPUT_OPTIONS, lower=True
),
cv.Optional(CONF_OPERATING_AGE, default="28d"): cv.enum(
OPERATING_AGE_OPTIONS, lower=True
),
cv.Optional(CONF_SAMPLE_RATE, default="LP"): cv.enum(
SAMPLE_RATE_OPTIONS, upper=True
),
cv.Optional(CONF_SUPPLY_VOLTAGE, default="3.3V"): cv.enum(
VOLTAGE_OPTIONS, upper=True
),
cv.Required(CONF_MODEL): _MODEL_VALIDATOR,
cv.Optional(CONF_ALGORITHM_OUTPUT): _ALGORITHM_OUTPUT_VALIDATOR,
**{
cv.Optional(key, default=default): validator
for key, (validator, default) in _BLOB_OPTIONS.items()
},
cv.Optional(CONF_TEMPERATURE_OFFSET, default=0): cv.temperature_delta,
cv.Optional(
CONF_STATE_SAVE_INTERVAL, default="6hours"
@@ -1,5 +1,5 @@
import esphome.codegen as cg
from esphome.components import i2c
from esphome.components import bme68x_bsec2, i2c
from esphome.components.bme68x_bsec2 import (
CONFIG_SCHEMA_BASE,
BME68xBSEC2Component,
@@ -13,6 +13,11 @@ AUTO_LOAD = ["bme68x_bsec2"]
DEPENDENCIES = ["i2c"]
MULTI_CONF = True
# The user-facing domain is this module (the base component only appears
# via AUTO_LOAD), so the batch-download hook must be re-exported here to
# take effect.
PREFETCH_FILES = bme68x_bsec2.PREFETCH_FILES
bme68x_bsec2_i2c_ns = cg.esphome_ns.namespace("bme68x_bsec2_i2c")
BME68xBSEC2I2CComponent = bme68x_bsec2_i2c_ns.class_(
"BME68xBSEC2I2CComponent", BME68xBSEC2Component, i2c.I2CDevice
+2
View File
@@ -10,6 +10,7 @@ CONF_ACCELEROMETER_RANGE = "accelerometer_range"
CONF_B_CONSTANT = "b_constant"
CONF_BREATH_VOC_EQUIVALENT = "breath_voc_equivalent"
CONF_BYTE_ORDER = "byte_order"
CONF_CHANNEL_COLORS = "channel_colors"
CONF_CLIMATE_ID = "climate_id"
CONF_CO2_EQUIVALENT = "co2_equivalent"
CONF_COLOR_DEPTH = "color_depth"
@@ -22,6 +23,7 @@ CONF_GYROSCOPE_ODR = "gyroscope_odr"
CONF_GYROSCOPE_RANGE = "gyroscope_range"
CONF_IAQ = "iaq"
CONF_IGNORE_NOT_FOUND = "ignore_not_found"
CONF_IS_WRGB = "is_wrgb"
CONF_LIBRETINY = "libretiny"
CONF_LOOP = "loop"
CONF_NOX_INDEX = "nox_index"
+75 -53
View File
@@ -1070,6 +1070,26 @@ def _parse_pio_platform_version(value):
return value
def _normalize_p4_engineering_sample(value: ConfigType) -> bool:
"""Fill in CONF_ENGINEERING_SAMPLE when unset, warning that production
silicon (rev3) is assumed. Returns the normalized flag."""
if (engineering_sample := value.get(CONF_ENGINEERING_SAMPLE)) is None:
_LOGGER.warning(
"Defaulting to ESP32-P4 production silicon (rev3).\n"
"If you have an early engineering sample (pre-rev3), add this to your config:\n"
"\n"
" esp32:\n"
" engineering_sample: true\n"
"\n"
"To check your chip revision, look for 'chip revision: vX.Y' in the boot log.\n"
"Engineering samples will show a revision below v3.0.\n"
"The 'debug:' component also reports the revision (e.g. Revision: 100 = v1.0, 300 = v3.0)."
)
engineering_sample = False
value[CONF_ENGINEERING_SAMPLE] = engineering_sample
return engineering_sample
def _detect_variant(value):
board = value.get(CONF_BOARD)
variant = value.get(CONF_VARIANT)
@@ -1082,6 +1102,8 @@ def _detect_variant(value):
# name rather than carrying a PIO board name through the IDF build.
if CORE.using_toolchain_esp_idf:
value = value.copy()
if variant == VARIANT_ESP32P4:
_normalize_p4_engineering_sample(value)
value[CONF_BOARD] = VARIANT_FRIENDLY[variant].lower()
return value
if variant not in STANDARD_BOARDS:
@@ -1092,22 +1114,8 @@ def _detect_variant(value):
)
value = value.copy()
value[CONF_BOARD] = STANDARD_BOARDS[variant]
if variant == VARIANT_ESP32P4:
engineering_sample = value.get(CONF_ENGINEERING_SAMPLE)
if engineering_sample is None:
_LOGGER.warning(
"No board specified for ESP32-P4. Defaulting to production silicon (rev3).\n"
"If you have an early engineering sample (pre-rev3), add this to your config:\n"
"\n"
" esp32:\n"
" engineering_sample: true\n"
"\n"
"To check your chip revision, look for 'chip revision: vX.Y' in the boot log.\n"
"Engineering samples will show a revision below v3.0.\n"
"The 'debug:' component also reports the revision (e.g. Revision: 100 = v1.0, 300 = v3.0)."
)
elif engineering_sample:
value[CONF_BOARD] = "esp32-p4-evboard"
if variant == VARIANT_ESP32P4 and _normalize_p4_engineering_sample(value):
value[CONF_BOARD] = "esp32-p4-evboard"
elif board in BOARDS:
variant = variant or BOARDS[board][KEY_VARIANT]
if variant != BOARDS[board][KEY_VARIANT]:
@@ -1117,6 +1125,14 @@ def _detect_variant(value):
)
value = value.copy()
value[CONF_VARIANT] = variant
if variant == VARIANT_ESP32P4:
board_is_es = BOARDS[board].get("engineering_sample", False)
engineering_sample = value.setdefault(CONF_ENGINEERING_SAMPLE, board_is_es)
if engineering_sample != board_is_es:
raise cv.Invalid(
f"'{CONF_ENGINEERING_SAMPLE}' does not match board '{board}'",
path=[CONF_ENGINEERING_SAMPLE],
)
elif not variant:
raise cv.Invalid(
"This board is unknown, if you are sure you want to compile with this board selection, "
@@ -1128,6 +1144,9 @@ def _detect_variant(value):
"This board is unknown; the specified variant '%s' will be used but this may not work as expected.",
variant,
)
if variant == VARIANT_ESP32P4:
value = value.copy()
_normalize_p4_engineering_sample(value)
return value
@@ -1431,20 +1450,6 @@ def final_validate(config):
path=[CONF_ENGINEERING_SAMPLE],
)
)
if (
config[CONF_VARIANT] == VARIANT_ESP32P4
and config.get(CONF_ENGINEERING_SAMPLE) is not None
):
board_is_es = BOARDS.get(config[CONF_BOARD], {}).get(
"engineering_sample", False
)
if config[CONF_ENGINEERING_SAMPLE] != board_is_es:
errs.append(
cv.Invalid(
f"'{CONF_ENGINEERING_SAMPLE}' does not match board '{config[CONF_BOARD]}'",
path=[CONF_ENGINEERING_SAMPLE],
)
)
if advanced[CONF_EXECUTE_FROM_PSRAM]:
if config[CONF_VARIANT] not in {VARIANT_ESP32S3, VARIANT_ESP32P4}:
errs.append(
@@ -2517,15 +2522,14 @@ async def to_code(config):
f"CONFIG_ESPTOOLPY_FLASHFREQ_{flash_frequency[:-3]}M", True
)
# ESP32-P4: ESP-IDF 5.5.3 changed the default of ESP32P4_SELECTS_REV_LESS_V3
# from y to n. PlatformIO uses sections.ld.in (for rev <3) or
# sections.rev3.ld.in (for rev >=3) based on board definition.
# Set the sdkconfig option to match the board's chip revision.
# ESP32-P4: pre-v3 and rev3 (v3.0+) silicon are not binary compatible.
# CONFIG_ESP32P4_SELECTS_REV_LESS_V3 selects which layout ESP-IDF links;
# validation normalizes CONF_ENGINEERING_SAMPLE from the board when unset.
if variant == VARIANT_ESP32P4:
is_eng_sample = BOARDS.get(config[CONF_BOARD], {}).get(
"engineering_sample", False
add_idf_sdkconfig_option(
"CONFIG_ESP32P4_SELECTS_REV_LESS_V3",
config.get(CONF_ENGINEERING_SAMPLE, False),
)
add_idf_sdkconfig_option("CONFIG_ESP32P4_SELECTS_REV_LESS_V3", is_eng_sample)
# Set minimum chip revision for ESP32 variant
# Setting this to 3.0 or higher reduces flash size by excluding workaround code,
@@ -3280,27 +3284,45 @@ def copy_files():
__version__,
)
# Remote extra build files are fetched into the shared download cache in
# one parallel batch (conditional requests skip unchanged files), then
# copied into the build tree like their local counterparts.
sources: dict[str, Path] = {}
remote: list[tuple[str, str]] = []
for file in CORE.data[KEY_ESP32][KEY_EXTRA_BUILD_FILES].values():
name: str = file[KEY_NAME]
path: Path = file[KEY_PATH]
if str(path).startswith("http"):
import requests
from esphome.happy_eyeballs import ensure_happy_eyeballs
ensure_happy_eyeballs()
try:
req = requests.get(path, timeout=30)
req.raise_for_status()
except requests.exceptions.RequestException as e:
raise EsphomeError(
f"Could not download extra build file {path}: {e}"
) from e
CORE.relative_build_path(name).parent.mkdir(parents=True, exist_ok=True)
CORE.relative_build_path(name).write_bytes(req.content)
remote.append((name, str(path)))
else:
copy_file_if_changed(path, CORE.relative_build_path(name))
sources[name] = path
if remote:
# Imported lazily: requests (via external_files) is a heavy import
# and remote extra build files are rare.
from esphome import external_files
downloads: list[external_files.RemoteFile] = []
for name, url in remote:
cache_path = external_files.compute_local_file_path(KEY_ESP32, url)
# Unverifiable bytes: an unrevalidated copy is an error, matching
# the old always-download behavior on network failure.
downloads.append(
external_files.RemoteFile(url, cache_path, allow_stale=False)
)
sources[name] = cache_path
try:
external_files.download_content_many(
downloads, description="extra build file(s)"
)
except cv.MultipleInvalid as e:
details = "; ".join(str(err) for err in e.errors)
raise EsphomeError(
f"Could not download extra build file(s): {details}"
) from e
except cv.Invalid as e:
raise EsphomeError(f"Could not download extra build file(s): {e}") from e
for name, source in sources.items():
copy_file_if_changed(source, CORE.relative_build_path(name))
def _decode_pc(config, addr):
+15 -14
View File
@@ -360,17 +360,6 @@ static bool has_fault_addr() {
return s_raw_crash_data.exception == PANIC_EXCEPTION_FAULT && !s_raw_crash_data.pseudo_excause;
}
// Append both cores' backtrace addresses to buf; returns the new position.
static int append_all_backtraces(char *buf, int size, int pos) {
pos = append_addrs_to_hint(buf, size, pos, s_raw_crash_data.backtrace, s_raw_crash_data.backtrace_count,
s_raw_crash_data.reg_frame_count);
#if SOC_CPU_CORES_NUM > 1
pos = append_addrs_to_hint(buf, size, pos, s_raw_crash_data.other_backtrace, s_raw_crash_data.other_backtrace_count,
s_raw_crash_data.other_reg_frame_count);
#endif
return pos;
}
// The record was captured by a different firmware build (it survives soft
// resets, including the OTA reboot), so symbolizing its addresses against the
// current ELF would produce misleading symbols. Print them with lowercase
@@ -443,11 +432,23 @@ void crash_handler_log() {
}
#endif
// Build addr2line hint with all captured addresses for easy copy-paste
// Build addr2line hints for easy copy-paste. One line per core: the two
// backtraces are separate stacks, and a combined list decodes as one
// impossible call chain (and can overflow the buffer, dropping addresses).
static const char *const ADDR2LINE_CMD = "addr2line -pfiaC -e firmware.elf";
char hint[256];
int pos = snprintf(hint, sizeof(hint), "Use: addr2line -pfiaC -e firmware.elf 0x%08" PRIX32, s_raw_crash_data.pc);
append_all_backtraces(hint, sizeof(hint), pos);
int pos = snprintf(hint, sizeof(hint), "Use: %s 0x%08" PRIX32, ADDR2LINE_CMD, s_raw_crash_data.pc);
append_addrs_to_hint(hint, sizeof(hint), pos, s_raw_crash_data.backtrace, s_raw_crash_data.backtrace_count,
s_raw_crash_data.reg_frame_count);
ESP_LOGE(TAG, "%s", hint);
#if SOC_CPU_CORES_NUM > 1
if (s_raw_crash_data.other_backtrace_count > 0) {
pos = snprintf(hint, sizeof(hint), "Other core: %s", ADDR2LINE_CMD);
append_addrs_to_hint(hint, sizeof(hint), pos, s_raw_crash_data.other_backtrace,
s_raw_crash_data.other_backtrace_count, s_raw_crash_data.other_reg_frame_count);
ESP_LOGE(TAG, "%s", hint);
}
#endif
}
} // namespace esphome::esp32
+2
View File
@@ -648,6 +648,8 @@ void ESP32BLE::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_pa
case ESP_GAP_BLE_SET_PKT_LENGTH_COMPLETE_EVT:
case ESP_GAP_BLE_PHY_UPDATE_COMPLETE_EVT: // BLE 5.0 PHY update complete
case ESP_GAP_BLE_CHANNEL_SELECT_ALGORITHM_EVT: // BLE 5.0 channel selection algorithm
case ESP_GAP_BLE_LOCAL_IR_EVT: // Local identity root key generated at security init
case ESP_GAP_BLE_LOCAL_ER_EVT: // Local encryption root key generated at security init
return;
default:
@@ -1,5 +1,7 @@
from __future__ import annotations
import copy
from dataclasses import dataclass
import logging
from esphome import automation
@@ -8,6 +10,7 @@ from esphome.components import ble_device_base, esp32_ble, ota
from esphome.components.const import CONF_ON_SCAN_END, CONF_SCAN_PARAMETERS, CONF_WINDOW
from esphome.components.esp32 import (
add_idf_sdkconfig_option,
idf_version,
request_bluetooth,
request_software_coexistence,
)
@@ -35,10 +38,12 @@ from esphome.const import (
CONF_SERVICE_UUID,
CONF_TRIGGER_ID,
)
from esphome.core import CORE, CoroPriority, coroutine_with_priority
from esphome.core import CORE, CoroPriority, TimePeriod, coroutine_with_priority
from esphome.enum import StrEnum
from esphome.types import ConfigType
DOMAIN = "esp32_ble_tracker"
AUTO_LOAD = ["ble_device_base", "esp32_ble"]
DEPENDENCIES = ["esp32"]
CODEOWNERS = ["@bdraco"]
@@ -125,10 +130,71 @@ def validate_max_connections_deprecated(config: ConfigType) -> ConfigType:
return config
# ESP-IDF 5.5.5 fixed a coexistence bug on the ESP32 where BLE scans ran far
# longer than the configured window (espressif/esp-idf#18931). Before the fix,
# the default 30 ms window in a 320 ms interval effectively scanned at a much
# higher duty cycle than requested; with the fix, that same default only
# listens 9.4 % of the time and misses most advertisements when wifi shares
# the radio. Espressif recommends setting the window equal to the interval in
# that case: the coexistence arbiter still shares the radio with wifi, and
# BLE uses the airtime wifi does not claim.
IDF_SCAN_WINDOW_FIX_VERSION = cv.Version(5, 5, 5)
@dataclass
class TrackerData:
"""Per-run validation state, namespaced under DOMAIN in CORE.data."""
scan_window_defaulted: bool = False
def _get_data() -> TrackerData:
if DOMAIN not in CORE.data:
CORE.data[DOMAIN] = TrackerData()
return CORE.data[DOMAIN]
def _scan_window_default() -> TimePeriod:
"""Schema default for the scan window.
Records that the user did not set a window, so _raise_defaulted_scan_window
can tell a defaulted 30 ms from an explicit one; the raise itself must wait
for the outer schema because it depends on software_coexistence, a sibling
key not yet resolved here.
"""
_get_data().scan_window_defaulted = True
return cv.positive_time_period(ble_device_base.DEFAULT_SCAN_WINDOW)
def _raise_defaulted_scan_window(config: ConfigType) -> ConfigType:
"""Raise a defaulted scan window to the interval where that is safe.
Only when the coexistence arbiter is compiled in (software_coexistence,
present iff wifi is configured and not disabled by the user) and the IDF
honors the window strictly (>= 5.5.5); without the arbiter a full-duty
scan would starve wifi outright, and a user-set window is never touched.
Raising to the interval cannot invalidate the already-validated
parameters, so no re-validation is needed.
"""
if (
_get_data().scan_window_defaulted
and config.get(CONF_SOFTWARE_COEXISTENCE)
and idf_version() >= IDF_SCAN_WINDOW_FIX_VERSION
):
params = config[CONF_SCAN_PARAMETERS]
# Copy so the config dump shows a plain value instead of a YAML
# anchor/alias pair pointing at the interval.
params[CONF_WINDOW] = copy.copy(params[CONF_INTERVAL])
return config
# 320 ms is the ESP-IDF reference scan interval; the shared schema also
# tightens validation to the controller's 2.5 ms .. 10240 ms range and rejects
# window/interval pairs that collapse to the same 0.625 ms unit count.
SCAN_PARAMETERS_SCHEMA = ble_device_base.scan_parameters_schema("320ms")
# The window default is conditional (see _scan_window_default above).
SCAN_PARAMETERS_SCHEMA = ble_device_base.scan_parameters_schema(
"320ms", window_default=_scan_window_default
)
# Codegen helpers are owned by ble_device_base; kept under the historical names
# here for the components that import them from this module.
@@ -183,6 +249,7 @@ CONFIG_SCHEMA = cv.All(
}
).extend(cv.COMPONENT_SCHEMA),
validate_max_connections_deprecated,
_raise_defaulted_scan_window,
)
@@ -221,46 +221,12 @@ void ESP32RMTLEDStripLightOutput::write_state(light::LightState *state) {
}
light::ESPColorView ESP32RMTLEDStripLightOutput::get_view_internal(int32_t index) const {
int32_t r = 0, g = 0, b = 0;
switch (this->rgb_order_) {
case ORDER_RGB:
r = 0;
g = 1;
b = 2;
break;
case ORDER_RBG:
r = 0;
g = 2;
b = 1;
break;
case ORDER_GRB:
r = 1;
g = 0;
b = 2;
break;
case ORDER_GBR:
r = 2;
g = 0;
b = 1;
break;
case ORDER_BGR:
r = 2;
g = 1;
b = 0;
break;
case ORDER_BRG:
r = 1;
g = 2;
b = 0;
break;
}
uint8_t multiplier = this->is_rgbw_ || this->is_wrgb_ ? 4 : 3;
uint8_t white = this->is_wrgb_ ? 0 : this->white_index_;
return {this->buf_ + (index * multiplier) + r + (white <= r),
this->buf_ + (index * multiplier) + g + (white <= g),
this->buf_ + (index * multiplier) + b + (white <= b),
this->is_rgbw_ || this->is_wrgb_ ? this->buf_ + (index * multiplier) + white : nullptr,
const light::ChannelColors &colors = this->channel_colors_;
uint8_t *led = this->buf_ + (index * colors.bytes_per_led());
return {led + colors.r,
led + colors.g,
led + colors.b,
colors.has_white() ? led + colors.w : nullptr,
&this->effect_data_[index],
&this->correction_};
}
@@ -271,46 +237,12 @@ void ESP32RMTLEDStripLightOutput::dump_config() {
" Pin: %u",
this->pin_);
ESP_LOGCONFIG(TAG, " RMT Symbols: %" PRIu32, this->rmt_symbols_);
const char *rgb_order;
switch (this->rgb_order_) {
case ORDER_RGB:
rgb_order = "RGB";
break;
case ORDER_RBG:
rgb_order = "RBG";
break;
case ORDER_GRB:
rgb_order = "GRB";
break;
case ORDER_GBR:
rgb_order = "GBR";
break;
case ORDER_BGR:
rgb_order = "BGR";
break;
case ORDER_BRG:
rgb_order = "BRG";
break;
default:
rgb_order = "UNKNOWN";
break;
}
if (this->is_rgbw_ || this->is_wrgb_) {
char rgbw_order[5];
uint8_t white = this->is_wrgb_ ? 0 : this->white_index_;
uint8_t rgb_index = 0;
for (uint8_t i = 0; i < 4; i++) {
rgbw_order[i] = i == white ? 'W' : rgb_order[rgb_index++];
}
rgbw_order[4] = '\0';
ESP_LOGCONFIG(TAG, " RGBW Order: %s", rgbw_order);
} else {
ESP_LOGCONFIG(TAG, " RGB Order: %s", rgb_order);
}
char channel_colors[5];
ESP_LOGCONFIG(TAG,
" Channel colors: %s\n"
" Max refresh rate: %" PRIu32 "\n"
" Number of LEDs: %u",
this->max_refresh_rate_.value_or(0), this->num_leds_);
this->channel_colors_.to_string(channel_colors), this->max_refresh_rate_.value_or(0), this->num_leds_);
}
float ESP32RMTLEDStripLightOutput::get_setup_priority() const { return setup_priority::HARDWARE; }
@@ -3,6 +3,7 @@
#ifdef USE_ESP32
#include "esphome/components/light/addressable_light.h"
#include "esphome/components/light/channel_colors.h"
#include "esphome/components/light/light_output.h"
#include "esphome/core/color.h"
#include "esphome/core/component.h"
@@ -15,15 +16,6 @@
namespace esphome::esp32_rmt_led_strip {
enum RGBOrder : uint8_t {
ORDER_RGB,
ORDER_RBG,
ORDER_GRB,
ORDER_GBR,
ORDER_BGR,
ORDER_BRG,
};
struct LedParams {
rmt_symbol_word_t bit0;
rmt_symbol_word_t bit1;
@@ -39,7 +31,7 @@ class ESP32RMTLEDStripLightOutput final : public light::AddressableLight {
int32_t size() const override { return this->num_leds_; }
light::LightTraits get_traits() override {
auto traits = light::LightTraits();
if (this->is_rgbw_ || this->is_wrgb_) {
if (this->channel_colors_.has_white()) {
traits.set_supported_color_modes({light::ColorMode::RGB_WHITE, light::ColorMode::WHITE});
} else {
traits.set_supported_color_modes({light::ColorMode::RGB});
@@ -50,13 +42,7 @@ class ESP32RMTLEDStripLightOutput final : public light::AddressableLight {
void set_pin(uint8_t pin) { this->pin_ = pin; }
void set_inverted(bool inverted) { this->invert_out_ = inverted; }
void set_num_leds(uint16_t num_leds) { this->num_leds_ = num_leds; }
void set_is_rgbw(bool is_rgbw) { this->is_rgbw_ = is_rgbw; }
void set_is_wrgb(bool is_wrgb) { this->is_wrgb_ = is_wrgb; }
void set_rgbw_order(uint8_t white_index) {
this->is_rgbw_ = true;
this->is_wrgb_ = false;
this->white_index_ = white_index;
}
void set_channel_colors(light::ChannelColors channel_colors) { this->channel_colors_ = channel_colors; }
void set_use_dma(bool use_dma) { this->use_dma_ = use_dma; }
void set_use_psram(bool use_psram) { this->use_psram_ = use_psram; }
@@ -66,7 +52,6 @@ class ESP32RMTLEDStripLightOutput final : public light::AddressableLight {
void set_led_params(uint32_t bit0_high, uint32_t bit0_low, uint32_t bit1_high, uint32_t bit1_low,
uint32_t reset_time_high, uint32_t reset_time_low);
void set_rgb_order(RGBOrder rgb_order) { this->rgb_order_ = rgb_order; }
void set_rmt_symbols(uint32_t rmt_symbols) { this->rmt_symbols_ = rmt_symbols; }
void clear_effect_data() override {
@@ -79,7 +64,7 @@ class ESP32RMTLEDStripLightOutput final : public light::AddressableLight {
protected:
light::ESPColorView get_view_internal(int32_t index) const override;
size_t get_buffer_size_() const { return this->num_leds_ * (this->is_rgbw_ || this->is_wrgb_ ? 4 : 3); }
size_t get_buffer_size_() const { return this->num_leds_ * this->channel_colors_.bytes_per_led(); }
uint8_t *buf_{nullptr};
uint8_t *effect_data_{nullptr};
@@ -94,15 +79,11 @@ class ESP32RMTLEDStripLightOutput final : public light::AddressableLight {
uint32_t rmt_symbols_{48};
uint8_t pin_;
uint16_t num_leds_;
bool is_rgbw_{false};
bool is_wrgb_{false};
// An index after the RGB channels makes offset adjustment a no-op for three-channel strips.
uint8_t white_index_{3};
bool use_dma_{false};
bool use_psram_{false};
bool invert_out_{false};
RGBOrder rgb_order_{ORDER_RGB};
light::ChannelColors channel_colors_{0, 1, 2, light::ChannelColors::NO_WHITE};
uint32_t last_refresh_{0};
optional<uint32_t> max_refresh_rate_{};
+13 -52
View File
@@ -1,10 +1,9 @@
from dataclasses import dataclass
import logging
from esphome import pins
import esphome.codegen as cg
from esphome.components import esp32, esp32_rmt, light
from esphome.components.const import CONF_USE_PSRAM
from esphome.components.const import CONF_CHANNEL_COLORS, CONF_IS_WRGB, CONF_USE_PSRAM
from esphome.components.esp32 import include_builtin_idf_component
import esphome.config_validation as cv
from esphome.const import (
@@ -22,8 +21,6 @@ from esphome.const import (
)
from esphome.types import ConfigType
_LOGGER = logging.getLogger(__name__)
CODEOWNERS = ["@jesserockz"]
DEPENDENCIES = ["esp32"]
@@ -32,17 +29,6 @@ ESP32RMTLEDStripLightOutput = esp32_rmt_led_strip_ns.class_(
"ESP32RMTLEDStripLightOutput", light.AddressableLight
)
RGBOrder = esp32_rmt_led_strip_ns.enum("RGBOrder")
RGB_ORDERS = {
"RGB": RGBOrder.ORDER_RGB,
"RBG": RGBOrder.ORDER_RBG,
"GRB": RGBOrder.ORDER_GRB,
"GBR": RGBOrder.ORDER_GBR,
"BGR": RGBOrder.ORDER_BGR,
"BRG": RGBOrder.ORDER_BRG,
}
@dataclass
class LEDStripTimings:
@@ -62,8 +48,6 @@ CHIPSETS = {
"SM16703": LEDStripTimings(300, 900, 900, 300, 0, 0),
}
CONF_IS_WRGB = "is_wrgb"
CONF_RGBW_ORDER = "rgbw_order"
CONF_BIT0_HIGH = "bit0_high"
CONF_BIT0_LOW = "bit0_low"
CONF_BIT1_HIGH = "bit1_high"
@@ -72,26 +56,6 @@ CONF_RESET_HIGH = "reset_high"
CONF_RESET_LOW = "reset_low"
def _validate_rgbw_order(value: str) -> str:
value = cv.string(value).upper()
if len(value) != 4 or set(value) != set("RGBW"):
raise cv.Invalid("RGBW order must be a permutation of RGBW")
return value
def _split_rgbw_order(rgbw_order: str) -> tuple[str, int]:
return rgbw_order.replace("W", ""), rgbw_order.index("W")
def _validate_rgbw_order_exclusivity(config: ConfigType) -> ConfigType:
if CONF_RGBW_ORDER in config and (config[CONF_IS_RGBW] or config[CONF_IS_WRGB]):
raise cv.Invalid(
f"'{CONF_RGBW_ORDER}' cannot be used with '{CONF_IS_RGBW}' or "
f"'{CONF_IS_WRGB}'"
)
return config
CONFIG_SCHEMA = cv.All(
esp32.only_on_variant(
unsupported=list(esp32_rmt.VARIANTS_NO_RMT),
@@ -102,8 +66,11 @@ CONFIG_SCHEMA = cv.All(
cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(ESP32RMTLEDStripLightOutput),
cv.Required(CONF_PIN): pins.internal_gpio_output_pin_schema,
cv.Required(CONF_NUM_LEDS): cv.positive_not_null_int,
cv.Optional(CONF_RGB_ORDER): cv.enum(RGB_ORDERS, upper=True),
cv.Optional(CONF_RGBW_ORDER): _validate_rgbw_order,
cv.Optional(CONF_CHANNEL_COLORS): light.validate_channel_colors,
# Deprecated in favour of CONF_CHANNEL_COLORS, remove in 2027.3.0
cv.Optional(CONF_RGB_ORDER): cv.one_of(*light.RGB_ORDERS, upper=True),
cv.Optional(CONF_IS_RGBW): cv.boolean,
cv.Optional(CONF_IS_WRGB): cv.boolean,
cv.SplitDefault(
CONF_RMT_SYMBOLS,
esp32=192,
@@ -117,8 +84,6 @@ CONFIG_SCHEMA = cv.All(
): cv.int_range(min=2),
cv.Optional(CONF_MAX_REFRESH_RATE): cv.positive_time_period_microseconds,
cv.Optional(CONF_CHIPSET): cv.one_of(*CHIPSETS, upper=True),
cv.Optional(CONF_IS_RGBW, default=False): cv.boolean,
cv.Optional(CONF_IS_WRGB, default=False): cv.boolean,
cv.Optional(CONF_USE_DMA): cv.All(
esp32.only_on_variant(
supported=[esp32.VARIANT_ESP32P4, esp32.VARIANT_ESP32S3]
@@ -153,12 +118,13 @@ CONFIG_SCHEMA = cv.All(
}
).extend(cv.COMPONENT_SCHEMA),
cv.has_exactly_one_key(CONF_CHIPSET, CONF_BIT0_HIGH),
cv.has_exactly_one_key(CONF_RGB_ORDER, CONF_RGBW_ORDER),
_validate_rgbw_order_exclusivity,
light.migrate_channel_colors(
removed_in="2027.3.0", component="esp32_rmt_led_strip"
),
)
async def to_code(config):
async def to_code(config: ConfigType) -> None:
# Re-enable ESP-IDF's RMT driver (excluded by default to save compile time)
include_builtin_idf_component("esp_driver_rmt")
@@ -198,14 +164,9 @@ async def to_code(config):
)
)
if (rgbw_order := config.get(CONF_RGBW_ORDER)) is not None:
rgb_order, white_index = _split_rgbw_order(rgbw_order)
cg.add(var.set_rgb_order(RGB_ORDERS[rgb_order]))
cg.add(var.set_rgbw_order(white_index))
else:
cg.add(var.set_rgb_order(config[CONF_RGB_ORDER]))
cg.add(var.set_is_rgbw(config[CONF_IS_RGBW]))
cg.add(var.set_is_wrgb(config[CONF_IS_WRGB]))
cg.add(
var.set_channel_colors(light.channel_colors_struct(config[CONF_CHANNEL_COLORS]))
)
cg.add(var.set_use_psram(config[CONF_USE_PSRAM]))
cg.add(var.set_rmt_symbols(config[CONF_RMT_SYMBOLS]))
if CONF_USE_DMA in config:
+61 -23
View File
@@ -1,7 +1,6 @@
from __future__ import annotations
import contextlib
import hashlib
import io
import logging
from pathlib import Path
@@ -24,6 +23,7 @@ from esphome.components.image import (
get_image_type_enum,
get_transparency_enum,
is_svg_file,
validate_byte_order,
validate_settings,
validate_transparency,
validate_type,
@@ -43,15 +43,13 @@ from esphome.const import (
)
from esphome.core import CORE, HexInt
from esphome.cpp_generator import MockObj, MockObjClass
from esphome.external_files import RemoteFile
from esphome.types import ConfigType
CODEOWNERS = ["@esphome/core"]
_LOGGER = logging.getLogger(__name__)
# If the MDI file cannot be downloaded within this time, abort.
IMAGE_DOWNLOAD_TIMEOUT = 30 # seconds
SOURCE_LOCAL = "local"
SOURCE_WEB = "web"
@@ -65,16 +63,16 @@ MDI_SOURCES = {
SOURCE_MEMORY: "https://raw.githubusercontent.com/Pictogrammers/Memory/refs/heads/main/src/svg/",
}
# Shared by the schema validator and the prefetch extractor so they cannot
# drift.
_MDI_ICON_RE = re.compile(r"^[a-zA-Z0-9\-]+$")
def compute_local_image_path(value) -> Path:
def compute_local_image_path(value: str | ConfigType) -> Path:
url = value[CONF_URL] if isinstance(value, dict) else value
h = hashlib.new("sha256")
h.update(url.encode())
key = h.hexdigest()[:8]
# Downloaded files are cached under the shared `image` domain directory so
# the cache location is unaffected by which platform requested the file.
base_dir = external_files.compute_local_file_dir(DOMAIN)
return base_dir / key
return external_files.compute_local_file_path(DOMAIN, url)
def local_path(value):
@@ -83,16 +81,20 @@ def local_path(value):
def download_file(url, path):
external_files.download_content(url, path, IMAGE_DOWNLOAD_TIMEOUT)
# The shared NETWORK_TIMEOUT applies; a per-caller timeout would be
# silently ignored on a per-run memo hit anyway (memos key by path).
external_files.download_content(url, path)
return str(path)
def download_gh_svg(value, source):
mdi_id = value[CONF_ICON] if isinstance(value, dict) else value
def _gh_svg_url_path(mdi_id: str, source: str) -> tuple[str, Path]:
base_dir = external_files.compute_local_file_dir(DOMAIN) / source
path = base_dir / f"{mdi_id}.svg"
return MDI_SOURCES[source] + mdi_id + ".svg", base_dir / f"{mdi_id}.svg"
url = MDI_SOURCES[source] + mdi_id + ".svg"
def download_gh_svg(value: str | ConfigType, source: str) -> str:
mdi_id = value[CONF_ICON] if isinstance(value, dict) else value
url, path = _gh_svg_url_path(mdi_id, source)
return download_file(url, path)
@@ -101,17 +103,53 @@ def download_image(value):
return download_file(value, compute_local_image_path(value))
def validate_file_shorthand(value):
value = cv.string_strict(value)
def _parse_remote_shorthand(value: str) -> RemoteFile | None:
"""Parse a string `file:` shorthand to its remote file; None if local.
Raises cv.Invalid for a malformed icon name. Shared by the schema
validator and the prefetch extractor so they cannot drift.
"""
parts = value.strip().split(":")
if len(parts) == 2 and parts[0] in MDI_SOURCES:
match = re.match(r"^[a-zA-Z0-9\-]+$", parts[1])
if match is None:
if _MDI_ICON_RE.match(parts[1]) is None:
raise cv.Invalid(f"Could not parse mdi icon name from '{value}'.")
return download_gh_svg(parts[1], parts[0])
return RemoteFile(*_gh_svg_url_path(parts[1], parts[0]))
if value.startswith(("http://", "https://")):
return download_image(value)
return RemoteFile(value, compute_local_image_path(value))
return None
def _extract_file_ref(value: object) -> RemoteFile | None:
"""Map a raw, pre-schema `file:` value to its remote file.
Returns None for local files and anything it does not recognize; the
schema validators stay authoritative.
"""
if isinstance(value, str):
try:
return _parse_remote_shorthand(value)
except cv.Invalid:
return None
if isinstance(value, dict):
source = value.get(CONF_SOURCE)
if source == SOURCE_WEB and isinstance(url := value.get(CONF_URL), str):
return RemoteFile(url, compute_local_image_path(url))
if source in MDI_SOURCES and isinstance(icon := value.get(CONF_ICON), str):
return RemoteFile(*_gh_svg_url_path(icon, source))
return None
def _extract_entry_ref(entry: ConfigType) -> RemoteFile | None:
return _extract_file_ref(entry.get(CONF_FILE))
PREFETCH_FILES = external_files.single_stage_prefetch(_extract_entry_ref)
def validate_file_shorthand(value):
value = cv.string_strict(value)
if (remote := _parse_remote_shorthand(value)) is not None:
return download_file(remote.url, remote.path)
value = cv.file_(value)
return local_path(value)
@@ -163,7 +201,7 @@ OPTIONS_SCHEMA = {
"NONE", "FLOYDSTEINBERG", upper=True
),
cv.Optional(CONF_INVERT_ALPHA, default=False): cv.boolean,
cv.Optional(CONF_BYTE_ORDER): cv.one_of("BIG_ENDIAN", "LITTLE_ENDIAN", upper=True),
cv.Optional(CONF_BYTE_ORDER): validate_byte_order,
cv.Optional(CONF_TRANSPARENCY, default=CONF_OPAQUE): validate_transparency(),
}
+185 -61
View File
@@ -1,6 +1,5 @@
from collections.abc import MutableMapping
from collections.abc import Iterable, MutableMapping
import functools
import hashlib
from itertools import accumulate
import logging
from pathlib import Path
@@ -17,7 +16,6 @@ from freetype import (
FT_Exception,
ft_pixel_mode_mono,
)
import requests
from esphome import external_files
import esphome.codegen as cg
@@ -36,7 +34,7 @@ from esphome.const import (
CONF_WEIGHT,
)
from esphome.core import CORE, HexInt
from esphome.happy_eyeballs import ensure_happy_eyeballs
from esphome.external_files import RemoteFile
from esphome.types import ConfigType
_LOGGER = logging.getLogger(__name__)
@@ -296,46 +294,80 @@ def validate_weight_name(value):
return FONT_WEIGHTS[cv.one_of(*FONT_WEIGHTS, lower=True, space="-")(value)]
def _compute_local_font_path(value: dict) -> Path:
url = value[CONF_URL]
h = hashlib.new("sha256")
h.update(url.encode())
key = h.hexdigest()[:8]
base_dir = external_files.compute_local_file_dir(DOMAIN)
_LOGGER.debug("_compute_local_font_path: %s", base_dir / key)
return base_dir / key
def _web_font_path(value: dict) -> Path:
return external_files.compute_local_file_path(DOMAIN, value[CONF_URL]) / "font.ttf"
def download_gfont(value):
def _gfonts_css_url(value: dict) -> str:
return (
f"https://fonts.googleapis.com/css2?family={value[CONF_FAMILY]}"
f":ital,wght@{int(value[CONF_ITALIC])},{value[CONF_WEIGHT]}"
)
def _gfonts_cache_path(value: dict, suffix: str) -> Path:
name = f"{value[CONF_FAMILY]}@{value[CONF_WEIGHT]}@{value[CONF_ITALIC]}@v1"
return external_files.compute_local_file_dir(DOMAIN) / f"{name}.{suffix}"
def _gfonts_ttf_path(value: dict) -> Path:
return _gfonts_cache_path(value, "ttf")
def _gfonts_css_path(value: dict) -> Path:
return _gfonts_cache_path(value, "css")
def _parse_gfonts_css(css: str) -> str | None:
"""Extract the truetype URL from a Google Fonts CSS response."""
match = re.search(r"src:\s+url\((.+)\)\s+format\('truetype'\);", css)
return match.group(1) if match else None
def download_gfont(value: ConfigType) -> ConfigType:
if value in FONT_CACHE:
return value
name = (
f"{value[CONF_FAMILY]}:ital,wght@{int(value[CONF_ITALIC])},{value[CONF_WEIGHT]}"
)
url = f"https://fonts.googleapis.com/css2?family={name}"
path = (
external_files.compute_local_file_dir(DOMAIN)
/ f"{value[CONF_FAMILY]}@{value[CONF_WEIGHT]}@{value[CONF_ITALIC]}@v1.ttf"
)
path = _gfonts_ttf_path(value)
if not external_files.is_file_recent(path, value[CONF_REFRESH]):
_LOGGER.debug("download_gfont: path=%s", path)
url = _gfonts_css_url(value)
css_path = _gfonts_css_path(value)
try:
ensure_happy_eyeballs()
req = requests.get(url, timeout=external_files.NETWORK_TIMEOUT)
req.raise_for_status()
except requests.exceptions.RequestException as e:
css_bytes = external_files.download_content(url, css_path)
except cv.Invalid as e:
raise cv.Invalid(
f"Could not download font at {url}, please check the fonts exists "
f"at google fonts ({e})"
) from e
match = re.search(r"src:\s+url\((.+)\)\s+format\('truetype'\);", req.text)
if match is None:
if not (
external_files.is_fresh_this_run(css_path) or CORE.skip_external_update
):
# Same rule as PREFETCH_FILES stage two: a CSS body that could
# not be revalidated may name a rotated ttf URL. Use the cached
# font instead (the failed check already warned).
if path.exists():
FONT_CACHE[value] = path
return value
raise cv.Invalid(
f"Could not extract ttf file from gfonts response for {name}, "
f"please report this."
f"Could not refresh the Google Fonts CSS for "
f"{value[CONF_FAMILY]} and no cached font is available"
)
try:
css = css_bytes.decode("utf-8")
except UnicodeDecodeError as e:
# Do not leave an unusable body in the cache to be served again.
css_path.unlink(missing_ok=True)
raise cv.Invalid(
f"Bad response from Google Fonts for {value[CONF_FAMILY]}: "
f"not a text document"
) from e
ttf_url = _parse_gfonts_css(css)
if ttf_url is None:
css_path.unlink(missing_ok=True)
raise cv.Invalid(
f"Could not extract ttf file from gfonts response for "
f"{value[CONF_FAMILY]}, please report this."
)
ttf_url = match.group(1)
_LOGGER.debug("download_gfont: ttf_url=%s", ttf_url)
external_files.download_content(ttf_url, path)
@@ -346,11 +378,11 @@ def download_gfont(value):
return value
def download_web_font(value):
def download_web_font(value: ConfigType) -> ConfigType:
if value in FONT_CACHE:
return value
url = value[CONF_URL]
path = _compute_local_font_path(value) / "font.ttf"
path = _web_font_path(value)
external_files.download_content(url, path)
_LOGGER.debug("download_web_font: path=%s", path)
@@ -358,13 +390,18 @@ def download_web_font(value):
return value
# Shared by the schema and the prefetch extractor so they cannot drift.
_DEFAULT_WEIGHT = "regular"
_DEFAULT_ITALIC = False
_DEFAULT_REFRESH = "1d"
_WEIGHT_VALIDATOR = cv.Any(cv.int_, validate_weight_name)
_REFRESH_VALIDATOR = cv.All(cv.string, cv.source_refresh)
EXTERNAL_FONT_SCHEMA = cv.Schema(
{
cv.Optional(CONF_WEIGHT, default="regular"): cv.Any(
cv.int_, validate_weight_name
),
cv.Optional(CONF_ITALIC, default=False): cv.boolean,
cv.Optional(CONF_REFRESH, default="1d"): cv.All(cv.string, cv.source_refresh),
cv.Optional(CONF_WEIGHT, default=_DEFAULT_WEIGHT): _WEIGHT_VALIDATOR,
cv.Optional(CONF_ITALIC, default=_DEFAULT_ITALIC): cv.boolean,
cv.Optional(CONF_REFRESH, default=_DEFAULT_REFRESH): _REFRESH_VALIDATOR,
}
)
@@ -387,36 +424,123 @@ WEB_FONT_SCHEMA = cv.All(
)
def validate_file_shorthand(value):
value = cv.string_strict(value)
_GFONTS_SHORTHAND_RE = re.compile(r"^gfonts://([^@]+)(@.+)?$")
def _shorthand_to_file_dict(value: str) -> ConfigType | None:
"""Typed-dict form of a remote font shorthand.
Shared by the schema validator and the prefetch extractor so the two
cannot drift. Returns None for values that are not remote shorthand
(i.e. local paths); raises cv.Invalid for a malformed gfonts shorthand.
"""
if value.startswith("gfonts://"):
match = re.match(r"^gfonts://([^@]+)(@.+)?$", value)
if match is None:
if (match := _GFONTS_SHORTHAND_RE.match(value)) is None:
raise cv.Invalid("Could not parse gfonts shorthand syntax, please check it")
family = match.group(1)
weight = match.group(2)
data = {
data = {CONF_TYPE: TYPE_GFONTS, CONF_FAMILY: match.group(1)}
if match.group(2):
data[CONF_WEIGHT] = match.group(2)[1:]
return data
if value.startswith(("http://", "https://")):
return {CONF_TYPE: TYPE_WEB, CONF_URL: value}
return None
def _extract_remote_font(value: object) -> ConfigType | None:
"""Map a raw, pre-schema font `file:` value to a normalized remote spec.
Read-only mirror of `validate_file_shorthand` / `TYPED_FILE_SCHEMA` for
the prefetch hooks; returns None for local fonts and anything it does
not recognize. A wrong answer only wastes or misses a prefetch, the
schema validators stay authoritative.
"""
if isinstance(value, str):
try:
value = _shorthand_to_file_dict(value)
except cv.Invalid:
return None
if not isinstance(value, dict):
return None
font_type = value.get(CONF_TYPE)
if font_type == TYPE_WEB and isinstance(url := value.get(CONF_URL), str):
return {CONF_TYPE: TYPE_WEB, CONF_URL: url}
if font_type == TYPE_GFONTS and isinstance(family := value.get(CONF_FAMILY), str):
try:
italic = cv.boolean(value.get(CONF_ITALIC, _DEFAULT_ITALIC))
weight = _WEIGHT_VALIDATOR(value.get(CONF_WEIGHT, _DEFAULT_WEIGHT))
refresh = _REFRESH_VALIDATOR(value.get(CONF_REFRESH, _DEFAULT_REFRESH))
except cv.Invalid:
return None
return {
CONF_TYPE: TYPE_GFONTS,
CONF_FAMILY: family,
CONF_WEIGHT: weight,
CONF_ITALIC: italic,
CONF_REFRESH: refresh,
}
if weight is not None:
data[CONF_WEIGHT] = weight[1:]
return font_file_schema(data)
return None
if value.startswith(("http://", "https://")):
return font_file_schema(
{
CONF_TYPE: TYPE_WEB,
CONF_URL: value,
}
)
return font_file_schema(
{
CONF_TYPE: TYPE_LOCAL,
CONF_PATH: value,
}
)
def _iter_remote_specs(entries: list[ConfigType]) -> Iterable[ConfigType]:
"""Yield the remote spec of every `file:` value, including extras."""
for entry in entries:
values = [entry.get(CONF_FILE)]
extras = entry.get(CONF_EXTRAS)
if isinstance(extras, dict):
# The schema runs cv.ensure_list on extras, so a bare mapping
# is valid raw config; mirror that normalization here.
extras = [extras]
if isinstance(extras, list):
values.extend(
extra.get(CONF_FILE) for extra in extras if isinstance(extra, dict)
)
for value in values:
if (spec := _extract_remote_font(value)) is not None:
yield spec
def PREFETCH_FILES(entries: list[ConfigType]) -> Iterable[list[RemoteFile]]:
"""Batch-download hook: web fonts, then Google Fonts CSS, then ttf.
Stage one fetches web fonts and the CSS of stale gfonts; stage two
parses the now-cached CSS for the ttf URLs it names.
"""
stage1: list[RemoteFile] = []
# Keyed by cache path: the same font at several sizes is one download,
# one freshness stat, and one stage-two CSS parse.
stale_gfonts: dict[Path, ConfigType] = {}
seen_web: set[Path] = set()
for spec in _iter_remote_specs(entries):
if spec[CONF_TYPE] == TYPE_WEB:
if (path := _web_font_path(spec)) not in seen_web:
seen_web.add(path)
stage1.append(RemoteFile(spec[CONF_URL], path))
elif (css_path := _gfonts_css_path(spec)) not in stale_gfonts and (
not external_files.is_file_recent(
_gfonts_ttf_path(spec), spec[CONF_REFRESH]
)
):
stale_gfonts[css_path] = spec
stage1.append(RemoteFile(_gfonts_css_url(spec), css_path))
yield stage1
yield [
RemoteFile(ttf_url, _gfonts_ttf_path(spec))
for css_path, spec in stale_gfonts.items()
# Only trust CSS that stage one actually refreshed this run; a
# leftover from an earlier run may name a rotated ttf URL.
if external_files.is_fresh_this_run(css_path)
and css_path.exists()
and (ttf_url := _parse_gfonts_css(css_path.read_text("utf-8", "replace")))
is not None
]
def validate_file_shorthand(value: object) -> ConfigType:
value = cv.string_strict(value)
if (data := _shorthand_to_file_dict(value)) is None:
data = {CONF_TYPE: TYPE_LOCAL, CONF_PATH: value}
return font_file_schema(data)
TYPED_FILE_SCHEMA = cv.typed_schema(
@@ -0,0 +1,22 @@
from esphome import pins
import esphome.config_validation as cv
from esphome.const import CONF_ALLOW_OTHER_USES, CONF_INTERRUPT_PIN, CONF_INVERTED
from esphome.types import ConfigType
def validate_interrupt_pin(value: ConfigType) -> ConfigType:
# The expander components own INT polarity (active-low, hardcoded falling-edge ISR)
# and install a single ISR per GPIO, so neither inversion nor sharing is supported.
value = pins.internal_gpio_input_pin_schema(value)
if value.get(CONF_INVERTED):
raise cv.Invalid(
f"'{CONF_INVERTED}: true' is not supported on '{CONF_INTERRUPT_PIN}'; "
"the expander INT line is fixed active-low"
)
if value.get(CONF_ALLOW_OTHER_USES):
raise cv.Invalid(
f"'{CONF_ALLOW_OTHER_USES}: true' is not supported on '{CONF_INTERRUPT_PIN}'; "
"sharing the interrupt pin between multiple components is not implemented. "
f"Remove the '{CONF_INTERRUPT_PIN}' to fall back to polling."
)
return value
+20 -2
View File
@@ -29,6 +29,8 @@ from esphome.const import (
CONF_URL,
)
from esphome.core import ID
from esphome.external_files import RemoteFile
from esphome.types import ConfigType
DEPENDENCIES = ["i2c"]
AUTO_LOAD = ["touchscreen"]
@@ -103,8 +105,7 @@ def _validate_firmware_data(data: bytes, source: str) -> None:
def _cache_path(url: str) -> Path:
"""Cache path for a downloaded firmware blob, keyed by URL."""
key = hashlib.sha256(url.encode()).hexdigest()[:8]
return external_files.compute_local_file_dir(DOMAIN) / key
return external_files.compute_local_file_path(DOMAIN, url)
def firmware_path(firmware: dict) -> Path:
@@ -156,6 +157,23 @@ FIRMWARE_SCHEMA = cv.All(
)
def _extract_firmware_ref(entry: ConfigType) -> RemoteFile | None:
firmware = entry.get(CONF_FIRMWARE)
if firmware is None:
model = str(entry.get(CONF_MODEL, "CUSTOM")).upper()
firmware = MODELS.get(model, {}).get(CONF_FIRMWARE)
if (
isinstance(firmware, dict)
and CONF_FILE not in firmware
and isinstance(url := firmware.get(CONF_URL), str)
):
return RemoteFile(url, _cache_path(url))
return None
PREFETCH_FILES = external_files.single_stage_prefetch(_extract_firmware_ref)
def _config_schema(config):
model_option = {
cv.Optional(CONF_MODEL, default="CUSTOM"): cv.one_of(*MODELS, upper=True)
+138 -10
View File
@@ -13,10 +13,17 @@ static constexpr uint16_t STATE_REG = 0x9CB9; // Internal state read back b
static constexpr uint16_t BROADCAST_REG = 0x9D31; // Door status broadcast by the bus controller
static constexpr float CLOSE_POSITION_THRESHOLD = 0.05f;
static constexpr float OPEN_POSITION_THRESHOLD = 0.95f;
// Only the parity of the outstanding toggles says where the lamp is heading, so the count must not run away.
static constexpr uint8_t MAX_LIGHT_TOGGLES_IN_FLIGHT = 4;
// Command encoding: the high byte of the first register is the phase (0x02 pressed, 0x01 released) and the
// rest names the button - the low byte for the door commands, the second register for those that do not fit
// there. Both halves repeat that name, so neither register is a level to hold; they carry one event each.
static constexpr HoermannHcpCommand COMMAND_OPEN{"open", 0x0210, 0x0110};
static constexpr HoermannHcpCommand COMMAND_CLOSE{"close", 0x0220, 0x0120};
static constexpr HoermannHcpCommand COMMAND_IMPULSE{"impulse", 0x0240, 0x0140};
// The lamp is named in the second register, but its phase bytes follow no scheme the door commands share.
static constexpr HoermannHcpCommand COMMAND_TOGGLE_LAMP{"toggle light", 0x0100, 0x0800, 0x0200, 0x0200, false};
// High byte of the state register and the door state it stands for. State 0x00 is decoded separately because
// its low byte tells a plain stop from the vent position.
@@ -58,17 +65,29 @@ void HoermannHcp::update() {
// Status broadcasts alone keep the connection alive, so a command the controller never fetches would
// otherwise block every later one for as long as it keeps broadcasting.
if (this->next_command_ != nullptr && now - this->command_queued_at_ > this->connection_timeout_ms_) {
ESP_LOGW(TAG, "Bus controller did not fetch '%s' command, dropping it", this->next_command_->name);
this->next_command_ = nullptr;
this->command_written_at_ = 0;
this->clear_target_();
// Dropping after the press was presented leaves the door without its release value, which is worth saying
// apart from a command the controller never looked at.
if (this->command_written_at_ != 0) {
ESP_LOGW(TAG, "Bus controller stopped polling during '%s' command, dropping it mid key press",
this->next_command_->name);
} else {
ESP_LOGW(TAG, "Bus controller did not fetch '%s' command, dropping it", this->next_command_->name);
}
this->drop_command_();
// Children may have assumed the command would land, so let them re-derive from the door.
this->changed_ = true;
}
// A target waits for a door still travelling the other way to turn around. If it never does, the target has
// to go as well, otherwise it would cut a later move short. The connection timeout doubles as that window.
if (this->has_target_() && !this->target_started_ && now - this->command_queued_at_ > this->connection_timeout_ms_) {
if (this->has_target_() && !this->target_started_ && now - this->target_queued_at_ > this->connection_timeout_ms_) {
ESP_LOGW(TAG, "Door did not start moving towards the requested position, dropping it");
this->clear_target_();
}
// The door took the lamp key press but never reported the lamp changing, so stop expecting it to.
if (this->light_toggle_released_at_ != 0 && now - this->light_toggle_released_at_ > this->connection_timeout_ms_) {
ESP_LOGW(TAG, "Door did not report the lamp changing, giving up on the toggle");
this->forget_light_toggles_();
}
if (this->changed_) {
this->changed_ = false;
this->state_callback_.call();
@@ -151,6 +170,16 @@ modbus::ResponseStatus HoermannHcp::on_write_registers(uint16_t start_address,
this->on_state_reg_(registers[2]);
if (registers.size() > 1)
this->on_position_reg_(registers[1]);
if (registers.size() > 6) {
this->on_light_reg_(registers[6]);
return {};
}
// Nothing refreshes the lamp any more, so what was read before must not be commanded against.
this->set_light_seen_(false);
if (!this->short_broadcast_logged_) {
this->short_broadcast_logged_ = true;
ESP_LOGD(TAG, "Broadcast of %u registers carries no lamp state", static_cast<unsigned>(registers.size()));
}
return {};
}
@@ -165,11 +194,11 @@ void HoermannHcp::push_command_registers_(modbus::RegisterValues &registers) {
this->command_written_at_ = millis();
ESP_LOGI(TAG, "Sending '%s' command to door", command->name);
registers.push_back(command->pressed_value);
registers.push_back(0x0000);
registers.push_back(command->pressed_value_2);
return;
}
if (millis() - this->command_written_at_ <= this->key_press_delay_ms_) {
// Still inside the key-press window, so keep presenting 0x0000.
// Between the two events there is nothing to report, including in the second register.
push_zeros(registers, 2);
return;
}
@@ -177,8 +206,12 @@ void HoermannHcp::push_command_registers_(modbus::RegisterValues &registers) {
ESP_LOGD(TAG, "Released '%s' command", command->name);
this->command_written_at_ = 0;
this->next_command_ = nullptr;
// A toggle whose count was already settled, by a lamp change reported from the door's side, has nothing left
// to wait for, so it must not re-arm the watchdog.
if (command == &COMMAND_TOGGLE_LAMP && this->light_toggles_in_flight_ != 0)
this->light_toggle_released_at_ = millis();
registers.push_back(command->released_value);
registers.push_back(0x0000);
registers.push_back(command->released_value_2);
}
void HoermannHcp::on_position_reg_(uint16_t value) {
@@ -225,6 +258,13 @@ void HoermannHcp::on_state_reg_(uint16_t value) {
ESP_LOGW(TAG, "Unknown door state 0x%02X", state);
}
// Low byte of register 6: bit 0x10 is the lamp, bit 0x04 the relay. The reference implementation records
// 0x00, 0x04, 0x10 and 0x14, so only the lamp bit decides here.
void HoermannHcp::on_light_reg_(uint16_t value) {
this->set_light_seen_(true);
this->set_light_on_((value & 0x0010) != 0);
}
bool HoermannHcp::queue_command_(const HoermannHcpCommand &command) {
if (!this->valid_) {
// Queueing now would fire the command whenever the controller comes back, which may be much later.
@@ -236,7 +276,8 @@ bool HoermannHcp::queue_command_(const HoermannHcpCommand &command) {
return false;
}
// A new command supersedes any half-open target the door was still travelling to.
this->clear_target_();
if (command.clears_target)
this->clear_target_();
this->next_command_ = &command;
this->command_queued_at_ = millis();
return true;
@@ -245,6 +286,31 @@ bool HoermannHcp::queue_command_(const HoermannHcpCommand &command) {
bool HoermannHcp::open_door() { return this->queue_command_(COMMAND_OPEN); }
bool HoermannHcp::close_door() { return this->queue_command_(COMMAND_CLOSE); }
bool HoermannHcp::impulse_door() { return this->queue_command_(COMMAND_IMPULSE); }
bool HoermannHcp::toggle_light() {
if (this->light_toggles_in_flight_ >= MAX_LIGHT_TOGGLES_IN_FLIGHT) {
ESP_LOGW(TAG, "Too many lamp toggles are still waiting to be confirmed, dropping this one");
return false;
}
if (!this->queue_command_(COMMAND_TOGGLE_LAMP))
return false;
this->light_toggles_in_flight_++;
return true;
}
bool HoermannHcp::is_light_toggle_pending_() const { return this->next_command_ == &COMMAND_TOGGLE_LAMP; }
uint8_t HoermannHcp::unsent_light_toggles_() const {
return this->is_light_toggle_pending_() && this->command_written_at_ == 0 ? 1 : 0;
}
bool HoermannHcp::cancel_light_toggle() {
// Once the pressed value has been presented the key press is already on the wire, so only an untouched
// command can be withdrawn.
if (!this->is_light_toggle_pending_() || this->command_written_at_ != 0)
return false;
ESP_LOGD(TAG, "Cancelling '%s' command the controller had not fetched", this->next_command_->name);
this->drop_command_();
return true;
}
bool HoermannHcp::stop_door() {
if (!is_moving(this->door_state_)) {
@@ -270,6 +336,7 @@ bool HoermannHcp::set_position(float position) {
if (!this->queue_command_(opening ? COMMAND_OPEN : COMMAND_CLOSE))
return false;
this->target_position_ = position;
this->target_queued_at_ = millis();
this->target_direction_ = opening ? DoorState::OPENING : DoorState::CLOSING;
// A door already travelling that way is on its way; one moving the other way has to turn around first.
this->target_started_ = this->door_state_ == this->target_direction_;
@@ -292,9 +359,48 @@ void HoermannHcp::set_valid_(bool valid) {
}
ESP_LOGW(TAG, "Bus controller connection lost (no request for %" PRIu32 "ms)", millis() - this->last_response_);
// Drop what the controller never fetched, so it neither blocks later commands nor fires on reconnect.
this->drop_command_();
// The door cannot be watched while the bus is quiet, so a target left armed would stop it long afterwards.
this->clear_target_();
this->forget_light_toggles_();
// The lamp can be switched at the door while the bus is quiet, so what was last read is no longer trusted.
this->set_light_seen_(false);
this->short_broadcast_logged_ = false;
}
void HoermannHcp::drop_command_() {
const bool was_light_toggle = this->is_light_toggle_pending_();
// Cleared first so the settling below no longer counts this command among the toggles still to be sent.
this->next_command_ = nullptr;
this->command_written_at_ = 0;
this->clear_target_();
if (was_light_toggle) {
// A lamp toggle says nothing about where the door was going, so it leaves the target alone.
this->light_toggle_settled_();
} else {
this->clear_target_();
}
}
void HoermannHcp::light_toggle_settled_() {
if (this->light_toggles_in_flight_ == 0)
return;
this->light_toggles_in_flight_--;
// Only a toggle the door has been shown can still be confirmed, so unsent ones leave nothing to wait for.
if (this->light_toggles_in_flight_ == this->unsent_light_toggles_())
this->light_toggle_released_at_ = 0;
// The light was showing where the lamp was heading, so it has to be told to look again.
this->changed_ = true;
}
void HoermannHcp::forget_light_toggles_() {
// Nothing outstanding must always mean nothing to wait for, or the watchdog below would fire for ever.
this->light_toggle_released_at_ = 0;
// A toggle the door has not been shown yet is still going to fire, so it keeps counting.
const uint8_t unsent = this->unsent_light_toggles_();
if (this->light_toggles_in_flight_ == unsent)
return;
this->light_toggles_in_flight_ = unsent;
this->changed_ = true;
}
void HoermannHcp::set_door_state_(DoorState state) {
@@ -333,4 +439,26 @@ void HoermannHcp::clear_target_() {
this->target_started_ = false;
}
void HoermannHcp::set_light_on_(bool on) {
if (this->light_on_ == on)
return;
this->light_on_ = on;
this->changed_ = true;
if (this->light_toggles_in_flight_ <= this->unsent_light_toggles_()) {
// The door has not been shown a toggle that could explain this, so the lamp was switched at the door.
ESP_LOGD(TAG, "Lamp %s at the door", ONOFF(on));
return;
}
// The door acted, so one of the toggles it has seen has arrived. Any others still count.
this->light_toggle_settled_();
}
void HoermannHcp::set_light_seen_(bool seen) {
if (this->light_seen_ == seen)
return;
this->light_seen_ = seen;
// A resting door changes nothing else, so without this the light would never hear about it.
this->changed_ = true;
}
} // namespace esphome::hoermann_hcp
+38 -1
View File
@@ -22,11 +22,15 @@ enum class DoorState : uint8_t {
};
// A HCP command is a simulated key press: the pressed value is presented to the bus controller, then after a
// short delay the released value. The second command register remains zero.
// short delay the released value. Each half also carries a second register, which only the lamp command uses.
struct HoermannHcpCommand {
const char *name;
uint16_t pressed_value;
uint16_t released_value;
uint16_t pressed_value_2{0x0000};
uint16_t released_value_2{0x0000};
// A door command supersedes a half-open target; the lamp has no bearing on where the door is going.
bool clears_target{true};
};
class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice {
@@ -52,19 +56,41 @@ class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice {
bool impulse_door();
bool stop_door();
bool set_position(float position);
bool toggle_light();
DoorState get_door_state() const { return this->door_state_; }
float get_current_position() const { return this->current_position_; }
bool is_valid() const { return this->valid_; }
bool is_light_on() const { return this->light_on_; }
// False until a broadcast has actually carried the lamp register. Bus traffic alone makes the connection
// valid without saying anything about the lamp, so is_light_on() would still be its default.
bool is_light_known() const { return this->light_seen_; }
// Where the lamp ends up once every toggle on its way has landed, each of which inverts it. Until then the
// lamp still reads as its old self, so this is what a request has to be judged against.
bool is_light_heading_on() const { return this->light_on_ != (this->light_toggles_in_flight_ % 2 != 0); }
// Drops a lamp toggle the controller has not started reading, so a reversing request cancels it outright
// instead of fighting it. Returns false if there is nothing to cancel.
bool cancel_light_toggle();
protected:
// True while a lamp toggle is queued but not yet fetched, so the lamp is about to invert.
bool is_light_toggle_pending_() const;
// Toggles the door has not been shown yet, which is at most the one still waiting in the command slot.
uint8_t unsent_light_toggles_() const;
void record_response_();
// Returns false when the bus controller has not fetched the previous command yet.
bool queue_command_(const HoermannHcpCommand &command);
// Throws away the pending command, taking any armed target with it unless the command was the lamp toggle.
void drop_command_();
// One outstanding toggle reached the lamp, was withdrawn, or was thrown away.
void light_toggle_settled_();
// Stops expecting the toggles the door has already been shown to reach the lamp.
void forget_light_toggles_();
// Appends the two key-press registers and advances the pending command's press/release state.
void push_command_registers_(modbus::RegisterValues &registers);
void on_position_reg_(uint16_t value);
void on_state_reg_(uint16_t value);
void on_light_reg_(uint16_t value);
void set_valid_(bool valid);
void set_door_state_(DoorState state);
@@ -72,6 +98,8 @@ class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice {
void update_current_position_();
bool has_target_() const { return this->target_position_ != 0.0f; }
void clear_target_();
void set_light_on_(bool on);
void set_light_seen_(bool seen);
CallbackManager<void()> state_callback_;
@@ -82,8 +110,13 @@ class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice {
// Pending command / key-press state machine.
const HoermannHcpCommand *next_command_{nullptr};
uint32_t command_queued_at_{0};
// Separate from command_queued_at_ so an unrelated command cannot extend the target's start deadline.
uint32_t target_queued_at_{0};
uint32_t command_written_at_{0};
uint32_t last_response_{0};
// When the door was last handed a lamp key press. It reports the lamp a moment later, so this bounds the
// wait. Queueing another toggle deliberately leaves it alone, so the one already sent keeps its deadline.
uint32_t light_toggle_released_at_{0};
// A command is "pressed" for this long before its end value is sent.
uint16_t key_press_delay_ms_{100};
@@ -102,9 +135,13 @@ class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice {
DoorState target_direction_{DoorState::STOPPED};
// Position as reported by the bus controller, 0..200 across the full travel.
uint8_t position_raw_{0};
uint8_t light_toggles_in_flight_{0};
bool target_started_{false};
bool valid_{false};
bool changed_{false};
bool light_on_{false};
bool light_seen_{false};
bool short_broadcast_logged_{false};
};
} // namespace esphome::hoermann_hcp
@@ -0,0 +1,24 @@
import esphome.codegen as cg
from esphome.components import light
import esphome.config_validation as cv
from esphome.types import ConfigType
from .. import CONF_HOERMANN_HCP_ID, HoermannHcp, hoermann_hcp_ns
DEPENDENCIES = ["hoermann_hcp"]
HoermannHcpLight = hoermann_hcp_ns.class_(
"HoermannHcpLight", light.LightOutput, cg.Component
)
CONFIG_SCHEMA = (
light.light_schema(HoermannHcpLight, light.LightType.BINARY)
.extend({cv.GenerateID(CONF_HOERMANN_HCP_ID): cv.use_id(HoermannHcp)})
.extend(cv.COMPONENT_SCHEMA)
)
async def to_code(config: ConfigType) -> None:
parent = await cg.get_variable(config[CONF_HOERMANN_HCP_ID])
var = await light.new_light(config, parent)
await cg.register_component(var, config)
@@ -0,0 +1,82 @@
#include "hoermann_hcp_light.h"
#include "esphome/core/log.h"
namespace esphome::hoermann_hcp {
static const char *const TAG = "hoermann_hcp.light";
light::LightTraits HoermannHcpLight::get_traits() {
auto traits = light::LightTraits();
traits.set_supported_color_modes({light::ColorMode::ON_OFF});
return traits;
}
void HoermannHcpLight::setup() {
// Nothing is known about the lamp until the bus controller is heard from, so flag the entity until then.
this->status_set_warning(LOG_STR("waiting for the bus controller"));
this->parent_->add_on_state_callback([this]() { this->update_from_state_(); });
}
void HoermannHcpLight::setup_state(light::LightState *state) { this->light_state_ = state; }
void HoermannHcpLight::write_state(light::LightState *state) {
bool binary;
state->current_values_as_binary(&binary);
// A publish of ours only reaches write_state() a loop pass later, by which time the lamp may have moved on,
// so it is recognised by the value it carried rather than by the current one.
const optional<bool> published = this->published_state_;
this->published_state_.reset();
// LightState::setup() always performs a call, so the very first write here is the restored state coming back
// rather than a request.
const bool restored = !this->boot_replay_done_;
this->boot_replay_done_ = true;
const bool heading_on = this->parent_->is_light_heading_on();
if (binary == heading_on)
return;
if (restored) {
ESP_LOGD(TAG, "Ignoring the restored state, the door decides what the lamp is doing");
} else if (published != binary) {
if (!this->parent_->is_light_known()) {
// Commanding a lamp that has not been read could switch off one that is already on.
ESP_LOGW(TAG, "Door has not reported the lamp yet, ignoring the requested state");
} else if (this->parent_->cancel_light_toggle() || this->parent_->toggle_light()) {
// A toggle the controller has not fetched is withdrawn outright rather than fought with a second one.
return;
} else {
ESP_LOGW(TAG, "Light command was not accepted by the door");
}
}
// Nothing was sent, so the entity has to go back to showing the lamp rather than the request.
this->publish_lamp_state_(heading_on);
}
void HoermannHcpLight::update_from_state_() {
if (this->light_state_ == nullptr)
return;
if (!this->parent_->is_valid()) {
this->status_set_warning(LOG_STR("bus controller not responding"));
return;
}
if (!this->parent_->is_light_known()) {
// Commands are refused until the door says, so say so rather than looking healthy and doing nothing.
this->status_set_warning(LOG_STR("door has not reported the lamp"));
return;
}
this->status_clear_warning();
const bool heading_on = this->parent_->is_light_heading_on();
if (this->light_state_->remote_values.is_on() != heading_on)
this->publish_lamp_state_(heading_on);
}
// Re-enters write_state() a loop pass later, where published_state_ marks the write as ours.
void HoermannHcpLight::publish_lamp_state_(bool on) {
this->published_state_ = on;
auto call = this->light_state_->make_call();
call.set_state(on);
// The bus reports the lamp on every broadcast, so nothing here is worth restoring from flash.
call.set_save(false);
call.perform();
}
} // namespace esphome::hoermann_hcp
@@ -0,0 +1,30 @@
#pragma once
#include "esphome/components/light/light_output.h"
#include "esphome/core/component.h"
#include "../hoermann_hcp.h"
namespace esphome::hoermann_hcp {
class HoermannHcpLight : public light::LightOutput, public Component {
public:
explicit HoermannHcpLight(HoermannHcp *parent) : parent_(parent) {}
void setup() override;
void setup_state(light::LightState *state) override;
light::LightTraits get_traits() override;
void write_state(light::LightState *state) override;
protected:
void update_from_state_();
void publish_lamp_state_(bool on);
HoermannHcp *const parent_;
light::LightState *light_state_{nullptr};
// Value last published and not yet seen come back, so the write carrying it is that publish, not a request.
optional<bool> published_state_;
// Set by the first write_state(), which is always the restored state replayed on boot.
bool boot_replay_done_{false};
};
} // namespace esphome::hoermann_hcp
+136 -16
View File
@@ -10,7 +10,14 @@ from PIL import Image, UnidentifiedImageError
import esphome.codegen as cg
from esphome.components.const import CONF_BYTE_ORDER, KEY_METADATA
import esphome.config_validation as cv
from esphome.const import CONF_DEFAULTS, CONF_FILE, CONF_ID, CONF_PLATFORM, CONF_TYPE
from esphome.const import (
CONF_DEFAULTS,
CONF_FILE,
CONF_FILES,
CONF_ID,
CONF_PLATFORM,
CONF_TYPE,
)
from esphome.core import CORE
from esphome.types import ConfigType
@@ -48,6 +55,9 @@ TRANSPARENCY_TYPES = (
CONF_ALPHA_CHANNEL,
)
# Shared validator for the image platform schemas and `_drop_incompatible_byte_order`.
validate_byte_order = cv.one_of("BIG_ENDIAN", "LITTLE_ENDIAN", upper=True)
def get_image_type_enum(type):
return getattr(ImageType, f"IMAGE_TYPE_{type.upper()}")
@@ -404,6 +414,120 @@ def get_image_metadata(image_id: str) -> ImageMetaData | None:
return get_all_image_metadata().get(image_id)
# ---------------------------------------------------------------------------
# `defaults:`/`files:` expansion: a `platform:` entry merges shared `defaults:`
# into every `files:` entry; the platform's CONFIG_SCHEMA validates each.
# Permanent, unlike the legacy migration below.
# ---------------------------------------------------------------------------
def _drop_incompatible_byte_order(
merged: dict, explicit: dict, *, index: int | None = None
) -> dict:
"""Drop `byte_order` when the resolved type doesn't support it, unless written directly on `explicit`.
With `index`, inherited values are validated before being dropped (the legacy flattener always drops).
"""
if CONF_BYTE_ORDER in explicit:
return merged
type_class = IMAGE_TYPE.get(str(merged.get(CONF_TYPE, "")).upper())
if (
CONF_BYTE_ORDER in merged
and isinstance(type_class, type)
and issubclass(type_class, ImageEncoder)
and not type_class.is_endian()
):
if index is not None:
try:
validate_byte_order(merged[CONF_BYTE_ORDER])
except cv.Invalid as exc:
exc.prepend([index])
raise
del merged[CONF_BYTE_ORDER]
return merged
def _expand_platform_entry(index: int, entry: dict) -> list[dict]:
if CONF_FILES not in entry:
if CONF_DEFAULTS in entry:
raise cv.Invalid(
f"'{CONF_DEFAULTS}' may only be used together with '{CONF_FILES}'",
path=[index],
)
return [entry]
extra_keys = set(entry) - {CONF_PLATFORM, CONF_DEFAULTS, CONF_FILES}
if extra_keys:
raise cv.Invalid(
f"'{CONF_FILES}' cannot be combined with "
f"{', '.join(sorted(extra_keys))} on the same entry",
path=[index],
)
files = entry[CONF_FILES]
if files is None:
raise cv.Invalid(f"'{CONF_FILES}' must not be empty", path=[index])
if not isinstance(files, list):
raise cv.Invalid(f"'{CONF_FILES}' must be a list", path=[index])
if not files:
raise cv.Invalid(f"'{CONF_FILES}' must not be empty", path=[index])
defaults = entry.get(CONF_DEFAULTS, {})
if defaults is None:
defaults = {}
if not isinstance(defaults, dict):
raise cv.Invalid(f"'{CONF_DEFAULTS}' must be a mapping", path=[index])
# Neither `id:` nor `platform:` makes sense inside `defaults:`.
for disallowed in (CONF_ID, CONF_PLATFORM):
if disallowed in defaults:
raise cv.Invalid(
f"'{disallowed}' is not allowed inside '{CONF_DEFAULTS}'",
path=[index],
)
from esphome import yaml_util
platform = entry[CONF_PLATFORM]
result: list[dict] = []
for file_entry in files:
if not isinstance(file_entry, dict):
raise cv.Invalid(
f"each entry in '{CONF_FILES}' must be a mapping", path=[index]
)
# The platform is chosen by the entry's own `platform:` key, not per file.
if CONF_PLATFORM in file_entry:
raise cv.Invalid(
f"'{CONF_PLATFORM}' is not allowed inside '{CONF_FILES}'",
path=[index],
)
# Keep the `files:` item's source range so whole-entry errors anchor there;
# `make_data_base` needs a real ESPHomeDataBase, so skip it for plain dicts.
source = (
file_entry if isinstance(file_entry, yaml_util.ESPHomeDataBase) else None
)
merged = yaml_util.make_data_base(
{CONF_PLATFORM: platform, **defaults, **file_entry}, source
)
result.append(_drop_incompatible_byte_order(merged, file_entry, index=index))
return result
def expand_platform_config(config: list) -> list:
"""Expand `defaults:`/`files:` entries; the platform's own CONFIG_SCHEMA validates each result."""
result = []
for i, entry in enumerate(config):
if isinstance(entry, dict) and CONF_PLATFORM in entry:
result.extend(_expand_platform_entry(i, entry))
else:
result.append(entry)
return result
EXPAND_PLATFORM_CONFIG = expand_platform_config
# --------------------- end defaults/files expansion -------------------------
# ---------------------------------------------------------------------------
# Legacy top-level component -> `image:` platform deprecation helpers
# -- REMOVE after 2027.1.0 together with the `animation:`/`online_image:` shims.
@@ -496,11 +620,17 @@ def _is_legacy_image_format(config: object) -> bool:
proper error instead of the migration silently dropping the input.
"""
if isinstance(config, list):
# A bare list of (not-yet-platform-tagged) image dicts.
# Exclude `files:` entries -- the list branch would otherwise silently
# migrate them to `platform: file` instead of raising the missing-platform error.
return bool(config) and all(
isinstance(entry, dict) and CONF_PLATFORM not in entry for entry in config
isinstance(entry, dict)
and CONF_PLATFORM not in entry
and CONF_FILES not in entry
for entry in config
)
if not isinstance(config, dict):
if not isinstance(config, dict) or CONF_PLATFORM in config or CONF_FILES in config:
# `platform:`/`files:` dicts are new-format (left for list-wrapping +
# expansion); the legacy flattener has no `files:` branch and would drop them.
return False
# A single image dict, or the grouped `defaults:`/`images:`/type-key form.
return (
@@ -532,18 +662,8 @@ def _flatten_legacy_image_config(config: object) -> list[dict]:
def _add(entry: dict, extra: dict) -> None:
merged = {**defaults, **extra, **entry}
# The legacy `defaults:`/type-grouped forms only applied `byte_order` to
# types that support it. Replicate that so an endian default merged into
# e.g. a binary image stays valid.
type_class = IMAGE_TYPE.get(str(merged.get(CONF_TYPE, "")).upper())
if (
CONF_BYTE_ORDER in merged
and isinstance(type_class, type)
and issubclass(type_class, ImageEncoder)
and not type_class.is_endian()
):
del merged[CONF_BYTE_ORDER]
result.append(merged)
# Always drop, matching the pre-platform behavior -- see `_drop_incompatible_byte_order`.
result.append(_drop_incompatible_byte_order(merged, {}))
def _add_entries(entries: object, extra: dict) -> None:
# `entries` may be a single image dict or a list of them; non-dict
+6 -2
View File
@@ -154,8 +154,12 @@ bool Infrared::on_receive(remote_base::RemoteReceiveData data) {
// Forward received IR data to API server
#if defined(USE_API) && defined(USE_IR_RF)
if (api::global_api_server != nullptr) {
api::global_api_server->send_infrared_rf_receive_event(this->get_device_id_or_zero(), this->get_entity_key(),
&data.get_raw_data());
#ifdef USE_DEVICES
uint32_t device_id = this->get_device_id();
#else
uint32_t device_id = 0;
#endif
api::global_api_server->send_infrared_rf_receive_event(device_id, this->get_object_id_hash(), &data.get_raw_data());
}
#endif
return false; // Don't consume the event, allow other listeners to process it
@@ -3,17 +3,76 @@
#include "esphome/core/log.h"
#include "internal_temperature.h"
#include "Arduino.h"
#include <cmath>
#include <hardware/adc.h>
#include <pico/time.h>
// The RP2 variant headers (pulled in transitively by Arduino.h) define
// ADC_RESOLUTION as the pin-level ADC bit count, which would be substituted
// into the constant below. Nothing here uses the Arduino definition, so drop
// it for this file. Not restored with pop_macro: the uses below would then be
// substituted again.
#undef ADC_RESOLUTION
namespace esphome::internal_temperature {
static const char *const TAG = "internal_temperature.rp2";
// 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 constexpr float ADC_VREF = 3.3f;
static constexpr float ADC_RESOLUTION = 4096.0f; // 12-bit
// RP2040 datasheet 4.9.5 / RP2350 datasheet 12.4.6: T = 27 - (V - 0.706) / 0.001721
static constexpr float TEMPERATURE_AT_REFERENCE = 27.0f;
static constexpr float REFERENCE_VOLTAGE = 0.706f;
static constexpr float VOLTS_PER_DEGREE = 0.001721f;
// The sensor is powered down again after each read, so every conversion is the
// first one after enabling. Let the bias circuitry settle first, matching what
// the adc component does for its own temperature readings.
static constexpr uint32_t SETTLE_TIME_US = 1000;
static float read_internal_temperature() {
// adc_init() resets the ADC block, so this runs at most once for this
// component. The adc component guards its own adc_init() the same way, so a
// redundant reset is still possible when both are used. That is harmless
// because both re-select their input on every read.
static bool adc_ready = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
if (!adc_ready) {
adc_init();
adc_ready = true;
}
adc_set_temp_sensor_enabled(true);
busy_wait_us(SETTLE_TIME_US);
adc_select_input(TEMPERATURE_ADC_INPUT);
const uint16_t raw = adc_read();
adc_set_temp_sensor_enabled(false);
const float voltage = raw * (ADC_VREF / ADC_RESOLUTION);
return TEMPERATURE_AT_REFERENCE - (voltage - REFERENCE_VOLTAGE) / VOLTS_PER_DEGREE;
}
void InternalTemperatureSensor::update() {
float temperature = NAN;
bool success = false;
temperature = analogReadTemp();
temperature = read_internal_temperature();
success = (temperature != 0.0f);
if (success && std::isfinite(temperature)) {
+8 -3
View File
@@ -184,8 +184,6 @@ static int32_t get_firmware_int(const char *version_string) {
return result;
}
float LD2420Component::get_setup_priority() const { return setup_priority::BUS; }
void LD2420Component::dump_config() {
ESP_LOGCONFIG(TAG,
"LD2420:\n"
@@ -746,7 +744,14 @@ void LD2420Component::set_reg_value(uint16_t reg, uint16_t value) {
this->send_cmd_from_array(cmd_frame);
}
void LD2420Component::handle_cmd_error(uint8_t error) { ESP_LOGE(TAG, "Command failed: %s", ERR_MESSAGE[error]); }
void LD2420Component::handle_cmd_error(uint16_t error) {
if (error < std::size(ERR_MESSAGE)) {
ESP_LOGE(TAG, "Command failed: %s", ERR_MESSAGE[error]);
} else {
// The error word comes from the device reply frame; unknown codes must not index ERR_MESSAGE
ESP_LOGE(TAG, "Command failed: error 0x%04X", error);
}
}
int LD2420Component::get_gate_threshold_(uint8_t gate) {
uint8_t error;
+1 -2
View File
@@ -105,10 +105,9 @@ class LD2420Component final : public Component, public uart::UARTDevice {
void apply_config_action();
void factory_reset_action();
void revert_config_action();
float get_setup_priority() const override;
int send_cmd_from_array(CmdFrameT cmd_frame);
void report_gate_data();
void handle_cmd_error(uint8_t error);
void handle_cmd_error(uint16_t error);
void set_operating_mode(const char *state);
void auto_calibrate_sensitivity();
void update_radar_data(uint16_t const *gate_energy, uint8_t sample_number);
+107 -1
View File
@@ -1,9 +1,12 @@
from collections.abc import Callable
from dataclasses import dataclass, field
import enum
import logging
import esphome.automation as auto
import esphome.codegen as cg
from esphome.components import mqtt, power_supply, web_server
from esphome.components.const import CONF_CHANNEL_COLORS, CONF_IS_WRGB
import esphome.config_validation as cv
from esphome.const import (
CONF_BLUE,
@@ -23,6 +26,7 @@ from esphome.const import (
CONF_ICON,
CONF_ID,
CONF_INITIAL_STATE,
CONF_IS_RGBW,
CONF_MQTT_ID,
CONF_NAME,
CONF_ON_STATE,
@@ -32,6 +36,7 @@ from esphome.const import (
CONF_POWER_SUPPLY,
CONF_RED,
CONF_RESTORE_MODE,
CONF_RGB_ORDER,
CONF_STATE,
CONF_TRIGGER_ID,
CONF_WARM_WHITE,
@@ -61,6 +66,7 @@ from .effects import (
from .types import ( # noqa: F401
AddressableLight,
AddressableLightState,
ChannelColors,
ColorMode,
LightOutput,
LightState,
@@ -71,6 +77,8 @@ from .types import ( # noqa: F401
light_ns,
)
_LOGGER = logging.getLogger(__name__)
CODEOWNERS = ["@esphome/core"]
IS_PLATFORM_COMPONENT = True
@@ -165,7 +173,105 @@ def available_effects_str(effects: list) -> str:
return ", ".join(f"'{name}'" for name in available) if available else "none"
def _final_validate(config: ConfigType) -> ConfigType:
# Accepted values of the deprecated `rgb_order` key.
RGB_ORDERS = ("RGB", "RBG", "GRB", "GBR", "BGR", "BRG")
_RGB_CHANNELS = frozenset("RGB")
_RGBW_CHANNELS = frozenset("RGBW")
def validate_channel_colors(value: str) -> str:
"""Validate the channel order of an addressable strip, e.g. "GRB" or "WRGB"."""
value = cv.string_strict(value).upper()
channels = frozenset(value)
if len(channels) != len(value) or channels not in (_RGB_CHANNELS, _RGBW_CHANNELS):
raise cv.Invalid(
f"'{value}' is not a valid channel order. List each of R, G and B exactly "
"once, optionally with a single W, in the order the strip expects them "
"(for example GRB, GRBW or WRGB)"
)
return value
def channel_colors_struct(value: str) -> cg.StructInitializer:
"""Build the C++ `light::ChannelColors` for a validated channel order string."""
return cg.StructInitializer(
ChannelColors,
("r", value.index("R")),
("g", value.index("G")),
("b", value.index("B")),
(
"w",
value.index("W")
if "W" in value
else cg.RawExpression(f"{ChannelColors}::NO_WHITE"),
),
)
def _quote_and_join(keys: list[str]) -> str:
"""Quote each key and join them into a readable list, e.g. "'a', 'b' and 'c'"."""
quoted = [f"'{key}'" for key in keys]
if len(quoted) == 1:
return quoted[0]
return f"{', '.join(quoted[:-1])} and {quoted[-1]}"
def migrate_channel_colors(
*, removed_in: str, component: str
) -> Callable[[ConfigType], ConfigType]:
"""Fold the deprecated `rgb_order`, `is_rgbw` and `is_wrgb` keys into `channel_colors`.
This also enforces that `channel_colors` is set, which the schema cannot do on its
own while the deprecated keys are still accepted. After this runs, `to_code` only
ever sees `channel_colors`.
"""
def validator(config: ConfigType) -> ConfigType:
config = config.copy()
deprecated = [
key for key in (CONF_RGB_ORDER, CONF_IS_RGBW, CONF_IS_WRGB) if key in config
]
if CONF_CHANNEL_COLORS in config:
if deprecated:
raise cv.Invalid(
f"'{CONF_CHANNEL_COLORS}' cannot be combined with "
f"{_quote_and_join(deprecated)}"
)
return config
if CONF_RGB_ORDER not in config:
raise cv.Invalid(
f"'{CONF_CHANNEL_COLORS}' is required", path=[CONF_CHANNEL_COLORS]
)
rgb_order = config.pop(CONF_RGB_ORDER)
is_rgbw = config.pop(CONF_IS_RGBW, False)
is_wrgb = config.pop(CONF_IS_WRGB, False)
if is_rgbw and is_wrgb:
raise cv.Invalid(
f"'{CONF_IS_RGBW}' and '{CONF_IS_WRGB}' cannot both be enabled"
)
if is_wrgb:
channel_colors = f"W{rgb_order}"
elif is_rgbw:
channel_colors = f"{rgb_order}W"
else:
channel_colors = rgb_order
_LOGGER.warning(
"[%s] %s %s deprecated, use '%s: %s'. Will be removed in %s",
component,
_quote_and_join(deprecated),
"are" if len(deprecated) > 1 else "is",
CONF_CHANNEL_COLORS,
channel_colors,
removed_in,
)
config[CONF_CHANNEL_COLORS] = channel_colors
return config
return validator
def _final_validate(config: ConfigType) -> None:
"""Validate all recorded effect name references against their target lights.
This runs once per light platform instance. If no light platform is configured,
+41
View File
@@ -0,0 +1,41 @@
#pragma once
#include <cstdint>
namespace esphome::light {
/// Which byte of an addressable LED's data carries each colour.
///
/// Built from a configuration string such as "GRB" or "WRGB": every field holds the
/// position that colour occupies in the bytes the strip expects. `w` is NO_WHITE when
/// the strip has no separate white channel.
struct ChannelColors {
/// Value of `w` for a strip that only has red, green and blue channels.
static constexpr uint8_t NO_WHITE = 0xFF;
uint8_t r;
uint8_t g;
uint8_t b;
uint8_t w;
bool has_white() const { return this->w != NO_WHITE; }
uint8_t bytes_per_led() const { return this->has_white() ? 4 : 3; }
/// Write the order back out as text, e.g. "GRBW".
///
/// `buf` must have room for at least 5 characters. Returns `buf` so the result can be
/// passed straight to a log call.
const char *to_string(char *buf) const {
buf[this->r] = 'R';
buf[this->g] = 'G';
buf[this->b] = 'B';
if (this->has_white()) {
buf[this->w] = 'W';
}
buf[this->bytes_per_led()] = '\0';
return buf;
}
};
} // namespace esphome::light
+3
View File
@@ -16,6 +16,9 @@ LightColorValues = light_ns.class_("LightColorValues")
LightStateRTCState = light_ns.struct("LightStateRTCState")
LightCall = light_ns.class_("LightCall")
# Addressable strips
ChannelColors = light_ns.struct("ChannelColors")
# Color modes
ColorMode = light_ns.enum("ColorMode", is_class=True)
COLOR_MODES = {
+2 -2
View File
@@ -1,6 +1,6 @@
from esphome import pins
import esphome.codegen as cg
from esphome.components import i2c
from esphome.components import gpio_expander, i2c
import esphome.config_validation as cv
from esphome.const import (
CONF_ID,
@@ -25,7 +25,7 @@ CONFIG_SCHEMA = (
cv.Schema(
{
cv.Required(CONF_ID): cv.declare_id(MCP23016),
cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema,
cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin,
}
)
.extend(cv.COMPONENT_SCHEMA)
+2 -20
View File
@@ -1,8 +1,8 @@
from esphome import pins
import esphome.codegen as cg
from esphome.components import gpio_expander
import esphome.config_validation as cv
from esphome.const import (
CONF_ALLOW_OTHER_USES,
CONF_ID,
CONF_INPUT,
CONF_INTERRUPT,
@@ -32,28 +32,10 @@ MCP23XXX_INTERRUPT_MODES = {
}
def _validate_interrupt_pin(value):
# The MCP component owns INT polarity (active-low, hardcoded falling-edge ISR)
# and installs a single ISR per GPIO, so neither inversion nor sharing is supported.
value = pins.internal_gpio_input_pin_schema(value)
if value.get(CONF_INVERTED):
raise cv.Invalid(
f"'{CONF_INVERTED}: true' is not supported on '{CONF_INTERRUPT_PIN}'; "
"the MCP23xxx INT line is fixed active-low"
)
if value.get(CONF_ALLOW_OTHER_USES):
raise cv.Invalid(
f"'{CONF_ALLOW_OTHER_USES}: true' is not supported on '{CONF_INTERRUPT_PIN}'; "
"sharing the interrupt pin between multiple MCP23xxx (or other components) "
"is not implemented. Remove the interrupt_pin to fall back to polling."
)
return value
MCP23XXX_CONFIG_SCHEMA = cv.Schema(
{
cv.Optional(CONF_OPEN_DRAIN_INTERRUPT, default=False): cv.boolean,
cv.Optional(CONF_INTERRUPT_PIN): _validate_interrupt_pin,
cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin,
}
).extend(cv.COMPONENT_SCHEMA)
@@ -166,12 +166,7 @@ MANIFEST_SCHEMA_V2 = cv.Schema(
def _compute_local_file_path(config: dict) -> Path:
url = config[CONF_URL]
h = hashlib.new("sha256")
h.update(url.encode())
key = h.hexdigest()[:8]
base_dir = external_files.compute_local_file_dir(DOMAIN)
return base_dir / key
return external_files.compute_local_file_path(DOMAIN, config[CONF_URL])
def _convert_manifest_v1_to_v2(v1_manifest):
@@ -389,11 +384,14 @@ def _download_http_models(config: ConfigType) -> ConfigType:
return config
external_files.download_content_many(
((url, path / "manifest.json") for path, url in http_models.items()),
(
external_files.RemoteFile(url, path / "manifest.json")
for path, url in http_models.items()
),
description="wake word manifest(s)",
)
model_files: list[tuple[str, Path]] = []
model_files: list[external_files.RemoteFile] = []
errors: list[cv.Invalid] = []
for path, url in http_models.items():
try:
@@ -412,7 +410,7 @@ def _download_http_models(config: ConfigType) -> ConfigType:
cv.Invalid(f"Manifest file at {url} is missing the 'model' key")
)
continue
model_files.append((urljoin(url, model), path / model))
model_files.append(external_files.RemoteFile(urljoin(url, model), path / model))
if errors:
raise cv.MultipleInvalid(errors)
+22 -11
View File
@@ -219,14 +219,25 @@ void ModbusServerHub::parse_modbus_frames() {
this->clear_rx_buffer_(LOG_STR("timeout after partial response"), true);
}
uint16_t Modbus::find_custom_frame_end_(uint16_t min_length) const {
// Custom functions could be any length - we have to rely on the CRC to determine completeness.
uint16_t Modbus::find_frame_end_by_crc_(uint16_t min_length) const {
// Unknown-length functions (user-defined codes, unimplemented management codes, unassigned values)
// could be any length - we have to rely on the CRC to determine completeness.
// If a CRC match is never found, the buffer will eventually overflow and be cleared.
const uint8_t *raw = &this->rx_buffer_[0];
const size_t size = this->rx_buffer_.size();
for (uint16_t len = min_length; len <= std::min(size, size_t(MAX_FRAME_SIZE)); len++) {
if (crc16(raw, len) == 0)
return len;
const auto max_len = static_cast<uint16_t>(std::min(size, size_t(MAX_FRAME_SIZE)));
if (min_length > max_len)
return 0;
// The Modbus CRC (poly 0xa001, refin/refout false) keeps its running state in the returned value,
// so we seed once over the first min_length bytes and extend one byte at a time instead of
// recomputing the whole prefix for every candidate length.
uint16_t crc = crc16(raw, min_length);
if (crc == 0)
return min_length;
for (uint16_t len = min_length; len < max_len; len++) {
crc = crc16(&raw[len], 1, crc);
if (crc == 0)
return len + 1;
}
return 0;
}
@@ -241,11 +252,11 @@ bool Modbus::parse_modbus_server_frame_() {
uint8_t address = this->rx_buffer_[0];
uint8_t function_code = this->rx_buffer_[1];
if (helpers::is_function_code_custom(function_code)) {
frame_length = this->find_custom_frame_end_(frame_length);
if (helpers::is_function_code_unknown_length(function_code)) {
frame_length = this->find_frame_end_by_crc_(frame_length);
if (frame_length == 0)
return size < MAX_FRAME_SIZE; // Continue to parse until we hit max size
ESP_LOGD(TAG, "User-defined function %02X found", function_code);
ESP_LOGD(TAG, "Unknown-length function %02X found", function_code);
} else {
if (crc16(&this->rx_buffer_[0], frame_length) != 0)
return false;
@@ -272,11 +283,11 @@ bool ModbusServerHub::parse_modbus_client_frame_() {
uint8_t address = this->rx_buffer_[0];
uint8_t function_code = this->rx_buffer_[1];
if (helpers::is_function_code_custom(function_code)) {
frame_length = this->find_custom_frame_end_(frame_length);
if (helpers::is_function_code_unknown_length(function_code)) {
frame_length = this->find_frame_end_by_crc_(frame_length);
if (frame_length == 0)
return size < MAX_FRAME_SIZE; // Continue to parse until we hit max size
ESP_LOGD(TAG, "User-defined function %02X found", function_code);
ESP_LOGD(TAG, "Unknown-length function %02X found", function_code);
} else {
if (crc16(&this->rx_buffer_[0], frame_length) != 0)
return false;
+1 -1
View File
@@ -82,7 +82,7 @@ class Modbus : public uart::UARTDevice, public Component {
bool send_frame_(const ModbusFrame &frame);
// Scans forward from min_length to find a frame boundary by CRC match for custom function codes.
// Returns the matched frame length, or 0 if no valid CRC was found within MAX_FRAME_SIZE.
uint16_t find_custom_frame_end_(uint16_t min_length) const;
uint16_t find_frame_end_by_crc_(uint16_t min_length) const;
uint32_t last_modbus_byte_{0};
uint32_t last_receive_check_{0};
@@ -55,6 +55,38 @@ inline bool is_function_code_custom(uint8_t function_code) {
masked_function_code <= FUNCTION_CODE_USER_DEFINED_SPACE_2_END);
}
/// True for any function code whose frame length the parsers cannot predict - everything the
/// server_pdu_length()/client_pdu_length() switches fall through to `default` on (keep the case list
/// in step with those switches). Deliberately wider than is_function_code_custom(): the user-defined
/// ranges are unknown to the parser too, but so are the assigned-but-unimplemented codes
/// (READ_EXCEPTION_STATUS, DIAGNOSTICS, GET_COMM_EVENT_*, REPORT_SERVER_ID) and every unassigned value.
/// The 0x80 exception flag is masked off first, so a frame with it set classifies by its base code -
/// even though a spec exception reply has a known 2-byte PDU. That is deliberate, matching what
/// is_function_code_custom() has always done: some vendors use codes with the 0x80 bit set as ordinary
/// codes with longer payloads, so the response parser CRC-scans these rather than assuming the spec
/// length. For an intact spec exception the scan matches at its first candidate, so only a corrupt one
/// pays (recovery by timeout instead of an immediate CRC failure).
inline bool is_function_code_unknown_length(uint8_t function_code) {
switch (static_cast<FunctionCode>(function_code & FUNCTION_CODE_MASK)) {
case FunctionCode::READ_COILS:
case FunctionCode::READ_DISCRETE_INPUTS:
case FunctionCode::READ_HOLDING_REGISTERS:
case FunctionCode::READ_INPUT_REGISTERS:
case FunctionCode::WRITE_SINGLE_COIL:
case FunctionCode::WRITE_SINGLE_REGISTER:
case FunctionCode::WRITE_MULTIPLE_COILS:
case FunctionCode::WRITE_MULTIPLE_REGISTERS:
case FunctionCode::READ_FILE_RECORD:
case FunctionCode::WRITE_FILE_RECORD:
case FunctionCode::MASK_WRITE_REGISTER:
case FunctionCode::READ_WRITE_MULTIPLE_REGISTERS:
case FunctionCode::READ_FIFO_QUEUE:
return false;
default:
return true;
}
}
// Returns the expected length of a server response PDU based on the function code.
// If too few bytes have arrived to determine the length, returns the minimum length. `size` is the
// number of bytes available so far, which may exceed the eventual PDU (e.g. include the frame's CRC
-63
View File
@@ -63,7 +63,6 @@ from esphome.const import (
PlatformFramework,
)
from esphome.core import CORE, CoroPriority, coroutine_with_priority
from esphome.core.entity_helpers import ObjectIdEntity, validate_no_object_id_conflicts
from esphome.types import ConfigType
DEPENDENCIES = ["network"]
@@ -333,68 +332,6 @@ CONFIG_SCHEMA = cv.All(
)
# Platforms whose MQTT components subscribe to an object_id-derived command topic.
# Keep in sync with the platforms extending cv.MQTT_COMMAND_COMPONENT_SCHEMA, plus
# text, whose MQTT component subscribes a command topic that cannot be overridden.
_COMMAND_TOPIC_PLATFORMS = frozenset(
{
"alarm_control_panel",
"button",
"climate",
"cover",
"datetime",
"fan",
"light",
"lock",
"number",
"select",
"switch",
"text",
"update",
"valve",
}
)
# Platforms whose MQTT components derive extra sub-topics (position/command,
# mode/command, speed/command, ...) from the object_id, each with its own config
# key; custom state and command topics cannot exempt them from conflicting.
_SUB_TOPIC_PLATFORMS = frozenset({"climate", "cover", "fan", "valve"})
def _topics_conflict(entities: list[ObjectIdEntity], config: ConfigType) -> bool:
"""Check whether more than one entity actually uses an object_id-derived topic.
An empty topic_prefix disables default topics entirely, custom state and
command topics avoid the default topics, and disabling discovery (globally
or per entity) avoids the discovery config topic.
"""
if config[CONF_TOPIC_PREFIX]:
platform = entities[0].platform
if platform in _SUB_TOPIC_PLATFORMS:
return True
if sum(CONF_STATE_TOPIC not in entity.config for entity in entities) > 1:
return True
if (
platform in _COMMAND_TOPIC_PLATFORMS
and sum(CONF_COMMAND_TOPIC not in entity.config for entity in entities) > 1
):
return True
if not config[CONF_DISCOVERY]:
return False
discovery_entities = sum(
entity.config.get(CONF_DISCOVERY, True) for entity in entities
)
return discovery_entities > 1
FINAL_VALIDATE_SCHEMA = validate_no_object_id_conflicts(
"mqtt builds default topics and discovery topics from the entity object_id, "
"which is the name converted to ASCII",
conflict_filter=_topics_conflict,
)
def exp_mqtt_message(config):
if config is None:
return cg.optional(cg.TemplateArguments(MQTTMessage))
+2 -2
View File
@@ -1,6 +1,6 @@
from esphome import pins
import esphome.codegen as cg
from esphome.components import i2c
from esphome.components import gpio_expander, i2c
import esphome.config_validation as cv
from esphome.const import (
CONF_ID,
@@ -29,7 +29,7 @@ CONFIG_SCHEMA = (
cv.Schema(
{
cv.Required(CONF_ID): cv.declare_id(PCA6416AComponent),
cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema,
cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin,
}
)
.extend(cv.COMPONENT_SCHEMA)
+2 -2
View File
@@ -1,6 +1,6 @@
from esphome import pins
import esphome.codegen as cg
from esphome.components import i2c
from esphome.components import gpio_expander, i2c
import esphome.config_validation as cv
from esphome.const import (
CONF_ID,
@@ -30,7 +30,7 @@ CONFIG_SCHEMA = (
{
cv.Required(CONF_ID): cv.declare_id(PCA9554Component),
cv.Optional(CONF_PIN_COUNT, default=8): cv.one_of(4, 8, 16),
cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema,
cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin,
}
)
.extend(cv.COMPONENT_SCHEMA)
+2 -2
View File
@@ -1,6 +1,6 @@
from esphome import pins
import esphome.codegen as cg
from esphome.components import i2c
from esphome.components import gpio_expander, i2c
import esphome.config_validation as cv
from esphome.const import (
CONF_ID,
@@ -28,7 +28,7 @@ CONFIG_SCHEMA = (
{
cv.Required(CONF_ID): cv.declare_id(PCF8574Component),
cv.Optional(CONF_PCF8575, default=False): cv.boolean,
cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema,
cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin,
}
)
.extend(cv.COMPONENT_SCHEMA)
+2 -2
View File
@@ -1,6 +1,6 @@
from esphome import pins
import esphome.codegen as cg
from esphome.components import i2c
from esphome.components import gpio_expander, i2c
import esphome.config_validation as cv
from esphome.const import (
CONF_ID,
@@ -34,7 +34,7 @@ CONFIG_SCHEMA = (
{
cv.Required(CONF_ID): cv.declare_id(PI4IOE5V6408Component),
cv.Optional(CONF_RESET, default=True): cv.boolean,
cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema,
cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin,
}
)
.extend(cv.COMPONENT_SCHEMA)
@@ -3,7 +3,6 @@ from esphome.components import web_server_base
from esphome.components.web_server_base import CONF_WEB_SERVER_BASE_ID
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_INCLUDE_INTERNAL, CONF_NAME, CONF_RELABEL
from esphome.core.entity_helpers import validate_no_object_id_conflicts
from esphome.cpp_types import EntityBase
AUTO_LOAD = ["web_server_base"]
@@ -36,11 +35,6 @@ CONFIG_SCHEMA = cv.Schema(
},
).extend(cv.COMPONENT_SCHEMA)
FINAL_VALIDATE_SCHEMA = validate_no_object_id_conflicts(
"prometheus builds metric labels from the entity object_id, "
"which is the name converted to ASCII"
)
async def to_code(config):
paren = await cg.get_variable(config[CONF_WEB_SERVER_BASE_ID])
@@ -99,8 +99,12 @@ bool RadioFrequency::on_receive(remote_base::RemoteReceiveData data) {
// Forward received RF data to API server
#if defined(USE_API) && defined(USE_RADIO_FREQUENCY)
if (api::global_api_server != nullptr) {
api::global_api_server->send_infrared_rf_receive_event(this->get_device_id_or_zero(), this->get_entity_key(),
&data.get_raw_data());
#ifdef USE_DEVICES
uint32_t device_id = this->get_device_id();
#else
uint32_t device_id = 0;
#endif
api::global_api_server->send_infrared_rf_receive_event(device_id, this->get_object_id_hash(), &data.get_raw_data());
}
#endif
return false; // Don't consume the event, allow other listeners to process it
@@ -220,7 +220,7 @@ void RotaryEncoderSensor::loop() {
}
if (this->pin_i_ != nullptr && this->pin_i_->digital_read()) {
this->store_.counter = 0;
this->store_.counter = std::clamp<int32_t>(0, this->store_.min_value, this->store_.max_value);
}
int counter = this->store_.counter;
if (this->store_.last_read != counter || this->publish_initial_value_) {
+96 -54
View File
@@ -388,6 +388,74 @@ async def to_code(config):
_configure_lwip()
# --- lwIP sizing. See _configure_lwip() for the platform comparison table. ---
# TCP_SND_BUF: 4×MSS=5,840 matches ESP32. Down from arduino-pico's 8×MSS.
# ESPAsyncWebServer allocates malloc(tcp_sndbuf()) per response chunk.
LWIP_TCP_SND_BUF = "(4*TCP_MSS)"
# TCP_WND: receive window. 4×MSS matches ESP32. Down from arduino-pico's 8×MSS.
LWIP_TCP_WND = "(4*TCP_MSS)"
# TCP_SND_QUEUELEN: max pbufs queued per PCB for the send buffer
# ESP-IDF formula: (4 * TCP_SND_BUF + (TCP_MSS - 1)) / TCP_MSS
# With 4×MSS: (4*5840 + 1459) / 1460 = 17 — match ESP32
LWIP_TCP_SND_QUEUELEN = 17
# MEMP_NUM_TCP_SEG: pool shared by every PCB, so it must not be the per-PCB
# queue length — lwIP's sanity check only demands >=, the floor for a single
# connection. 2× lets two PCBs fill up before the rest see ERR_MEM. Measured
# at 20 bytes per entry, so under 700 bytes total.
LWIP_MEMP_NUM_TCP_SEG = 2 * LWIP_TCP_SND_QUEUELEN
# PBUF_POOL_SIZE: RP2040 has 264KB RAM, more generous than LibreTiny.
# 16 matches ESP32 (vs arduino-pico's 24). Receive side only; the send path
# copies into PBUF_RAM out of MEM_SIZE.
LWIP_PBUF_POOL_SIZE = 16
# MEM_SIZE: lwIP heap backing PBUF_RAM, where tcp_write() copies outgoing
# data. TCP_OVERSIZE defaults to TCP_MSS, so each queued segment takes a full
# MSS block whatever was written (pbuf 16 + PBUF_TRANSPORT 54 + MSS 1460 +
# block header ≈ 1.5KB); a PCB at a full TCP_SND_BUF holds four, ~6KB.
#
# Two of those is ~12KB of arduino-pico's 16KB heap and already fails: mem.c
# is first-fit, so a *contiguous* 1.5KB block must be free, and at 75%
# occupancy interleaved with ARP/DHCP/DNS/mDNS the largest run collapses well
# before the total does — hence the intermittent failures. With rp2's
# max_connections of 4, a third sender has nothing left.
#
# 32KB is arduino-pico's own next tier (__LWIP_MEMMULT=2 boards).
# Must stay under 64000 or lwIP widens mem_size_t to u32_t.
LWIP_MEM_SIZE = 32768
def build_lwip_defines(
tcp_sockets: int, udp_sockets: int, listening_tcp: int
) -> dict[str, str]:
"""Render the lwIP override values for the Jinja2 template.
The template uses #include_next to chain to the framework's original
lwipopts.h, then #undef/#define only these. Split out from
_configure_lwip() so the values that actually reach the generated header
can be checked without standing up CORE.
Both malloc flags stay 0 (framework defaults); see _configure_lwip(). The
static pools are the only IRQ-safe allocator on this platform, so the fix
is to size them correctly rather than to make them dynamic.
"""
return {
"TCP_SND_BUF": LWIP_TCP_SND_BUF,
"TCP_WND": LWIP_TCP_WND,
"TCP_SND_QUEUELEN": str(LWIP_TCP_SND_QUEUELEN),
"MEM_SIZE": str(LWIP_MEM_SIZE),
"MEMP_NUM_TCP_SEG": str(LWIP_MEMP_NUM_TCP_SEG),
"PBUF_POOL_SIZE": str(LWIP_PBUF_POOL_SIZE),
"MEMP_NUM_TCP_PCB": str(tcp_sockets),
"MEMP_NUM_TCP_PCB_LISTEN": str(listening_tcp),
"MEMP_NUM_UDP_PCB": str(udp_sockets),
}
def _configure_lwip() -> None:
"""Configure lwIP options for RP2040 by generating a custom lwipopts.h.
@@ -407,25 +475,36 @@ def _configure_lwip() -> None:
TCP_SND_BUF 2×MSS 4×MSS 8×MSS 4×MSS
TCP_WND 4×MSS 4×MSS 8×MSS 4×MSS
TCP_SND_QUEUELEN ~8 17 32 17
MEM_LIBC_MALLOC 1 1 0 0*
MEMP_MEM_MALLOC 1 1 0 0**
MEM_SIZE N/A*** N/A*** 16KB 16KB
MEM_SIZE N/A*** N/A*** 16KB 32KB
PBUF_POOL_SIZE 10 16 24 16
MEMP_NUM_TCP_SEG 10 16 32 17
MEMP_NUM_TCP_SEG 10 16 32 34****
MEMP_NUM_TCP_PCB 5 16 5 dynamic
MEMP_NUM_TCP_PCB_LISTEN 4 16 8**** dynamic
MEMP_NUM_TCP_PCB_LISTEN 4 16 8***** dynamic
MEMP_NUM_UDP_PCB 4 16 7 dynamic
TCP_SND_QUEUELEN ~8 17 32 17
* MEM_LIBC_MALLOC must stay 0: arduino-pico uses
PICO_CYW43_ARCH_THREADSAFE_BACKGROUND which runs lwIP callbacks from
a low-priority pendsv IRQ. The pico-sdk explicitly blocks
MEM_LIBC_MALLOC=1 because libc malloc uses mutexes (unsafe in IRQ).
** MEMP_MEM_MALLOC must stay 0: the dedicated lwIP heap (MEM_SIZE=16KB)
is too small to hold all pools dynamically. The PBUF_POOL alone needs
~24KB (16 × 1524 bytes). Increasing MEM_SIZE would negate BSS savings.
*** ESP8266/ESP32 use MEM_LIBC_MALLOC=1 (system heap, no dedicated pool).
**** opt.h default; arduino-pico doesn't override MEMP_NUM_TCP_PCB_LISTEN.
** MEMP_MEM_MALLOC must stay 0 for IRQ safety, not size. memp_malloc()
pops the pool free list inside SYS_ARCH_PROTECT, but lwIP's heap takes
its protection from LWIP_ALLOW_MEM_FREE_FROM_OTHER_CONTEXT (default 0),
so under NO_SYS=1 mem_malloc()/mem_free() are unprotected and memp.c
calls mem_malloc() outside the guard anyway. RX pbufs would then be
allocated from the pendsv IRQ on the same unguarded free list the main
loop uses for tcp_write(). Tried on hardware: faults within seconds on
CYW43. Ethernet survives only because it polls from the main loop.
*** ESP8266/ESP32 ship MEMP_MEM_MALLOC=1, so their pool entries come from
the heap on demand and MEMP_NUM_*/PBUF_POOL_SIZE are labels, not caps
(MEM_LIBC_MALLOC=1 points that heap at the system heap). Both flags are
0 here, so ours are hard limits; don't copy their numbers.
**** MEMP_NUM_TCP_SEG is *global* while TCP_SND_QUEUELEN is *per-PCB*, so
sizing it to the per-PCB value lets one busy connection drain it for
every other. 2× covers two PCBs; MEM_SIZE is the real limit past that.
***** opt.h default; arduino-pico doesn't override MEMP_NUM_TCP_PCB_LISTEN.
"dynamic" = auto-calculated from component socket registrations via
socket.get_socket_counts() with minimums of 8 TCP / 6 UDP / 2 TCP_LISTEN.
"""
@@ -444,48 +523,7 @@ def _configure_lwip() -> None:
# UDP PCBs (2) are absorbed by the generous minimum of 6.
listening_tcp = max(MIN_TCP_LISTEN_SOCKETS, sc.tcp_listen)
# TCP_SND_BUF: 4×MSS=5,840 matches ESP32. Down from arduino-pico's 8×MSS.
# ESPAsyncWebServer allocates malloc(tcp_sndbuf()) per response chunk.
tcp_snd_buf = "(4*TCP_MSS)"
# TCP_WND: receive window. 4×MSS matches ESP32. Down from arduino-pico's 8×MSS.
tcp_wnd = "(4*TCP_MSS)"
# TCP_SND_QUEUELEN: max pbufs queued for send buffer
# ESP-IDF formula: (4 * TCP_SND_BUF + (TCP_MSS - 1)) / TCP_MSS
# With 4×MSS: (4*5840 + 1459) / 1460 = 17 — match ESP32
tcp_snd_queuelen = 17
# MEMP_NUM_TCP_SEG: segment pool, must be >= TCP_SND_QUEUELEN (lwIP sanity check)
memp_num_tcp_seg = tcp_snd_queuelen
# PBUF_POOL_SIZE: RP2040 has 264KB RAM, more generous than LibreTiny.
# 16 matches ESP32 (vs arduino-pico's 24). With MEMP_MEM_MALLOC=1,
# this is a max count (allocated on demand from heap).
pbuf_pool_size = 16
# Build the lwIP override defines for the Jinja2 template.
# The template uses #include_next to chain to the framework's original
# lwipopts.h, then #undef/#define only the values we need to change.
#
# Note: MEMP_MEM_MALLOC stays 0 (framework default). While the memp
# allocations use the dedicated lwIP heap (IRQ-safe), the 16KB MEM_SIZE
# is too small to hold all pools dynamically under stress. The PBUF_POOL
# alone needs ~24KB (16 × 1524 bytes). Increasing MEM_SIZE would negate
# the BSS savings.
#
# MEM_LIBC_MALLOC stays 0 (framework default): arduino-pico uses
# PICO_CYW43_ARCH_THREADSAFE_BACKGROUND which runs lwIP callbacks from
# a low-priority pendsv IRQ where libc malloc (mutex-based) is unsafe.
lwip_defines: dict[str, str] = {
"TCP_SND_BUF": tcp_snd_buf,
"TCP_WND": tcp_wnd,
"TCP_SND_QUEUELEN": str(tcp_snd_queuelen),
"MEMP_NUM_TCP_SEG": str(memp_num_tcp_seg),
"PBUF_POOL_SIZE": str(pbuf_pool_size),
"MEMP_NUM_TCP_PCB": str(tcp_sockets),
"MEMP_NUM_TCP_PCB_LISTEN": str(listening_tcp),
"MEMP_NUM_UDP_PCB": str(udp_sockets),
}
lwip_defines = build_lwip_defines(tcp_sockets, udp_sockets, listening_tcp)
# Store for copy_files() to generate the header
CORE.data[KEY_RP2][KEY_LWIP_OPTS] = lwip_defines
@@ -500,7 +538,8 @@ def _configure_lwip() -> None:
udp_min = " (min)" if udp_sockets > sc.udp else ""
listen_min = " (min)" if listening_tcp > sc.tcp_listen else ""
_LOGGER.info(
"Configuring lwIP: TCP=%d%s [%s], UDP=%d%s [%s], TCP_LISTEN=%d%s [%s]",
"Configuring lwIP: %d byte heap; TCP=%d%s [%s], UDP=%d%s [%s], TCP_LISTEN=%d%s [%s]",
LWIP_MEM_SIZE,
tcp_sockets,
tcp_min,
sc.tcp_details,
@@ -521,7 +560,7 @@ def _generate_lwipopts_h() -> None:
in the build directory, and a pre-build script injects this directory
into the compiler include path before the framework's own include dir.
"""
from jinja2 import Environment
from jinja2 import Environment, StrictUndefined
lwip_defines = CORE.data[KEY_RP2].get(KEY_LWIP_OPTS)
if not lwip_defines:
@@ -534,7 +573,10 @@ def _generate_lwipopts_h() -> None:
template_text = (Path(__file__).parent / "lwipopts.h.jinja").read_text(
encoding="utf-8"
)
jinja_env = Environment(keep_trailing_newline=True)
# StrictUndefined: a placeholder with no value would otherwise render
# empty, emitting a bare #define that compiles and silently means
# something else in lwIP's config.
jinja_env = Environment(keep_trailing_newline=True, undefined=StrictUndefined)
template = jinja_env.from_string(template_text)
content = template.render(**lwip_defines)
+12 -1
View File
@@ -20,13 +20,24 @@
#undef TCP_WND
#define TCP_WND {{ TCP_WND }}
// Queued segment limits: derived from 4xMSS buffer size, matching ESP32
// Per-PCB send queue: derived from 4xMSS buffer size, matching ESP32
#undef TCP_SND_QUEUELEN
#define TCP_SND_QUEUELEN {{ TCP_SND_QUEUELEN }}
// Segment pool: global across every PCB, so it is sized above the per-PCB
// queue length rather than equal to it. lwIP's sanity check only requires
// >= TCP_SND_QUEUELEN, which is the floor for a single connection.
#undef MEMP_NUM_TCP_SEG
#define MEMP_NUM_TCP_SEG {{ MEMP_NUM_TCP_SEG }}
// lwIP heap backing PBUF_RAM, which is what tcp_write() copies into.
// Raised from arduino-pico's 16KB: TCP_OVERSIZE is TCP_MSS, so a single PCB
// at a full TCP_SND_BUF pins about 6KB. Two of those left the 16KB heap at
// 75%, and mem.c is first-fit, so the largest contiguous run ran out well
// before the total did.
#undef MEM_SIZE
#define MEM_SIZE {{ MEM_SIZE }}
// Packet buffer pool: 16 matches ESP32 (down from 24)
#undef PBUF_POOL_SIZE
#define PBUF_POOL_SIZE {{ PBUF_POOL_SIZE }}
@@ -107,10 +107,10 @@ void RP2040PIOLEDStripLightOutput::setup() {
pio_get_dreq(this->pio_, this->sm_, true)); // set the DREQ to the state machine's TX FIFO
dma_channel_configure(this->dma_chan_, &this->dma_config_,
&this->pio_->txf[this->sm_], // write to the state machine's TX FIFO
this->buf_, // read from memory
this->is_rgbw_ ? num_leds_ * 4 : num_leds_ * 3, // number of bytes to transfer
false // don't start yet
&this->pio_->txf[this->sm_], // write to the state machine's TX FIFO
this->buf_, // read from memory
this->get_buffer_size_(), // number of bytes to transfer
false // don't start yet
);
// Initialize the semaphore for this DMA channel
@@ -142,58 +142,25 @@ void RP2040PIOLEDStripLightOutput::write_state(light::LightState *state) {
}
light::ESPColorView RP2040PIOLEDStripLightOutput::get_view_internal(int32_t index) const {
int32_t r = 0, g = 0, b = 0;
switch (this->rgb_order_) {
case ORDER_RGB:
r = 0;
g = 1;
b = 2;
break;
case ORDER_RBG:
r = 0;
g = 2;
b = 1;
break;
case ORDER_GRB:
r = 1;
g = 0;
b = 2;
break;
case ORDER_GBR:
r = 2;
g = 0;
b = 1;
break;
case ORDER_BGR:
r = 2;
g = 1;
b = 0;
break;
case ORDER_BRG:
r = 1;
g = 2;
b = 0;
break;
}
uint8_t multiplier = this->is_rgbw_ ? 4 : 3;
return {this->buf_ + (index * multiplier) + r,
this->buf_ + (index * multiplier) + g,
this->buf_ + (index * multiplier) + b,
this->is_rgbw_ ? this->buf_ + (index * multiplier) + 3 : nullptr,
const light::ChannelColors &colors = this->channel_colors_;
uint8_t *led = this->buf_ + (index * colors.bytes_per_led());
return {led + colors.r,
led + colors.g,
led + colors.b,
colors.has_white() ? led + colors.w : nullptr,
&this->effect_data_[index],
&this->correction_};
}
void RP2040PIOLEDStripLightOutput::dump_config() {
char channel_colors[5];
ESP_LOGCONFIG(TAG,
"RP2040 PIO LED Strip Light Output:\n"
" Pin: GPIO%d\n"
" Number of LEDs: %d\n"
" RGBW: %s\n"
" RGB Order: %s\n"
" Channel colors: %s\n"
" Max Refresh Rate: %f Hz",
this->pin_, this->num_leds_, YESNO(this->is_rgbw_), rgb_order_to_string(this->rgb_order_),
this->max_refresh_rate_);
this->pin_, this->num_leds_, this->channel_colors_.to_string(channel_colors), this->max_refresh_rate_);
}
float RP2040PIOLEDStripLightOutput::get_setup_priority() const { return setup_priority::HARDWARE; }
@@ -7,6 +7,7 @@
#include "esphome/core/helpers.h"
#include "esphome/components/light/addressable_light.h"
#include "esphome/components/light/channel_colors.h"
#include "esphome/components/light/light_output.h"
#include <hardware/dma.h>
@@ -18,15 +19,6 @@
namespace esphome::rp2040_pio_led_strip {
enum RGBOrder : uint8_t {
ORDER_RGB,
ORDER_RBG,
ORDER_GRB,
ORDER_GBR,
ORDER_BGR,
ORDER_BRG,
};
enum Chipset : uint8_t {
CHIPSET_WS2812,
CHIPSET_WS2812B,
@@ -36,25 +28,6 @@ enum Chipset : uint8_t {
CHIPSET_CUSTOM = 0xFF,
};
inline const char *rgb_order_to_string(RGBOrder order) {
switch (order) {
case ORDER_RGB:
return "RGB";
case ORDER_RBG:
return "RBG";
case ORDER_GRB:
return "GRB";
case ORDER_GBR:
return "GBR";
case ORDER_BGR:
return "BGR";
case ORDER_BRG:
return "BRG";
default:
return "UNKNOWN";
}
}
using init_fn = void (*)(PIO pio, uint sm, uint offset, uint pin, float freq);
class RP2040PIOLEDStripLightOutput final : public light::AddressableLight {
@@ -66,13 +39,14 @@ class RP2040PIOLEDStripLightOutput final : public light::AddressableLight {
int32_t size() const override { return this->num_leds_; }
light::LightTraits get_traits() override {
auto traits = light::LightTraits();
this->is_rgbw_ ? traits.set_supported_color_modes({light::ColorMode::RGB_WHITE, light::ColorMode::WHITE})
: traits.set_supported_color_modes({light::ColorMode::RGB});
this->channel_colors_.has_white()
? traits.set_supported_color_modes({light::ColorMode::RGB_WHITE, light::ColorMode::WHITE})
: traits.set_supported_color_modes({light::ColorMode::RGB});
return traits;
}
void set_pin(uint8_t pin) { this->pin_ = pin; }
void set_num_leds(uint32_t num_leds) { this->num_leds_ = num_leds; }
void set_is_rgbw(bool is_rgbw) { this->is_rgbw_ = is_rgbw; }
void set_channel_colors(light::ChannelColors channel_colors) { this->channel_colors_ = channel_colors; }
void set_max_refresh_rate(float interval_us) { this->max_refresh_rate_ = interval_us; }
@@ -81,7 +55,6 @@ class RP2040PIOLEDStripLightOutput final : public light::AddressableLight {
void set_init_function(init_fn init) { this->init_ = init; }
void set_chipset(Chipset chipset) { this->chipset_ = chipset; };
void set_rgb_order(RGBOrder rgb_order) { this->rgb_order_ = rgb_order; }
void clear_effect_data() override {
for (int i = 0; i < this->size(); i++) {
this->effect_data_[i] = 0;
@@ -93,7 +66,7 @@ class RP2040PIOLEDStripLightOutput final : public light::AddressableLight {
protected:
light::ESPColorView get_view_internal(int32_t index) const override;
size_t get_buffer_size_() const { return this->num_leds_ * (3 + this->is_rgbw_); }
size_t get_buffer_size_() const { return this->num_leds_ * this->channel_colors_.bytes_per_led(); }
static void dma_write_complete_handler();
@@ -102,14 +75,13 @@ class RP2040PIOLEDStripLightOutput final : public light::AddressableLight {
uint8_t pin_;
uint32_t num_leds_;
bool is_rgbw_;
pio_hw_t *pio_;
uint sm_;
uint dma_chan_;
dma_channel_config dma_config_;
RGBOrder rgb_order_{ORDER_RGB};
light::ChannelColors channel_colors_{0, 1, 2, light::ChannelColors::NO_WHITE};
Chipset chipset_{CHIPSET_CUSTOM};
uint32_t last_refresh_{0};
@@ -3,6 +3,7 @@ from dataclasses import dataclass
from esphome import pins
import esphome.codegen as cg
from esphome.components import light, rp2
from esphome.components.const import CONF_CHANNEL_COLORS
import esphome.config_validation as cv
from esphome.const import (
CONF_CHIPSET,
@@ -13,6 +14,7 @@ from esphome.const import (
CONF_PIN,
CONF_RGB_ORDER,
)
from esphome.types import ConfigType
from esphome.util import _LOGGER
@@ -37,7 +39,7 @@ def get_nops(timing):
return nops
def generate_assembly_code(id, rgbw, t0h, t0l, t1h, t1l):
def generate_assembly_code(id, t0h, t0l, t1h, t1l):
"""
Generate assembly code with the given timing values.
"""
@@ -139,8 +141,6 @@ RP2040PIOLEDStripLightOutput = rp2040_pio_led_strip_ns.class_(
"RP2040PIOLEDStripLightOutput", light.AddressableLight
)
RGBOrder = rp2040_pio_led_strip_ns.enum("RGBOrder")
Chipset = rp2040_pio_led_strip_ns.enum("Chipset")
CHIPSETS = {
@@ -159,15 +159,6 @@ class LEDStripTimings:
T1L: int
RGB_ORDERS = {
"RGB": RGBOrder.ORDER_RGB,
"RBG": RGBOrder.ORDER_RBG,
"GRB": RGBOrder.ORDER_GRB,
"GBR": RGBOrder.ORDER_GBR,
"BGR": RGBOrder.ORDER_BGR,
"BRG": RGBOrder.ORDER_BRG,
}
CHIPSET_TIMINGS = {
"WS2812": LEDStripTimings(20, 40, 46, 34),
"WS2812B": LEDStripTimings(23, 49, 46, 26),
@@ -199,10 +190,12 @@ CONFIG_SCHEMA = cv.All(
cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(RP2040PIOLEDStripLightOutput),
cv.Required(CONF_PIN): pins.internal_gpio_output_pin_number,
cv.Required(CONF_NUM_LEDS): cv.positive_not_null_int,
cv.Required(CONF_RGB_ORDER): cv.enum(RGB_ORDERS, upper=True),
cv.Optional(CONF_CHANNEL_COLORS): light.validate_channel_colors,
# Deprecated in favour of CONF_CHANNEL_COLORS, remove in 2027.3.0
cv.Optional(CONF_RGB_ORDER): cv.one_of(*light.RGB_ORDERS, upper=True),
cv.Optional(CONF_IS_RGBW): cv.boolean,
cv.Required(CONF_PIO): cv.one_of(0, 1, int=True),
cv.Optional(CONF_CHIPSET): cv.enum(CHIPSETS, upper=True),
cv.Optional(CONF_IS_RGBW, default=False): cv.boolean,
cv.Inclusive(
CONF_BIT0_HIGH,
"custom",
@@ -222,10 +215,13 @@ CONFIG_SCHEMA = cv.All(
}
),
cv.has_exactly_one_key(CONF_CHIPSET, CONF_BIT0_HIGH),
light.migrate_channel_colors(
removed_in="2027.3.0", component="rp2040_pio_led_strip"
),
)
async def to_code(config):
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_OUTPUT_ID])
id = config[CONF_ID].id
await light.register_light(var, config)
@@ -234,8 +230,9 @@ async def to_code(config):
cg.add(var.set_num_leds(config[CONF_NUM_LEDS]))
cg.add(var.set_pin(config[CONF_PIN]))
cg.add(var.set_rgb_order(config[CONF_RGB_ORDER]))
cg.add(var.set_is_rgbw(config[CONF_IS_RGBW]))
cg.add(
var.set_channel_colors(light.channel_colors_struct(config[CONF_CHANNEL_COLORS]))
)
cg.add(var.set_pio(config[CONF_PIO]))
cg.add(var.set_program(cg.RawExpression(f"&rp2040_pio_led_strip_{id}_program")))
@@ -255,7 +252,6 @@ async def to_code(config):
key,
generate_assembly_code(
id,
config[CONF_IS_RGBW],
CHIPSET_TIMINGS[chipset].T0H,
CHIPSET_TIMINGS[chipset].T0L,
CHIPSET_TIMINGS[chipset].T1H,
@@ -270,7 +266,6 @@ async def to_code(config):
key,
generate_assembly_code(
id,
config[CONF_IS_RGBW],
time_to_cycles(config[CONF_BIT0_HIGH]),
time_to_cycles(config[CONF_BIT0_LOW]),
time_to_cycles(config[CONF_BIT1_HIGH]),
+2 -3
View File
@@ -5,6 +5,7 @@ from esphome.components.const import CONF_BYTE_ORDER
from esphome.components.image import (
IMAGE_TYPE,
Image_,
validate_byte_order,
validate_settings,
validate_transparency,
validate_type,
@@ -128,9 +129,7 @@ def runtime_image_schema(image_class: cg.MockObjClass = RuntimeImage) -> cv.Sche
cv.Required(CONF_FORMAT): cv.one_of(*IMAGE_FORMATS, upper=True),
cv.Optional(CONF_RESIZE): cv.dimensions,
cv.Required(CONF_TYPE): validate_type(IMAGE_TYPE),
cv.Optional(CONF_BYTE_ORDER): cv.one_of(
"BIG_ENDIAN", "LITTLE_ENDIAN", upper=True
),
cv.Optional(CONF_BYTE_ORDER): validate_byte_order,
cv.Optional(CONF_TRANSPARENCY, default="OPAQUE"): validate_transparency(),
cv.Optional(CONF_PLACEHOLDER): cv.use_id(Image_),
}
+1 -1
View File
@@ -234,7 +234,7 @@ async def to_code(config: ConfigType) -> None:
psram.request_external_task_stack()
# sendspin-cpp library
esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.7.1")
esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.7.2")
cg.add_define("USE_SENDSPIN", True) # for MDNS
+6 -4
View File
@@ -283,8 +283,11 @@ DeltaFilter::DeltaFilter(float min_a0, float min_a1, float max_a0, float max_a1)
void DeltaFilter::set_baseline(float (*fn)(float)) { this->baseline_ = fn; }
optional<float> DeltaFilter::new_value(float value) {
// Always yield the first value.
if (std::isnan(this->last_value_)) {
const bool no_value = std::isnan(value);
const bool no_reference = std::isnan(this->last_value_);
if (no_value && no_reference)
return {};
if (no_value || no_reference) {
this->last_value_ = value;
return value;
}
@@ -293,8 +296,7 @@ optional<float> DeltaFilter::new_value(float value) {
float min = fabsf(this->min_a0_ + ref * this->min_a1_);
float max = fabsf(this->max_a0_ + ref * this->max_a1_);
float delta = fabsf(value - ref);
// if there is no reference, e.g. for the first value, just accept this one,
// otherwise accept only if within range.
// accept only if within range
if (delta > min && delta <= max) {
this->last_value_ = value;
return value;
+73 -35
View File
@@ -2,9 +2,7 @@ import hashlib
from pathlib import Path
import re
import requests
from esphome import pins
from esphome import external_files, pins
import esphome.codegen as cg
from esphome.components import light, sensor, uart
from esphome.components.const import CONF_SHA256
@@ -28,8 +26,9 @@ from esphome.const import (
UNIT_VOLT,
UNIT_WATT,
)
from esphome.core import CORE, HexInt
from esphome.happy_eyeballs import ensure_happy_eyeballs
from esphome.core import HexInt
from esphome.external_files import RemoteFile
from esphome.types import ConfigType
DOMAIN = "shelly_dimmer"
AUTO_LOAD = ["sensor"]
@@ -76,46 +75,85 @@ def parse_firmware_version(value):
return major, minor
def get_firmware(value):
def _firmware_cache_path(name: str) -> Path:
return external_files.compute_local_file_dir(DOMAIN) / f"{name}_fw_stm.bin"
def _firmware_path(url: str, sha: str | None) -> Path:
"""Cache path for a firmware blob: sha-keyed when verifiable, else
URL-keyed. Shared by the validator and the prefetch hook."""
return _firmware_cache_path(
sha.lower() if sha else external_files.url_cache_key(url)
)
def get_firmware(value: ConfigType) -> list[HexInt] | None:
if not value[CONF_UPDATE]:
return None
def dl(url):
try:
ensure_happy_eyeballs()
req = requests.get(url, timeout=30)
req.raise_for_status()
except requests.exceptions.RequestException as e:
raise cv.Invalid(f"Could not download firmware file ({url}): {e}") from e
h = hashlib.new("sha256")
h.update(req.content)
return req.content, h.hexdigest()
url = value[CONF_URL]
if CONF_SHA256 in value: # we have a hash, enable caching
path = Path(CORE.data_dir) / DOMAIN / (value[CONF_SHA256] + "_fw_stm.bin")
if not path.is_file():
firmware_data, dl_hash = dl(url)
if dl_hash != value[CONF_SHA256]:
raise cv.Invalid(
f"Hash mismatch for {url}: {dl_hash} != {value[CONF_SHA256]}"
)
path.parent.mkdir(exist_ok=True, parents=True)
path.write_bytes(firmware_data)
else:
if expected := value.get(CONF_SHA256):
expected = expected.lower()
path = _firmware_path(url, expected)
if path.is_file():
firmware_data = path.read_bytes()
else: # no caching, download every time
firmware_data, dl_hash = dl(url)
if hashlib.sha256(firmware_data).hexdigest() == expected:
return [HexInt(x) for x in firmware_data]
# A corrupted or foreign cache entry must never be trusted just
# because the file exists; discard it and download again.
path.unlink()
firmware_data = external_files.download_content(url, path)
if (actual := hashlib.sha256(firmware_data).hexdigest()) != expected:
path.unlink(missing_ok=True)
raise cv.Invalid(f"Hash mismatch for {url}: {actual} != {expected}")
else:
# No hash to verify the bytes, so an unrevalidated copy is an
# error rather than a silent fallback.
firmware_data = external_files.download_content(
url,
_firmware_path(url, None),
allow_stale=False,
)
return [HexInt(x) for x in firmware_data]
def _extract_firmware_ref(entry: ConfigType) -> RemoteFile | None:
firmware = entry.get(CONF_FIRMWARE)
if not isinstance(firmware, dict):
return None
try:
# cv.boolean, not truthiness: `update: "false"` is a valid False.
if not cv.boolean(firmware.get(CONF_UPDATE, False)):
return None
except cv.Invalid:
return None
url = firmware.get(CONF_URL)
sha = firmware.get(CONF_SHA256)
if url is None and (known := KNOWN_FIRMWARE.get(str(firmware.get(CONF_VERSION)))):
url, sha = known
if not isinstance(url, str):
return None
if sha is not None:
# Reject anything but a well-formed hash; a raw string would
# otherwise become a path component before validation runs.
try:
sha = validate_sha256(sha)
except (cv.Invalid, ValueError, TypeError):
return None
path = _firmware_path(url, sha)
if sha is not None and path.is_file():
# Content-addressed and already on disk; get_firmware verifies it
# by hash, so there is nothing to revalidate.
return None
# No hash means no stale copies, matching the validator's policy.
return RemoteFile(url, path, allow_stale=sha is not None)
PREFETCH_FILES = external_files.single_stage_prefetch(_extract_firmware_ref)
def validate_firmware(value):
config = value.copy()
if CONF_URL not in config:
+30 -10
View File
@@ -45,6 +45,11 @@ namespace esphome::socket {
static const char *const TAG = "socket.lwip";
#ifdef USE_ESP8266
// optimistic_yield() rate limit in microseconds of CONT time; cheap when hot.
static constexpr uint32_t ESP8266_YIELD_INTERVAL_US = 1000;
#endif
// set to 1 to enable verbose lwip logging
#if 0 // NOLINT(readability-avoid-unconditional-preprocessor-if)
#define LWIP_LOG(msg, ...) ESP_LOGVV(TAG, "socket %p: " msg, this, ##__VA_ARGS__)
@@ -535,6 +540,14 @@ ssize_t LWIPRawImpl::read_locked_(void *buf, size_t len) {
}
ssize_t LWIPRawImpl::read(void *buf, size_t len) {
#ifdef USE_ESP8266
// Would block: yield to SYS so queued WiFi RX reaches lwip and this read
// may succeed. Without this, inbound segments can sit unprocessed for
// seconds while the main loop polls (CONT/SYS are cooperative on ESP8266).
if (this->waiting_for_data_()) {
optimistic_yield(ESP8266_YIELD_INTERVAL_US);
}
#endif
// See waiting_for_data_() for safety of unlocked reads.
if (this->recv_timeout_cs_ > 0 && this->waiting_for_data_()) {
this->wait_for_data_();
@@ -545,6 +558,8 @@ ssize_t LWIPRawImpl::read(void *buf, size_t len) {
}
ssize_t LWIPRawImpl::readv(const struct iovec *iov, int iovcnt) {
// No ESP8266 SYS yield here: only read() needs it today. If a consumer
// switches to scatter-gather reads, mirror the yield from read().
// See waiting_for_data_() for safety of unlocked reads.
if (this->recv_timeout_cs_ > 0 && this->waiting_for_data_()) {
this->wait_for_data_();
@@ -609,19 +624,24 @@ int LWIPRawImpl::internal_output_() {
}
LWIP_LOG("tcp_output(%p)", this->pcb_);
err_t err = tcp_output(this->pcb_);
if (err == ERR_ABRT) {
// sometimes lwip returns ERR_ABRT for no apparent reason
// the connection works fine afterwards, and back with ESPAsyncTCP we
// indirectly also ignored this error
// FIXME: figure out where this is returned and what it means in this context
LWIP_LOG(" -> err ERR_ABRT");
return 0;
}
if (err != ERR_OK) {
LWIP_LOG(" -> err %d", err);
errno = ECONNRESET;
return -1;
// ERR_ABRT: sometimes lwip returns it for no apparent reason; the
// connection works fine afterwards, and back with ESPAsyncTCP we
// indirectly also ignored this error, so treat it as success for
// flush purposes too.
// FIXME: figure out where this is returned and what it means in this context
if (err != ERR_ABRT) {
errno = ECONNRESET;
return -1;
}
}
#ifdef USE_ESP8266
// Flushed: yield to SYS so the queued segments reach the WiFi driver
// instead of waiting seconds for an unrelated SYS slot. Callers only get
// here after a successful tcp_write, so idle paths never yield.
optimistic_yield(ESP8266_YIELD_INTERVAL_US);
#endif
return 0;
}
+2 -2
View File
@@ -1,6 +1,6 @@
from esphome import pins
import esphome.codegen as cg
from esphome.components import i2c
from esphome.components import gpio_expander, i2c
import esphome.config_validation as cv
from esphome.const import (
CONF_ID,
@@ -28,7 +28,7 @@ CONFIG_SCHEMA = (
cv.Schema(
{
cv.Required(CONF_ID): cv.declare_id(TCA9555Component),
cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema,
cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin,
}
)
.extend(cv.COMPONENT_SCHEMA)
@@ -20,14 +20,18 @@ void TemplateText::setup() {
// Need std::string for pref_->setup() to fill from flash
std::string value{this->initial_value_ != nullptr ? this->initial_value_ : ""};
uint32_t extra = 0;
extra += this->traits.get_min_length() << 2;
extra += this->traits.get_max_length() << 4;
extra += fnv1_hash(this->traits.get_pattern_c_str()) << 6;
// TextSaver::setup() picks the key for the platform and migrates old data once
uint32_t key = this->preference_key_base_() + extra;
uint32_t old_key = this->old_preference_key_base_() + extra;
this->pref_->setup(key, old_key, value);
// For future hash migration: use migrate_entity_preference_() with:
// old_key = get_preference_hash() + extra
// new_key = get_preference_hash_v2() + extra
// See: https://github.com/esphome/backlog/issues/85
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
uint32_t key = this->get_preference_hash();
#pragma GCC diagnostic pop
key += this->traits.get_min_length() << 2;
key += this->traits.get_max_length() << 4;
key += fnv1_hash(this->traits.get_pattern_c_str()) << 6;
this->pref_->setup(key, value);
if (!value.empty())
this->publish_state(value);
}
@@ -14,9 +14,7 @@ class TemplateTextSaverBase {
public:
virtual bool save(const std::string &value) { return true; }
/// old_id is the pre-2026.8.0 preference key; data stored under it is moved to id once.
/// See: https://github.com/esphome/backlog/issues/85
virtual void setup(uint32_t id, uint32_t old_id, std::string &value) {}
virtual void setup(uint32_t id, std::string &value) {}
protected:
ESPPreferenceObject pref_;
@@ -47,16 +45,11 @@ template<uint8_t SZ> class TextSaver : public TemplateTextSaverBase {
// Make the preference object. Fill the provided location with the saved data
// If it is available, else leave it alone
void setup(uint32_t id, uint32_t old_id, std::string &value) override {
char temp[SZ + 1];
#ifdef USE_PREFERENCE_KEY_LOOKUP
void setup(uint32_t id, std::string &value) override {
this->pref_ = global_preferences->make_preference<uint8_t[SZ + 1]>(id);
bool hasdata = migrate_preference(this->pref_, reinterpret_cast<uint8_t *>(temp), SZ + 1, old_id, id);
#else
// Slot-based backends keep the old key; it is only a validity tag on a positional slot
this->pref_ = global_preferences->make_preference<uint8_t[SZ + 1]>(old_id);
char temp[SZ + 1];
bool hasdata = this->pref_.load(&temp);
#endif
if (hasdata) {
size_t len = static_cast<uint8_t>(temp[0]);
+2 -2
View File
@@ -292,8 +292,8 @@ bool USBUartTypePL2303::config_step(USBUartChannel *channel, uint8_t step, bool
// Data bits
line_coding[6] = channel->get_data_bits();
ESP_LOGD(TAG, "PL2303: SET_LINE_REQUEST baud=%u stop=%u parity=%u data=%u", baud, line_coding[4], line_coding[5],
line_coding[6]);
ESP_LOGD(TAG, "PL2303: SET_LINE_REQUEST baud=%" PRIu32 " stop=%u parity=%u data=%u", baud, line_coding[4],
line_coding[5], line_coding[6]);
std::vector<uint8_t> lc_vec(line_coding, line_coding + 7);
this->config_transfer_(SET_LINE_REQUEST_TYPE, SET_LINE_REQUEST, 0, iface, lc_vec);
@@ -249,7 +249,7 @@ void WebServerOTAComponent::setup() {
return;
}
// AsyncWebServer takes ownership of the handler and will delete it when the server is destroyed
// The handler lives for the life of the process; WebServerBase never destroys its server
base->add_handler(new OTARequestHandler(this)); // NOLINT
}
@@ -112,9 +112,18 @@ class AuthMiddlewareHandler : public MiddlewareHandler {
class WebServerBase final {
public:
// The AsyncWebServer is created once and intentionally never deleted: on Arduino
// platforms ESPAsyncWebServer owns its registered handlers, so destroying it would
// also destroy live components (e.g. the captive portal) out from under us.
// init()/deinit() refcount users and start/stop the listener; handlers are
// registered once at creation and survive listener restarts.
void init() {
if (this->initialized_) {
this->initialized_++;
this->initialized_++;
if (this->server_ != nullptr) {
if (this->initialized_ == 1) {
// Restart the listener after a previous deinit()
this->server_->begin();
}
return;
}
this->server_ = new AsyncWebServer(this->port_);
@@ -126,14 +135,13 @@ class WebServerBase final {
for (auto *handler : this->handlers_)
this->server_->addHandler(handler);
this->initialized_++;
}
void deinit() {
if (this->initialized_ == 0)
return; // unbalanced deinit()
this->initialized_--;
if (this->initialized_ == 0) {
delete this->server_;
this->server_ = nullptr;
this->server_->end();
}
}
AsyncWebServer *get_server() const { return this->server_; }
@@ -136,10 +136,21 @@ bool WiFiComponent::wifi_apply_power_save_() {
https://github.com/d-a-v/Arduino/blob/0e7d21e17144cfc5f53c016191daca8723e89ee8/libraries/ESP8266WiFi/src/ESP8266WiFiSTA.cpp#L251
*/
#undef netif_set_addr // need to call lwIP-v1.4 netif_set_addr()
#undef netif_set_down // need to call lwIP-v1.4 netif_set_down()
extern "C" {
struct netif *eagle_lwip_getif(int netif_index);
void netif_set_addr(struct netif *netif, const ip4_addr_t *ip, const ip4_addr_t *netmask, const ip4_addr_t *gw);
void netif_set_down(struct netif *netif);
};
// The SDK can free its WiFi connection node before taking the STA netif down, letting lwIP
// timers (e.g. IGMP reports armed by mDNS) transmit into the dead driver and crash in
// cnx_node_search; taking the netif down first makes the glue drop such frames (#18308).
static void sta_netif_down() {
struct netif *iface = eagle_lwip_getif(STATION_IF);
if (iface != nullptr)
netif_set_down(iface);
}
#endif
bool WiFiComponent::wifi_sta_ip_config_(const optional<ManualIP> &manual_ip) {
@@ -523,6 +534,9 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) {
global_wifi_component->sta_state_ = static_cast<uint8_t>(ESP8266WiFiSTAState::ERROR_FAILED);
}
global_wifi_component->error_from_callback_ = true;
#if LWIP_VERSION_MAJOR != 1
sta_netif_down();
#endif
#ifdef USE_WIFI_CONNECT_STATE_LISTENERS
global_wifi_component->pending_.disconnect = true;
#endif
@@ -536,6 +550,9 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) {
// https://lbsfilm.at/blog/wpa2-authenticationmode-downgrade-in-espressif-microprocessors
if (it.old_mode != AUTH_OPEN && it.new_mode == AUTH_OPEN) {
ESP_LOGW(TAG, "Potential Authmode downgrade detected, disconnecting");
#if LWIP_VERSION_MAJOR != 1
sta_netif_down();
#endif
wifi_station_disconnect();
global_wifi_component->error_from_callback_ = true;
}
@@ -719,8 +736,12 @@ bool WiFiComponent::wifi_scan_start_(bool passive) {
bool WiFiComponent::wifi_disconnect_() {
bool ret = true;
// Only call disconnect if interface is up
if (wifi_get_opmode() & WIFI_STA)
if (wifi_get_opmode() & WIFI_STA) {
#if LWIP_VERSION_MAJOR != 1
sta_netif_down();
#endif
ret = wifi_station_disconnect();
}
station_config conf{};
memset(&conf, 0, sizeof(conf));
ETS_UART_INTR_DISABLE();
@@ -307,6 +307,11 @@ void ZigbeeComponent::setup() {
return;
}
#endif
#ifdef CONFIG_ZB_ZCZR
ezb_bdb_set_router_rejoin_required(true);
#endif
ezb_aps_secur_enable_distributed_security(false);
ezb_nwk_set_min_join_lqi(32);
if (ezb_app_signal_add_handler(ZigbeeComponent::app_signal_handler) != ESP_OK) {
+1 -1
View File
@@ -285,7 +285,7 @@ async def attributes_to_code(
async def esp32_to_code(config: ConfigType) -> "MockObj":
add_idf_component(
name="espressif/esp-zigbee-lib",
ref="2.0.3",
ref="2.0.4",
)
# add sdkconfigs later so they can overwrite esp32 defaults
+143 -2
View File
@@ -1,14 +1,15 @@
from __future__ import annotations
import abc
from contextlib import contextmanager
from collections.abc import Iterator
from contextlib import contextmanager, suppress
import contextvars
import copy
import functools
import heapq
import logging
import re
from typing import Any
from typing import TYPE_CHECKING, Any
import voluptuous as vol
@@ -40,6 +41,9 @@ from esphome.util import OrderedDict, safe_print
from esphome.voluptuous_schema import ExtraKeysInvalid
from esphome.yaml_util import ESPHomeDataBase, ESPLiteralValue, is_secret
if TYPE_CHECKING:
from esphome.external_files import RemoteFile
_LOGGER = logging.getLogger(__name__)
@@ -616,6 +620,23 @@ class LoadValidationStep(ConfigValidationStep):
elif not isinstance(self.conf, list):
result[self.domain] = self.conf = [self.conf]
# Permanent expansion hook: a platform-tagged entry may expand into
# several (e.g. `image`'s `defaults:`/`files:`), for `platform:`-tagged dicts only.
if (expand := component.expand_platform_config) is not None and all(
isinstance(entry, dict) and CONF_PLATFORM in entry
for entry in self.conf
):
with result.catch_error(path):
expanded = expand(self.conf)
if not isinstance(expanded, list):
# A non-list return is a component bug (not a user error):
# raise explicitly (survives -O/-OO) so it escapes catch_error.
raise TypeError(
f"{self.domain}: EXPAND_PLATFORM_CONFIG must "
f"return a list, got {type(expanded).__name__}"
)
result[self.domain] = self.conf = expanded
# Process AUTO_LOAD
_process_auto_load(result, component, path)
@@ -717,6 +738,125 @@ class AutoLoadValidationStep(ConfigValidationStep):
)
# Backstop against a runaway PREFETCH_FILES generator; no real component
# needs anywhere near this many stages (font, the deepest, uses two).
_MAX_PREFETCH_STAGES = 10
class PrefetchRemoteFilesValidationStep(ConfigValidationStep):
"""Batch-download remote files referenced by the raw config.
Each round, the batches yielded by every ``PREFETCH_FILES`` hook (see
``ComponentManifest.prefetch_files``) download in one parallel pass, so
per-entry schema validators find a warm cache. Must run between
AutoLoadValidationStep (-1.0) and MetadataValidationStep (-2.0):
metadata steps push priority-0 schema steps that pop immediately, so
this is the last point where every raw entry list is intact. Best
effort: failures are logged and memoized per run; the per-entry
validators stay authoritative.
"""
priority = -1.5
def run(self, result: Config) -> None:
active: list[tuple[str, Iterator[list[RemoteFile]]]] = []
def warn_hook_failed(name: str, err: Exception) -> None:
# A broken hook must not fail validation; it only loses the
# batching speedup.
_LOGGER.warning("Remote file prefetch for %s failed: %s", name, err)
_LOGGER.debug("Prefetch hook traceback", exc_info=err)
def start_hook(
name: str, manifest: ComponentManifest, entries: list[ConfigType]
) -> None:
if (hook := manifest.prefetch_files) is None:
return
try:
active.append((name, iter(hook(entries))))
except Exception as err: # noqa: BLE001 # pylint: disable=broad-except
warn_hook_failed(name, err)
for domain, conf in result.items():
if not isinstance(domain, str) or domain.startswith("."):
continue
if (component := get_component(domain)) is None:
continue
if component.prefetch_files is None and not component.is_platform_component:
continue
if conf is None or isinstance(conf, core.AutoLoad):
continue
entries = [
entry
for entry in (conf if isinstance(conf, list) else [conf])
if isinstance(entry, dict)
]
if not entries:
continue
# A domain-level hook on a platform component receives every
# entry; overlap with per-platform hooks dedupes by path.
start_hook(domain, component, entries)
if not component.is_platform_component:
continue
by_platform: dict[str, list[ConfigType]] = {}
for entry in entries:
if isinstance(p_name := entry.get(CONF_PLATFORM), str):
by_platform.setdefault(p_name, []).append(entry)
for p_name, p_entries in by_platform.items():
if (platform := get_platform(domain, p_name)) is not None:
start_hook(f"{domain}.{p_name}", platform, p_entries)
# One stage per round; later stages can read what earlier ones
# fetched.
for _ in range(_MAX_PREFETCH_STAGES):
if not active:
break
items: list[RemoteFile] = []
still_active: list[tuple[str, Iterator[list[RemoteFile]]]] = []
for name, generator in active:
try:
batch = list(next(generator))
except StopIteration:
continue
except Exception as err: # noqa: BLE001 # pylint: disable=broad-except
warn_hook_failed(name, err)
continue
items.extend(batch)
still_active.append((name, generator))
active = still_active
self._download(items)
for name, generator in active:
# A tripped backstop means a broken hook.
_LOGGER.warning(
"Remote file prefetch for %s stopped after %d stages",
name,
_MAX_PREFETCH_STAGES,
)
if (close := getattr(generator, "close", None)) is not None:
# close() runs hook code too; it must not fail validation.
with suppress(Exception):
close()
@staticmethod
def _download(items: list[RemoteFile]) -> None:
if not items:
return
# Imported lazily: requests is a heavy import (~85ms) and is only
# needed when a config actually references remote files.
from esphome import external_files
try:
external_files.download_content_many(items, description="remote file(s)")
except cv.Invalid as err:
# INFO: the trace if an extractor's cache path ever drifts from
# its validator's, hiding the memoized failure replay.
_LOGGER.info("Remote file prefetch download failed: %s", err)
except Exception as err: # noqa: BLE001 # pylint: disable=broad-except
# The batch downloader itself broke; make it visible.
_LOGGER.warning("Remote file prefetch failed: %s", err)
_LOGGER.debug("Prefetch download traceback", exc_info=err)
class MetadataValidationStep(ConfigValidationStep):
"""Validate component metadata
@@ -1259,6 +1399,7 @@ def validate_config(
for domain, conf in config.items():
result.add_validation_step(LoadValidationStep(domain, conf))
result.add_validation_step(PrefetchRemoteFilesValidationStep())
result.add_validation_step(IDPassValidationStep())
result.add_validation_step(CoreFinalValidateStep())
result.add_validation_step(PinUseValidationCheck())
+4
View File
@@ -99,6 +99,10 @@ from esphome.schema_extractors import (
schema_extractor_registry,
schema_extractor_typed,
)
# Deprecated re-export for external components; remove before 2027.2.0
# pylint: disable-next=unused-import
from esphome.util import parse_esphome_version # noqa: F401
from esphome.voluptuous_schema import _Schema
from esphome.yaml_util import SensitiveStr, make_data_base
+1 -1
View File
@@ -4,7 +4,7 @@ from enum import Enum
from esphome.enum import StrEnum
__version__ = "2026.8.0-dev"
__version__ = "2026.8.0"
ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_"
VALID_SUBSTITUTIONS_CHARACTERS = (
+4 -4
View File
@@ -120,8 +120,8 @@ class Application {
// NOLINTBEGIN(bugprone-macro-parentheses)
#define ENTITY_TYPE_(type, singular, plural, count, upper) \
void register_##singular(type *obj) { this->plural##_.push_back(obj); } \
void register_##singular(type *obj, const char *name, uint32_t entity_key, uint32_t entity_fields) { \
obj->configure_entity_(name, entity_key, entity_fields); \
void register_##singular(type *obj, const char *name, uint32_t object_id_hash, uint32_t entity_fields) { \
obj->configure_entity_(name, object_id_hash, entity_fields); \
this->plural##_.push_back(obj); \
}
#define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \
@@ -329,7 +329,7 @@ class Application {
#define GET_ENTITY_METHOD(entity_type, entity_name, entities_member) \
entity_type *get_##entity_name##_by_key(uint32_t key, uint32_t device_id, bool include_internal = false) { \
for (auto *obj : this->entities_member##_) { \
if (obj->get_entity_key() == key && obj->get_device_id() == device_id && \
if (obj->get_object_id_hash() == key && obj->get_device_id() == device_id && \
(include_internal || !obj->is_internal())) \
return obj; \
} \
@@ -340,7 +340,7 @@ class Application {
#define GET_ENTITY_METHOD(entity_type, entity_name, entities_member) \
entity_type *get_##entity_name##_by_key(uint32_t key, bool include_internal = false) { \
for (auto *obj : this->entities_member##_) { \
if (obj->get_entity_key() == key && (include_internal || !obj->is_internal())) \
if (obj->get_object_id_hash() == key && (include_internal || !obj->is_internal())) \
return obj; \
} \
return nullptr; \
+20 -32
View File
@@ -8,7 +8,7 @@ namespace esphome {
static const char *const TAG = "entity_base";
void EntityBase::configure_entity_(const char *name, uint32_t entity_key, uint32_t entity_fields) {
void EntityBase::configure_entity_(const char *name, uint32_t object_id_hash, uint32_t entity_fields) {
this->name_ = StringRef(name);
if (this->name_.empty()) {
#ifdef USE_DEVICES
@@ -30,15 +30,15 @@ void EntityBase::configure_entity_(const char *name, uint32_t entity_key, uint32
}
}
this->flags_.has_own_name = false;
// Dynamic name - must calculate key at runtime
this->calc_entity_key_();
// Dynamic name - must calculate hash at runtime
this->calc_object_id_();
} else {
this->flags_.has_own_name = true;
// Static name - use pre-computed key if provided
if (entity_key != 0) {
this->entity_key_ = entity_key;
// Static name - use pre-computed hash if provided
if (object_id_hash != 0) {
this->object_id_hash_ = object_id_hash;
} else {
this->calc_entity_key_();
this->calc_object_id_();
}
}
// Unpack entity string table indices and flags from entity_fields.
@@ -147,15 +147,9 @@ std::string EntityBase::get_icon() const {
}
#endif // !USE_ESP8266
// Calculate the entity key directly from the raw name (no transformations)
void EntityBase::calc_entity_key_() { this->entity_key_ = fnv1_hash_bytes(this->name_.c_str(), this->name_.size()); }
// Reconstruct the OLD (pre-2026.8.0) object_id-based hash for preference key compatibility.
// Named entities historically used the hash pre-computed by Python code generation, which
// sanitized per UTF-8 code point; entities without their own name computed the hash at
// runtime per byte. See https://github.com/esphome/backlog/issues/85
uint32_t EntityBase::calc_old_object_id_hash_() const {
return fnv1_hash_object_id(this->name_.c_str(), this->name_.size(), this->flags_.has_own_name);
// Calculate Object ID Hash directly from name using snake_case + sanitize
void EntityBase::calc_object_id_() {
this->object_id_hash_ = fnv1_hash_object_id(this->name_.c_str(), this->name_.size());
}
size_t EntityBase::write_object_id_to(char *buf, size_t buf_size) const {
@@ -173,22 +167,16 @@ StringRef EntityBase::get_object_id_to(std::span<char, OBJECT_ID_MAX_LEN> buf) c
}
ESPPreferenceObject EntityBase::make_entity_preference_(size_t size, uint32_t version) {
// The old key hashed the sanitized object_id, so multiple entity names could collide on
// one key and overwrite each other's stored preferences; the new key hashes the raw name.
// See: https://github.com/esphome/backlog/issues/85
uint32_t old_key = this->old_preference_key_base_() ^ version;
#ifdef USE_PREFERENCE_KEY_LOOKUP
uint32_t new_key = this->preference_key_base_() ^ version;
auto pref = global_preferences->make_preference(size, new_key);
// All in-tree entity preferences fit the stack buffer, so migration never hits the heap
SmallBufferWithHeapFallback<64> buffer(size);
migrate_preference(pref, buffer.get(), size, old_key, new_key);
return pref;
#else
// Slot-based backends keep the old key: it is only a validity tag on a positional slot,
// so collisions cannot corrupt data there and keeping it preserves stored state.
return global_preferences->make_preference(size, old_key);
#endif
// The key hashes the sanitized object_id, so multiple entity names can collide on one
// key and overwrite each other's stored preferences ("Living Room" and "living_room",
// or two UTF-8 names that both sanitize to underscores). Keys hashed from the raw name
// fix this, but they change the entity key API clients track, which the Home Assistant
// esphome integration cannot handle yet. See: https://github.com/esphome/backlog/issues/85
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
uint32_t key = this->get_preference_hash() ^ version;
#pragma GCC diagnostic pop
return global_preferences->make_preference(size, key);
}
#ifdef USE_ENTITY_ICON
+38 -42
View File
@@ -73,17 +73,8 @@ class EntityBase {
// Get whether this Entity has its own name or it should use the device friendly_name.
bool has_own_name() const { return this->flags_.has_own_name; }
// Get the unique key of this Entity: FNV-1 hash of the raw entity name.
// This is the key sent to API clients and used to route entity state.
uint32_t get_entity_key() const { return this->entity_key_; }
/// Returns the LEGACY object_id hash, unchanged from previous releases, so existing
/// callers keep getting stable values (for example preference keys). This is no longer
/// the key sent to API clients; that is get_entity_key().
ESPDEPRECATED("Use get_entity_key() for the entity key sent to API clients, or "
"make_entity_preference<T>() for preference storage. Will be removed in 2027.1.0.",
"2026.8.0")
uint32_t get_object_id_hash() const { return this->calc_old_object_id_hash_(); }
// Get the unique Object ID of this Entity
uint32_t get_object_id_hash() const { return this->object_id_hash_; }
/// Get object_id with zero heap allocation
/// For static case: returns StringRef to internal storage (buffer unused)
@@ -190,23 +181,39 @@ class EntityBase {
// Set has_state - for components that need to manually set this
void set_has_state(bool state) { this->flags_.has_state = state; }
/// Get this entity's device id, or 0 when devices are not compiled in (main device).
uint32_t get_device_id_or_zero() const {
#ifdef USE_DEVICES
return this->get_device_id();
#else
return 0;
#endif
}
/// Get the LEGACY preference key: FNV-1 hash of the sanitized object_id, XOR device_id.
/// Intentionally keeps the old algorithm so external callers that store preferences under
/// this key keep stable keys; make_entity_preference() migrates to the new raw-name key,
/// this method never will.
/**
* @brief Get a unique hash for storing preferences/settings for this entity.
*
* This method returns a hash that uniquely identifies the entity for the purpose of
* storing preferences (such as calibration, state, etc.). Unlike get_object_id_hash(),
* this hash also incorporates the device_id (if devices are enabled), ensuring uniqueness
* across multiple devices that may have entities with the same object_id.
*
* Use this method when storing or retrieving preferences/settings that should be unique
* per device-entity pair. Use get_object_id_hash() when you need a hash that identifies
* the entity regardless of the device it belongs to.
*
* For backward compatibility, if device_id is 0 (the main device), the hash is unchanged
* from previous versions, so existing single-device configurations will continue to work.
*
* @return uint32_t The unique hash for preferences, including device_id if available.
* @deprecated Use make_entity_preference<T>() instead, or preferences won't be migrated.
* See https://github.com/esphome/backlog/issues/85
*/
ESPDEPRECATED("Use make_entity_preference<T>() instead, or preferences won't be migrated. "
"See https://github.com/esphome/backlog/issues/85. Will be removed in 2027.1.0.",
"2026.8.0")
uint32_t get_preference_hash() { return this->old_preference_key_base_(); }
"2026.7.0")
uint32_t get_preference_hash() {
#ifdef USE_DEVICES
// Combine object_id_hash with device_id to ensure uniqueness across devices
// Note: device_id is 0 for the main device, so XORing with 0 preserves the original hash
// This ensures backward compatibility for existing single-device configurations
return this->get_object_id_hash() ^ this->get_device_id();
#else
// Without devices, just use object_id_hash as before
return this->get_object_id_hash();
#endif
}
/// Create a preference object for storing this entity's state/settings.
/// @tparam T The type of data to store (must be trivially copyable)
@@ -223,9 +230,9 @@ class EntityBase {
// before push_back, so codegen can emit a single combined call per entity.
friend class Application;
/// Combined entity setup from codegen: set name, entity key, entity string indices, and flags.
/// Combined entity setup from codegen: set name, object_id hash, entity string indices, and flags.
/// Bit layout of entity_fields is defined by the ENTITY_FIELD_*_SHIFT constants above.
void configure_entity_(const char *name, uint32_t entity_key, uint32_t entity_fields);
void configure_entity_(const char *name, uint32_t object_id_hash, uint32_t entity_fields);
#ifdef USE_DEVICES
// Codegen-only setter — only accessible from setup() via friend declaration.
@@ -233,24 +240,13 @@ class EntityBase {
#endif
/// Non-template helper for make_entity_preference() to avoid code bloat.
/// Migrates preferences from the old sanitized-object_id key to the raw-name key
/// on key-lookup platforms. See: https://github.com/esphome/backlog/issues/85
/// When the preference hash algorithm changes, migration logic goes here.
ESPPreferenceObject make_entity_preference_(size_t size, uint32_t version);
void calc_entity_key_();
/// Reconstruct the OLD (pre-2026.8.0) sanitized-object_id hash for preference keys.
uint32_t calc_old_object_id_hash_() const;
/// Preference key base for this entity: raw-name entity key XOR device_id.
uint32_t preference_key_base_() const { return this->entity_key_ ^ this->get_device_id_or_zero(); }
/// Legacy preference key base: sanitized-object_id hash XOR device_id.
/// Note: device_id is 0 for the main device, so XORing with 0 preserves the original hash.
uint32_t old_preference_key_base_() const { return this->calc_old_object_id_hash_() ^ this->get_device_id_or_zero(); }
void calc_object_id_();
StringRef name_;
uint32_t entity_key_{};
uint32_t object_id_hash_{};
#ifdef USE_DEVICES
Device *device_{};
#endif
+79 -111
View File
@@ -25,86 +25,25 @@ from esphome.core.config import (
from esphome.cpp_generator import MockObj, RawStatement, add, get_variable
from esphome.cpp_types import App
import esphome.final_validate as fv
from esphome.helpers import cpp_string_escape, fnv1_hash_name, sanitize, snake_case
from esphome.helpers import (
cpp_string_escape,
fnv1_hash,
fnv1_hash_object_id,
sanitize,
snake_case,
)
from esphome.types import ConfigType, EntityMetadata
_LOGGER = logging.getLogger(__name__)
DOMAIN = "entity_string_pool"
_OBJECT_ID_DOMAIN = "entity_object_ids"
@dataclass
class ObjectIdEntity:
"""An entity tracked by the sanitized object_id its name resolves to."""
name: str
platform: str
config: ConfigType
def _get_object_id_registry() -> dict[tuple[str, str, str], list[ObjectIdEntity]]:
"""(device_id, platform, sanitized object_id) -> entities resolving to it."""
return CORE.data.setdefault(_OBJECT_ID_DOMAIN, {})
def validate_no_object_id_conflicts(
reason: str,
conflict_filter: Callable[[list[ObjectIdEntity], ConfigType], bool] | None = None,
) -> Callable[[ConfigType], ConfigType]:
"""Create a final-validate step that rejects entities with colliding object_ids.
Entity keys are hashed from the raw name, so names that only differ in characters
lost during sanitizing (for example two UTF-8 names) validate fine in general.
Components that still address entities by the sanitized object_id string must
reject those configs until they are migrated to raw names.
Args:
reason: One sentence stating what the component builds from the object_id,
e.g. "mqtt builds default topics from the entity object_id"
conflict_filter: Optional predicate receiving the colliding entities and the
component config; return False when the component is not affected
Returns:
A validator function for use as (or within) FINAL_VALIDATE_SCHEMA
"""
def validator(config: ConfigType) -> ConfigType:
# Skip in testing_mode, which is used for grouped component testing
if CORE.testing_mode:
return config
conflicts = {
key: entities
for key, entities in _get_object_id_registry().items()
if len(entities) > 1
and (conflict_filter is None or conflict_filter(entities, config))
}
if not conflicts:
return config
lines = [f"{reason}, so these entities would conflict:"]
lines.extend(
f" - {platform} entities "
+ ", ".join(f"'{e.name}'" for e in entities)
+ (f" on device '{device_id}'" if device_id else "")
+ f" share the object_id '{object_id}'"
for (device_id, platform, object_id), entities in conflicts.items()
)
lines.append(
"To fix: Add unique ASCII characters (e.g., '1', '2', or 'A', 'B') "
"to distinguish the names"
)
raise cv.Invalid("\n".join(lines))
return validator
# Private config keys for storing registered string indices
_KEY_DC_IDX = "_entity_dc_idx"
_KEY_UOM_IDX = "_entity_uom_idx"
_KEY_ICON_IDX = "_entity_icon_idx"
_KEY_ENTITY_NAME = "_entity_name"
_KEY_ENTITY_KEY = "_entity_key"
_KEY_OBJECT_ID_HASH = "_entity_object_id_hash"
# Bit layout for entity_fields in configure_entity_().
# Keep in sync with ENTITY_FIELD_*_SHIFT constants in esphome/core/entity_base.h
@@ -367,7 +306,7 @@ def finalize_entity_strings(var: MockObj, config: ConfigType) -> None:
standalone ``var->configure_entity_(name, hash, packed)``.
"""
entity_name = config[_KEY_ENTITY_NAME]
entity_key = config[_KEY_ENTITY_KEY]
object_id_hash = config[_KEY_OBJECT_ID_HASH]
dc_idx = config.get(_KEY_DC_IDX, 0)
uom_idx = config.get(_KEY_UOM_IDX, 0)
icon_idx = config.get(_KEY_ICON_IDX, 0)
@@ -387,30 +326,57 @@ def finalize_entity_strings(var: MockObj, config: ConfigType) -> None:
register_method = config.get(_KEY_REGISTER_METHOD)
if register_method is not None:
expr = getattr(App, f"register_{register_method}")(
var, entity_name, entity_key, packed
var, entity_name, object_id_hash, packed
)
else:
expr = var.configure_entity_(entity_name, entity_key, packed)
expr = var.configure_entity_(entity_name, object_id_hash, packed)
if comment:
add(RawStatement(f"{expr}; // {comment}"))
else:
add(expr)
def get_base_entity_name(
def get_base_entity_object_id(
name: str, friendly_name: str | None, device_name: str | None = None
) -> str:
"""Return the base name whose hash becomes this entity's key on the device.
"""Calculate the base object ID for an entity that will be set via set_object_id().
Follows the name selection in C++ EntityBase::configure_entity_() (entity_base.cpp):
entity name, then sub-device name, then friendly name, then the device name.
This function calculates what object_id_c_str_ should be set to in C++.
This is a config-time approximation for duplicate checking: when
name_add_mac_suffix is enabled the device appends the MAC suffix at runtime,
which is unknown here and identical for every entity on the device, so
ignoring it cannot change whether two entities collide with each other.
The C++ EntityBase::write_object_id_to() (entity_base.cpp) works as:
- If !has_own_name && is_name_add_mac_suffix_enabled():
return str_sanitize(str_snake_case(App.get_friendly_name())) // Dynamic
- Else:
return object_id_c_str_ ?? "" // What we set via set_object_id()
Since we're calculating what to pass to set_object_id(), we always need to
generate the object_id the same way, regardless of name_add_mac_suffix setting.
Args:
name: The entity name (empty string if no name)
friendly_name: The friendly name from CORE.friendly_name
device_name: The device name if entity is on a sub-device
Returns:
The base object ID to use for duplicate checking and to pass to set_object_id()
"""
return name or device_name or friendly_name or CORE.name
if name:
# Entity has its own name (has_own_name will be true)
base_str = name
elif device_name:
# Entity has empty name and is on a sub-device
# C++ EntityBase::set_name() uses device->get_name() when device is set
base_str = device_name
elif friendly_name:
# Entity has empty name (has_own_name will be false)
# C++ uses App.get_friendly_name() which returns friendly_name or device name
base_str = friendly_name
else:
# Fallback to device name
base_str = CORE.name
return sanitize(snake_case(base_str))
def setup_entity(var_or_platform, config=None, platform=None):
@@ -469,15 +435,15 @@ async def _setup_entity_impl(var: MockObj, config: ConfigType, platform: str) ->
device: MockObj = await get_variable(device_id_obj)
add(var.set_device_(device))
# Pre-compute entity name and entity key for configure_entity_()
# Pre-compute entity name and object_id hash for configure_entity_()
# which is emitted later by finalize_entity_strings().
# For named entities: pre-compute the key from the raw entity name
# For empty-name entities: pass 0, C++ calculates the key at runtime from
# device name, friendly_name, or app name
# For named entities: pre-compute hash from entity name
# For empty-name entities: pass 0, C++ calculates hash at runtime from
# device name, friendly_name, or app name (bug-for-bug compatibility)
entity_name = config[CONF_NAME]
entity_key = fnv1_hash_name(entity_name) if entity_name else 0
object_id_hash = fnv1_hash_object_id(entity_name) if entity_name else 0
config[_KEY_ENTITY_NAME] = entity_name
config[_KEY_ENTITY_KEY] = entity_key
config[_KEY_OBJECT_ID_HASH] = object_id_hash
# Store flags for packing into configure_entity_()
config[_KEY_DISABLED_BY_DEFAULT] = int(config[CONF_DISABLED_BY_DEFAULT])
if CONF_INTERNAL in config:
@@ -590,13 +556,16 @@ def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigTy
# Use the device ID string directly for uniqueness
device_id = device_id_obj.id
# Hash the same raw name the device hashes into the entity key at runtime.
# This handles empty names correctly by using device/friendly names.
base_name = get_base_entity_name(entity_name, CORE.friendly_name, device_name)
name_hash = fnv1_hash_name(base_name)
# Calculate what object_id will actually be used
# This handles empty names correctly by using device/friendly names
name_key = get_base_entity_object_id(
entity_name, CORE.friendly_name, device_name
)
# Check for duplicates: two entities on the same device and platform must not
# share an entity key, since the key is what routes state to API clients
# Check for duplicates by the FNV-1 hash of the object_id, which is the entity
# key that routes state to API clients. This rejects names that sanitize to the
# same object_id, and also two different object_ids whose 32-bit hashes collide.
name_hash = fnv1_hash(name_key)
unique_key = (device_id, platform, name_hash)
if unique_key in CORE.unique_ids:
# Get the existing entity metadata
@@ -621,14 +590,26 @@ def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigTy
if existing_component != "unknown":
conflict_msg += f" from component '{existing_component}'"
# Different names can only clash here through a genuine hash collision
# Distinguish names that sanitize to the same object_id from a genuine
# 32-bit hash collision between two different object_ids
collision_msg = ""
if entity_name != existing_name:
collision_msg = (
f"\n The names '{entity_name}' and '{existing_name}' produce the"
f"\n same entity key hash ({name_hash:#010x})."
"\n To fix: Rename one of the entities"
existing_object_id = get_base_entity_object_id(
existing_name, CORE.friendly_name, existing_device or None
)
if existing_object_id == name_key:
collision_msg = (
f"\n Original names: '{entity_name}' and '{existing_name}'"
f"\n Both convert to ASCII ID: '{name_key}'"
"\n To fix: Add unique ASCII characters (e.g., '1', '2', or 'A', 'B')"
"\n to distinguish them"
)
else:
collision_msg = (
f"\n The object_ids '{name_key}' and '{existing_object_id}'"
f"\n produce the same entity key hash ({name_hash:#010x})."
"\n To fix: Rename one of the entities"
)
# Skip duplicate entity name validation when testing_mode is enabled
# This flag is used for grouped component testing
@@ -640,19 +621,6 @@ def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigTy
f"{collision_msg}"
)
# Components that still address entities by the sanitized object_id reject
# colliding names in final validation via validate_no_object_id_conflicts(),
# so track every entity by the object_id its name resolves to. Scoped per
# device and platform to match the strictness configs had before entity keys
# moved to raw names: same-named entities on different sub-devices were
# already accepted then, internal entities were already skipped (above), and
# overlaps between platforms that share an MQTT component type (sensor and
# text_sensor both publish under "sensor") were already possible.
object_id = sanitize(snake_case(base_name))
_get_object_id_registry().setdefault(
(device_id, platform, object_id), []
).append(ObjectIdEntity(base_name, platform, config))
# Store metadata about this entity
entity_metadata: EntityMetadata = {
"name": entity_name,
+4 -25
View File
@@ -809,19 +809,6 @@ constexpr uint32_t FNV1_OFFSET_BASIS = 2166136261UL;
/// FNV-1 32-bit prime
constexpr uint32_t FNV1_PRIME = 16777619UL;
/// Calculate a FNV-1 hash over raw bytes with an explicit length. Unlike fnv1_hash(const char *),
/// each byte is hashed as an unsigned value, so results are platform-independent for bytes >= 0x80.
/// IMPORTANT: Must match Python fnv1_hash_name() in esphome/helpers.py, which hashes the UTF-8
/// encoded bytes of the name. Used to compute entity keys from raw names.
inline uint32_t fnv1_hash_bytes(const char *str, size_t len) {
uint32_t hash = FNV1_OFFSET_BASIS;
for (size_t i = 0; i < len; i++) {
hash *= FNV1_PRIME;
hash ^= static_cast<uint8_t>(str[i]);
}
return hash;
}
/// Extend a FNV-1 hash with an integer (hashes each byte).
template<std::integral T> constexpr uint32_t fnv1_hash_extend(uint32_t hash, T value) {
using UnsignedT = std::make_unsigned_t<T>;
@@ -1026,20 +1013,12 @@ template<size_t N> inline char *str_sanitize_to(char (&buffer)[N], const char *s
// str_sanitize moved to alloc_helpers.h - remove this comment before 2026.11.0
/// Calculate FNV-1 hash of a string while applying snake_case + sanitize transformations.
/// This is the LEGACY entity hash, kept only to reconstruct preference keys that existing
/// devices already have stored; see https://github.com/esphome/backlog/issues/85.
/// With per_code_point set, UTF-8 continuation bytes are skipped so each multi-byte character
/// contributes one underscore — this matches Python fnv1_hash_object_id() in esphome/helpers.py,
/// which produced the hash for named entities. The per-byte form (default) matches the old
/// runtime hash for entities without their own name. Do not change either behavior.
/// Known limitation: Python's lower() is Unicode aware, so the rare code points it maps to a
/// different number of characters or to ASCII (e.g. 'İ', the Kelvin sign) reconstruct wrong;
/// such names skip migration once and fall back to their defaults.
inline uint32_t fnv1_hash_object_id(const char *str, size_t len, bool per_code_point = false) {
/// This computes object_id hashes directly from names without creating an intermediate buffer.
/// IMPORTANT: Must match Python fnv1_hash_object_id() in esphome/helpers.py.
/// If you modify this function, update the Python version and tests in both places.
inline uint32_t fnv1_hash_object_id(const char *str, size_t len) {
uint32_t hash = FNV1_OFFSET_BASIS;
for (size_t i = 0; i < len; i++) {
if (per_code_point && (static_cast<uint8_t>(str[i]) & 0xC0) == 0x80)
continue; // UTF-8 continuation byte, already counted via its lead byte
hash *= FNV1_PRIME;
// Apply snake_case (space->underscore, uppercase->lowercase) then sanitize
hash ^= static_cast<uint8_t>(to_sanitized_char(to_snake_case_char(str[i])));
+7 -7
View File
@@ -24,9 +24,10 @@
#endif
// Key-lookup preference backends find stored data by key; their platforms add the
// USE_PREFERENCE_KEY_LOOKUP define from Python codegen, which enables preference key
// migration. Slot-based backends (ESP8266, RP2040) instead allocate a storage slot for
// every make_preference() call and use the key only as a validity tag on that slot;
// USE_PREFERENCE_KEY_LOOKUP define from Python codegen, which enables one-shot reads
// of stored data by key (the primitive preference key migrations need). Slot-based
// backends (ESP8266, RP2040) instead allocate a storage slot for every
// make_preference() call and use the key only as a validity tag on that slot;
// migration is not possible there, and key collisions cannot corrupt data.
namespace esphome {
@@ -104,10 +105,9 @@ concept PreferencesContract = requires(T prefs, size_t len, uint32_t type, bool
};
// Key-lookup platforms additionally provide load_from_key(), a one-shot read
// of a stored preference by key that migrate_preference() relies on; see the
// key-lookup note at the top of this file. Not part of PreferencesContract,
// so it is asserted in preferences.h only where USE_PREFERENCE_KEY_LOOKUP
// is set.
// of a stored preference by key; see the key-lookup note at the top of this
// file. Not part of PreferencesContract, so it is asserted in preferences.h
// only where USE_PREFERENCE_KEY_LOOKUP is set.
template<typename T>
concept PreferencesKeyLookupContract = requires(T prefs, uint32_t type, uint8_t *data, size_t len) {
{ prefs.load_from_key(type, data, len) } -> std::same_as<bool>;
-25
View File
@@ -1,25 +0,0 @@
#include "esphome/core/preferences.h"
#include "esphome/core/log.h"
#include <cinttypes>
namespace esphome {
#ifdef USE_PREFERENCE_KEY_LOOKUP
static const char *const TAG = "preferences";
bool migrate_preference(ESPPreferenceObject &new_pref, uint8_t *scratch, size_t size, uint32_t old_key,
uint32_t new_key) {
if (new_pref.load(scratch, size))
return true; // Current data present - never overwrite newer data with the old copy
// One-shot read by key: no backend is allocated for the old key, so boots with
// nothing to migrate (for example fresh installs) cost no heap
if (old_key == new_key || !global_preferences->load_from_key(old_key, scratch, size))
return false; // No data stored under the old key, nothing to migrate
if (!new_pref.save(scratch, size)) {
ESP_LOGW(TAG, "Pref migration %" PRIx32 " -> %" PRIx32 " failed", old_key, new_key);
}
return true;
}
#endif // USE_PREFERENCE_KEY_LOOKUP
} // namespace esphome
-12
View File
@@ -56,17 +56,5 @@ namespace esphome {
static_assert(PreferencesKeyLookupContract<ESPPreferences>,
"This platform emits USE_PREFERENCE_KEY_LOOKUP but its preferences manager does not provide "
"load_from_key() (esphome/core/preference_backend.h)");
/// Copy preference data stored under old_key into new_pref (created for new_key) if the keys
/// differ and new_pref has no data yet. scratch must hold at least size bytes.
/// Returns true when scratch holds the entity's current data (loaded or just migrated).
/// The old entry is intentionally left in place so a firmware downgrade still finds its data.
/// If saving under the new key fails, callers that consume scratch (like TextSaver) still get
/// valid data for this boot, callers that reload from the preference fall back to their
/// defaults, and the migration simply runs again on the next boot.
/// Only available on key-lookup preference backends; slot-based backends keep their old
/// keys instead. See: https://github.com/esphome/backlog/issues/85
bool migrate_preference(ESPPreferenceObject &new_pref, uint8_t *scratch, size_t size, uint32_t old_key,
uint32_t new_key);
} // namespace esphome
#endif // USE_PREFERENCE_KEY_LOOKUP
+7
View File
@@ -15,6 +15,13 @@ inline void ESPHOME_ALWAYS_INLINE wake_loop_impl() {
// Set the wake-requested flag BEFORE esp_schedule so the consumer is
// guaranteed to see it on its next gate check.
wake_request_set();
// Skip the post when a wake was already signalled and not yet consumed by
// wakeable_delay(): esp_schedule() -> ets_post() can enter SDK WiFi pm code,
// which must not be poked per-byte from the software serial RX ISR (see
// esphome#18409). The flag can stay latched while the loop is awake, which
// is intentional; posts are only needed to cut a suspend short.
if (g_main_loop_woke)
return;
g_main_loop_woke = true;
esp_schedule();
}
+2
View File
@@ -109,6 +109,8 @@ def _get_idf_env(version: str | None = None) -> dict[str, str]:
env_cache = _cache().env
if version not in env_cache:
env_cache[version] = os.environ.copy()
# Do not leak PYTHONPATH into child env
env_cache[version].pop("PYTHONPATH", None)
# Use provided IDF framework if available
if "IDF_PATH" not in os.environ:
+128 -30
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
from collections.abc import Callable
import contextlib
import gzip
import hashlib
import io
@@ -8,7 +9,6 @@ import logging
from pathlib import Path
import secrets
import socket
import sys
import time
from typing import Any
@@ -76,6 +76,14 @@ _SUPPORTED_OTA_TYPES: frozenset[int] = frozenset(
UPLOAD_BLOCK_SIZE = 8192
UPLOAD_BUFFER_SIZE = UPLOAD_BLOCK_SIZE * 8
# Flaky Wi-Fi links often drop the first OTA attempt, and the device may need time
# to clean up a half-open connection (its handshake watchdog runs at 20s) before it
# accepts a new one, so wait between attempts instead of failing the upload outright.
# Every resolved address is tried once, and this many extra attempts are shared
# across the addresses on top of that.
EXTRA_UPLOAD_ATTEMPTS = 2
UPLOAD_RETRY_DELAY = 5.0
_LOGGER = logging.getLogger(__name__)
# Authentication method lookup table: response -> (hash_func, nonce_size, name)
@@ -171,6 +179,23 @@ class OTAError(EsphomeError):
pass
class OTANetworkError(OTAError):
"""Network-level OTA failure (timeout, reset, closed connection); retrying may succeed."""
def _committed_error(err: OTANetworkError) -> OTAError:
"""Wrap a network failure that happened once the device had the full image.
Past that point the device commits and reboots on its own, so the failure
must not be retried; a re-upload could flash a device that already updated.
"""
return OTAError(
f"{err} (the device may have already committed the update and "
f"be rebooting; check whether it comes back with the new "
f"firmware before uploading again)"
)
def recv_decode(
sock: socket.socket, amount: int, decode: bool = True
) -> bytes | list[int]:
@@ -209,19 +234,22 @@ def receive_exactly(
try:
data += recv_decode(sock, 1, decode=decode) # type: ignore[operator]
except OSError as err:
raise OTAError(f"receiving {msg} response: {err}") from err
raise OTANetworkError(f"receiving {msg} response: {err}") from err
try:
check_error(data, expect)
except OTAError as err:
sock.close()
raise OTAError(f"receiving {msg}: {err}") from err
# type(err) preserves OTANetworkError vs OTAError so callers can tell
# retryable network failures from device-reported errors; subclasses
# must accept a single message argument
raise type(err)(f"receiving {msg}: {err}") from err
while len(data) < amount:
try:
data += recv_decode(sock, amount - len(data), decode=decode) # type: ignore[operator]
except OSError as err:
raise OTAError(f"receiving {msg}: {err}") from err
raise OTANetworkError(f"receiving {msg}: {err}") from err
return data
@@ -237,7 +265,7 @@ def check_error(data: list[int] | bytes, expect: int | list[int] | None) -> None
# accept-any-response reads (e.g. feature negotiation, auth nonces) would be
# silently passed through and surface later as cryptic decode/timeout failures.
if not data:
raise OTAError(
raise OTANetworkError(
"Device closed connection without responding. "
"This may indicate the device ran out of memory, "
"a network issue, or the connection was interrupted."
@@ -274,7 +302,7 @@ def send_check(
sock.sendall(data)
except OSError as err:
raise OTAError(f"sending {msg}: {err}") from err
raise OTANetworkError(f"sending {msg}: {err}") from err
def perform_ota(
@@ -306,7 +334,7 @@ def perform_ota(
send_check(sock, MAGIC_BYTES, "magic bytes")
_, version = receive_exactly(sock, 2, "version", RESPONSE_OK)
_LOGGER.debug("Device support OTA version: %s", version)
_LOGGER.info("Connection established; device supports OTA version %s", version)
supported_versions = (OTA_VERSION_1_0, OTA_VERSION_2_0)
if version not in supported_versions:
raise OTAError(
@@ -417,6 +445,8 @@ def perform_ota(
hash_func, nonce_size, hash_name = _AUTH_METHODS[auth]
perform_auth(sock, password, hash_func, nonce_size, hash_name)
_LOGGER.info("Handshake complete")
# Timeout must match device-side OTA_SOCKET_TIMEOUT_DATA to prevent premature failures
sock.settimeout(90.0)
@@ -449,21 +479,43 @@ def perform_ota(
offset = 0
progress = ProgressBar("Uploading")
while True:
chunk = upload_contents[offset : offset + UPLOAD_BLOCK_SIZE]
if not chunk:
break
offset += len(chunk)
try:
while True:
chunk = upload_contents[offset : offset + UPLOAD_BLOCK_SIZE]
if not chunk:
break
offset += len(chunk)
try:
sock.sendall(chunk)
except OSError as err:
# A send failure can hide an error byte the device reported
# just before dropping the connection; surface that as the
# real, non-retryable cause when it is available
try:
sock.settimeout(1.0)
check_error(recv_decode(sock, 1), None)
except (OSError, OTANetworkError) as probe_err:
_LOGGER.debug(
"No device error behind the send failure: %s", probe_err
)
raise OTANetworkError(f"sending data: {err}") from err
try:
sock.sendall(chunk)
if version >= OTA_VERSION_2_0:
receive_exactly(sock, 1, "chunk result", RESPONSE_CHUNK_OK)
except OSError as err:
sys.stderr.write("\n")
raise OTAError(f"sending data: {err}") from err
try:
receive_exactly(sock, 1, "chunk result", RESPONSE_CHUNK_OK)
except OTANetworkError as err:
if offset < upload_size:
raise
# The device already had the complete image when this ack
# was lost, so it may be committing; do not retry
raise _committed_error(err) from err
progress.update(offset / upload_size)
progress.update(offset / upload_size)
except OTAError:
# Terminate the progress bar line before the error is logged
progress.done()
raise
progress.done()
# Enable nodelay for last checks
@@ -472,11 +524,25 @@ def perform_ota(
_LOGGER.info("Upload took %.2f seconds, waiting for result...", duration)
receive_exactly(sock, 1, "update receive result", RESPONSE_RECEIVE_OK)
receive_exactly(sock, 1, "update end result", RESPONSE_UPDATE_END_OK)
send_check(sock, RESPONSE_OK, "end acknowledgement")
# Once the device has the complete image it commits the update and
# reboots on its own; the exact commit point is not observable from
# here, so treat everything past the data phase as non-retryable. A
# re-upload could flash a device that already updated successfully.
try:
receive_exactly(sock, 1, "update receive result", RESPONSE_RECEIVE_OK)
receive_exactly(sock, 1, "update end result", RESPONSE_UPDATE_END_OK)
except OTANetworkError as err:
raise _committed_error(err) from err
_LOGGER.info("OTA successful")
try:
send_check(sock, RESPONSE_OK, "end acknowledgement")
except OTANetworkError as err:
# The device treats a missing end acknowledgement as non-fatal and is
# already rebooting into the new firmware, so the update succeeded
_LOGGER.warning("Failed sending end acknowledgement: %s", err)
_LOGGER.info("OTA successful (end acknowledgement not delivered)")
else:
_LOGGER.info("OTA successful")
# Do not connect logs until it is fully on
time.sleep(1)
@@ -510,8 +576,33 @@ def run_ota_impl_(
)
raise OTAError(err) from err
for r in res:
af, socktype, _, _, sa = r
if not res:
_LOGGER.error("No addresses to connect to for %s", remote_host)
return 1, None
# Every address is tried at least once and EXTRA_UPLOAD_ATTEMPTS retries
# are shared across the addresses, cycling through them. Wait before an
# attempt when the previous one actually reached the device, or when
# revisiting an address, so a flaky link can recover and the device can
# clean up a half-open connection (its handshake watchdog runs at 20s);
# moving on to the next address family stays immediate. Known limitation:
# a silent mid-transfer drop with no reset can wedge the device until its
# 90s data timeout, which outlasts this budget; the retries target the
# common failures where the device resets or closes the link promptly.
total_attempts = len(res) + EXTRA_UPLOAD_ATTEMPTS
last_error = ""
reached_device = False
for attempt in range(total_attempts):
af, socktype, _, _, sa = res[attempt % len(res)]
if reached_device or attempt >= len(res):
_LOGGER.info(
"Retrying in %.0f seconds (attempt %d of %d)...",
UPLOAD_RETRY_DELAY,
attempt + 1,
total_attempts,
)
time.sleep(UPLOAD_RETRY_DELAY)
reached_device = False
_LOGGER.info("Connecting to %s port %s...", sa[0], sa[1])
sock = socket.socket(af, socktype)
sock.settimeout(20.0)
@@ -519,23 +610,30 @@ def run_ota_impl_(
sock.connect(sa)
except OSError as err:
sock.close()
_LOGGER.error("Connecting to %s port %s failed: %s", sa[0], sa[1], err)
_LOGGER.warning("Connecting to %s port %s failed: %s", sa[0], sa[1], err)
last_error = f"connecting to {sa[0]} failed: {err}"
continue
_LOGGER.info("Connected to %s", sa[0])
with Path(filename).open("rb") as file_handle:
reached_device = True
with contextlib.closing(sock), Path(filename).open("rb") as file_handle:
try:
perform_ota(sock, password, file_handle, filename, ota_type)
except OTANetworkError as err:
# Transient network failure; retry
last_error = str(err)
_LOGGER.warning("%s", last_error)
continue
except OTAError as err:
# Device-reported error (wrong password, wrong flash size, ...);
# retrying cannot succeed, so fail immediately
_LOGGER.error(str(err))
return 1, None
finally:
sock.close()
# Successfully uploaded to sa[0]
return 0, sa[0]
_LOGGER.error("Connection failed.")
_LOGGER.error("Upload failed after %d attempts: %s", total_attempts, last_error)
return 1, None
+183 -41
View File
@@ -1,16 +1,16 @@
from __future__ import annotations
from collections.abc import Callable, Iterable
from collections.abc import Callable, Iterable, Iterator
from concurrent.futures import ThreadPoolExecutor
import contextlib
from dataclasses import dataclass, field
from datetime import UTC, datetime
import hashlib
import logging
import os
from pathlib import Path
import time
import requests
import esphome.config_validation as cv
from esphome.const import CONF_FILE, CONF_TYPE, CONF_URL, __version__
from esphome.core import CORE, EsphomeError, TimePeriodSeconds
@@ -21,8 +21,54 @@ from esphome.types import ConfigType
_LOGGER = logging.getLogger(__name__)
CODEOWNERS = ["@landonr"]
DOMAIN = "external_files"
NETWORK_TIMEOUT = 30
@dataclass(frozen=True, slots=True)
class RemoteFile:
"""A remote file to prefetch, yielded in stages by ``PREFETCH_FILES``
hooks. A dataclass rather than a tuple so fields can be added later."""
url: str
path: Path
# False when nothing downstream can verify the bytes; a copy that
# cannot be revalidated is then an error, not a silent fallback.
allow_stale: bool = True
@dataclass(frozen=True, slots=True)
class FailedDownload:
"""What went wrong for a cache path this run, kept for fast replay."""
url: str
message: str
cause: BaseException
@dataclass
class ExternalFilesRunData:
"""Per-run download state, cleared by ``CORE.reset()`` between runs."""
# Verified fresh this run; later touches skip even the conditional HEAD.
fresh_paths: set[Path] = field(default_factory=set)
# Served from disk without revalidation; strict callers reject these.
stale_paths: set[Path] = field(default_factory=set)
# Served under skip_external_update, deliberately unchecked; skips the
# network like fresh_paths but never counts as verified.
unchecked_paths: set[Path] = field(default_factory=set)
# Failed with no usable copy; later touches replay the error fast.
failed_paths: dict[Path, FailedDownload] = field(default_factory=dict)
def _run_data() -> ExternalFilesRunData:
if (data := CORE.data.get(DOMAIN)) is not None:
return data
# setdefault: first touch may race on download_content_many's workers.
return CORE.data.setdefault(DOMAIN, ExternalFilesRunData())
IF_MODIFIED_SINCE = "If-Modified-Since"
IF_NONE_MATCH = "If-None-Match"
ETAG = "ETag"
@@ -93,6 +139,9 @@ def _write_etag(local_file_path: Path, etag: str | None) -> None:
def has_remote_file_changed(
url: str, local_file_path: Path, timeout: int = NETWORK_TIMEOUT
) -> bool:
# Deferred so configs with no remote files skip the heavy import.
import requests
ensure_happy_eyeballs()
if local_file_path.exists():
_LOGGER.debug("has_remote_file_changed: File exists at %s", local_file_path)
@@ -127,6 +176,9 @@ def has_remote_file_changed(
)
if (new_etag := response.headers.get(ETAG)) and new_etag != etag:
_write_etag(local_file_path, new_etag)
# A confirmed 304 supersedes any earlier failed
# revalidation of this file.
_run_data().stale_paths.discard(local_file_path)
return False
_LOGGER.debug("has_remote_file_changed: File modified")
return True
@@ -136,6 +188,9 @@ def has_remote_file_changed(
url,
e,
)
# The copy is a fallback, not a verified 304; record that so
# callers that must not use unverified bytes can reject it.
_run_data().stale_paths.add(local_file_path)
return False
_LOGGER.debug("has_remote_file_changed: File doesn't exists at %s", local_file_path)
@@ -159,14 +214,81 @@ def compute_local_file_dir(domain: str) -> Path:
return base_directory
def download_content(url: str, path: Path, timeout: int = NETWORK_TIMEOUT) -> bytes:
def url_cache_key(url: str) -> str:
"""Short stable cache key for a URL."""
return hashlib.sha256(url.encode()).hexdigest()[:8]
def compute_local_file_path(domain: str, url: str) -> Path:
"""Cache path for a URL-keyed download under the domain's cache dir.
Pure (no mkdir); parent directories are created at write time.
"""
return Path(CORE.data_dir) / domain / url_cache_key(url)
def is_fresh_this_run(path: Path) -> bool:
"""Whether `path` was verified or downloaded during this run."""
return path in _run_data().fresh_paths
def download_content(
url: str,
path: Path,
timeout: int = NETWORK_TIMEOUT,
allow_stale: bool = True,
return_content: bool = True,
) -> bytes:
"""Download `url` into `path` and return the bytes, using the cache.
On network failure an on-disk copy is served with a warning, unless
``allow_stale=False``. ``CORE.skip_external_update`` always serves the
copy. ``return_content=False`` skips the disk read on cache hits.
"""
# Deferred so configs with no remote files skip the heavy import.
import requests
def _cached() -> bytes:
return path.read_bytes() if return_content else b""
# Memoized paths skip the network entirely; concurrent access is safe
# because download_content_many dedupes by path before fanning out.
run_data = _run_data()
fresh_paths = run_data.fresh_paths
if (path in fresh_paths or path in run_data.unchecked_paths) and path.exists():
return _cached()
if allow_stale and path in run_data.stale_paths and path.exists():
# Strict callers fall through to try the network themselves.
_LOGGER.info("Using cached copy of %s that could not be revalidated", url)
return _cached()
if (failure := run_data.failed_paths.get(path)) is not None:
if not path.exists():
if failure.url == url:
raise cv.Invalid(failure.message) from failure.cause
raise cv.Invalid(
f"Could not download from {url}: an earlier download of "
f"{failure.url} to the same cache file failed: {failure.cause}"
) from failure.cause
# The file appeared since the failure; revalidate normally.
del run_data.failed_paths[path]
ensure_happy_eyeballs()
if CORE.skip_external_update and path.exists():
_LOGGER.debug("Skipping update for %s (refresh disabled)", url)
return path.read_bytes()
run_data.unchecked_paths.add(path)
return _cached()
if not has_remote_file_changed(url, path, timeout):
if path in run_data.stale_paths:
# The HEAD fell back to the copy without confirming it.
if not allow_stale:
raise cv.Invalid(
f"Could not check {url} for updates due to a network error "
f"and the cached copy cannot be verified"
)
return _cached()
_LOGGER.debug("Remote file has not changed %s", url)
return path.read_bytes()
fresh_paths.add(path)
return _cached()
_LOGGER.info("Downloading %s", url)
_LOGGER.debug("Saving to %s", path)
@@ -185,16 +307,24 @@ def download_content(url: str, path: Path, timeout: int = NETWORK_TIMEOUT) -> by
data = req.content
except requests.exceptions.RequestException as e:
if path.exists():
# Memoized so a flaky host warns once per run, not per consumer.
run_data.stale_paths.add(path)
if not allow_stale:
raise cv.Invalid(f"Could not download from {url}: {e}") from e
_LOGGER.warning(
"Could not download from %s due to network error (%s), using cached file",
url,
e,
)
return path.read_bytes()
raise cv.Invalid(f"Could not download from {url}: {e}") from e
return _cached()
message = f"Could not download from {url}: {e}"
run_data.failed_paths[path] = FailedDownload(url, message, e)
raise cv.Invalid(message) from e
write_file(path, data)
_write_etag(path, req.headers.get(ETAG))
fresh_paths.add(path)
run_data.stale_paths.discard(path)
return data
@@ -207,50 +337,47 @@ DEFAULT_DOWNLOAD_WORKERS = 8
def download_content_many(
items: Iterable[tuple[str, Path]],
items: Iterable[RemoteFile],
timeout: int = NETWORK_TIMEOUT,
max_workers: int = DEFAULT_DOWNLOAD_WORKERS,
description: str = "remote file(s)",
) -> None:
"""Run `download_content` for each (url, path) pair concurrently.
"""Run `download_content` for each `RemoteFile` concurrently.
`description` names the kind of files in the progress log line, e.g.
"wake word manifest(s)".
Wall time drops from `sum(latency)` to roughly `max(latency)` for cached
files where the HEAD round-trip dominates. All workers run to
completion before this returns; every `cv.Invalid` raised by a worker
is collected and surfaced together as `cv.MultipleInvalid` so the user
sees every broken file in a single validation pass instead of fixing
them one round-trip at a time.
Items are de-duplicated by `path` -- two callers asking for the same
cache file (e.g. the same URL referenced twice in a config) would
otherwise race on `download_content`'s non-atomic write. When the
same `path` appears more than once, the last URL wins (standard dict
comprehension semantics); in practice duplicate paths only arise when
the URL is duplicated, so the choice doesn't matter.
`description` names the files in the progress log line. All workers run
to completion; every `cv.Invalid` raised is surfaced together as
`cv.MultipleInvalid`. Items dedupe by `path` (avoiding write races on
the same cache file); the last URL wins and a strict
`allow_stale=False` from any duplicate is kept.
"""
seen: dict[Path, str] = {path: url for url, path in items}
if not seen:
seen: dict[Path, RemoteFile] = {}
for file in items:
if (prior := seen.get(file.path)) is not None and not prior.allow_stale:
file = RemoteFile(file.url, file.path, allow_stale=False)
seen[file.path] = file
unique = list(seen.values())
if not unique:
return
ensure_happy_eyeballs()
_LOGGER.info("Checking %d %s for updates", len(seen), description)
if len(seen) == 1:
path, url = next(iter(seen.items()))
download_content(url, path, timeout)
_LOGGER.info("Checking %d %s for updates", len(unique), description)
def _download_one(file: RemoteFile) -> None:
download_content(
file.url,
file.path,
timeout,
allow_stale=file.allow_stale,
return_content=False,
)
if len(unique) == 1:
_download_one(unique[0])
return
def _download_one(path_url: tuple[Path, str]) -> None:
# `seen` stores entries as (path, url) so the dict can dedupe by
# path; flip them back to download_content's (url, path) order.
path, url = path_url
download_content(url, path, timeout)
workers = max(1, min(max_workers, len(seen)))
workers = max(1, min(max_workers, len(unique)))
errors: list[cv.Invalid] = []
with ThreadPoolExecutor(max_workers=workers) as ex:
futures = [ex.submit(_download_one, item) for item in seen.items()]
futures = [ex.submit(_download_one, file) for file in unique]
for future in futures:
try:
future.result()
@@ -263,6 +390,21 @@ def download_content_many(
raise cv.MultipleInvalid(errors)
def single_stage_prefetch(
extract: Callable[[ConfigType], RemoteFile | None],
) -> Callable[[list[ConfigType]], Iterator[list[RemoteFile]]]:
"""Build a one-batch ``PREFETCH_FILES`` hook from a per-entry extractor.
Covers the common case of one remote file per raw config entry;
components with staged downloads write their own generator.
"""
def prefetch_files(entries: list[ConfigType]) -> Iterator[list[RemoteFile]]:
yield [ref for entry in entries if (ref := extract(entry)) is not None]
return prefetch_files
# Each component that uses external_files defines its own local
# `TYPE_WEB = "web"`; the string is repeated here rather than imported
# because there is no canonical `TYPE_WEB` in `esphome.const` to share.
@@ -282,7 +424,7 @@ def download_web_files_in_config(
slotted directly into a `cv.All(...)` chain.
"""
download_content_many(
(conf_file[CONF_URL], path_for(conf_file))
RemoteFile(conf_file[CONF_URL], path_for(conf_file))
for entry in config
if (conf_file := entry.get(CONF_FILE, {})).get(CONF_TYPE) == WEB_TYPE
)
+178 -83
View File
@@ -25,9 +25,13 @@ _LOGGER = logging.getLogger(__name__)
# Attempts per mirror URL before falling through to the next mirror; only
# mid-stream drops retry (resuming when the server gave a validator),
# connect errors move on immediately.
# connect errors move on to the next mirror immediately.
_MIRROR_ATTEMPTS = 3
# Passes over the whole mirror list when a transient network error is in
# the mix; matches git.py's _NETWORK_MAX_ATTEMPTS (3 tries, 2s/4s backoff).
_MIRROR_SWEEP_ATTEMPTS = 3
def get_project_link_flags() -> list[str]:
"""Return the sorted -Wl, linker flags from the current build."""
@@ -151,6 +155,8 @@ def run_command(
_LOGGER.debug("%s - running ...", cmd_str)
run_env = os.environ.copy()
# Do not leak PYTHONPATH
run_env.pop("PYTHONPATH", None)
if env:
run_env.update(env)
@@ -887,37 +893,51 @@ def _failure_reason(e: Exception) -> str:
return str(e).split(" for url: ", maxsplit=1)[0] or repr(e)
def download_from_mirrors(
mirrors: list[str],
substitutions: dict[str, str],
target: io.RawIOBase | IO[bytes] | PathType,
timeout: int = 30,
) -> str:
def _spent_attempts_error(e: Exception, attempts: int) -> Exception:
"""Wrap a failure whose mirror already consumed download attempts, so
the sweep classifies it as permanent."""
from esphome.core import EsphomeError
err = EsphomeError(f"failed after {attempts} attempts: {_failure_reason(e)}")
err.__cause__ = e
return err
def _is_transient_download_error(e: Exception) -> bool:
"""Return True when a download failure is worth retrying.
Connection-level failures and HTTP 429/5xx are transient. Other HTTP
errors, local errors, and exhausted-attempts EsphomeError wrappers
(their per-mirror retries are already spent) are permanent.
"""
Download file from multiple mirrors with substitution support.
# Imported lazily: requests is a heavy import (~85ms) and is only
# needed when actually downloading, never during config validation.
import requests
Args:
mirrors: list of mirror URLs
substitutions: Dictionary of substitutions to apply to URLs
target: Target file path or file-like object
timeout: Download timeout in seconds
if isinstance(e, requests.exceptions.HTTPError):
resp = e.response
return resp is not None and (resp.status_code == 429 or resp.status_code >= 500)
return isinstance(
e,
(
requests.exceptions.ConnectionError,
requests.exceptions.Timeout,
requests.exceptions.ChunkedEncodingError,
),
)
Returns:
The source URL.
Mirror URL templates that reference a substitution not present in
``substitutions`` are skipped, so callers can offer templates that only
apply to some downloads.
def _try_mirrors_once(
urls: list[str],
path_target: Path | None,
f: IO[bytes] | None,
timeout: int,
failures: list[tuple[str, Exception]],
) -> str | None:
"""Single pass over the resolved mirror ``urls``, one try per URL.
A path target downloads through ``download_with_resume``, so an
interrupted download resumes on the next esphome run; a file-like target
only resumes mid-stream drops within this call.
Raises:
ValueError: If mirrors list is empty.
EsphomeError: If all download attempts fail; the message lists every
attempted URL with its individual failure reason. Also raised if
no template matched the provided substitutions.
Returns the source URL on success, or None with each URL's exception
appended to ``failures``.
"""
# Imported lazily: requests is a heavy import (~85ms) and is only
# needed when actually downloading, never during config validation.
@@ -925,43 +945,7 @@ def download_from_mirrors(
from esphome.core import EsphomeError
ensure_happy_eyeballs()
# 1. Classify the target: filesystem path or open file object
path_target: Path | None = None
f: IO[bytes] | None = None
if isinstance(target, (str, os.PathLike)):
path_target = Path(target)
elif isinstance(target, (io.RawIOBase, io.IOBase)):
f = target
else:
raise TypeError(
f"target must be str, Path, or file-like object: {type(target)}"
)
# 2. Try each mirror in order
failures: list[tuple[str, Exception]] = []
skipped: list[tuple[str, str]] = []
for mirror in mirrors:
# 3. Apply substitutions to URL
try:
url = mirror.format(**substitutions)
except KeyError as e:
# The template references a substitution not provided for
# this download (e.g. SHORT_VERSION only exists for x.y.0
# versions) - expected, the template just doesn't apply.
_LOGGER.debug("Skipping mirror %s: %s not available", mirror, e)
skipped.append((mirror, f"not applicable ({e.args[0]} not available)"))
continue
except (IndexError, ValueError) as e:
# A malformed template (unbalanced braces, bad format spec)
# is an authoring error, not an expected fallthrough - warn
# even if a later mirror succeeds.
_LOGGER.warning("Skipping malformed mirror URL template %s: %r", mirror, e)
skipped.append((mirror, f"skipped ({e!r})"))
continue
for url in urls:
_LOGGER.debug("Trying to download from %s", url)
# Path targets delegate to download_with_resume so a partial
@@ -986,14 +970,14 @@ def download_from_mirrors(
failures.append((url, e))
continue
# 4. Download; mid-stream failures retry the same mirror with
# resume (see download_with_resume) instead of starting over.
# There is no checksum to verify a resumed file against, so a
# stitch is only trusted when the server proves consistency: the
# If-Range validator guarantees 206 only for unchanged content,
# and the expected total length (when the first response carried
# one) guards against short or shifted bodies. Without a
# validator the retry restarts from zero.
# File-like targets download here; mid-stream failures retry the
# same mirror with resume (see download_with_resume) instead of
# starting over. There is no checksum to verify a resumed file
# against, so a stitch is only trusted when the server proves
# consistency: the If-Range validator guarantees 206 only for
# unchanged content, and the expected total length (when the first
# response carried one) guards against short or shifted bodies.
# Without a validator the retry restarts from zero.
offset = 0
expected_total = 0
validator = None
@@ -1001,9 +985,12 @@ def download_from_mirrors(
try:
resp, offset = _open_ranged(url, offset, timeout, validator)
except (requests.RequestException, OSError) as e:
# Connect/HTTP error, no bytes flowed — next mirror.
# Connect/HTTP error, no bytes flowed — next mirror. Wrap
# when earlier attempts were already spent on this mirror.
_LOGGER.debug("Failed to download %s: %s", url, str(e))
failures.append((url, e))
failures.append(
(url, _spent_attempts_error(e, attempt + 1) if attempt else e)
)
break
try:
@@ -1031,7 +1018,7 @@ def download_from_mirrors(
_LOGGER.debug("Downloaded successfully from: %s", url)
# 5. Reset file pointer and return
# Reset file pointer and return
f.seek(0)
return url
@@ -1054,16 +1041,124 @@ def download_from_mirrors(
)
offset = 0
if attempt == _MIRROR_ATTEMPTS - 1:
failures.append((url, e))
failures.append((url, _spent_attempts_error(e, _MIRROR_ATTEMPTS)))
# 6. Report every attempted URL if all mirrors failed. Falling back
# past an early mirror is normal (e.g. only one of the framework URL
# templates matches a given version's tag), so raising only the last
# error would hide the failure that actually matters.
if failures:
attempts = "".join(
f"\n {url}\n {_failure_reason(e)}" for url, e in failures
return None
def download_from_mirrors(
mirrors: list[str],
substitutions: dict[str, str],
target: io.RawIOBase | IO[bytes] | PathType,
timeout: int = 30,
) -> str:
"""
Download file from multiple mirrors with substitution support.
Args:
mirrors: list of mirror URLs
substitutions: Dictionary of substitutions to apply to URLs
target: Target file path or file-like object
timeout: Download timeout in seconds
Returns:
The source URL.
Mirror URL templates that reference a substitution not present in
``substitutions`` are skipped, so callers can offer templates that only
apply to some downloads.
A path target downloads through ``download_with_resume``, so an
interrupted download resumes on the next esphome run; a file-like target
only resumes mid-stream drops within this call.
When every mirror fails and at least one failure is transient (dropped
connection, timeout, HTTP 429/5xx), the whole list is retried with a
short backoff; permanent failures (e.g. 404) raise immediately.
Raises:
ValueError: If mirrors list is empty.
EsphomeError: If all download attempts fail; the message lists every
attempted URL with its individual failure reason. Also raised if
no template matched the provided substitutions.
"""
from esphome.core import EsphomeError
ensure_happy_eyeballs()
# 1. Classify the target: filesystem path or open file object
path_target: Path | None = None
f: IO[bytes] | None = None
if isinstance(target, (str, os.PathLike)):
path_target = Path(target)
elif isinstance(target, (io.RawIOBase, io.IOBase)):
f = target
else:
raise TypeError(
f"target must be str, Path, or file-like object: {type(target)}"
)
# 2. Resolve the mirror templates (invariant across retry sweeps)
urls: list[str] = []
skipped: list[tuple[str, str]] = []
for mirror in mirrors:
try:
urls.append(mirror.format(**substitutions))
except KeyError as e:
# The template references a substitution not provided for
# this download (e.g. SHORT_VERSION only exists for x.y.0
# versions) - expected, the template just doesn't apply.
_LOGGER.debug("Skipping mirror %s: %s not available", mirror, e)
skipped.append((mirror, f"not applicable ({e.args[0]} not available)"))
except (IndexError, ValueError) as e:
# A malformed template (unbalanced braces, bad format spec)
# is an authoring error, not an expected fallthrough - warn
# even if a later mirror succeeds.
_LOGGER.warning("Skipping malformed mirror URL template %s: %r", mirror, e)
skipped.append((mirror, f"skipped ({e!r})"))
# 3. Sweep the mirror list, retrying transient failures with backoff:
# a single pass keeps mirror failover fast, re-sweeping keeps one
# network blip from failing the build when only one mirror applies.
failures: list[tuple[str, Exception]] = []
for sweep in range(1, _MIRROR_SWEEP_ATTEMPTS + 1):
sweep_failures: list[tuple[str, Exception]] = []
if (
url := _try_mirrors_once(urls, path_target, f, timeout, sweep_failures)
) is not None:
return url
failures.extend(sweep_failures)
# Permanent failures (404, verification mismatch) won't heal;
# only retry when a transient error is in the mix (as git.py does).
transient = next(
((u, e) for u, e in sweep_failures if _is_transient_download_error(e)),
None,
)
if transient is None:
break
if sweep < _MIRROR_SWEEP_ATTEMPTS:
delay = 2**sweep
_LOGGER.warning(
"Download of %s failed (%s); retrying in %d seconds (attempt %d/%d)",
transient[0],
_failure_reason(transient[1]),
delay,
sweep + 1,
_MIRROR_SWEEP_ATTEMPTS,
)
time.sleep(delay)
# 4. Report every attempted URL if all mirrors failed. failures spans
# all sweeps (deduplicated by URL and reason), so neither an early
# mirror's failure nor an earlier sweep's failure mode is hidden.
if failures:
seen: set[tuple[str, str]] = set()
attempts = ""
for url, e in failures:
reason = _failure_reason(e)
if (url, reason) not in seen:
seen.add((url, reason))
attempts += f"\n {url}\n {reason}"
attempts += "".join(f"\n {mirror}\n {reason}" for mirror, reason in skipped)
raise EsphomeError(
f"Failed to download from all mirrors:{attempts}"
+23 -10
View File
@@ -91,13 +91,8 @@ def fnv1a_32bit_hash(string: str) -> int:
def fnv1_hash_object_id(name: str) -> int:
"""Compute FNV-1 hash of name with snake_case + sanitize transformations.
IMPORTANT: Must produce same result as C++ fnv1_hash_object_id() in helpers.h
with per_code_point set. This is the OLD entity hash; it computes preference
keys that existing devices already have stored (see
https://github.com/esphome/backlog/issues/85) and is also still used for live
keys derived from config IDs (see the motion component's calibration key).
Note: lower() here is Unicode aware while the C++ reconstruction is not; see
the known limitation note on the C++ function.
IMPORTANT: Must produce same result as C++ fnv1_hash_object_id() in helpers.h.
If you modify this function, update the C++ version and tests in both places.
"""
return fnv1_hash(sanitize(snake_case(name)))
@@ -105,9 +100,9 @@ def fnv1_hash_object_id(name: str) -> int:
def fnv1_hash_name(name: str) -> int:
"""Compute FNV-1 hash of the raw entity name (UTF-8 bytes, no transformations).
IMPORTANT: Must produce same result as C++ fnv1_hash_bytes() in helpers.h,
which hashes the name bytes as stored on the device.
Used for pre-computing entity keys at code generation time.
2026.8 beta firmware stored preferences under keys derived from this hash;
a future key migration must reconstruct those keys to recover that data
(see https://github.com/esphome/backlog/issues/85).
"""
return _fnv1_hash(name.encode("utf-8"))
@@ -357,6 +352,24 @@ def resolve_ip_address(
return res
def format_ip_url(family: int, sockaddr: tuple, port: int, path: str) -> str:
"""Build an ``http://host:port/path`` URL for a resolved address.
``family``/``sockaddr`` come from a :func:`resolve_ip_address` entry. IPv6
literals must be wrapped in brackets in URLs; link-local addresses need a
percent-encoded zone index per RFC 6874.
"""
import socket
ip = sockaddr[0]
if family == socket.AF_INET6:
scope = sockaddr[3] if len(sockaddr) >= 4 else 0
host_part = f"[{ip}%25{scope}]" if scope else f"[{ip}]"
else:
host_part = ip
return f"http://{host_part}:{port}{path}"
def sort_ip_addresses(address_list: list[str]) -> list[str]:
"""Takes a list of IP addresses in string form, e.g. from mDNS or MQTT,
and sorts them into the best order to actually try connecting to them.
+2 -2
View File
@@ -48,7 +48,7 @@ dependencies:
rules:
- if: "target in [esp32, esp32p4]"
espressif/esp-zigbee-lib:
version: 2.0.3
version: 2.0.4
rules:
- if: "target in [esp32h2, esp32c5, esp32c6]"
espressif/lan87xx:
@@ -98,7 +98,7 @@ dependencies:
esp32async/asynctcp:
version: 3.4.91
sendspin/sendspin-cpp:
version: 0.7.1
version: 0.7.2
lvgl/lvgl:
version: 9.5.0
fastled/FastLED:
+49 -38
View File
@@ -1,4 +1,4 @@
from collections.abc import Callable
from collections.abc import Callable, Iterable
from contextlib import AbstractContextManager
from dataclasses import dataclass
import importlib
@@ -16,6 +16,7 @@ from esphome.types import ConfigType
if TYPE_CHECKING:
from esphome.cpp_generator import MockObjClass
from esphome.external_files import RemoteFile
# `esphome.core.config` is imported lazily in `_lookup_module` when the
# "esphome" pseudo-component is first resolved. It pulls in
@@ -135,6 +136,21 @@ class ComponentManifest:
"""
return getattr(self.module, "FINAL_VALIDATE_SCHEMA", None)
@property
def prefetch_files(
self,
) -> Callable[[list[ConfigType]], Iterable[list["RemoteFile"]]] | None:
"""Optional `PREFETCH_FILES` hook for batched remote file downloads.
A generator called once per run with the component's raw, pre-schema
config entries; each yield is a stage of ``RemoteFile`` downloaded in
one parallel pass before schema validation, so a later stage may
derive URLs from earlier files' content. Best effort: skip anything
unrecognized. On platform components, place it on the platform
sub-module; a domain-module hook receives every entry.
"""
return getattr(self.module, "PREFETCH_FILES", None)
@property
def legacy_config_migrate(self) -> Callable[[ConfigType], ConfigType | None] | None:
"""Optional `LEGACY_CONFIG_MIGRATE` callable on a platform component module.
@@ -148,6 +164,14 @@ class ComponentManifest:
"""
return getattr(self.module, "LEGACY_CONFIG_MIGRATE", None)
@property
def expand_platform_config(
self,
) -> Callable[[list[ConfigType]], list[ConfigType]] | None:
"""Optional `EXPAND_PLATFORM_CONFIG` callable; runs on the normalized `platform:`-tagged
entry list before per-entry CONFIG_SCHEMA. Must return a list (raise `cv.Invalid` for user errors)."""
return getattr(self.module, "EXPAND_PLATFORM_CONFIG", None)
@property
def resources(self) -> list[FileResource]:
"""Return a list of all file resources defined in the package of this component.
@@ -253,10 +277,9 @@ def _lookup_module(domain: str, exception: bool) -> ComponentManifest | None:
# If `domain` is the legacy name of a renamed component, redirect to the
# canonical module so the rest of the loader (and every caller of
# `get_component(legacy)`) transparently sees the new component.
alias_map = _get_alias_map()
if domain in alias_map:
canonical = alias_map[domain]
manif = _lookup_module(canonical, exception)
alias_meta = get_alias_metadata().get(domain)
if alias_meta is not None:
manif = _lookup_module(alias_meta.canonical, exception)
if manif is not None:
_COMPONENT_CACHE[domain] = manif
return manif
@@ -313,8 +336,10 @@ def _replace_component_manifest(domain: str, manifest: ComponentManifest) -> Non
# ---------------------------------------------------------------------------
#
# A component can declare ``ALIASES = ["legacy_name"]`` (and optionally
# ``ALIAS_REMOVAL_VERSION = "YYYY.M.0"``) in its ``__init__.py``. Two
# integrations are then wired up automatically:
# ``ALIAS_REMOVAL_VERSION = "YYYY.M.0"``) in its ``__init__.py``, then run
# ``script/build_alias_registry.py`` to regenerate
# ``esphome/component_aliases.py`` (CI and a unit test fail if the registry
# is stale). Two integrations are then wired up automatically:
#
# 1. **Python imports** — a ``sys.meta_path`` finder (``_AliasFinder``)
# intercepts ``esphome.components.<legacy>``/``...<legacy>.<sub>``
@@ -328,13 +353,13 @@ def _replace_component_manifest(domain: str, manifest: ComponentManifest) -> Non
# dependency checks, schema validation and codegen all see only the
# canonical name.
#
# Both lookups are populated by ``_build_alias_map``, which **AST-parses**
# every component's ``__init__.py`` rather than importing it. That keeps the
# cost low: scanning ~400 components on disk takes ~5 ms instead of the
# multi-second cost of executing every component's import side-effects.
# Both lookups read the checked-in registry in ``esphome.component_aliases``
# (generated by ``script/build_alias_registry.py``, verified in CI), so no
# component-directory scan happens at runtime. ``_build_alias_map`` below is
# the generator's scan implementation; it **AST-parses** each component's
# ``__init__.py`` rather than importing it.
_ALIAS_MAP_CACHE: dict[str, str] | None = None
_ALIAS_META_CACHE: dict[str, "AliasMeta"] | None = None
@@ -351,31 +376,17 @@ class AliasMeta:
removal_version: str | None
def _ensure_alias_caches() -> None:
"""Populate both alias caches from a single directory scan.
``_build_alias_map`` returns both maps together, so building them in one
shot avoids scanning every component's ``__init__.py`` twice when a run
needs both the canonical map (loader) and the metadata map (config
pre-pass).
"""
global _ALIAS_MAP_CACHE, _ALIAS_META_CACHE
if _ALIAS_MAP_CACHE is None or _ALIAS_META_CACHE is None:
_ALIAS_MAP_CACHE, _ALIAS_META_CACHE = _build_alias_map()
def _get_alias_map() -> dict[str, str]:
"""Return the legacy-name → canonical-name map, building it lazily."""
_ensure_alias_caches()
return _ALIAS_MAP_CACHE
def get_alias_metadata() -> dict[str, AliasMeta]:
"""Return the legacy-name → :class:`AliasMeta` map (cached).
"""Return the legacy-name → :class:`AliasMeta` map, built lazily from
the generated registry."""
global _ALIAS_META_CACHE # noqa: PLW0603
if _ALIAS_META_CACHE is None:
from esphome.component_aliases import COMPONENT_ALIASES
Used by the YAML pre-pass to format a per-alias deprecation warning.
"""
_ensure_alias_caches()
_ALIAS_META_CACHE = {
alias: AliasMeta(canonical=canonical, removal_version=removal_version)
for alias, (canonical, removal_version) in COMPONENT_ALIASES.items()
}
return _ALIAS_META_CACHE
@@ -521,11 +532,11 @@ class _AliasFinder(importlib.abc.MetaPathFinder):
# least three parts, so ``parts[2]`` (the domain) always exists.
parts = fullname.split(".")
domain = parts[2]
alias_map = _get_alias_map()
if domain not in alias_map:
alias_meta = get_alias_metadata().get(domain)
if alias_meta is None:
return None
parts[2] = alias_map[domain]
parts[2] = alias_meta.canonical
canonical_fullname = ".".join(parts)
try:
canonical_module = importlib.import_module(canonical_fullname)
+77 -14
View File
@@ -6,6 +6,7 @@ from pathlib import Path
import ssl
import tempfile
import time
from typing import TYPE_CHECKING
import paho.mqtt.client as mqtt
@@ -31,6 +32,9 @@ from esphome.helpers import get_int_env, get_str_env
from esphome.types import ConfigType
from esphome.util import safe_print
if TYPE_CHECKING:
import threading
_LOGGER = logging.getLogger(__name__)
@@ -164,6 +168,7 @@ def get_esphome_device_ip(
password: str | None = None,
client_id: str | None = None,
timeout: float = 25,
stop_event: "threading.Event | None" = None,
) -> list[str]:
if CONF_MQTT not in config:
raise EsphomeError(
@@ -182,55 +187,113 @@ def get_esphome_device_ip(
dev_name = config[CONF_ESPHOME][CONF_NAME]
dev_ip = None
failed = False
topic = "esphome/discover/" + dev_name
_LOGGER.info("Starting looking for IP in topic %s", topic)
def on_message(client, userdata, msg):
nonlocal dev_ip
nonlocal dev_ip, failed
time_ = datetime.now().astimezone().time().strftime("[%H:%M:%S]")
payload = msg.payload.decode(errors="backslashreplace")
if len(payload) > 0:
message = time_ + " " + payload
_LOGGER.debug(message)
data = json.loads(payload)
try:
data = json.loads(payload)
except ValueError:
data = None
if not isinstance(data, dict):
# A raise in this handler would kill paho's network thread
_LOGGER.warning("Ignoring unparsable discovery payload")
return
if "name" not in data or data["name"] != dev_name:
_LOGGER.warning("Wrong device answer")
return
dev_ip = []
addresses = []
key = "ip"
n = 0
while key in data:
dev_ip.append(data[key])
value = data[key]
if (
isinstance(value, str)
and (value := value.strip())
and value.isprintable()
):
addresses.append(value)
else:
# repr-escaped and truncated: must not forge log lines
_LOGGER.warning(
"Ignoring invalid address in discovery answer: %s",
repr(value)[:100],
)
n = n + 1
key = "ip" + str(n)
if dev_ip:
client.disconnect()
if not addresses:
_LOGGER.warning("Device answer did not include an IP address")
failed = True
return
dev_ip = addresses
failed = False # a complete answer wins over an earlier empty one
client.disconnect()
def on_connect(client, userdata, flags, return_code):
topic = "esphome/ping/" + dev_name
_LOGGER.info("Send discover via MQTT broker topic: %s", topic)
client.publish(topic, None, retain=False)
if stop_event is not None and stop_event.is_set():
# Teardown already started; don't open a broker connection at all
return []
def on_disconnect(client, userdata, result_code):
nonlocal failed
if result_code != 0:
_LOGGER.warning("Disconnected from MQTT broker (%s)", result_code)
failed = True
mqtt_client = prepare(
config, [topic], on_message, on_connect, username, password, client_id
)
# Discovery is one-shot; prepare()'s reconnect-forever on_disconnect runs
# on the network thread and would make loop_stop() below join forever.
mqtt_client.on_disconnect = on_disconnect
mqtt_client.loop_start()
while timeout > 0:
if dev_ip is not None:
break
timeout -= 0.250
time.sleep(0.250)
mqtt_client.loop_stop()
if stop_event is None:
import threading
stop_event = threading.Event() # never set; wait() below is a plain sleep
stopped = stop_event.is_set() # teardown may have started during connect
try:
if not stopped:
mqtt_client.loop_start()
while timeout > 0:
if dev_ip is not None or failed:
break
if stop_event.wait(0.250):
stopped = True
break
timeout -= 0.250
finally:
# A cleanup failure must not replace the discovery result or its
# EsphomeError; a second disconnect after on_message's is harmless.
try:
mqtt_client.disconnect()
except Exception: # pylint: disable=broad-except
_LOGGER.debug("Error disconnecting from MQTT broker", exc_info=True)
mqtt_client.loop_stop() # only signals and joins; does not raise
if dev_ip is None:
if stopped:
# Aborted by the caller, not a failure; stay quiet
return []
raise EsphomeError("Failed to find IP via MQTT")
_LOGGER.info("Found IP: %s", dev_ip)
_LOGGER.info("Found IP via MQTT broker: %s", ", ".join(dev_ip))
return dev_ip

Some files were not shown because too many files have changed in this diff Show More