Compare commits

..

24 Commits

Author SHA1 Message Date
J. Nick Koston 5ce8c324b0 [ci] sync-device-classes: drop branch-switch hack, skip no-commit-to-branch instead 2026-05-15 09:50:10 -07:00
J. Nick Koston 4e70e0b4d7 [ci] sync-device-classes: skip pylint hook 2026-05-15 09:42:21 -07:00
J. Nick Koston e63cb94f94 [ci] sync-device-classes: use uv for installs 2026-05-15 09:40:15 -07:00
Jonathan Swoboda ff968a4629 [ci] Fix sync-device-classes workflow (failing daily for weeks) (#16448) 2026-05-15 09:36:01 -07:00
Edward Firmo d832ce51cd [nextion] Replace connect_info vector with fixed-size field parser, always log device info (#16059)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-05-14 22:42:10 -05:00
Kevin Ahrendt d663d80fde [sound_level] Use RingBufferAudioSource (#16436) 2026-05-14 20:33:36 -07:00
Kevin Ahrendt c5c627d534 [audio] Bump microMP3 to v0.2.1 (#16429) 2026-05-14 20:31:11 -07:00
Jonathan Swoboda d046dd7276 [esp32_hosted] Bump esp_hosted to 2.12.7 (#16440) 2026-05-14 22:51:14 -04:00
Jonathan Swoboda 56983f414f [espidf] Gate esp_idf_size --ng on IDF version (#16441) 2026-05-14 22:41:36 -04:00
Jonathan Swoboda a92b607754 [ci] Add ci-run-all label to force full CI matrix (#16421) 2026-05-14 18:54:13 -04:00
Jonathan Swoboda 313d974983 [multiple] Fix -Wformat= mismatches in component .cpp sources (#16433) 2026-05-14 18:53:42 -04:00
Jonathan Swoboda 1d86d856d1 [docker] Install libusb-1.0 so ESP-IDF tools can validate openocd (#16424)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-14 15:51:59 -07:00
Jonathan Swoboda 1bb191aa77 [ci] Skip dashboard-deprecation bot on release/beta-bump PRs (#16427) 2026-05-14 15:51:36 -07:00
dependabot[bot] 5d9d6e83f7 Bump ruff from 0.15.12 to 0.15.13 (#16437)
Signed-off-by: dependabot[bot] <support@github.com>
2026-05-14 15:41:32 -07:00
Jonathan Swoboda f3d7743460 [tests] Fix -Wformat= mismatches in test YAML lambdas/logger.log (#16435) 2026-05-14 18:40:40 -04:00
Jonathan Swoboda f291dc8d2f [esp32] Sweep ESP-IDF toolchain warnings + bump deprecated mark_failed (#16432) 2026-05-14 18:39:16 -04:00
Jonathan Swoboda a8e69a15e4 [clang-tidy] Enable readability-container-contains (#16438) 2026-05-14 18:38:09 -04:00
Keith Burzinski 7436d1c199 [tinyusb] Reject logger.hardware_uart: USB_CDC (#16417)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 20:29:56 +00:00
Keith Burzinski 348b92910e [tinyusb] Reject tinyusb: configured without a USB class companion (#16413)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 15:07:38 -05:00
Jonathan Swoboda f89a6f4f9c [espidf] Trim has_outdated_files watch list; embed IDF version in sdkconfig (#16416) 2026-05-14 04:02:22 +00:00
Jesse Hills c3ee962b83 Merge branch 'beta' into dev 2026-05-14 15:25:38 +12:00
Jonathan Swoboda e593cb6efc [espidf] Stop perpetual reconfigure loop on native ESP-IDF builds (#16415) 2026-05-13 23:19:30 -04:00
J. Nick Koston d2107e40c8 [ci] Prohibit curly braces in PR titles for MDX safety (#16412) 2026-05-14 14:03:45 +12:00
Jesse Hills 78b60ac6fa Bump version to 2026.6.0-dev 2026-05-14 12:33:43 +12:00
86 changed files with 1019 additions and 2425 deletions
-1
View File
@@ -116,7 +116,6 @@ Checks: >-
-portability-template-virtual-member-function,
-readability-ambiguous-smartptr-reset-call,
-readability-avoid-nested-conditional-operator,
-readability-container-contains,
-readability-container-data-pointer,
-readability-convert-member-functions-to-static,
-readability-else-after-return,
+1 -1
View File
@@ -1 +1 @@
593fd53fa09944a59af3f38521e31d87fe10b60326b8d82bb76413c5149b312c
27aaab4e0ebfc10491720345aa746fc2dffa6a3985f73ec111b12dd99078d46f
+32 -7
View File
@@ -249,6 +249,7 @@ jobs:
integration-test-buckets: ${{ steps.determine.outputs.integration-test-buckets }}
clang-tidy: ${{ steps.determine.outputs.clang-tidy }}
clang-tidy-mode: ${{ steps.determine.outputs.clang-tidy-mode }}
clang-tidy-full-scan: ${{ steps.determine.outputs.clang-tidy-full-scan }}
python-linters: ${{ steps.determine.outputs.python-linters }}
import-time: ${{ steps.determine.outputs.import-time }}
device-builder: ${{ steps.determine.outputs.device-builder }}
@@ -287,7 +288,12 @@ jobs:
GH_TOKEN: ${{ github.token }}
run: |
. venv/bin/activate
output=$(python script/determine-jobs.py)
EXTRA_ARGS=""
if [[ "${{ contains(github.event.pull_request.labels.*.name, 'ci-run-all') }}" == "true" ]]; then
EXTRA_ARGS="--force-all"
echo "::notice::ci-run-all label detected -- forcing every CI job to run"
fi
output=$(python script/determine-jobs.py $EXTRA_ARGS)
echo "Test determination output:"
echo "$output" | jq
@@ -296,6 +302,7 @@ jobs:
echo "integration-test-buckets=$(echo "$output" | jq -c '.integration_test_buckets')" >> $GITHUB_OUTPUT
echo "clang-tidy=$(echo "$output" | jq -r '.clang_tidy')" >> $GITHUB_OUTPUT
echo "clang-tidy-mode=$(echo "$output" | jq -r '.clang_tidy_mode')" >> $GITHUB_OUTPUT
echo "clang-tidy-full-scan=$(echo "$output" | jq -r '.clang_tidy_full_scan')" >> $GITHUB_OUTPUT
echo "python-linters=$(echo "$output" | jq -r '.python_linters')" >> $GITHUB_OUTPUT
echo "import-time=$(echo "$output" | jq -r '.import_time')" >> $GITHUB_OUTPUT
echo "device-builder=$(echo "$output" | jq -r '.device_builder')" >> $GITHUB_OUTPUT
@@ -500,7 +507,13 @@ jobs:
id: check_full_scan
run: |
. venv/bin/activate
if python script/clang_tidy_hash.py --check; then
# determine-jobs.clang-tidy-full-scan is true when core C++ changed
# OR the ci-run-all label forced --force-all. Independent of the
# hash check, both must produce a full scan in the job itself.
if [ "${{ needs.determine-jobs.outputs.clang-tidy-full-scan }}" = "true" ]; then
echo "full_scan=true" >> $GITHUB_OUTPUT
echo "reason=determine_jobs" >> $GITHUB_OUTPUT
elif python script/clang_tidy_hash.py --check; then
echo "full_scan=true" >> $GITHUB_OUTPUT
echo "reason=hash_changed" >> $GITHUB_OUTPUT
else
@@ -512,7 +525,7 @@ jobs:
run: |
. venv/bin/activate
if [ "${{ steps.check_full_scan.outputs.full_scan }}" = "true" ]; then
echo "Running FULL clang-tidy scan (hash changed)"
echo "Running FULL clang-tidy scan (reason: ${{ steps.check_full_scan.outputs.reason }})"
script/clang-tidy --all-headers --fix ${{ matrix.options }} ${{ matrix.ignore_errors && '|| true' || '' }}
else
echo "Running clang-tidy on changed files only"
@@ -572,7 +585,13 @@ jobs:
id: check_full_scan
run: |
. venv/bin/activate
if python script/clang_tidy_hash.py --check; then
# determine-jobs.clang-tidy-full-scan is true when core C++ changed
# OR the ci-run-all label forced --force-all. Independent of the
# hash check, both must produce a full scan in the job itself.
if [ "${{ needs.determine-jobs.outputs.clang-tidy-full-scan }}" = "true" ]; then
echo "full_scan=true" >> $GITHUB_OUTPUT
echo "reason=determine_jobs" >> $GITHUB_OUTPUT
elif python script/clang_tidy_hash.py --check; then
echo "full_scan=true" >> $GITHUB_OUTPUT
echo "reason=hash_changed" >> $GITHUB_OUTPUT
else
@@ -584,7 +603,7 @@ jobs:
run: |
. venv/bin/activate
if [ "${{ steps.check_full_scan.outputs.full_scan }}" = "true" ]; then
echo "Running FULL clang-tidy scan (hash changed)"
echo "Running FULL clang-tidy scan (reason: ${{ steps.check_full_scan.outputs.reason }})"
script/clang-tidy --all-headers --fix --environment esp32-arduino-tidy
else
echo "Running clang-tidy on changed files only"
@@ -661,7 +680,13 @@ jobs:
id: check_full_scan
run: |
. venv/bin/activate
if python script/clang_tidy_hash.py --check; then
# determine-jobs.clang-tidy-full-scan is true when core C++ changed
# OR the ci-run-all label forced --force-all. Independent of the
# hash check, both must produce a full scan in the job itself.
if [ "${{ needs.determine-jobs.outputs.clang-tidy-full-scan }}" = "true" ]; then
echo "full_scan=true" >> $GITHUB_OUTPUT
echo "reason=determine_jobs" >> $GITHUB_OUTPUT
elif python script/clang_tidy_hash.py --check; then
echo "full_scan=true" >> $GITHUB_OUTPUT
echo "reason=hash_changed" >> $GITHUB_OUTPUT
else
@@ -673,7 +698,7 @@ jobs:
run: |
. venv/bin/activate
if [ "${{ steps.check_full_scan.outputs.full_scan }}" = "true" ]; then
echo "Running FULL clang-tidy scan (hash changed)"
echo "Running FULL clang-tidy scan (reason: ${{ steps.check_full_scan.outputs.reason }})"
script/clang-tidy --all-headers --fix ${{ matrix.options }}
else
echo "Running clang-tidy on changed files only"
@@ -12,6 +12,12 @@ jobs:
dashboard-deprecation-comment:
name: Dashboard deprecation comment
runs-on: ubuntu-latest
# Release-bump PRs (bump-X.Y.Z -> beta, beta -> release) inevitably
# roll up everything merged into dev since the last cut, which can
# include dashboard changes that have already been reviewed once.
# The bot's purpose is to warn new contributors before they invest
# time -- that only applies to PRs entering dev.
if: github.event.pull_request.base.ref == 'dev'
steps:
- name: Generate a token
id: generate-token
+7 -6
View File
@@ -68,14 +68,15 @@ jobs:
return;
}
// Check for angle brackets not wrapped in backticks.
// Astro docs MDX treats bare < as JSX component opening tags.
// Check for MDX syntax characters not wrapped in backticks.
// Astro docs MDX treats bare `<` as JSX component opening tags and
// bare `{` as JS expressions, so both must be escaped in changelog entries.
const stripped = title.replace(/`[^`]*`/g, '');
if (/[<>]/.test(stripped)) {
if (/[<>{}]/.test(stripped)) {
core.setFailed(
'PR title contains `<` or `>` not wrapped in backticks.\n' +
'Astro docs MDX interprets bare `<` as JSX components.\n' +
'Please wrap angle brackets with backticks, e.g.: [component] Add `<feature>` support'
'PR title contains `<`, `>`, `{`, or `}` not wrapped in backticks.\n' +
'Astro docs MDX interprets bare `<` as JSX components and bare `{` as JS expressions.\n' +
'Please wrap these characters with backticks, e.g.: [component] Add `<feature>` support'
);
return;
}
+69 -1
View File
@@ -212,6 +212,74 @@ jobs:
docker buildx imagetools create $(jq -Rcnr 'inputs | . / "," | map("-t " + .) | join(" ")' <<< "${{ steps.tags.outputs.tags}}") \
$(printf '${{ steps.tags.outputs.image }}@sha256:%s ' *)
deploy-ha-addon-repo:
if: github.repository == 'esphome/esphome' && needs.init.outputs.branch_build == 'false'
runs-on: ubuntu-latest
needs:
- init
- deploy-manifest
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
owner: esphome
repositories: home-assistant-addon
permission-actions: write # actions.createWorkflowDispatch on the target repo (only API call made with this token)
- name: Trigger Workflow
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ steps.generate-token.outputs.token }}
script: |
let description = "ESPHome";
if (context.eventName == "release") {
description = ${{ toJSON(github.event.release.body) }};
}
github.rest.actions.createWorkflowDispatch({
owner: "esphome",
repo: "home-assistant-addon",
workflow_id: "bump-version.yml",
ref: "main",
inputs: {
version: "${{ needs.init.outputs.tag }}",
content: description
}
})
deploy-esphome-schema:
if: github.repository == 'esphome/esphome' && needs.init.outputs.branch_build == 'false'
runs-on: ubuntu-latest
needs: [init]
environment: ${{ needs.init.outputs.deploy_env }}
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
owner: esphome
repositories: esphome-schema
permission-actions: write # actions.createWorkflowDispatch on the target repo (only API call made with this token)
- name: Trigger Workflow
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ steps.generate-token.outputs.token }}
script: |
github.rest.actions.createWorkflowDispatch({
owner: "esphome",
repo: "esphome-schema",
workflow_id: "generate-schemas.yml",
ref: "main",
inputs: {
version: "${{ needs.init.outputs.tag }}",
}
})
version-notifier:
if: github.repository == 'esphome/esphome' && needs.init.outputs.branch_build == 'false'
runs-on: ubuntu-latest
@@ -234,7 +302,7 @@ jobs:
with:
github-token: ${{ steps.generate-token.outputs.token }}
script: |
await github.rest.actions.createWorkflowDispatch({
github.rest.actions.createWorkflowDispatch({
owner: "esphome",
repo: "version-notifier",
workflow_id: "notify.yml",
+39 -6
View File
@@ -41,19 +41,52 @@ jobs:
with:
python-version: "3.14"
- name: Set up uv
# An order of magnitude faster than pip on cold boots, with its
# own wheel cache. ``--system`` (below) installs into the
# setup-python interpreter so subsequent ``pre-commit`` /
# ``script/run-in-env.py`` steps find the deps without a
# ``uv run`` prefix.
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: true
- name: Install Home Assistant
run: |
python -m pip install --upgrade pip
pip install -e lib/home-assistant
pip install -r requirements_test.txt pre-commit
uv pip install --system -e lib/home-assistant
uv pip install --system -r requirements.txt -r requirements_test.txt pre-commit
- name: Sync
run: |
python ./script/sync-device_class.py
- name: Run pre-commit hooks
run: |
python script/run-in-env.py pre-commit run --all-files
- name: Apply pre-commit auto-fixes
# First pass: let formatters (ruff, end-of-file-fixer, etc.) modify
# files. pre-commit exits non-zero whenever a hook touches anything,
# which would otherwise abort the workflow before the auto-fixes
# can flow into the sync PR.
#
# SKIP:
# - no-commit-to-branch is a local guard against committing on
# dev/release/beta; CI runs on dev by definition, and
# peter-evans/create-pull-request creates the branch itself.
# - pylint surfaces import-error / relative-beyond-top-level
# noise here because this workflow installs only a subset of
# the runtime deps (HA + requirements*.txt); main CI already
# gates pylint on real PRs.
env:
SKIP: pylint,no-commit-to-branch
run: python script/run-in-env.py pre-commit run --all-files || true
- name: Verify pre-commit clean
# Second pass: re-run all hooks against the now-fixed tree.
# Auto-fixers exit 0 (nothing to change); any remaining failure
# from a check-only hook (flake8 / yamllint / ci-custom) is a
# real issue and fails the workflow loudly. Same SKIP list as
# above for the same reasons.
env:
SKIP: pylint,no-commit-to-branch
run: python script/run-in-env.py pre-commit run --all-files
- name: Commit changes
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
+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.5.2
PROJECT_NUMBER = 2026.6.0-dev
# Using the PROJECT_BRIEF tag one can provide an optional one line description
# for a project that appears at the top of each page and should give viewer a
+1 -22
View File
@@ -50,7 +50,6 @@ from esphome.const import (
CONF_TOPIC,
CONF_USERNAME,
CONF_WEB_SERVER,
CONF_WIFI,
ENV_NOGITIGNORE,
KEY_CORE,
KEY_TARGET_PLATFORM,
@@ -734,13 +733,6 @@ def write_cpp_file() -> int:
def compile_program(args: ArgsProtocol, config: ConfigType) -> int:
# Keep this gate here, NOT in config validation: device-builder needs
# `esphome config` to keep succeeding with placeholders so onboarding can run.
if CONF_WIFI in config:
from esphome.components.wifi import check_placeholder_credentials
check_placeholder_credentials(config)
# NOTE: "Build path:" format is parsed by script/ci_memory_impact_extract.py
# If you change this format, update the regex in that script as well
_LOGGER.info("Compiling app... Build path: %s", CORE.build_path)
@@ -2449,10 +2441,7 @@ def run_esphome(argv):
# Skipped when -s overrides are passed, since the cache was written
# against the previous substitution set.
config: ConfigType | None = None
cache_eligible = (
args.command in ("upload", "logs") and not command_line_substitutions
)
if cache_eligible:
if args.command in ("upload", "logs") and not command_line_substitutions:
from esphome.compiled_config import load_compiled_config
config = load_compiled_config(conf_path)
@@ -2467,16 +2456,6 @@ def run_esphome(argv):
command_line_substitutions,
skip_external_update=skip_external,
)
# Refresh the cache so the next upload/logs hits the fast path
# instead of re-running read_config. Skip when the storage
# sidecar is absent (no compile has run): the cache would
# never be loaded back, so writing secrets to disk is wasted.
if cache_eligible and config is not None:
from esphome.compiled_config import save_compiled_config
from esphome.storage_json import ext_storage_path
if ext_storage_path(conf_path.name).exists():
save_compiled_config(config)
if config is None:
return 2
CORE.config = config
@@ -1,6 +1,5 @@
#include "api_connection.h"
#ifdef USE_API
#include "api_connection_buffer.h" // for encode_to_buffer / get_batch_delay_ms_ inlines
#ifdef USE_API_NOISE
#include "api_frame_helper_noise.h"
#endif
@@ -1306,9 +1305,6 @@ void APIConnection::on_voice_assistant_announce_request(const VoiceAssistantAnno
bool APIConnection::send_voice_assistant_get_configuration_response_(const VoiceAssistantConfigurationRequest &msg) {
VoiceAssistantConfigurationResponse resp;
if (!this->check_voice_assistant_api_connection_()) {
// send_message encodes synchronously, so this stack local outlives the encode
const std::vector<std::string> empty_wake_words;
resp.active_wake_words = &empty_wake_words;
return this->send_message(resp);
}
+40 -11
View File
@@ -11,8 +11,7 @@
#endif
#include "api_pb2.h"
#include "api_pb2_service.h"
#include "list_entities.h"
#include "subscribe_state.h"
#include "api_server.h"
#include "esphome/core/application.h"
#include "esphome/core/component.h"
#ifdef USE_ESP32_CRASH_HANDLER
@@ -37,9 +36,6 @@ class ComponentIterator;
namespace esphome::api {
// Forward-declared to break the api_server.h cycle; full-type inlines are in api_connection_buffer.h.
class APIServer;
// Keepalive timeout in milliseconds
static constexpr uint32_t KEEPALIVE_TIMEOUT_MS = 60000;
// Maximum number of entities to process in a single batch during initial state/info sending
@@ -415,10 +411,44 @@ class APIConnection final : public APIServerConnectionBase {
// Non-template buffer management for send_message
bool send_message_(uint32_t payload_size, uint8_t message_type, MessageEncodeFn encode_fn, const void *msg);
// Core batch encoding logic. ALWAYS_INLINE so encode_fn devirtualizes at hot call sites.
// Defined in api_connection_buffer.h (needs APIServer complete).
static uint16_t ESPHOME_ALWAYS_INLINE encode_to_buffer(uint32_t calculated_size, MessageEncodeFn encode_fn,
const void *msg, APIConnection *conn, uint32_t remaining_size);
// Core batch encoding logic. Computes header size, checks fit, resizes buffer, encodes.
// ALWAYS_INLINE so the compiler can devirtualize encode_fn at hot call sites.
static inline uint16_t ESPHOME_ALWAYS_INLINE encode_to_buffer(uint32_t calculated_size, MessageEncodeFn encode_fn,
const void *msg, APIConnection *conn,
uint32_t remaining_size) {
#ifdef HAS_PROTO_MESSAGE_DUMP
if (conn->flags_.log_only_mode) {
auto *proto_msg = static_cast<const ProtoMessage *>(msg);
DumpBuffer dump_buf;
conn->log_send_message_(proto_msg->message_name(), proto_msg->dump_to(dump_buf));
return 1;
}
#endif
const uint8_t footer_size = conn->helper_->frame_footer_size();
// First message uses max padding (already in buffer), subsequent use exact header size
size_t to_add;
if (conn->flags_.batch_first_message) {
conn->flags_.batch_first_message = false;
conn->batch_header_size_ = conn->helper_->frame_header_padding();
to_add = calculated_size;
} else {
conn->batch_header_size_ = conn->helper_->frame_header_size(calculated_size, conn->batch_message_type_);
to_add = calculated_size + conn->batch_header_size_ + footer_size;
}
// Check if it fits (using actual header size, not max padding)
uint16_t total_calculated_size = calculated_size + conn->batch_header_size_ + footer_size;
if (total_calculated_size > remaining_size)
return 0;
auto &shared_buf = conn->parent_->get_shared_buffer_ref();
shared_buf.resize(shared_buf.size() + to_add);
ProtoWriteBuffer buffer{&shared_buf, shared_buf.size() - calculated_size};
encode_fn(msg, buffer PROTO_ENCODE_DEBUG_INIT(&shared_buf));
return total_calculated_size;
}
// Noinline version of encode_to_buffer for cold paths (entity info, zero-payload messages).
// All cold callers share this single copy instead of each getting an ALWAYS_INLINE expansion.
@@ -762,8 +792,7 @@ class APIConnection final : public APIServerConnectionBase {
// Read by process_batch_multi_ to pass into MessageInfo.
uint8_t batch_header_size_{0};
// Defined in api_connection_buffer.h (needs APIServer complete).
uint32_t get_batch_delay_ms_() const;
uint32_t get_batch_delay_ms_() const { return this->parent_->get_batch_delay(); }
// Message will use 8 more bytes than the minimum size, and typical
// MTU is 1500. Sometimes users will see as low as 1460 MTU.
// If its IPv6 the header is 40 bytes, and if its IPv4
@@ -1,54 +0,0 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_API
// Inline APIConnection methods that need APIServer complete. Include this
// instead of api_connection.h when calling encode_to_buffer or get_batch_delay_ms_.
#include "api_connection.h"
#include "api_server.h"
namespace esphome::api {
inline uint16_t ESPHOME_ALWAYS_INLINE APIConnection::encode_to_buffer(uint32_t calculated_size,
MessageEncodeFn encode_fn, const void *msg,
APIConnection *conn, uint32_t remaining_size) {
#ifdef HAS_PROTO_MESSAGE_DUMP
if (conn->flags_.log_only_mode) {
auto *proto_msg = static_cast<const ProtoMessage *>(msg);
DumpBuffer dump_buf;
conn->log_send_message_(proto_msg->message_name(), proto_msg->dump_to(dump_buf));
return 1;
}
#endif
const uint8_t footer_size = conn->helper_->frame_footer_size();
// First message uses max padding (already in buffer), subsequent use exact header size
size_t to_add;
if (conn->flags_.batch_first_message) {
conn->flags_.batch_first_message = false;
conn->batch_header_size_ = conn->helper_->frame_header_padding();
to_add = calculated_size;
} else {
conn->batch_header_size_ = conn->helper_->frame_header_size(calculated_size, conn->batch_message_type_);
to_add = calculated_size + conn->batch_header_size_ + footer_size;
}
// Check if it fits (using actual header size, not max padding)
uint16_t total_calculated_size = calculated_size + conn->batch_header_size_ + footer_size;
if (total_calculated_size > remaining_size)
return 0;
auto &shared_buf = conn->parent_->get_shared_buffer_ref();
shared_buf.resize(shared_buf.size() + to_add);
ProtoWriteBuffer buffer{&shared_buf, shared_buf.size() - calculated_size};
encode_fn(msg, buffer PROTO_ENCODE_DEBUG_INIT(&shared_buf));
return total_calculated_size;
}
inline uint32_t APIConnection::get_batch_delay_ms_() const { return this->parent_->get_batch_delay(); }
} // namespace esphome::api
#endif
+5
View File
@@ -30,6 +30,11 @@ APIServer *global_api_server = nullptr; // NOLINT(cppcoreguidelines-avoid-non-c
APIServer::APIServer() { global_api_server = this; }
// Custom deleter defined here so `delete` sees the complete APIConnection type.
// This prevents libc++ from emitting an "incomplete type" error when other
// translation units only have the forward declaration of APIConnection.
void APIServer::APIConnectionDeleter::operator()(APIConnection *p) const { delete p; }
void APIServer::socket_failed_(const LogString *msg) {
ESP_LOGW(TAG, "Socket %s: errno %d", LOG_STR_ARG(msg), errno);
this->destroy_socket_();
+10 -4
View File
@@ -3,8 +3,6 @@
#include "esphome/core/defines.h"
#ifdef USE_API
#include "api_buffer.h"
// Must precede clients_ so APIConnection is complete for default_delete (libc++).
#include "api_connection.h"
#include "api_noise_context.h"
#include "api_pb2.h"
#include "api_pb2_service.h"
@@ -14,6 +12,8 @@
#include "esphome/core/controller.h"
#include "esphome/core/log.h"
#include "esphome/core/string_ref.h"
#include "list_entities.h"
#include "subscribe_state.h"
#ifdef USE_LOGGER
#include "esphome/components/logger/logger.h"
#endif
@@ -191,9 +191,15 @@ class APIServer final : public Component,
bool is_connected_with_state_subscription() const;
// Range-for view over the populated slice [0, api_connection_count_). Read-only with respect
// to ownership; callers get `const unique_ptr&` so they can invoke non-const methods on the
// to ownership callers get `const unique_ptr&` so they can invoke non-const methods on the
// APIConnection but cannot reset/move the slot and break the count invariant.
using APIConnectionPtr = std::unique_ptr<APIConnection>;
// Custom deleter is defined out-of-line in api_server.cpp so libc++ does not
// eagerly instantiate `delete static_cast<APIConnection *>(p)` here, where
// only the forward declaration of APIConnection is visible (incomplete type).
struct APIConnectionDeleter {
void operator()(APIConnection *p) const;
};
using APIConnectionPtr = std::unique_ptr<APIConnection, APIConnectionDeleter>;
class ActiveClientsView {
const APIConnectionPtr *begin_;
const APIConnectionPtr *end_;
@@ -135,26 +135,12 @@ void BluetoothConnection::loop() {
// - For V3_WITH_CACHE: Services are never sent, disable after INIT state
// - For V3_WITHOUT_CACHE: Disable only after service discovery is complete
// (send_service_ == DONE_SENDING_SERVICES, which is only set after services are sent)
// Never disable while DISCONNECTING — BLEClientBase::loop() needs to keep running so the
// 10s safety timeout can force IDLE if CLOSE_EVT is never delivered.
if (this->state() != espbt::ClientState::INIT && this->state() != espbt::ClientState::DISCONNECTING &&
(this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE ||
this->send_service_ == DONE_SENDING_SERVICES)) {
if (this->state() != espbt::ClientState::INIT && (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE ||
this->send_service_ == DONE_SENDING_SERVICES)) {
this->disable_loop();
}
}
void BluetoothConnection::on_disconnect_complete(esp_err_t reason) {
// Called from both the CLOSE_EVT handler and the DISCONNECTING safety timeout in the
// base class. Free the proxy slot, notify the API client, and reset send_service_.
// address_ may already be 0 if reset_connection_ ran earlier on this teardown.
if (this->address_ == 0) {
return;
}
ESP_LOGD(TAG, "[%d] [%s] Close, reason=0x%02x, freeing slot", this->connection_index_, this->address_str_, reason);
this->reset_connection_(reason);
}
void BluetoothConnection::reset_connection_(esp_err_t reason) {
// Send disconnection notification
this->proxy_->send_device_connection(this->address_, false, 0, reason);
@@ -386,6 +372,14 @@ bool BluetoothConnection::gattc_event_handler(esp_gattc_cb_event_t event, esp_ga
this->proxy_->send_device_connection(this->address_, false, 0, param->disconnect.reason);
break;
}
case ESP_GATTC_CLOSE_EVT: {
ESP_LOGD(TAG, "[%d] [%s] Close, reason=0x%02x, freeing slot", this->connection_index_, this->address_str_,
param->close.reason);
// Now the GATT connection is fully closed and controller resources are freed
// Safe to mark the connection slot as available
this->reset_connection_(param->close.reason);
break;
}
case ESP_GATTC_OPEN_EVT: {
if (param->open.status != ESP_GATT_OK && param->open.status != ESP_GATT_ALREADY_OPEN) {
this->reset_connection_(param->open.status);
@@ -33,8 +33,6 @@ class BluetoothConnection final : public esp32_ble_client::BLEClientBase {
protected:
friend class BluetoothProxy;
void on_disconnect_complete(esp_err_t reason) override;
bool supports_efficient_uuids_() const;
void send_service_for_discovery_();
void reset_connection_(esp_err_t reason);
@@ -1,6 +1,5 @@
#include "bluetooth_proxy.h"
#include "esphome/components/api/api_server.h"
#include "esphome/core/log.h"
#include "esphome/core/macros.h"
#include "esphome/core/application.h"
+48 -112
View File
@@ -46,7 +46,7 @@ from esphome.const import (
Toolchain,
__version__,
)
from esphome.core import CORE, EsphomeError, HexInt, Library
from esphome.core import CORE, HexInt, Library
from esphome.core.config import BOARD_MAX_LENGTH
from esphome.coroutine import CoroPriority, coroutine_with_priority
from esphome.espidf.component import generate_idf_component
@@ -113,7 +113,6 @@ ARDUINO_FRAMEWORK_NAME = "framework-arduinoespressif32"
ARDUINO_FRAMEWORK_PKG = f"pioarduino/{ARDUINO_FRAMEWORK_NAME}"
ARDUINO_LIBS_NAME = f"{ARDUINO_FRAMEWORK_NAME}-libs"
ARDUINO_LIBS_PKG = f"pioarduino/{ARDUINO_LIBS_NAME}"
ARDUINO_ESP32_COMPONENT_NAME = "espressif/arduino-esp32"
LOG_LEVELS_IDF = [
"NONE",
@@ -793,15 +792,19 @@ PLATFORM_VERSION_LOOKUP = {
}
def _resolve_framework_version(value: ConfigType) -> cv.Version:
"""Resolve a named or raw framework version and validate the minimum.
def _check_pio_versions(config):
config = config.copy()
value = config[CONF_FRAMEWORK]
Normalises value[CONF_VERSION] to its string form and returns the parsed
cv.Version. Shared between the PIO and esp-idf toolchain paths; toolchain-
specific concerns (source defaults, platform_version) live in the per-
toolchain functions.
"""
if value[CONF_VERSION] in PLATFORM_VERSION_LOOKUP:
if CONF_SOURCE in value or CONF_PLATFORM_VERSION in value:
raise cv.Invalid(
"Version needs to be explicitly set when a custom source or platform_version is used."
)
platform_lookup = PLATFORM_VERSION_LOOKUP[value[CONF_VERSION]]
value[CONF_PLATFORM_VERSION] = _parse_pio_platform_version(str(platform_lookup))
if value[CONF_TYPE] == FRAMEWORK_ARDUINO:
version = ARDUINO_FRAMEWORK_VERSION_LOOKUP[value[CONF_VERSION]]
else:
@@ -814,38 +817,7 @@ def _resolve_framework_version(value: ConfigType) -> cv.Version:
if value[CONF_TYPE] == FRAMEWORK_ARDUINO:
if version < cv.Version(3, 0, 0):
raise cv.Invalid("Only Arduino 3.0+ is supported.")
recommended = ARDUINO_FRAMEWORK_VERSION_LOOKUP["recommended"]
else:
if version < cv.Version(5, 0, 0):
raise cv.Invalid("Only ESP-IDF 5.0+ is supported.")
recommended = ESP_IDF_FRAMEWORK_VERSION_LOOKUP["recommended"]
if version != recommended:
_LOGGER.warning(
"The selected framework version is not the recommended one. "
"If there are connectivity or build issues please remove the manual version."
)
return version
def _check_pio_versions(config: ConfigType) -> ConfigType:
config = config.copy()
value = config[CONF_FRAMEWORK]
is_named_version = value[CONF_VERSION] in PLATFORM_VERSION_LOOKUP
if is_named_version and (CONF_SOURCE in value or CONF_PLATFORM_VERSION in value):
raise cv.Invalid(
"Version needs to be explicitly set when a custom source or platform_version is used."
)
if is_named_version:
value[CONF_PLATFORM_VERSION] = _parse_pio_platform_version(
str(PLATFORM_VERSION_LOOKUP[value[CONF_VERSION]])
)
version = _resolve_framework_version(value)
if value[CONF_TYPE] == FRAMEWORK_ARDUINO:
recommended_version = ARDUINO_FRAMEWORK_VERSION_LOOKUP["recommended"]
platform_lookup = ARDUINO_PLATFORM_VERSION_LOOKUP.get(version)
value[CONF_SOURCE] = value.get(
CONF_SOURCE, _format_framework_arduino_version(version)
@@ -853,6 +825,9 @@ def _check_pio_versions(config: ConfigType) -> ConfigType:
if _is_framework_url(value[CONF_SOURCE]):
value[CONF_SOURCE] = f"{ARDUINO_FRAMEWORK_PKG}@{value[CONF_SOURCE]}"
else:
if version < cv.Version(5, 0, 0):
raise cv.Invalid("Only ESP-IDF 5.0+ is supported.")
recommended_version = ESP_IDF_FRAMEWORK_VERSION_LOOKUP["recommended"]
platform_lookup = ESP_IDF_PLATFORM_VERSION_LOOKUP.get(version)
value[CONF_SOURCE] = value.get(
CONF_SOURCE,
@@ -868,6 +843,12 @@ def _check_pio_versions(config: ConfigType) -> ConfigType:
)
value[CONF_PLATFORM_VERSION] = _parse_pio_platform_version(str(platform_lookup))
if version != recommended_version:
_LOGGER.warning(
"The selected framework version is not the recommended one. "
"If there are connectivity or build issues please remove the manual version."
)
if value[CONF_PLATFORM_VERSION] != _parse_pio_platform_version(
str(PLATFORM_VERSION_LOOKUP["recommended"])
):
@@ -879,26 +860,19 @@ def _check_pio_versions(config: ConfigType) -> ConfigType:
return config
def _check_esp_idf_versions(config: ConfigType) -> ConfigType:
config = config.copy()
def _check_esp_idf_versions(config):
config = _check_pio_versions(config)
value = config[CONF_FRAMEWORK]
# platform_version is a PlatformIO concept; drop it if a user carried it
# over from a PIO-style config. CONF_SOURCE, on the other hand, is kept:
# it lets a user override the framework tarball URL under the esp-idf
# toolchain (the espidf framework downloader consults it).
value.pop(CONF_PLATFORM_VERSION, None)
# Remove unwanted keys if present
for key in (CONF_SOURCE, CONF_PLATFORM_VERSION):
value.pop(key, None)
version = _resolve_framework_version(value)
# Official ESP-IDF frameworks don't use extra
version = cv.Version.parse(value[CONF_VERSION])
version = cv.Version(version.major, version.minor, version.patch)
if CONF_SOURCE in value:
_LOGGER.warning(
"A custom framework source is set. "
"If there are connectivity or build issues please remove the manual source."
)
# Official ESP-IDF frameworks don't use the 'extra' semver component.
value[CONF_VERSION] = str(cv.Version(version.major, version.minor, version.patch))
value[CONF_VERSION] = str(version)
return config
@@ -1744,31 +1718,6 @@ async def _add_yaml_idf_components(components: list[ConfigType]):
)
@coroutine_with_priority(CoroPriority.FINAL - 1)
async def _finalize_arduino_aware_flags():
"""Build flags that depend on whether arduino-esp32 is linked in.
Scheduler runs lower priority values later, so ``FINAL - 1`` fires
after every ``FINAL`` job (incl. ``_add_yaml_idf_components``) --
by then ``KEY_COMPONENTS`` is fully populated.
- Skip our esp_panic_handler wrap when Arduino is linked; Arduino
wraps the same symbol and the linker errors on the duplicate.
- Define USE_ARDUINO in the hybrid esp-idf+arduino-esp32-component
case so ESPHome's ``#ifdef USE_ARDUINO`` paths light up. The
framework=arduino branch already adds it inline in to_code.
"""
arduino_linked = (
CORE.using_arduino
or ARDUINO_ESP32_COMPONENT_NAME in CORE.data[KEY_ESP32][KEY_COMPONENTS]
)
if not arduino_linked:
cg.add_build_flag("-Wl,--wrap=esp_panic_handler")
cg.add_define("USE_ESP32_CRASH_HANDLER")
elif not CORE.using_arduino:
cg.add_build_flag("-DUSE_ARDUINO")
async def to_code(config):
framework_ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]
conf = config[CONF_FRAMEWORK]
@@ -1816,12 +1765,11 @@ async def to_code(config):
Path(__file__).parent / "iram_fix.py.script",
)
else:
# Demote IDF's blanket -Werror to warnings so third-party libs
# and user lambdas don't need a -Wno-error=<class> per warning.
# The sdkconfig knob disables IDF's rewrite to -Werror=all (which
# can't be globally undone); -Wno-error then handles the demotion.
add_idf_sdkconfig_option("CONFIG_COMPILER_DISABLE_DEFAULT_ERRORS", False)
cg.add_build_flag("-Wno-error")
cg.add_build_flag("-Wno-error=format")
cg.add_build_flag("-Wno-error=maybe-uninitialized")
cg.add_build_flag("-Wno-error=overloaded-virtual")
cg.add_build_flag("-Wno-error=reorder")
cg.add_build_flag("-Wno-error=volatile")
# -Wno- (not -Wno-error=): suppress entirely, too noisy on C++ aggregates
cg.add_build_flag("-Wno-missing-field-initializers")
@@ -1829,8 +1777,11 @@ async def to_code(config):
cg.add_build_flag("-DUSE_ESP32")
cg.add_define("USE_NATIVE_64BIT_TIME")
cg.add_build_flag("-Wl,-z,noexecstack")
# Deferred so KEY_COMPONENTS is fully populated -- see the coroutine.
CORE.add_job(_finalize_arduino_aware_flags)
# Arduino already wraps esp_panic_handler for its own backtrace handler,
# so only add our wrap when using ESP-IDF framework to avoid linker conflicts.
if conf[CONF_TYPE] == FRAMEWORK_ESP_IDF:
cg.add_build_flag("-Wl,--wrap=esp_panic_handler")
cg.add_define("USE_ESP32_CRASH_HANDLER")
cg.add_define("ESPHOME_BOARD", config[CONF_BOARD])
variant = config[CONF_VARIANT]
cg.add_build_flag(f"-DUSE_ESP32_VARIANT_{variant}")
@@ -2537,8 +2488,9 @@ def _write_sdkconfig():
def _platformio_library_to_dependency(library: Library) -> tuple[str, dict[str, str]]:
dependency: dict[str, str] = {}
name, _version, path = generate_idf_component(library)
name, version, path = generate_idf_component(library)
dependency["override_path"] = str(path)
dependency["version"] = version
return name, dependency
@@ -2590,7 +2542,7 @@ def _write_idf_component_yml():
if CORE.using_toolchain_esp_idf:
add_idf_component(
name=ARDUINO_ESP32_COMPONENT_NAME,
name="espressif/arduino-esp32",
ref=str(CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]),
)
@@ -2657,29 +2609,13 @@ def copy_files():
def _decode_pc(config, addr):
# _decode_pc runs from the api log processor's asyncio callback, which
# only catches EsphomeError. Any other exception escaping here tears down
# the protocol and triggers an infinite reconnect/replay loop. Convert
# toolchain-resolution errors (e.g. missing build dir / cmake cache) into
# EsphomeError so the caller can disable decoding cleanly.
if CORE.using_toolchain_esp_idf:
from esphome.espidf import toolchain as idf_toolchain
from esphome.platformio import toolchain
try:
addr2line_path = idf_toolchain.get_addr2line_path()
firmware_elf_path = idf_toolchain.get_elf_path()
except RuntimeError as err:
raise EsphomeError(f"ESP-IDF toolchain not available: {err}") from err
else:
from esphome.platformio import toolchain
idedata = toolchain.get_idedata(config)
addr2line_path = idedata.addr2line_path
firmware_elf_path = idedata.firmware_elf_path
if not addr2line_path or not firmware_elf_path:
idedata = toolchain.get_idedata(config)
if not idedata.addr2line_path or not idedata.firmware_elf_path:
_LOGGER.debug("decode_pc no addr2line")
return
command = [str(addr2line_path), "-pfiaC", "-e", str(firmware_elf_path), addr]
command = [idedata.addr2line_path, "-pfiaC", "-e", idedata.firmware_elf_path, addr]
try:
translation = subprocess.check_output(command, close_fds=False).decode().strip()
except Exception: # pylint: disable=broad-except
@@ -72,7 +72,6 @@ void BLEClientBase::loop() {
// never delivered CLOSE_EVT/DISCONNECT_EVT, services would leak without this call.
this->release_services();
this->set_idle_();
this->on_disconnect_complete(ESP_GATT_CONN_TIMEOUT);
}
}
@@ -419,7 +418,6 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_
this->log_gattc_lifecycle_event_("CLOSE");
this->release_services();
this->set_idle_();
this->on_disconnect_complete(param->close.reason);
break;
}
case ESP_GATTC_SEARCH_RES_EVT: {
@@ -140,12 +140,6 @@ class BLEClientBase : public espbt::ESPBTClient, public Component {
void log_gattc_warning_(const char *operation, esp_err_t err);
void log_connection_params_(const char *param_type);
void handle_connection_result_(esp_err_t ret);
/// Hook called once a connection has been fully torn down (after release_services() and
/// set_idle_()), from both the CLOSE_EVT handler and the DISCONNECTING safety timeout.
/// Subclasses with extra per-connection accounting (e.g. bluetooth_proxy slot state)
/// override this to release that state. `reason` is the controller reason code, or
/// ESP_GATT_CONN_TIMEOUT for the safety-timeout path.
virtual void on_disconnect_complete(esp_err_t reason) {}
/// Transition to IDLE and reset conn_id — call when the connection is fully dead.
void set_idle_() {
this->set_state(espbt::ClientState::IDLE);
@@ -155,10 +149,6 @@ class BLEClientBase : public espbt::ESPBTClient, public Component {
void set_disconnecting_() {
this->disconnecting_started_ = millis();
this->set_state(espbt::ClientState::DISCONNECTING);
// BluetoothConnection::loop() disables the component loop after service discovery
// completes, so the DISCONNECTING timeout check in loop() would never run if CLOSE_EVT
// gets lost. Re-enable the loop so the 10s safety timeout can force IDLE.
this->enable_loop();
}
// Compact error logging helpers to reduce flash usage
void log_error_(const char *message);
@@ -121,7 +121,7 @@ void Esp32HostedUpdate::setup() {
}
} else {
ESP_LOGW(TAG, "Invalid app description magic word: 0x%08" PRIx32 " (expected 0x%08" PRIx32 ")",
app_desc->magic_word, static_cast<uint32_t>(ESP_APP_DESC_MAGIC_WORD));
app_desc->magic_word, ESP_APP_DESC_MAGIC_WORD);
this->state_ = update::UPDATE_STATE_NO_UPDATE;
}
} else {
+12 -12
View File
@@ -5,7 +5,6 @@
#include <Arduino.h>
#include <core_esp8266_features.h>
#include <coredecls.h>
extern "C" {
#include <user_interface.h>
@@ -72,22 +71,23 @@ uint32_t IRAM_ATTR HOT millis() {
return result;
}
// Delegate to Arduino's 1-arg esp_delay(), which uses os_timer + esp_suspend to
// suspend the cont task for `ms` milliseconds without polling millis(). This
// matches pre-2026.5.0 behavior (when esphome::delay() forwarded to ::delay())
// and lets the SDK run freely while we wait, which timing-sensitive
// interrupt-driven code (e.g. ESP8266 software-serial RX in components like
// fingerprint_grow) depends on. The poll-based busy-wait that this replaced
// rarely yielded inside short waits like delay(1), starving WiFi/SDK tasks and
// extending interrupt latency. Unlike ::delay(), esp_delay()'s 1-arg form does
// not call millis(), so the slow Arduino millis() body is not pulled into IRAM
// by this path (the --wrap=millis goal of #15662 is preserved).
// Poll-based delay that avoids ::delay() — Arduino's __delay has an intra-object
// call to the original millis() that --wrap can't intercept, so calling ::delay()
// would keep the slow Arduino millis body alive in IRAM. optimistic_yield still
// enters esp_schedule()/esp_suspend_within_cont() via yield(), so SDK tasks and
// WiFi run correctly. Theoretically less power-efficient than Arduino's
// os_timer-based delay() for long waits, but nearly all ESPHome delays are short
// (sensor/I²C/SPI settling in the 1100 ms range) where the difference is
// negligible.
void HOT delay(uint32_t ms) {
if (ms == 0) {
optimistic_yield(1000);
return;
}
esp_delay(ms);
uint32_t start = millis();
while (millis() - start < ms) {
optimistic_yield(1000);
}
}
void arch_restart() {
@@ -206,7 +206,6 @@ uint8_t FingerprintGrowComponent::save_fingerprint_() {
break;
case ENROLL_MISMATCH:
ESP_LOGE(TAG, "Scans do not match");
[[fallthrough]];
default:
return this->data_[0];
}
@@ -15,16 +15,6 @@ void FT5x06Touchscreen::setup() {
this->attach_interrupt_(this->interrupt_pin_, gpio::INTERRUPT_FALLING_EDGE);
}
// reading the chip registers to get max x/y does not seem to work.
if (this->display_ != nullptr) {
if (this->x_raw_max_ == this->x_raw_min_) {
this->x_raw_max_ = this->display_->get_native_width();
}
if (this->y_raw_max_ == this->y_raw_min_) {
this->y_raw_max_ = this->display_->get_native_height();
}
}
// wait 200ms after reset.
this->set_timeout(200, [this] { this->continue_setup_(); });
}
@@ -49,6 +39,15 @@ void FT5x06Touchscreen::continue_setup_() {
this->mark_failed();
return;
}
// reading the chip registers to get max x/y does not seem to work.
if (this->display_ != nullptr) {
if (this->x_raw_max_ == this->x_raw_min_) {
this->x_raw_max_ = this->display_->get_native_width();
}
if (this->y_raw_max_ == this->y_raw_min_) {
this->y_raw_max_ = this->display_->get_native_height();
}
}
}
void FT5x06Touchscreen::update_touches() {
@@ -72,7 +71,7 @@ void FT5x06Touchscreen::update_touches() {
uint16_t x = encode_uint16(data[i][0] & 0x0F, data[i][1]);
uint16_t y = encode_uint16(data[i][2] & 0xF, data[i][3]);
ESP_LOGV(TAG, "Read %X status, id: %d, pos %d/%d", status, id, x, y);
ESP_LOGD(TAG, "Read %X status, id: %d, pos %d/%d", status, id, x, y);
if (status == 0 || status == 2) {
this->add_raw_touch_position_(id, x, y);
}
@@ -89,10 +89,10 @@ def _set_num_channels_from_config(config):
def _set_stream_limits(config):
if config.get(CONF_SPDIF_MODE, False):
# SPDIF mode: 16/24/32-bit audio and stereo at configured sample rate
# SPDIF mode: fixed to 16-bit stereo at configured sample rate
audio.set_stream_limits(
min_bits_per_sample=16,
max_bits_per_sample=32,
max_bits_per_sample=16,
min_channels=2,
max_channels=2,
min_sample_rate=config.get(CONF_SAMPLE_RATE),
@@ -213,6 +213,9 @@ def _final_validate(config):
)
if config[CONF_CHANNEL] != CONF_STEREO:
raise cv.Invalid("SPDIF mode only supports stereo channel configuration")
# bits_per_sample is converted to float by the schema
if config[CONF_BITS_PER_SAMPLE] != 16:
raise cv.Invalid("SPDIF mode only supports 16 bits per sample")
if not config[CONF_USE_APLL]:
raise cv.Invalid(
"SPDIF mode requires 'use_apll: true' for accurate clock generation"
@@ -138,21 +138,21 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() {
// Reset lockstep records queue so it starts paired with the (also-reset) i2s_event_queue_.
xQueueReset(this->write_records_queue_);
const uint32_t dma_buffers_duration_ms = DMA_BUFFER_DURATION_MS * SPDIF_DMA_BUFFERS_COUNT;
// Ensure ring buffer duration is at least the duration of all DMA buffers
const uint32_t ring_buffer_duration = std::max(dma_buffers_duration_ms, this->buffer_duration_ms_);
// The DMA buffers may have more bits per sample, so calculate buffer sizes based on the input audio stream info
const size_t bytes_per_frame = this->current_stream_info_.frames_to_bytes(1);
// Round the ring buffer size down to a multiple of bytes_per_frame so the wrap boundary stays frame-aligned and
// avoids unnecessary single-frame splices.
const size_t ring_buffer_size =
(this->current_stream_info_.ms_to_bytes(ring_buffer_duration) / bytes_per_frame) * bytes_per_frame;
// For SPDIF mode, one DMA buffer = one SPDIF block = 192 PCM frames (~4 ms at 48 kHz),
// not the ~15 ms a standard I2S DMA buffer holds. Derive the DMA floor from actual block size.
// For SPDIF mode, one DMA buffer = one SPDIF block = 192 PCM frames
const uint32_t frames_to_fill_single_dma_buffer = SPDIF_BLOCK_SAMPLES;
const size_t bytes_to_fill_single_dma_buffer =
this->current_stream_info_.frames_to_bytes(frames_to_fill_single_dma_buffer);
const size_t dma_buffers_floor_bytes = bytes_to_fill_single_dma_buffer * SPDIF_DMA_BUFFERS_COUNT;
// Round the ring buffer size down to a multiple of bytes_per_frame so the wrap boundary stays frame-aligned and
// avoids unnecessary single-frame splices. Ensure it is at least large enough to cover all DMA buffers.
const size_t requested_ring_buffer_bytes =
(this->current_stream_info_.ms_to_bytes(this->buffer_duration_ms_) / bytes_per_frame) * bytes_per_frame;
const size_t ring_buffer_size = std::max(dma_buffers_floor_bytes, requested_ring_buffer_bytes);
bool successful_setup = false;
std::unique_ptr<audio::RingBufferAudioSource> audio_source;
@@ -177,8 +177,7 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() {
// on_sent events drain in lockstep without crediting any audio frames.
this->spdif_encoder_->set_preload_mode(true);
for (size_t i = 0; i < SPDIF_DMA_BUFFERS_COUNT; i++) {
// i2s_channel_preload_data is non-blocking (returns immediately when the preload buffer fills), so no wait.
esp_err_t preload_err = this->spdif_encoder_->flush_with_silence(0);
esp_err_t preload_err = this->spdif_encoder_->flush_with_silence(pdMS_TO_TICKS(DMA_BUFFER_DURATION_MS));
if (preload_err != ESP_OK) {
break; // DMA preload buffer full or error
}
@@ -411,9 +410,8 @@ esp_err_t I2SAudioSpeakerSPDIF::start_i2s_driver(audio::AudioStreamInfo &audio_s
this->sample_rate_, audio_stream_info.get_sample_rate());
return ESP_ERR_NOT_SUPPORTED;
}
const uint8_t bits_per_sample = audio_stream_info.get_bits_per_sample();
if (bits_per_sample != 16 && bits_per_sample != 24 && bits_per_sample != 32) {
ESP_LOGE(TAG, "Only supports 16, 24, or 32 bits per sample (got %u)", (unsigned) bits_per_sample);
if (audio_stream_info.get_bits_per_sample() != 16) {
ESP_LOGE(TAG, "Only supports 16 bits per sample");
return ESP_ERR_NOT_SUPPORTED;
}
if (audio_stream_info.get_channels() != 2) {
@@ -421,8 +419,11 @@ esp_err_t I2SAudioSpeakerSPDIF::start_i2s_driver(audio::AudioStreamInfo &audio_s
return ESP_ERR_NOT_SUPPORTED;
}
// Tell the encoder what input width to expect. 32-bit input is truncated to 24-bit on the wire.
this->spdif_encoder_->set_bytes_per_sample(bits_per_sample / 8);
if (this->slot_bit_width_ != I2S_SLOT_BIT_WIDTH_AUTO &&
(i2s_slot_bit_width_t) audio_stream_info.get_bits_per_sample() > this->slot_bit_width_) {
ESP_LOGE(TAG, "Stream bits per sample must be less than or equal to the speaker's configuration");
return ESP_ERR_NOT_SUPPORTED;
}
if (!this->parent_->try_lock()) {
ESP_LOGE(TAG, "Parent bus is busy");
@@ -19,6 +19,7 @@
namespace esphome::i2s_audio {
// Shared constants used by both standard and SPDIF speaker implementations
static constexpr uint32_t DMA_BUFFER_DURATION_MS = 15;
static constexpr size_t TASK_STACK_SIZE = 4096;
static constexpr ssize_t TASK_PRIORITY = 19;
@@ -16,7 +16,6 @@ namespace esphome::i2s_audio {
static const char *const TAG = "i2s_audio.speaker.std";
static constexpr uint32_t DMA_BUFFER_DURATION_MS = 15;
static constexpr size_t DMA_BUFFERS_COUNT = 4;
// Sized to comfortably absorb scheduling jitter: at most DMA_BUFFERS_COUNT events can be in flight,
// doubled so that a transient backlog never overruns the queue (which would desync the lockstep
@@ -17,7 +17,7 @@ static constexpr uint8_t PREAMBLE_M = 0x1d; // Left channel (not block start)
static constexpr uint8_t PREAMBLE_W = 0x1b; // Right channel
// BMC encoding of 4 zero bits starting at phase HIGH: 00_11_00_11 = 0x33
// Used as a constant in the 16-bit subframe path, where bits 4-11 are always zero.
// Since both aux nibbles (bits 4-7, 8-11) are zero for 16-bit audio and phase is preserved, both are 0x33.
static constexpr uint32_t BMC_ZERO_NIBBLE = 0x33;
// Constexpr BMC encoder for compile-time LUT generation.
@@ -36,43 +36,21 @@ static constexpr uint16_t bmc_lut_encode(uint32_t data, uint8_t num_bits) {
return bmc;
}
// Compile-time parity helper (constexpr-friendly, runs only at LUT build time).
static constexpr uint32_t bmc_lut_parity(uint32_t value, uint32_t num_bits) {
uint32_t p = 0;
for (uint32_t b = 0; b < num_bits; b++)
p ^= (value >> b) & 1u;
return p;
}
// Combined BMC + phase-delta lookup tables.
// Each entry packs the BMC pattern (lower bits, phase=high start) together with
// a phase-mask delta in bits 16-31 (0xFFFF if the input has odd parity, else 0).
// XORing the delta into the running phase mask propagates parity across chunks
// without an explicit popcount.
// 4-bit BMC lookup table: 16 entries x uint32_t = 64 bytes in flash.
// Bits 0-7 : 8-bit BMC pattern (phase=high start)
// Bits 16-31 : phase-mask delta (0xFFFFu if odd parity, else 0)
// 4-bit BMC lookup table: 16 entries (16 bytes in flash)
// Index: 4-bit data value (0-15), always phase=true start
static constexpr auto BMC_LUT_4 = [] {
std::array<uint32_t, 16> t{};
for (uint32_t i = 0; i < 16; i++) {
uint32_t bmc = bmc_lut_encode(i, 4);
uint32_t delta = bmc_lut_parity(i, 4) ? 0xFFFF0000u : 0u;
t[i] = bmc | delta;
}
std::array<uint8_t, 16> t{};
for (uint32_t i = 0; i < 16; i++)
t[i] = static_cast<uint8_t>(bmc_lut_encode(i, 4));
return t;
}();
// 8-bit BMC lookup table: 256 entries x uint32_t = 1024 bytes in flash.
// Bits 0-15 : 16-bit BMC pattern (phase=high start)
// Bits 16-31 : phase-mask delta (0xFFFFu if odd parity, else 0)
// 8-bit BMC lookup table: 256 entries (512 bytes in flash)
// Index: 8-bit data value (0-255), always phase=true start
static constexpr auto BMC_LUT_8 = [] {
std::array<uint32_t, 256> t{};
for (uint32_t i = 0; i < 256; i++) {
uint32_t bmc = bmc_lut_encode(i, 8);
uint32_t delta = bmc_lut_parity(i, 8) ? 0xFFFF0000u : 0u;
t[i] = bmc | delta;
}
std::array<uint16_t, 256> t{};
for (uint32_t i = 0; i < 256; i++)
t[i] = bmc_lut_encode(i, 8);
return t;
}();
@@ -85,7 +63,7 @@ bool SPDIFEncoder::setup() {
}
ESP_LOGV(TAG, "Buffer allocated (%zu bytes)", SPDIF_BLOCK_SIZE_BYTES);
// Build initial channel status block with default sample rate and width
// Build initial channel status block with default sample rate
this->build_channel_status_();
this->reset();
@@ -95,7 +73,7 @@ bool SPDIFEncoder::setup() {
void SPDIFEncoder::reset() {
this->spdif_block_ptr_ = this->spdif_block_buf_.get();
this->frame_in_block_ = 0;
this->block_buf_is_silence_block_ = false;
this->is_left_channel_ = true;
}
void SPDIFEncoder::set_sample_rate(uint32_t sample_rate) {
@@ -106,27 +84,31 @@ void SPDIFEncoder::set_sample_rate(uint32_t sample_rate) {
}
}
void SPDIFEncoder::set_bytes_per_sample(uint8_t bytes_per_sample) {
if (bytes_per_sample != 2 && bytes_per_sample != 3 && bytes_per_sample != 4) {
ESP_LOGE(TAG, "Unsupported bytes per sample: %u", (unsigned) bytes_per_sample);
return;
}
if (this->bytes_per_sample_ != bytes_per_sample) {
this->bytes_per_sample_ = bytes_per_sample;
this->build_channel_status_();
// Discard any partial block built at the previous width so we never mix widths on the wire.
this->reset();
ESP_LOGD(TAG, "Input width set to %u-bit", (unsigned) bytes_per_sample * 8);
}
}
void SPDIFEncoder::build_channel_status_() {
// IEC 60958-3 Consumer Channel Status Block (192 bits = 24 bytes)
// Transmitted LSB-first within each byte, one bit per frame via C bit.
// Any cached silence block was built for the previous channel status; it is now stale.
this->block_buf_is_silence_block_ = false;
// Transmitted LSB-first within each byte, one bit per frame via C bit
//
// Byte 0: Control bits
// Bit 0: 0 = Consumer format (not professional AES3)
// Bit 1: 0 = PCM audio (not non-audio data like AC3)
// Bit 2: 0 = No copyright assertion
// Bits 3-5: 000 = No pre-emphasis
// Bits 6-7: 00 = Mode 0 (basic consumer format)
//
// Byte 1: Category code (0x00 = general, 0x01 = CD, etc.)
//
// Byte 2: Source/channel numbers
// Bits 0-3: Source number (0 = unspecified)
// Bits 4-7: Channel number (0 = unspecified)
//
// Byte 3: Sample frequency and clock accuracy
// Bits 0-3: Sample frequency code
// Bits 4-5: Clock accuracy (00 = Level II, ±1000 ppm, appropriate for ESP32)
// Bits 6-7: Reserved (0)
//
// Bytes 4-23: Reserved (zeros for basic compliance)
// Clear all bytes first
this->channel_status_.fill(0);
// Byte 0: Consumer, PCM audio, no copyright, no pre-emphasis, Mode 0
@@ -158,148 +140,132 @@ void SPDIFEncoder::build_channel_status_() {
// Byte 3: freq_code in bits 0-3, clock accuracy (00) in bits 4-5
this->channel_status_[3] = freq_code; // Clock accuracy bits 4-5 are already 0
// Byte 4: Word length encoding (IEC 60958-3 consumer)
// bit 0: max length flag (0 = max 20 bits, 1 = max 24 bits)
// bits 1-3: word length code relative to the max
// For our supported widths:
// 16-bit (max 20): 0b0010 = 0x02 -- "16 bits, max 20"
// 24-bit (max 24): 0b1101 = 0x0D -- "24 bits, max 24"
// 32-bit input is truncated to 24-bit on the wire, so use the 24-bit code.
uint8_t word_length_code;
switch (this->bytes_per_sample_) {
case 2:
word_length_code = 0x02;
break;
case 3: // Shared case
case 4:
word_length_code = 0x0D;
break;
default:
word_length_code = 0x00; // not specified
break;
// Bytes 4-23 remain zero (word length not specified, no original sample freq, etc.)
}
HOT void SPDIFEncoder::encode_sample_(const uint8_t *pcm_sample) {
// ============================================================================
// Build raw 32-bit subframe (IEC 60958 format)
// ============================================================================
// Bit layout:
// Bits 0-3: Preamble (handled separately, not in raw_subframe)
// Bits 4-7: Auxiliary audio data (zeros for 16-bit audio)
// Bits 8-11: Audio LSB extension (zeros for 16-bit audio)
// Bits 12-27: 16-bit audio sample (MSB-aligned in 20-bit audio field)
// Bit 28: V (Validity) - 0 = valid audio
// Bit 29: U (User data) - 0
// Bit 30: C (Channel status) - from channel status block
// Bit 31: P (Parity) - even parity over bits 4-31
// ============================================================================
// Place 16-bit audio sample at bits 12-27 (little-endian input: [0]=LSB, [1]=MSB)
uint32_t raw_subframe = (static_cast<uint32_t>(pcm_sample[1]) << 20) | (static_cast<uint32_t>(pcm_sample[0]) << 12);
// V = 0 (valid audio), U = 0 (no user data)
// C = channel status bit for current frame (same bit used for both L and R subframes)
bool c_bit = this->get_channel_status_bit_(this->frame_in_block_);
if (c_bit) {
raw_subframe |= (1U << 30);
}
this->channel_status_[4] = word_length_code;
}
// Extract the C bit for the given frame from channel_status_ and shift it into bit 30
// so it can be OR'd directly into a raw subframe.
ESPHOME_ALWAYS_INLINE static inline uint32_t c_bit_for_frame(const std::array<uint8_t, 24> &channel_status,
uint32_t frame) {
return static_cast<uint32_t>((channel_status[frame >> 3] >> (frame & 7)) & 1u) << 30;
}
// Calculate even parity over bits 4-30
// This ensures consistent BMC ending phase regardless of audio content
uint32_t bits_4_30 = (raw_subframe >> 4) & 0x07FFFFFF; // 27 bits (4-30)
uint32_t ones_count = __builtin_popcount(bits_4_30);
uint32_t parity = ones_count & 1; // 1 if odd count, 0 if even
raw_subframe |= parity << 31; // Set P bit to make total even
// ============================================================================
// IEC 60958 subframe bit layout
// ============================================================================
// Bits 0-3: Preamble (handled separately, not in raw_subframe)
// Bits 4-7: Auxiliary audio data / 24-bit audio LSB
// Bits 8-11: Audio LSB extension (zero for 16-bit, low nibble of audio for 24-bit)
// Bits 12-27: Audio sample (16 high bits in 16-bit mode, mid 16 bits in 24-bit mode)
// Bit 28: V (Validity) - 0 = valid audio
// Bit 29: U (User data) - 0
// Bit 30: C (Channel status) - from channel status block
// Bit 31: P (Parity) - even parity over bits 4-31
// ============================================================================
// Build a raw IEC 60958 subframe from PCM little-endian input of width Bps bytes.
// Caller is responsible for OR-ing in the C bit and parity.
template<uint8_t Bps> ESPHOME_ALWAYS_INLINE static inline uint32_t build_raw_subframe(const uint8_t *pcm_sample) {
static_assert(Bps == 2 || Bps == 3 || Bps == 4, "Unsupported bytes per sample");
if constexpr (Bps == 2) {
// 16-bit input: MSB-aligned in the 20-bit audio field, bits 12-27.
return (static_cast<uint32_t>(pcm_sample[1]) << 20) | (static_cast<uint32_t>(pcm_sample[0]) << 12);
} else if constexpr (Bps == 3) {
// 24-bit input: full 24-bit audio field, bits 4-27.
return (static_cast<uint32_t>(pcm_sample[2]) << 20) | (static_cast<uint32_t>(pcm_sample[1]) << 12) |
(static_cast<uint32_t>(pcm_sample[0]) << 4);
} else { // Bps == 4
// 32-bit input truncated to 24-bit: drop the lowest byte.
return (static_cast<uint32_t>(pcm_sample[3]) << 20) | (static_cast<uint32_t>(pcm_sample[2]) << 12) |
(static_cast<uint32_t>(pcm_sample[1]) << 4);
}
}
// BMC-encode a subframe and write the two output uint32 words to dst. Caller passes
// raw_subframe with the C bit set (bit 30) and the P bit cleared (bit 31 = 0). P is
// derived from the cumulative parity-mask delta of the per-byte LUT lookups.
//
// I2S halfword swap means word[0] transmits as: bits 24-31, 16-23, 8-15, 0-7.
// word[1] transmits as: bits 16-31, 0-15. Within each halfword, MSB-first.
// All preambles end at phase HIGH, so phase=true at the start of bit 4.
//
// P-bit derivation: BMC_LUT_*'s upper half encodes the parity of the input chunk. Each
// chunk's parity delta is shifted down (`lut >> 16`) into a phase_mask that lives in the
// low 16 bits, so the same value can also be XORed against subsequent BMC patterns to
// invert phase. XOR'ing those deltas through all chunks (with bit 31 = 0) yields the
// parity of bits 4-30 in the low bits of phase_mask -- the required value of the P bit
// for even total parity. The BMC of bit 31 lives in bit 0 of the high-byte BMC output
// (i = 7 maps to position (8-1-7)*2 = 0); flipping the source bit flips only the lower
// BMC bit (= phase XOR bit), so applying P is `bmc_24_31 ^= phase_mask & 1u`.
template<uint8_t Bps>
ESPHOME_ALWAYS_INLINE static inline void bmc_encode_subframe(uint32_t raw_subframe, uint8_t preamble, uint32_t *dst) {
if constexpr (Bps == 2) {
// 16-bit path: bits 4-11 are zero, encoded inline as BMC_ZERO_NIBBLE constants.
// Eight zero source bits with start phase=HIGH end at phase=HIGH (popcount of zeros is even),
// so encoding of bits 12-15 starts at phase=true. Zeros contribute 0 to parity.
uint32_t nibble = (raw_subframe >> 12) & 0xF;
uint32_t lut_n = BMC_LUT_4[nibble];
uint32_t bmc_12_15 = lut_n & 0xFFu;
uint32_t phase_mask = lut_n >> 16; // 0xFFFFu if odd parity, else 0
uint32_t byte_mid = (raw_subframe >> 16) & 0xFF;
uint32_t lut_m = BMC_LUT_8[byte_mid];
uint32_t bmc_16_23 = (lut_m & 0xFFFFu) ^ phase_mask;
phase_mask ^= lut_m >> 16;
uint32_t byte_hi = (raw_subframe >> 24) & 0xFF; // bit 7 (= P) is 0 by precondition
uint32_t lut_h = BMC_LUT_8[byte_hi];
uint32_t bmc_24_31 = (lut_h & 0xFFFFu) ^ phase_mask;
phase_mask ^= lut_h >> 16;
// phase_mask now reflects parity of bits 4-30. Apply P by flipping bit 0 of bmc_24_31.
bmc_24_31 ^= phase_mask & 1u;
dst[0] = bmc_12_15 | (BMC_ZERO_NIBBLE << 8) | (BMC_ZERO_NIBBLE << 16) | (static_cast<uint32_t>(preamble) << 24);
dst[1] = bmc_24_31 | (bmc_16_23 << 16);
// ============================================================================
// Select preamble based on position in block and channel
// ============================================================================
// B = block start (left channel, frame 0 of 192-frame block)
// M = left channel (frames 1-191)
// W = right channel (all frames)
uint8_t preamble;
if (this->is_left_channel_) {
preamble = (this->frame_in_block_ == 0) ? PREAMBLE_B : PREAMBLE_M;
} else {
// 24-bit (and 32-bit truncated) path: bits 4-11 are live audio.
uint32_t byte_lo = (raw_subframe >> 4) & 0xFF;
uint32_t lut_l = BMC_LUT_8[byte_lo];
uint32_t bmc_4_11 = lut_l & 0xFFFFu;
uint32_t phase_mask = lut_l >> 16; // 0xFFFFu if odd parity, else 0
uint32_t nibble = (raw_subframe >> 12) & 0xF;
uint32_t lut_n = BMC_LUT_4[nibble];
uint32_t bmc_12_15 = (lut_n & 0xFFu) ^ (phase_mask & 0xFFu);
phase_mask ^= lut_n >> 16;
uint32_t byte_mid = (raw_subframe >> 16) & 0xFF;
uint32_t lut_m = BMC_LUT_8[byte_mid];
uint32_t bmc_16_23 = (lut_m & 0xFFFFu) ^ phase_mask;
phase_mask ^= lut_m >> 16;
uint32_t byte_hi = (raw_subframe >> 24) & 0xFF; // bit 7 (= P) is 0 by precondition
uint32_t lut_h = BMC_LUT_8[byte_hi];
uint32_t bmc_24_31 = (lut_h & 0xFFFFu) ^ phase_mask;
phase_mask ^= lut_h >> 16;
bmc_24_31 ^= phase_mask & 1u;
// word[0]: bits 24-31 = preamble, bits 8-23 = bmc(4-11), bits 0-7 = bmc(12-15)
// word[1]: bits 16-31 = bmc(16-23), bits 0-15 = bmc(24-31)
dst[0] = bmc_12_15 | (bmc_4_11 << 8) | (static_cast<uint32_t>(preamble) << 24);
dst[1] = bmc_24_31 | (bmc_16_23 << 16);
preamble = PREAMBLE_W;
}
}
template<uint8_t Bps> void SPDIFEncoder::encode_silence_frame_() {
static constexpr uint8_t SILENCE[4] = {0, 0, 0, 0};
uint32_t raw = build_raw_subframe<Bps>(SILENCE) | c_bit_for_frame(this->channel_status_, this->frame_in_block_);
uint8_t preamble_l = (this->frame_in_block_ == 0) ? PREAMBLE_B : PREAMBLE_M;
bmc_encode_subframe<Bps>(raw, preamble_l, this->spdif_block_ptr_);
bmc_encode_subframe<Bps>(raw, PREAMBLE_W, this->spdif_block_ptr_ + 2);
this->spdif_block_ptr_ += 4;
if (++this->frame_in_block_ >= SPDIF_BLOCK_SAMPLES) {
this->frame_in_block_ = 0;
// ============================================================================
// BMC encode the data portion (bits 4-31) using lookup tables
// ============================================================================
// The I2S uses 16-bit halfword swap: bits 16-31 transmit before bits 0-15.
// This applies to BOTH word[0] and word[1].
//
// word[0] transmission order: [16-23] → [24-31] → [0-7] → [8-15]
// For correct S/PDIF subframe order (preamble → aux → audio):
// - bits 16-23: preamble (8 BMC bits)
// - bits 24-31: BMC(subframe bits 4-7) - first aux nibble
// - bits 0-7: BMC(subframe bits 8-11) - second aux nibble
// - bits 8-15: BMC(subframe bits 12-15) - audio low nibble
//
// word[1] transmission order: [16-31] → [0-15]
// For correct S/PDIF subframe order:
// - bits 16-31: BMC(subframe bits 16-23) - audio mid byte
// - bits 0-15: BMC(subframe bits 24-31) - audio high nibble + VUCP
// ============================================================================
// All preambles end at phase HIGH. Bits 4-11 are always zero for 16-bit audio;
// two zero nibbles flip phase 8 times total → back to HIGH.
// So bits 12-15 always start encoding at phase=true.
// Bits 12-15: 4-bit LUT lookup (always phase=true start)
uint32_t nibble = (raw_subframe >> 12) & 0xF;
uint32_t bmc_12_15 = BMC_LUT_4[nibble];
// Phase tracking via branchless XOR mask:
// - 0x0000 means phase=true (use LUT value directly)
// - 0xFFFF means phase=false (complement LUT value)
// End phase = start XOR (popcount & 1) since zero-bits flip phase,
// and for even bit widths: #zeros parity == popcount parity.
uint32_t phase_mask = -(__builtin_popcount(nibble) & 1u) & 0xFFFF;
// Bits 16-23: 8-bit LUT lookup with phase correction
uint32_t byte_mid = (raw_subframe >> 16) & 0xFF;
uint32_t bmc_16_23 = BMC_LUT_8[byte_mid] ^ phase_mask;
phase_mask ^= -(__builtin_popcount(byte_mid) & 1u) & 0xFFFF;
// Bits 24-31: 8-bit LUT lookup with phase correction
uint32_t byte_hi = (raw_subframe >> 24) & 0xFF;
uint32_t bmc_24_31 = BMC_LUT_8[byte_hi] ^ phase_mask;
// ============================================================================
// Combine with correct positioning for I2S transmission
// ============================================================================
// I2S with halfword swap: transmits bits 16-31, then bits 0-15.
// Within each halfword, MSB (highest bit) is transmitted first.
//
// For upper halfword (bits 16-31): bit 31 → bit 16
// For lower halfword (bits 0-15): bit 15 → bit 0
//
// Desired S/PDIF order: preamble → bmc_4_7 → bmc_8_11 → bmc_12_15
//
// word[0] layout for correct transmission:
// bits 24-31: preamble (transmitted 1st, as MSB of upper halfword)
// bits 16-23: BMC_ZERO_NIBBLE (transmitted 2nd, aux bits 4-7)
// bits 8-15: BMC_ZERO_NIBBLE (transmitted 3rd, aux bits 8-11)
// bits 0-7: bmc_12_15 (transmitted 4th, audio low nibble)
//
// word[1] layout:
// bits 16-31: bmc_16_23 (transmitted 5th)
// bits 0-15: bmc_24_31 (transmitted 6th)
this->spdif_block_ptr_[0] =
bmc_12_15 | (BMC_ZERO_NIBBLE << 8) | (BMC_ZERO_NIBBLE << 16) | (static_cast<uint32_t>(preamble) << 24);
this->spdif_block_ptr_[1] = bmc_24_31 | (bmc_16_23 << 16);
this->spdif_block_ptr_ += 2;
// ============================================================================
// Update position tracking
// ============================================================================
if (!this->is_left_channel_) {
// Completed a stereo frame, advance frame counter
if (++this->frame_in_block_ >= SPDIF_BLOCK_SAMPLES) {
this->frame_in_block_ = 0;
}
}
this->is_left_channel_ = !this->is_left_channel_;
}
esp_err_t SPDIFEncoder::send_block_(TickType_t ticks_to_wait) {
@@ -329,162 +295,79 @@ esp_err_t SPDIFEncoder::send_block_(TickType_t ticks_to_wait) {
return err;
}
template<uint8_t Bps>
HOT esp_err_t SPDIFEncoder::write_typed_(const uint8_t *src, size_t size, TickType_t ticks_to_wait,
uint32_t *blocks_sent, size_t *bytes_consumed) {
const uint8_t *pcm_data = src;
const uint8_t *const pcm_end = src + size;
uint32_t block_count = 0;
// Hot state lives in locals so the compiler can keep it in registers across the
// per-frame encoding work; byte writes through block_ptr may alias the member fields,
// which would block register allocation if the encoding read them directly from this->*.
uint32_t *block_ptr = this->spdif_block_ptr_;
uint32_t *const block_buf = this->spdif_block_buf_.get();
uint32_t *const block_end = block_buf + SPDIF_BLOCK_SIZE_U32;
uint32_t frame = this->frame_in_block_;
const std::array<uint8_t, 24> &channel_status = this->channel_status_;
auto save_state = [&]() {
this->spdif_block_ptr_ = block_ptr;
this->frame_in_block_ = static_cast<uint8_t>(frame);
};
auto report_out_params = [&]() {
if (blocks_sent != nullptr)
*blocks_sent = block_count;
if (bytes_consumed != nullptr)
*bytes_consumed = pcm_data - src;
};
// Send a completed block if the buffer is full, propagating any error.
// send_block_ resets this->spdif_block_ptr_ to block_buf on success and leaves it
// unchanged on error -- mirror both behaviors in our local block_ptr.
auto maybe_send = [&]() -> esp_err_t {
if (block_ptr >= block_end) {
esp_err_t err = this->send_block_(ticks_to_wait);
if (err != ESP_OK) {
save_state();
report_out_params();
return err;
}
block_ptr = block_buf;
++block_count;
}
return ESP_OK;
};
// Hot path: encode L+R pairs in two peeled sub-loops. Frame 0 carries the only
// buffer-full check and uses PREAMBLE_B (a block fills exactly when frame wraps from
// 191 back to 0). Frames 1..191 use PREAMBLE_M and need no buffer-full check or
// preamble branch. The encoding body is inlined here so block_ptr lives in a register
// for the duration of the loop.
while (pcm_data + 2 * Bps <= pcm_end) {
if (frame == 0) {
esp_err_t err = maybe_send();
if (err != ESP_OK)
return err;
uint32_t c_bit = c_bit_for_frame(channel_status, 0);
uint32_t raw_l = build_raw_subframe<Bps>(pcm_data) | c_bit;
uint32_t raw_r = build_raw_subframe<Bps>(pcm_data + Bps) | c_bit;
bmc_encode_subframe<Bps>(raw_l, PREAMBLE_B, block_ptr);
bmc_encode_subframe<Bps>(raw_r, PREAMBLE_W, block_ptr + 2);
block_ptr += 4;
frame = 1;
pcm_data += 2 * Bps;
}
// The inner loop runs until min(SPDIF_BLOCK_SAMPLES, frame + input_frames). The
// input-size bound is folded into end_frame so a single `frame < end_frame` test
// governs termination.
uint32_t input_frames = static_cast<uint32_t>(pcm_end - pcm_data) / (2u * Bps);
uint32_t end_frame = SPDIF_BLOCK_SAMPLES;
if (frame + input_frames < end_frame)
end_frame = frame + input_frames;
while (frame < end_frame) {
uint32_t c_bit = c_bit_for_frame(channel_status, frame);
uint32_t raw_l = build_raw_subframe<Bps>(pcm_data) | c_bit;
uint32_t raw_r = build_raw_subframe<Bps>(pcm_data + Bps) | c_bit;
bmc_encode_subframe<Bps>(raw_l, PREAMBLE_M, block_ptr);
bmc_encode_subframe<Bps>(raw_r, PREAMBLE_W, block_ptr + 2);
block_ptr += 4;
++frame;
pcm_data += 2 * Bps;
}
if (frame >= SPDIF_BLOCK_SAMPLES)
frame = 0;
size_t SPDIFEncoder::get_pending_pcm_bytes() const {
if (this->spdif_block_ptr_ == nullptr || this->spdif_block_buf_ == nullptr) {
return 0;
}
// Send any complete block that was just finished.
if (block_ptr >= block_end) {
esp_err_t err = this->send_block_(ticks_to_wait);
if (err != ESP_OK) {
save_state();
report_out_params();
return err;
}
block_ptr = block_buf;
++block_count;
}
save_state();
report_out_params();
return ESP_OK;
// Each PCM sample (2 bytes) produces 2 uint32_t values in the SPDIF buffer
// So pending uint32s / 2 = pending samples, and each sample is 2 bytes
size_t pending_uint32s = this->spdif_block_ptr_ - this->spdif_block_buf_.get();
size_t pending_samples = pending_uint32s / 2;
return pending_samples * 2; // 2 bytes per sample
}
HOT esp_err_t SPDIFEncoder::write(const uint8_t *src, size_t size, TickType_t ticks_to_wait, uint32_t *blocks_sent,
size_t *bytes_consumed) {
if (size > 0) {
// Real PCM is about to be encoded into the buffer, so it is no longer a full-silence block.
this->block_buf_is_silence_block_ = false;
}
switch (this->bytes_per_sample_) {
case 2:
return this->write_typed_<2>(src, size, ticks_to_wait, blocks_sent, bytes_consumed);
case 3:
return this->write_typed_<3>(src, size, ticks_to_wait, blocks_sent, bytes_consumed);
case 4:
return this->write_typed_<4>(src, size, ticks_to_wait, blocks_sent, bytes_consumed);
default:
return ESP_ERR_INVALID_STATE;
}
}
const uint8_t *pcm_data = src;
const uint8_t *pcm_end = src + size;
uint32_t block_count = 0;
template<uint8_t Bps> esp_err_t SPDIFEncoder::flush_with_silence_typed_(TickType_t ticks_to_wait) {
// If a complete block is already pending (from a previous failed send), emit just that block.
// Otherwise pad the partial block with silence (or generate a full silence block if empty) and
// send. Always emits exactly one block on success.
if (this->spdif_block_ptr_ < &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) {
const bool was_empty = (this->spdif_block_ptr_ == this->spdif_block_buf_.get());
// Continuous-silence idle case: a full silence block is byte-identical every time for the
// active channel status, so when the buffer already holds one, re-send it as-is.
if (was_empty && this->block_buf_is_silence_block_) {
return this->send_block_(ticks_to_wait);
while (pcm_data < pcm_end) {
// Check if there's a pending complete block from a previous failed send
if (this->spdif_block_ptr_ >= &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) {
esp_err_t err = this->send_block_(ticks_to_wait);
if (err != ESP_OK) {
if (blocks_sent != nullptr) {
*blocks_sent = block_count;
}
if (bytes_consumed != nullptr) {
*bytes_consumed = pcm_data - src;
}
return err;
}
++block_count;
}
// Pad with silence frames at the configured width.
while (this->spdif_block_ptr_ < &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) {
this->encode_silence_frame_<Bps>();
}
// The buffer is a reusable full-silence block only if it was built entirely from silence; a
// partial real-audio block padded out with silence is not.
this->block_buf_is_silence_block_ = was_empty;
// Encode one 16-bit sample
this->encode_sample_(pcm_data);
pcm_data += 2;
}
return this->send_block_(ticks_to_wait);
// Send any complete block that was just finished
if (this->spdif_block_ptr_ >= &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) {
esp_err_t err = this->send_block_(ticks_to_wait);
if (err != ESP_OK) {
if (blocks_sent != nullptr) {
*blocks_sent = block_count;
}
if (bytes_consumed != nullptr) {
*bytes_consumed = pcm_data - src;
}
return err;
}
++block_count;
}
if (blocks_sent != nullptr) {
*blocks_sent = block_count;
}
if (bytes_consumed != nullptr) {
*bytes_consumed = size;
}
return ESP_OK;
}
esp_err_t SPDIFEncoder::flush_with_silence(TickType_t ticks_to_wait) {
switch (this->bytes_per_sample_) {
case 2:
return this->flush_with_silence_typed_<2>(ticks_to_wait);
case 3:
return this->flush_with_silence_typed_<3>(ticks_to_wait);
case 4:
return this->flush_with_silence_typed_<4>(ticks_to_wait);
default:
return ESP_ERR_INVALID_STATE;
// If a complete block is already pending (from a previous failed send), emit just that block.
// Otherwise pad the partial block with silence (or generate a full silence block if empty)
// and send. Always emits exactly one block on success.
if (this->spdif_block_ptr_ < &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) {
static const uint8_t SILENCE[2] = {0, 0};
while (this->spdif_block_ptr_ < &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) {
this->encode_sample_(SILENCE);
}
}
return this->send_block_(ticks_to_wait);
}
} // namespace esphome::i2s_audio
@@ -24,6 +24,8 @@ static constexpr uint16_t SPDIF_BLOCK_SIZE_BYTES = SPDIF_BLOCK_SAMPLES * (EMULAT
static constexpr uint32_t SPDIF_BLOCK_SIZE_U32 = SPDIF_BLOCK_SIZE_BYTES / sizeof(uint32_t); // 3072 bytes / 4 = 768
// I2S frame count for one SPDIF block (for new driver where frame = 8 bytes for 32-bit stereo)
static constexpr uint32_t SPDIF_BLOCK_I2S_FRAMES = SPDIF_BLOCK_SIZE_BYTES / 8; // 3072 / 8 = 384 frames
// PCM bytes needed for one complete SPDIF block (192 stereo frames * 2 bytes per sample * 2 channels)
static constexpr uint16_t SPDIF_PCM_BYTES_PER_BLOCK = SPDIF_BLOCK_SAMPLES * 2 * 2; // = 768 bytes
/// Callback signature for block completion (raw function pointer for minimal overhead)
/// @param user_ctx User context pointer passed during callback registration
@@ -62,16 +64,8 @@ class SPDIFEncoder {
/// @brief Check if currently in preload mode
bool is_preload_mode() const { return this->preload_mode_; }
/// @brief Set input PCM width: 2 = 16-bit, 3 = 24-bit, 4 = 32-bit (truncated to 24-bit on the wire).
/// Must be called before write() if input width changes from the default (16-bit). Triggers a
/// channel-status rebuild to reflect the new word length.
void set_bytes_per_sample(uint8_t bytes_per_sample);
/// @brief Get the configured input PCM width in bytes per sample
uint8_t get_bytes_per_sample() const { return this->bytes_per_sample_; }
/// @brief Convert PCM audio data to SPDIF BMC encoded data
/// @param src Source PCM audio data (stereo, width matches set_bytes_per_sample)
/// @param src Source PCM audio data (16-bit stereo)
/// @param size Size of source data in bytes
/// @param ticks_to_wait Timeout for blocking writes
/// @param blocks_sent Optional pointer to receive the number of complete SPDIF blocks sent
@@ -80,6 +74,17 @@ class SPDIFEncoder {
esp_err_t write(const uint8_t *src, size_t size, TickType_t ticks_to_wait, uint32_t *blocks_sent = nullptr,
size_t *bytes_consumed = nullptr);
/// @brief Get the number of PCM bytes currently pending in the partial block buffer
/// @return Number of pending PCM bytes (0 to SPDIF_PCM_BYTES_PER_BLOCK - 1)
size_t get_pending_pcm_bytes() const;
/// @brief Get the number of PCM frames currently pending in the partial block buffer
/// @return Number of pending PCM frames (0 to SPDIF_BLOCK_SAMPLES - 1)
uint32_t get_pending_frames() const { return this->get_pending_pcm_bytes() / 4; }
/// @brief Check if there is a partial block pending
bool has_pending_data() const { return this->spdif_block_ptr_ != this->spdif_block_buf_.get(); }
/// @brief Emit one complete SPDIF block: pad any pending partial block with silence and send,
/// or send a full silence block if nothing is pending. Always produces exactly one block on success.
/// @param ticks_to_wait Timeout for blocking writes
@@ -90,7 +95,7 @@ class SPDIFEncoder {
void reset();
/// @brief Set the sample rate for Channel Status Block encoding
/// @param sample_rate Sample rate in Hz (e.g., 44100, 48000)
/// @param sample_rate Sample rate in Hz (e.g., 44100, 48000, 96000)
/// Call this before writing audio data to ensure correct channel status.
void set_sample_rate(uint32_t sample_rate);
@@ -98,19 +103,8 @@ class SPDIFEncoder {
uint32_t get_sample_rate() const { return this->sample_rate_; }
protected:
/// @brief Encode a single stereo silence frame at the current block position.
/// @note Used only by flush_with_silence_typed_ to pad; the hot write path inlines the
/// encoding body directly into write_typed_ to keep block_ptr / frame_in_block_ in registers.
template<uint8_t Bps> void encode_silence_frame_();
/// @brief Templated write loop. Called from the public write() via runtime dispatch on bytes_per_sample_.
template<uint8_t Bps>
HOT esp_err_t write_typed_(const uint8_t *src, size_t size, TickType_t ticks_to_wait, uint32_t *blocks_sent,
size_t *bytes_consumed);
/// @brief Templated flush-with-silence. Pads the pending block with zeros at the configured width
/// (or builds a full silence block when nothing is pending) and sends it. Always emits one block.
template<uint8_t Bps> esp_err_t flush_with_silence_typed_(TickType_t ticks_to_wait);
/// @brief Encode a single 16-bit PCM sample into the current block position
HOT void encode_sample_(const uint8_t *pcm_sample);
/// @brief Send the completed block via the appropriate callback
esp_err_t send_block_(TickType_t ticks_to_wait);
@@ -118,6 +112,15 @@ class SPDIFEncoder {
/// @brief Build the channel status block from current configuration
void build_channel_status_();
/// @brief Get the channel status bit for a specific frame
/// @param frame Frame number (0-191)
/// @return The C bit value for this frame
ESPHOME_ALWAYS_INLINE inline bool get_channel_status_bit_(uint8_t frame) const {
// Channel status is 192 bits transmitted over 192 frames
// Bit N is transmitted in frame N, LSB-first within each byte
return (this->channel_status_[frame >> 3] >> (frame & 7)) & 1;
}
// Member ordering optimized to minimize padding (largest alignment first)
// 4-byte aligned members (pointers and uint32_t)
@@ -130,13 +133,9 @@ class SPDIFEncoder {
uint32_t sample_rate_{48000}; // Sample rate for Channel Status Block encoding
// 1-byte aligned members (grouped together to avoid internal padding)
uint8_t bytes_per_sample_{2}; // Input PCM width: 2/3/4 (16/24/32-bit). 32-bit truncates to 24-bit on the wire.
uint8_t frame_in_block_{0}; // 0-191, tracks stereo frame position within block
bool preload_mode_{false}; // Whether to use preload callback vs write callback
// True when spdif_block_buf_ currently holds a complete full-silence block valid for the active
// channel status. A full silence block is deterministic for a given sample rate and word length,
// so when this is set flush_with_silence() can re-send the buffer verbatim instead of re-encoding.
bool block_buf_is_silence_block_{false};
uint8_t frame_in_block_{0}; // 0-191, tracks stereo frame position within block
bool is_left_channel_{true}; // Alternates L/R for stereo samples
bool preload_mode_{false}; // Whether to use preload callback vs write callback
// Channel Status Block (192 bits = 24 bytes, transmitted over 192 frames)
// Placed last since std::array<uint8_t> has 1-byte alignment
+11 -13
View File
@@ -11,19 +11,11 @@
#include "esphome/core/time_64.h"
// IRAM_ATTR places a function in executable RAM so it is callable from an
// ISR even while flash is busy (XIP stall, OTA, logger flash write). All
// LibreTiny families that need it share the same .sram.text input section
// name; how that section is routed into RAM differs per family:
// RTL8720C: stock linker consumes *(.sram.text*) into .ram.code_text.
// RTL8710B: patch_linker.py.script injects KEEP(*(.sram.text*)) at the
// top of .ram_image2.data (which IS in ltchiptool's
// sections_ram). The stock linker has KEEP(*(.image2.ram.text*))
// in .ram_image2.text but that output section is NOT in
// ltchiptool's AmebaZ elf2bin sections_ram list, so code routed
// there is dropped from the flashed binary.
// LN882H: patch_linker.py.script injects KEEP(*(.sram.text*)) into
// .flash_copysection (> RAM0 AT> FLASH), after KEEP(*(.vectors))
// so the Cortex-M4 vector table stays 512-byte-aligned for VTOR.
// ISR even while flash is busy (XIP stall, OTA, logger flash write).
// Each family uses a section its stock linker already routes to RAM:
// RTL8710B → .image2.ram.text, RTL8720C → .sram.text. LN882H is the
// exception: its stock linker has no matching glob, so patch_linker.py
// injects KEEP(*(.sram.text*)) into .flash_copysection at pre-link.
//
// BK72xx (all variants) are left as a no-op: their SDK wraps flash
// operations in GLOBAL_INT_DISABLE() which masks FIQ + IRQ at the CPU for
@@ -34,7 +26,13 @@
// layer.
#if defined(USE_BK72XX)
#define IRAM_ATTR
#elif defined(USE_LIBRETINY_VARIANT_RTL8710B)
// Stock linker consumes *(.image2.ram.text*) into .ram_image2.text (> BD_RAM).
#define IRAM_ATTR __attribute__((noinline, section(".image2.ram.text")))
#else
// RTL8720C: stock linker consumes *(.sram.text*) into .ram.code_text.
// LN882H: patch_linker.py.script injects *(.sram.text*) into
// .flash_copysection (> RAM0 AT> FLASH).
#define IRAM_ATTR __attribute__((noinline, section(".sram.text")))
#endif
#define PROGMEM
@@ -6,22 +6,14 @@ import re
import subprocess
# ESPHome marks ISR code IRAM_ATTR, which on LibreTiny maps to a per-family
# section routed into RAM-executable memory (see esphome/core/hal.h). The
# input section name is always .sram.text; only the output section it lands
# in differs per family.
# section routed into RAM-executable memory (see esphome/core/hal.h).
#
# This script is NOT loaded on BK72xx (IRAM_ATTR is a no-op there; the SDK
# masks FIQ+IRQ around flash writes). On the remaining families:
# - RTL8720C: stock linker consumes *(.sram.text*) into .ram.code_text.
# - RTL8710B: stock linker has KEEP(*(.image2.ram.text*)) in .ram_image2.text,
# but ltchiptool's AmebaZ elf2bin (soc/ambz/binary.py) does NOT list
# .ram_image2.text in sections_ram, so code there is silently dropped from
# the flashed image. Inject KEEP(*(.sram.text*)) at the top of
# .ram_image2.data (which IS extracted) instead.
# - RTL8710B: hal.h uses section(".image2.ram.text"); stock linker consumes it.
# - RTL8720C: hal.h uses section(".sram.text"); stock linker consumes it.
# - LN882H: stock linker has no glob for ".sram.text", so we inject
# KEEP(*(.sram.text*)) into ".flash_copysection" (> RAM0 AT> FLASH)
# immediately after KEEP(*(.vectors)), so the vector table stays at
# __copysection_ram0_start (0x20000000) for correct Cortex-M4 VTOR alignment.
# KEEP(*(.sram.text*)) into ".flash_copysection" (> RAM0 AT> FLASH).
#
# All families also get a post-link summary showing where IRAM_ATTR landed.
@@ -35,25 +27,7 @@ _KEEP_LINE = (
"__esphome_sram_text_end = .; "
+ _MARKER + "\n"
)
# Inject after KEEP(*(.vectors)) so the vector table stays at
# __copysection_ram0_start (0x20000000). Cortex-M4 VTOR requires a 512-byte-
# aligned address; injecting before the vectors would push them to an
# unaligned offset and mis-route every IRQ handler.
_LN_COPY = re.compile(r"(KEEP\(\*\(\.vectors\)\)[^\n]*\n)")
# Inject at the top of .ram_image2.data, before __data_start__ so our code
# does not fall inside the data range markers. .ram_image2.data is one of the
# sections ltchiptool's AmebaZ elf2bin extracts; BD_RAM is rwx so the code is
# executable. AmbZ has no C runtime .data copy loop (the bootloader loads
# image2 into BD_RAM whole) so the inline code is not clobbered after boot.
#
# The regex is intentionally strict (no attribute / ALIGN between the section
# name and the opening brace, brace on its own line). If a future AmbZ SDK
# linker template changes this format, _pre_link raises RuntimeError on the
# unpatched .ld file(s), and the RTL8710B CI compile job in
# tests/test_build_components fails on the PR, surfacing the mismatch loudly
# rather than silently shipping a binary with IRAM_ATTR code dropped from
# one or both OTA slots.
_AMBZ_DATA = re.compile(r"(\.ram_image2\.data\s*:\s*\n?\s*\{\s*\n)")
_LN_COPY = re.compile(r"(\.flash_copysection\s*:\s*\{\s*\n)")
def _detect(env):
@@ -82,7 +56,7 @@ KNOWN_VARIANTS = frozenset({
def _inject_keep(host_section):
"""Return a patcher that injects _KEEP_LINE after `host_section` match."""
"""Return a patcher that injects _KEEP_LINE at the top of `host_section`."""
def patch(content):
if _MARKER in content:
return content
@@ -91,11 +65,12 @@ def _inject_keep(host_section):
# Variants not listed here intentionally have no .ld patcher:
# - RTL8720C: stock linker already consumes *(.sram.text*) into .ram.code_text.
# - RTL8710B: hal.h uses section(".image2.ram.text") which the stock linker
# already routes into .ram_image2.text (> BD_RAM).
# - RTL8720C: stock linker already consumes *(.sram.text*).
# - BK72xx (all): SDK masks FIQ+IRQ around flash writes, IRAM_ATTR is no-op.
_PATCHERS_BY_VARIANT = {
"LN882H": (_inject_keep(_LN_COPY),),
"RTL8710B": (_inject_keep(_AMBZ_DATA),),
}
@@ -106,14 +81,13 @@ def _patchers_for(variant):
def _pre_link(target, source, env):
build_dir = env.subst("$BUILD_DIR")
ld_files = [f for f in os.listdir(build_dir) if f.endswith(".ld")]
patched = []
unpatched = []
patched = 0
for name in ld_files:
path = os.path.join(build_dir, name)
with open(path, "r", encoding="utf-8") as fh:
original = fh.read()
if _MARKER in original:
patched.append(name)
patched += 1
continue
content = original
for fn in _patchers:
@@ -122,9 +96,7 @@ def _pre_link(target, source, env):
with open(path, "w", encoding="utf-8") as fh:
fh.write(content)
print("ESPHome: patched {} for IRAM_ATTR placement".format(name))
patched.append(name)
else:
unpatched.append(name)
patched += 1
if not patched:
raise RuntimeError(
"ESPHome: no .ld in {} was patched for IRAM_ATTR. Update the "
@@ -132,20 +104,6 @@ def _pre_link(target, source, env):
build_dir
)
)
# Every .ld in the build must be patched. RTL8710B generates one .ld per
# OTA slot (xip1, xip2); if only one matches, the unpatched slot would
# ship with IRAM_ATTR code dropped to zeros and brick the device on the
# boot after an OTA into that slot.
if unpatched:
raise RuntimeError(
"ESPHome: {} of {} .ld file(s) in {} were not patched for "
"IRAM_ATTR: {}. The regex in patch_linker.py.script "
"(_PATCHERS_BY_VARIANT[{!r}]) matched the others but not "
"these. Update the regex to cover all linker scripts.".format(
len(unpatched), len(ld_files), build_dir,
", ".join(unpatched), _variant,
)
)
# Substrings matched against demangled names as a fallback on RTL8720C,
-5
View File
@@ -55,7 +55,6 @@ from .automation import layers_to_code, lvgl_update
from .defines import (
CONF_ALIGN_TO_LAMBDA_ID,
LOGGER,
add_lv_use,
get_focused_widgets,
get_lv_images_used,
get_refreshed_widgets,
@@ -72,7 +71,6 @@ from .keypads import KEYPADS_CONFIG, keypads_to_code
from .lv_validation import lv_bool
from .lvcode import LvContext, LvglComponent, lv_event_t_ptr, lvgl_static
from .schemas import (
BASE_PROPS,
DISP_BG_SCHEMA,
FULL_STYLE_SCHEMA,
STYLE_REMAP,
@@ -102,7 +100,6 @@ from .widgets import (
get_screen_active,
set_obj_properties,
)
from .widgets.img import CONF_IMAGE
# Import only what we actually use directly in this file
from .widgets.msgbox import MSGBOX_SCHEMA, msgboxes_to_code
@@ -436,8 +433,6 @@ async def to_code(configs):
# This must be done after all widgets are created
styles_used = df.get_styles_used()
if any(BASE_PROPS.get(x) is lvalid.lv_image for x in styles_used):
add_lv_use(CONF_IMAGE)
for use in df.get_lv_uses():
df.add_define(f"LV_USE_{use.upper()}")
cg.add_define(f"USE_LVGL_{use.upper()}")
+1 -1
View File
@@ -572,7 +572,7 @@ void LvButtonMatrixType::set_obj(lv_obj_t *lv_obj) {
auto key_idx = lv_buttonmatrix_get_selected_button(self->obj);
if (key_idx == LV_BUTTONMATRIX_BUTTON_NONE)
return;
if (self->key_map_.count(key_idx) != 0) {
if (self->key_map_.contains(key_idx)) {
self->send_key_(self->key_map_[key_idx]);
return;
}
+4 -6
View File
@@ -74,11 +74,11 @@ inline void lv_style_set_text_font(lv_style_t *style, const font::Font *font) {
lv_style_set_text_font(style, font->get_lv_font());
}
#endif
#ifdef USE_IMAGE
#ifdef USE_LVGL_IMAGE
#if defined(USE_LVGL_IMAGE) && defined(USE_IMAGE)
#if LV_USE_IMAGE
// Shortcut / overload, so that the source of an image widget can easily be updated from within a lambda.
inline void lv_image_set_src(lv_obj_t *obj, image::Image *image) { ::lv_image_set_src(obj, image->get_lv_image_dsc()); }
#endif // LV_USE_IMAGE
inline void lv_obj_set_style_bitmap_mask_src(lv_obj_t *obj, image::Image *image, lv_style_selector_t selector) {
::lv_obj_set_style_bitmap_mask_src(obj, image->get_lv_image_dsc(), selector);
@@ -93,8 +93,7 @@ inline void lv_style_set_bg_image_src(lv_style_t *style, image::Image *image) {
inline void lv_style_set_bitmap_mask_src(lv_style_t *style, image::Image *image) {
::lv_style_set_bitmap_mask_src(style, image->get_lv_image_dsc());
}
#endif
#endif // USE_LVGL_IMAGE
#ifdef USE_LVGL_ANIMIMG
inline void lv_animimg_set_src(lv_obj_t *img, std::vector<image::Image *> images) {
auto *dsc = static_cast<std::vector<lv_image_dsc_t *> *>(lv_obj_get_user_data(img));
@@ -110,7 +109,6 @@ inline void lv_animimg_set_src(lv_obj_t *img, std::vector<image::Image *> images
lv_animimg_set_src(img, (const void **) dsc->data(), dsc->size());
}
#endif // USE_LVGL_ANIMIMG
#endif // USE_IMAGE
#ifdef USE_LVGL_METER
int16_t lv_get_needle_angle_for_value(lv_obj_t *obj, int32_t value);
-2
View File
@@ -9,7 +9,6 @@ from .defines import (
CONF_THEME,
LValidator,
add_lv_use,
get_styles_used,
get_theme_widget_map,
literal,
)
@@ -26,7 +25,6 @@ def has_style_props(config) -> bool:
async def style_set(svar, style):
for prop, validator in ALL_STYLES.items():
if (value := style.get(prop)) is not None:
get_styles_used().add(prop)
if isinstance(validator, LValidator):
value = await validator.process(value)
if isinstance(value, list):
+17 -4
View File
@@ -1,3 +1,5 @@
import logging
from esphome import automation
import esphome.codegen as cg
from esphome.components import display, esp32, uart
@@ -39,6 +41,8 @@ from .base_component import (
CONF_WAKE_UP_PAGE,
)
_LOGGER = logging.getLogger(__name__)
CODEOWNERS = ["@senexcrenshaw", "@edwardtfn"]
DEPENDENCIES = ["uart"]
@@ -55,6 +59,15 @@ NextionSetBrightnessAction = nextion_ns.class_(
)
def _deprecated_dump_device_info(value):
_LOGGER.warning(
"'dump_device_info' is deprecated and will be removed in ESPHome 2026.11.0. "
"Device info is now always logged at connection time. "
"Please remove this option from your configuration."
)
return value
def _validate_tft_upload(config):
has_tft_url = CONF_TFT_URL in config
for conf_key in (
@@ -81,7 +94,10 @@ CONFIG_SCHEMA = cv.All(
cv.positive_time_period_milliseconds,
cv.Range(max=TimePeriod(milliseconds=255)),
),
cv.Optional(CONF_DUMP_DEVICE_INFO, default=False): cv.boolean,
# Deprecated — device info is now always logged. Remove before 2026.11.0.
cv.Optional(CONF_DUMP_DEVICE_INFO): cv.All(
cv.boolean, _deprecated_dump_device_info
),
cv.Optional(CONF_EXIT_REPARSE_ON_START, default=False): cv.boolean,
cv.Optional(CONF_MAX_QUEUE_AGE, default="8000ms"): cv.All(
cv.positive_time_period_milliseconds,
@@ -277,9 +293,6 @@ async def to_code(config):
cg.add(var.set_auto_wake_on_touch(config[CONF_AUTO_WAKE_ON_TOUCH]))
if config[CONF_DUMP_DEVICE_INFO]:
cg.add_define("USE_NEXTION_CONFIG_DUMP_DEVICE_INFO")
if config[CONF_EXIT_REPARSE_ON_START]:
cg.add_define("USE_NEXTION_CONFIG_EXIT_REPARSE_ON_START")
+44 -31
View File
@@ -117,30 +117,41 @@ bool Nextion::check_connect_() {
ESP_LOGN(TAG, "connect: %s", response.c_str());
size_t start;
// Parse comok response fields directly
// Format: comok <touch>,<reserved>,<model>,<fw>,<mcu_code>,<serial>,<flash>
size_t field_count = 0;
size_t start = 0;
size_t end = 0;
std::vector<std::string> connect_info;
auto copy_field = [&](char *dst, size_t cap) {
size_t len = (end == std::string::npos ? response.size() : end) - start;
size_t n = len < cap ? len : cap;
std::memcpy(dst, response.data() + start, n);
dst[n] = '\0';
};
while ((start = response.find_first_not_of(',', end)) != std::string::npos) {
end = response.find(',', start);
connect_info.push_back(response.substr(start, end - start));
switch (field_count) {
case 2:
copy_field(this->device_model_, this->NEXTION_MODEL_MAX);
break;
case 3:
copy_field(this->firmware_version_, this->NEXTION_FW_MAX);
break;
case 5:
copy_field(this->serial_number_, this->NEXTION_SERIAL_MAX);
break;
case 6:
this->flash_size_ = static_cast<uint32_t>(std::strtoul(response.data() + start, nullptr, 10));
break;
default:
break;
}
++field_count;
}
this->is_detected_ = (connect_info.size() == 7);
this->is_detected_ = (field_count == 7);
if (this->is_detected_) {
ESP_LOGN(TAG, "Connect info: %zu", connect_info.size());
#ifdef USE_NEXTION_CONFIG_DUMP_DEVICE_INFO
this->device_model_ = connect_info[2];
this->firmware_version_ = connect_info[3];
this->serial_number_ = connect_info[5];
this->flash_size_ = connect_info[6];
#else // USE_NEXTION_CONFIG_DUMP_DEVICE_INFO
ESP_LOGI(TAG,
" Device Model: %s\n"
" FW Version: %s\n"
" Serial Number: %s\n"
" Flash Size: %s\n",
connect_info[2].c_str(), connect_info[3].c_str(), connect_info[5].c_str(), connect_info[6].c_str());
#endif // USE_NEXTION_CONFIG_DUMP_DEVICE_INFO
ESP_LOGN(TAG, "Connect info: %zu fields", field_count);
} else {
ESP_LOGE(TAG, "Bad connect value: '%s'", response.c_str());
}
@@ -178,24 +189,26 @@ void Nextion::dump_config() {
#ifdef USE_NEXTION_CONFIG_SKIP_CONNECTION_HANDSHAKE
ESP_LOGCONFIG(TAG, " Skip handshake: YES");
#else // USE_NEXTION_CONFIG_SKIP_CONNECTION_HANDSHAKE
#ifdef USE_NEXTION_CONFIG_DUMP_DEVICE_INFO
if (this->is_setup()) {
ESP_LOGCONFIG(TAG,
" Device Model: %s\n"
" FW Version: %s\n"
" Serial Number: %s\n"
" Flash Size: %" PRIu32 " bytes",
this->device_model_, this->firmware_version_, this->serial_number_, this->flash_size_);
} else {
ESP_LOGCONFIG(TAG, " Device info: not yet detected");
}
ESP_LOGCONFIG(TAG,
" Device Model: %s\n"
" FW Version: %s\n"
" Serial Number: %s\n"
" Flash Size: %s\n"
" Max queue age: %u ms\n"
" Startup override: %u ms\n",
this->device_model_.c_str(), this->firmware_version_.c_str(), this->serial_number_.c_str(),
this->flash_size_.c_str(), this->max_q_age_ms_, this->startup_override_ms_);
#endif // USE_NEXTION_CONFIG_DUMP_DEVICE_INFO
#ifdef USE_NEXTION_CONFIG_EXIT_REPARSE_ON_START
ESP_LOGCONFIG(TAG, " Exit reparse: YES\n");
" Exit reparse: YES\n"
#endif // USE_NEXTION_CONFIG_EXIT_REPARSE_ON_START
ESP_LOGCONFIG(TAG,
" Max queue age: %u ms\n"
" Startup override: %u ms\n"
" Wake On Touch: %s\n"
" Touch Timeout: %" PRIu16,
YESNO(this->connection_state_.auto_wake_on_touch_), this->touch_sleep_timeout_);
this->max_q_age_ms_, this->startup_override_ms_, YESNO(this->connection_state_.auto_wake_on_touch_),
this->touch_sleep_timeout_);
#endif // USE_NEXTION_CONFIG_SKIP_CONNECTION_HANDSHAKE
#ifdef USE_NEXTION_MAX_COMMANDS_PER_LOOP
+9 -6
View File
@@ -1610,12 +1610,15 @@ class Nextion : public NextionBase, public PollingComponent, public uart::UARTDe
nextion_writer_t writer_;
optional<float> brightness_;
#ifdef USE_NEXTION_CONFIG_DUMP_DEVICE_INFO
std::string device_model_;
std::string firmware_version_;
std::string serial_number_;
std::string flash_size_;
#endif // USE_NEXTION_CONFIG_DUMP_DEVICE_INFO
// Device info populated from comok response (fixed-size, no heap allocation).
// Sizes derived from Nextion Upload Protocol documentation and observed hardware.
static constexpr size_t NEXTION_MODEL_MAX = 24; ///< Max observed ~18 chars from product numbering rules
static constexpr size_t NEXTION_FW_MAX = 7; ///< 'S' prefix + integer (e.g. 'S99' or `123`)
static constexpr size_t NEXTION_SERIAL_MAX = 20; ///< Consistently 16 hex chars across all documented examples
char device_model_[NEXTION_MODEL_MAX + 1]{};
char firmware_version_[NEXTION_FW_MAX + 1]{};
char serial_number_[NEXTION_SERIAL_MAX + 1]{};
uint32_t flash_size_ = 0; ///< Flash size in bytes — plain integer, no string needed
void remove_front_no_sensors_();
+7 -35
View File
@@ -215,7 +215,7 @@ def _process_remote_package(config: dict[str, Any]) -> dict[str, Any]:
If loading fails after cloning, attempts a revert and retry in case
a prior cached checkout is stale.
"""
repo_root, revert = git.clone_or_update(
repo_dir, revert = git.clone_or_update(
url=config[CONF_URL],
ref=config.get(CONF_REF),
refresh=config[CONF_REFRESH],
@@ -225,10 +225,6 @@ def _process_remote_package(config: dict[str, Any]) -> dict[str, Any]:
)
files: list[dict[str, Any]] = []
# ``repo_root`` is the directory containing ``.git`` and must be passed
# to git for symlink-stub resolution. ``repo_dir`` may be narrowed to a
# subdirectory via the user's CONF_PATH and is used for file lookups.
repo_dir = repo_root
if base_path := config.get(CONF_PATH):
repo_dir = repo_dir / base_path
@@ -240,37 +236,13 @@ def _process_remote_package(config: dict[str, Any]) -> dict[str, Any]:
def _load_package_yaml(yaml_file: Path, filename: str) -> dict:
"""Load a YAML file from a remote package, validating min_version."""
def _load(path: Path) -> dict | str | None:
try:
return yaml_util.load_yaml(path)
except EsphomeError as e:
raise cv.Invalid(
f"{filename} is not a valid YAML file."
f" Please check the file contents.\n{e}"
) from e
new_yaml = _load(yaml_file)
if not isinstance(new_yaml, dict):
# On Windows, git defaults to core.symlinks=false unless the user
# has Developer Mode enabled or is running elevated. Files stored
# in the repo as symlinks (tree mode 120000) are then checked out
# as plain text files containing the symlink target path, so
# parsing them as YAML yields a bare scalar instead of a mapping.
# Best-effort: follow the symlink target ourselves and re-load.
target = git.resolve_symlink_stub(repo_root, yaml_file)
if target is not None:
new_yaml = _load(target)
if not isinstance(new_yaml, dict):
try:
new_yaml = yaml_util.load_yaml(yaml_file)
except EsphomeError as e:
raise cv.Invalid(
f"{filename} does not contain a YAML mapping at the top level "
f"(got {type(new_yaml).__name__}). "
f"If this file is a git symlink in the source repository, it "
f"may not have been materialized correctly on your platform "
f"(this is a known issue with git on Windows without Developer "
f"Mode enabled). Try pointing your package at the real file "
f"path instead."
)
f"{filename} is not a valid YAML file."
f" Please check the file contents.\n{e}"
) from e
esphome_config = new_yaml.get(CONF_ESPHOME) or {}
min_version = esphome_config.get(CONF_MIN_VERSION)
if min_version is not None and cv.Version.parse(min_version) > cv.Version.parse(
+2
View File
@@ -25,6 +25,7 @@ from esphome.const import (
CONF_TEMPERATURE_COMPENSATION,
CONF_TIME_CONSTANT,
CONF_VOC,
DEVICE_CLASS_AQI,
DEVICE_CLASS_HUMIDITY,
DEVICE_CLASS_PM1,
DEVICE_CLASS_PM10,
@@ -76,6 +77,7 @@ def _gas_sensor(
return sensor.sensor_schema(
icon=ICON_RADIATOR,
accuracy_decimals=0,
device_class=DEVICE_CLASS_AQI,
state_class=STATE_CLASS_MEASUREMENT,
).extend(
{
+3
View File
@@ -14,6 +14,7 @@ from esphome.const import (
CONF_TEMPERATURE,
CONF_TYPE,
CONF_VOC,
DEVICE_CLASS_AQI,
DEVICE_CLASS_CARBON_DIOXIDE,
DEVICE_CLASS_HUMIDITY,
DEVICE_CLASS_PM1,
@@ -92,11 +93,13 @@ CONFIG_SCHEMA = (
cv.Optional(CONF_VOC): sensor.sensor_schema(
icon=ICON_RADIATOR,
accuracy_decimals=0,
device_class=DEVICE_CLASS_AQI,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_NOX): sensor.sensor_schema(
icon=ICON_RADIATOR,
accuracy_decimals=0,
device_class=DEVICE_CLASS_AQI,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_CO2): sensor.sensor_schema(
+1 -1
View File
@@ -206,7 +206,7 @@ async def to_code(config: ConfigType) -> None:
)
# sendspin-cpp library
esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.6.1")
esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.5.0")
cg.add_define("USE_SENDSPIN", True) # for MDNS
+3
View File
@@ -15,6 +15,7 @@ from esphome.const import (
CONF_STORE_BASELINE,
CONF_TEMPERATURE_SOURCE,
CONF_VOC,
DEVICE_CLASS_AQI,
ICON_RADIATOR,
STATE_CLASS_MEASUREMENT,
)
@@ -71,11 +72,13 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(CONF_VOC): sensor.sensor_schema(
icon=ICON_RADIATOR,
accuracy_decimals=0,
device_class=DEVICE_CLASS_AQI,
state_class=STATE_CLASS_MEASUREMENT,
).extend(VOC_SENSOR),
cv.Optional(CONF_NOX): sensor.sensor_schema(
icon=ICON_RADIATOR,
accuracy_decimals=0,
device_class=DEVICE_CLASS_AQI,
state_class=STATE_CLASS_MEASUREMENT,
).extend(NOX_SENSOR),
cv.Optional(CONF_STORE_BASELINE, default=True): cv.boolean,
+2 -2
View File
@@ -16,7 +16,7 @@ GPIOPin *const NullPin::NULL_PIN = new NullPin(); // NOLINT(cppcoreguidelines-a
SPIDelegate *SPIComponent::register_device(SPIClient *device, SPIMode mode, SPIBitOrder bit_order, uint32_t data_rate,
GPIOPin *cs_pin, bool release_device, bool write_only) {
if (this->devices_.count(device) != 0) {
if (this->devices_.contains(device)) {
ESP_LOGE(TAG, "Device already registered");
return this->devices_[device];
}
@@ -27,7 +27,7 @@ SPIDelegate *SPIComponent::register_device(SPIClient *device, SPIMode mode, SPIB
}
void SPIComponent::unregister_device(SPIClient *device) {
if (this->devices_.count(device) == 0) {
if (!this->devices_.contains(device)) {
esph_log_e(TAG, "Device not registered");
return;
}
+7 -7
View File
@@ -30,8 +30,8 @@ static constexpr uint8_t OCP_140MA = 0x38; // 140 mA max current
static constexpr float LOW_DATA_RATE_OPTIMIZE_THRESHOLD = 16.38f; // 16.38 ms
uint8_t SX126x::read_fifo_(uint8_t offset, std::vector<uint8_t> &packet) {
this->enable();
this->wait_busy_();
this->enable();
this->transfer_byte(RADIO_READ_BUFFER);
this->transfer_byte(offset);
uint8_t status = this->transfer_byte(0x00);
@@ -43,8 +43,8 @@ uint8_t SX126x::read_fifo_(uint8_t offset, std::vector<uint8_t> &packet) {
}
void SX126x::write_fifo_(uint8_t offset, const std::vector<uint8_t> &packet) {
this->enable();
this->wait_busy_();
this->enable();
this->transfer_byte(RADIO_WRITE_BUFFER);
this->transfer_byte(offset);
for (const uint8_t &byte : packet) {
@@ -55,8 +55,8 @@ void SX126x::write_fifo_(uint8_t offset, const std::vector<uint8_t> &packet) {
}
uint8_t SX126x::read_opcode_(uint8_t opcode, uint8_t *data, uint8_t size) {
this->enable();
this->wait_busy_();
this->enable();
this->transfer_byte(opcode);
uint8_t status = this->transfer_byte(0x00);
for (int32_t i = 0; i < size; i++) {
@@ -67,8 +67,8 @@ uint8_t SX126x::read_opcode_(uint8_t opcode, uint8_t *data, uint8_t size) {
}
void SX126x::write_opcode_(uint8_t opcode, uint8_t *data, uint8_t size) {
this->enable();
this->wait_busy_();
this->enable();
this->transfer_byte(opcode);
for (int32_t i = 0; i < size; i++) {
this->transfer_byte(data[i]);
@@ -78,8 +78,8 @@ void SX126x::write_opcode_(uint8_t opcode, uint8_t *data, uint8_t size) {
}
void SX126x::read_register_(uint16_t reg, uint8_t *data, uint8_t size) {
this->enable();
this->wait_busy_();
this->enable();
this->write_byte(RADIO_READ_REGISTER);
this->write_byte((reg >> 8) & 0xFF);
this->write_byte((reg >> 0) & 0xFF);
@@ -91,8 +91,8 @@ void SX126x::read_register_(uint16_t reg, uint8_t *data, uint8_t size) {
}
void SX126x::write_register_(uint16_t reg, uint8_t *data, uint8_t size) {
this->enable();
this->wait_busy_();
this->enable();
this->write_byte(RADIO_WRITE_REGISTER);
this->write_byte((reg >> 8) & 0xFF);
this->write_byte((reg >> 0) & 0xFF);
@@ -394,7 +394,7 @@ void SX126x::run_image_cal() {
buf[1] = 0xE9;
} else if (this->frequency_ > 850000000) {
buf[0] = 0xD7;
buf[1] = 0xDB;
buf[1] = 0xD8;
} else if (this->frequency_ > 770000000) {
buf[0] = 0xC1;
buf[1] = 0xC5;
@@ -78,7 +78,7 @@ void Touchscreen::add_raw_touch_position_(uint8_t id, int16_t x_raw, int16_t y_r
if (this->swap_x_y_) {
std::swap(x_raw, y_raw);
}
if (this->touches_.count(id) == 0) {
if (!this->touches_.contains(id)) {
tp.state = STATE_PRESSED;
tp.id = id;
} else {
+7 -9
View File
@@ -206,17 +206,15 @@ void Tuya::handle_command_(uint8_t command, uint8_t version, const uint8_t *buff
if (this->status_pin_reported_ != -1) {
this->init_state_ = TuyaInitState::INIT_DATAPOINT;
this->send_empty_command_(TuyaCommandType::DATAPOINT_QUERY);
if (this->status_pin_ != nullptr) {
if (this->status_pin_->get_pin() != this->status_pin_reported_) {
ESP_LOGW(TAG, "Supplied status_pin does not equal the reported pin %i. Using supplied pin anyway.",
this->status_pin_reported_);
}
ESP_LOGV(TAG, "Configured status pin %i", this->status_pin_->get_pin());
this->set_interval("wifi", 1000, [this] { this->set_status_pin_(); });
} else {
ESP_LOGW(TAG, "MCU reported status_pin %i but no status_pin was configured; running in limited mode.",
bool is_pin_equals =
this->status_pin_ != nullptr && this->status_pin_->get_pin() == this->status_pin_reported_;
// Configure status pin toggling (if reported and configured) or WIFI_STATE periodic send
if (!is_pin_equals) {
ESP_LOGW(TAG, "Supplied status_pin does not equals the reported pin %i. Using supplied pin anyway.",
this->status_pin_reported_);
}
ESP_LOGV(TAG, "Configured status pin %i", this->status_pin_->get_pin());
this->set_interval("wifi", 1000, [this] { this->set_status_pin_(); });
} else {
this->init_state_ = TuyaInitState::INIT_WIFI;
ESP_LOGV(TAG, "Configured WIFI_STATE periodic send");
+4 -5
View File
@@ -513,11 +513,10 @@ async def uart_write_to_code(config, action_id, template_arg, args):
@coroutine_with_priority(CoroPriority.FINAL)
async def final_step():
"""Final code generation step to configure optional UART features."""
if (CORE.is_esp32 or CORE.is_esp8266) and CORE.has_networking:
# Wake-on-RX is essentially free (just an ISR function pointer
# registration on ESP32, an inline flag set on ESP8266 software
# serial) — enable by default to reduce RX buffer overflow risk by
# waking the main loop immediately when data arrives.
if CORE.is_esp32 and CORE.has_networking:
# Wake-on-RX is essentially free on ESP32 (just an ISR function pointer
# registration) — enable by default to reduce RX buffer overflow risk
# by waking the main loop immediately when data arrives.
cg.add_define("USE_UART_WAKE_LOOP_ON_RX")
@@ -4,9 +4,6 @@
#include "esphome/core/defines.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#ifdef USE_UART_WAKE_LOOP_ON_RX
#include "esphome/core/wake.h"
#endif
#ifdef USE_LOGGER
#include "esphome/components/logger/logger.h"
@@ -152,11 +149,7 @@ void ESP8266UartComponent::dump_config() {
if (this->hw_serial_ != nullptr) {
ESP_LOGCONFIG(TAG, " Using hardware serial interface.");
} else {
ESP_LOGCONFIG(TAG, " Using software serial"
#ifdef USE_UART_WAKE_LOOP_ON_RX
"\n Wake on data RX: ENABLED"
#endif
);
ESP_LOGCONFIG(TAG, " Using software serial");
}
this->check_logger_conflict();
}
@@ -273,12 +266,6 @@ void IRAM_ATTR ESP8266SoftwareSerial::gpio_intr(ESP8266SoftwareSerial *arg) {
arg->rx_in_pos_ = (arg->rx_in_pos_ + 1) % arg->rx_buffer_size_;
// Clear RX pin so that the interrupt doesn't re-trigger right away again.
arg->rx_pin_.clear_interrupt();
#ifdef USE_UART_WAKE_LOOP_ON_RX
// Wake the main loop so the consuming component drains the byte promptly
// instead of waiting for the next loop_interval_ tick. Important for timing
// sensitive setups that poll read() in a tight loop (e.g. fingerprint_grow).
wake_loop_isrsafe();
#endif
}
void IRAM_ATTR HOT ESP8266SoftwareSerial::write_byte(uint8_t data) {
if (this->gpio_tx_pin_ == nullptr) {
@@ -154,7 +154,7 @@ bool UponorSmatrixComponent::parse_byte_(uint8_t byte) {
}
// Log unknown device addresses
if (!found && !this->unknown_devices_.count(device_address)) {
if (!found && !this->unknown_devices_.contains(device_address)) {
ESP_LOGI(TAG, "Received packet for unknown device address 0x%08" PRIX32 " ", device_address);
this->unknown_devices_.insert(device_address);
}
+2 -2
View File
@@ -2638,9 +2638,9 @@ bool WebServer::isRequestHandlerTrivial() const { return false; }
void WebServer::add_sorting_info_(JsonObject &root, EntityBase *entity) {
#ifdef USE_WEBSERVER_SORTING
if (this->sorting_entitys_.find(entity) != this->sorting_entitys_.end()) {
if (this->sorting_entitys_.contains(entity)) {
root[ESPHOME_F("sorting_weight")] = this->sorting_entitys_[entity].weight;
if (this->sorting_groups_.find(this->sorting_entitys_[entity].group_id) != this->sorting_groups_.end()) {
if (this->sorting_groups_.contains(this->sorting_entitys_[entity].group_id)) {
root[ESPHOME_F("sorting_group")] = this->sorting_groups_[this->sorting_entitys_[entity].group_id].name;
}
}
+1 -51
View File
@@ -54,18 +54,10 @@ from esphome.const import (
CONF_TTLS_PHASE_2,
CONF_USE_ADDRESS,
CONF_USERNAME,
CONF_WIFI,
PLACEHOLDER_WIFI_SSID,
Platform,
PlatformFramework,
)
from esphome.core import (
CORE,
CoroPriority,
EsphomeError,
HexInt,
coroutine_with_priority,
)
from esphome.core import CORE, CoroPriority, HexInt, coroutine_with_priority
import esphome.final_validate as fv
from esphome.types import ConfigType
@@ -911,45 +903,3 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform(
"wifi_component_pico_w.cpp": {PlatformFramework.RP2040_ARDUINO},
}
)
def _placeholder_wifi_credentials(config: ConfigType) -> list[str]:
"""Return human-readable locations where the dashboard's placeholder wifi
values still appear. Empty list means no placeholders were found.
"""
placeholders: list[str] = []
wifi_conf = config.get(CONF_WIFI)
if not wifi_conf:
return placeholders
for idx, network in enumerate(wifi_conf.get(CONF_NETWORKS, [])):
ssid = network.get(CONF_SSID)
if isinstance(ssid, str) and ssid == PLACEHOLDER_WIFI_SSID:
placeholders.append(f"wifi.networks[{idx}].ssid")
ap_conf = wifi_conf.get(CONF_AP)
if ap_conf:
ap_ssid = ap_conf.get(CONF_SSID)
if isinstance(ap_ssid, str) and ap_ssid == PLACEHOLDER_WIFI_SSID:
placeholders.append("wifi.ap.ssid")
return placeholders
def check_placeholder_credentials(config: ConfigType) -> None:
"""Raise EsphomeError if any wifi credential is the dashboard placeholder.
Call only at compile time. NEVER from CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA,
or any path reached by `esphome config`; device-builder relies on
validation passing with the placeholders still in place.
"""
locations = _placeholder_wifi_credentials(config)
if not locations:
return
formatted = ", ".join(locations)
raise EsphomeError(
f"wifi configuration still contains the dashboard placeholder value "
f"'{PLACEHOLDER_WIFI_SSID}' at: {formatted}. "
f"Open secrets.yaml and replace 'wifi_ssid' (and 'wifi_password') "
f"with your real wifi credentials before flashing."
)
-2
View File
@@ -50,8 +50,6 @@ _LOGGER = logging.getLogger(__name__)
CODEOWNERS = ["@luar123", "@tomaszduda23"]
CONFLICTS_WITH = ["openthread"]
BASE_SCHEMA = cv.Schema(
{
cv.Optional(CONF_REPORT): cv.All(
+7 -3
View File
@@ -117,11 +117,15 @@ def final_validate_esp32(config: ConfigType) -> ConfigType:
if not CORE.is_esp32:
return config
if CONF_WIFI in fv.full_config.get():
if CONF_AP in fv.full_config.get()[CONF_WIFI]:
if config[CONF_ROUTER] and CONF_AP in fv.full_config.get()[CONF_WIFI]:
raise cv.Invalid(
"A Wifi Access Point can not be used together with Zigbee."
"Only Zigbee End Device can be used together with a Wifi Access Point."
)
if config[CONF_ROUTER]:
if CONF_AP in fv.full_config.get()[CONF_WIFI]:
_LOGGER.warning(
"Wifi Access Point might be unstable while Zigbee is active, use only as fallback."
)
elif config[CONF_ROUTER]:
_LOGGER.warning(
"The Zigbee Router might miss packets while Wifi is active and could destabilize "
"your network. Use only if Wifi is off most of the time."
+1 -10
View File
@@ -4,7 +4,7 @@ from enum import Enum
from esphome.enum import StrEnum
__version__ = "2026.5.2"
__version__ = "2026.6.0-dev"
ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_"
VALID_SUBSTITUTIONS_CHARACTERS = (
@@ -1415,12 +1415,3 @@ ENTITY_CATEGORY_DIAGNOSTIC = "diagnostic"
# The corresponding constant exists in c++
# when update_interval is set to never, it becomes SCHEDULER_DONT_RUN milliseconds
SCHEDULER_DONT_RUN = 4294967295
# Sentinel values written by the esphome-device-builder dashboard into
# secrets.yaml on first boot so that !secret wifi_ssid / !secret wifi_password
# references resolve cleanly through validation before the user has finished
# the onboarding wizard. Compilation refuses if these reach the binary so that
# a user who dismisses onboarding can't accidentally flash a device that will
# never associate with their wifi.
PLACEHOLDER_WIFI_SSID = "REPLACE_WITH_YOUR_WIFI_NETWORK"
PLACEHOLDER_WIFI_PASSWORD = "REPLACE_WITH_YOUR_WIFI_PASSWORD" # noqa: S105
-1
View File
@@ -711,7 +711,6 @@ async def to_code(config: ConfigType) -> None:
# Process areas
all_areas: list[dict[str, str | core.ID]] = []
if CONF_AREA in config:
CORE.area = config[CONF_AREA][CONF_NAME]
all_areas.append(config[CONF_AREA])
all_areas.extend(config[CONF_AREAS])
-1
View File
@@ -134,7 +134,6 @@
#define USE_MEDIA_SOURCE
#define USE_NEXTION_COMMAND_SPACING
#define USE_NEXTION_CONF_START_UP_PAGE
#define USE_NEXTION_CONFIG_DUMP_DEVICE_INFO
#define USE_NEXTION_CONFIG_EXIT_REPARSE_ON_START
#define USE_NEXTION_CONFIG_SKIP_CONNECTION_HANDSHAKE
#define USE_NEXTION_MAX_COMMANDS_PER_LOOP
+61 -38
View File
@@ -93,7 +93,7 @@ class URLSource(Source):
class GitSource(Source):
def __init__(self, url: str, ref: str | None):
def __init__(self, url: str, ref: str):
self.url = url
self.ref = ref
@@ -109,7 +109,7 @@ class GitSource(Source):
return path
def __str__(self):
return f"{self.url}#{self.ref}" if self.ref else self.url
return f"{self.url}#{self.ref}"
class InvalidIDFComponent(Exception):
@@ -154,6 +154,41 @@ class IDFComponent:
self.path = self.source.download(self.get_sanitized_name(), force=force)
def _sanitize_version(version: str) -> str:
"""
Sanitize a version string by removing common requirement prefixes or a leading v.
Args:
version: Version string to clean.
Returns:
Cleaned version string without common requirement symbols.
"""
version = version.strip()
prefixes = (
"^",
"~=",
"~",
">=",
"<=",
"==",
"!=",
">",
"<",
"=",
"v",
"V",
)
for p in prefixes:
if version.startswith(p):
version = version[len(p) :]
break
return version.strip()
def _get_package_from_pio_registry(
username: str | None, pkgname: str, requirements: str
) -> tuple[str, str, str | None, str | None]:
@@ -352,6 +387,7 @@ def _convert_library_to_component(library: Library) -> IDFComponent:
IDFComponent: The resolved component with name, version, and URL
Raises:
ValueError: If a repository URL is missing a reference (#)
RuntimeError: If no artifact can be found for the library
"""
name = None
@@ -360,25 +396,20 @@ def _convert_library_to_component(library: Library) -> IDFComponent:
# Repository is provided directly
if library.repository:
# Parse repository URL: path becomes the component name, fragment
# (if any) becomes the git ref stored on GitSource. A missing
# fragment is fine -- clone_or_update leaves the depth-1 clone on
# the remote's default branch, matching PIO's lib_deps behavior
# and external_components handling.
# Parse repository URL to extract name and version
split_result = urlsplit(library.repository)
if not split_result.fragment.strip():
raise ValueError(f"Missing ref in URL {library.repository}")
# Sanitize name
name = str(split_result.path).strip("/")
name = name.removesuffix(".git")
# IDF Component Manager only accepts "*", a 40-char commit hash, or
# semver here. The actual git ref is preserved in GitSource.ref;
# override_path makes this field cosmetic at build time.
version = "*"
# Sanitize version
version = _sanitize_version(split_result.fragment)
repository = urlunsplit(split_result._replace(fragment=""))
ref = split_result.fragment.strip() or None
source = GitSource(str(repository), ref)
source = GitSource(str(repository), split_result.fragment)
# Version is provided - resolve using PlatformIO registry
elif library.version:
@@ -588,6 +619,9 @@ def generate_idf_component_yml(component: IDFComponent) -> str:
if description:
data["description"] = description
# Do not use the version from library.json/library.properties; it may be incorrect.
data["version"] = component.version
repository = component.data.get("repository", {}).get("url", None)
if repository:
data["repository"] = repository
@@ -597,11 +631,20 @@ def generate_idf_component_yml(component: IDFComponent) -> str:
if "dependencies" not in data:
data["dependencies"] = {}
# Every dependency goes through _generate_idf_component →
# component.download() before this runs, so .path is always set.
data["dependencies"][dependency.get_sanitized_name()] = {
"override_path": str(dependency.path),
}
# Add this dependency to dependencies
dep = {}
dep["version"] = dependency.version
# Should use dependency.path as override path
try:
dep["override_path"] = str(dependency.path)
except RuntimeError as e:
# No local path: only a GitSource can substitute its URL.
if not isinstance(dependency.source, GitSource):
raise e
dep["git"] = dependency.source.url
data["dependencies"][dependency.get_sanitized_name()] = dep
return yaml_util.dump(data)
@@ -656,26 +699,6 @@ def _process_dependencies(component: IDFComponent):
if not dependencies:
return
# PIO's library.json accepts both the list-of-dicts form and the
# shorthand dict form ``{"owner/Name": "version_spec"}``. Normalize
# the dict form so the loop below sees a uniform list. Iterating a
# dict gives string keys, which would silently fail the
# ``"name" in dependency`` substring check and skip every entry.
if isinstance(dependencies, dict):
normalized = []
for raw_name, spec in dependencies.items():
if "/" in raw_name:
owner, pkgname = raw_name.split("/", 1)
else:
owner, pkgname = None, raw_name
entry = {"name": pkgname, "owner": owner}
if isinstance(spec, dict):
entry.update(spec)
else:
entry["version"] = spec
normalized.append(entry)
dependencies = normalized
_LOGGER.info("Processing %s@%s component dependencies...", name, version)
for dependency in dependencies:
# Validate dependency structure
+11 -132
View File
@@ -7,7 +7,6 @@ import json
import logging
import os
from pathlib import Path
import platform
import shutil
import subprocess
import sys
@@ -18,7 +17,7 @@ import requests
from esphome.config_validation import Version
from esphome.core import CORE
from esphome.helpers import ProgressBar, get_str_env, rmtree, write_file_if_changed
from esphome.helpers import ProgressBar, get_str_env, rmtree
PathType = str | os.PathLike
@@ -70,7 +69,7 @@ ESPHOME_IDF_DEFAULT_FEATURES = _str_to_lst_of_str(
ESPHOME_IDF_FRAMEWORK_MIRRORS = _str_to_lst_of_str(
os.environ.get(
"ESPHOME_IDF_FRAMEWORK_MIRRORS",
"https://github.com/esphome-libs/esp-idf/releases/download/v{VERSION}/esp-idf-v{VERSION}.tar.xz;https://github.com/esphome-libs/esp-idf/releases/download/v{MAJOR}.{MINOR}/esp-idf-v{MAJOR}.{MINOR}.tar.xz",
"https://github.com/espressif/esp-idf/releases/download/v{VERSION}/esp-idf-v{VERSION}.zip;https://github.com/espressif/esp-idf/releases/download/v{MAJOR}.{MINOR}/esp-idf-v{MAJOR}.{MINOR}.zip",
)
)
@@ -547,11 +546,11 @@ def _tar_extract_all(
if not (mode & stat.S_IXUSR):
mode &= ~(stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
mode |= stat.S_IRUSR | stat.S_IWUSR
elif not (member.isdir() or member.issym()):
# Block special files. Directories and symlinks keep
# their masked-original mode — passing None here would
# crash tarfile.extract on Python <3.12 (its chmod
# path calls os.chmod unconditionally).
elif member.isdir() or member.issym():
# Ignore mode for directories & symlinks
mode = None
else:
# Block special files
continue
member.mode = mode
@@ -781,109 +780,12 @@ def download_from_mirrors(
return None
def _write_idf_version_txt(framework_path: Path, version: str) -> None:
"""Write <framework_path>/version.txt if missing.
IDF's build.cmake picks the version it embeds in the firmware (and
stamps onto the bootloader) in this order: ``${IDF_PATH}/version.txt``
if present, else ``git describe`` against IDF_PATH, else the
``IDF_VERSION_MAJOR/MINOR/PATCH`` triplet from ``tools/cmake/version.cmake``.
On a clean esphome-libs tarball ``.git`` is fully stripped, so
git_describe returns ``HEAD-HASH-NOTFOUND`` (falsy) and the triplet
wins -- correct by luck. But a *partial* ``.git`` (e.g. a custom
framework.source pointed at a real git URL where build artifacts
mark the tree dirty) makes git_describe return ``<hash>-dirty``,
which is what then gets baked into the bootloader. Dropping
version.txt forces the right answer regardless.
"""
version_txt = framework_path / "version.txt"
if version_txt.exists():
return
try:
version_txt.write_text(f"v{version}\n", encoding="utf-8")
except OSError as e:
_LOGGER.warning(
"Could not write %s (%s); bootloader version string may be incorrect.",
version_txt,
e,
)
# Backport of espressif/esp-idf#18272: every ESPHome-supported IDF release
# through v6.0 ships a tools.json whose ninja 1.12.1 entry has no
# ``linux-arm64`` source. ``idf_tools.py`` then either fails to find a
# matching binary or grabs the x86_64 one, which can't execute on
# aarch64. cmake is already populated across the same release range; we
# only need to inject ninja. Values lifted verbatim from the IDF v6.0.1
# tools.json where the fix landed natively.
_NINJA_ARM64_BACKPORT: dict[str, dict[str, str | int]] = {
"1.12.1": {
"rename_dist": "ninja-linux-arm64-v1.12.1.zip",
"sha256": "5c25c6570b0155e95fce5918cb95f1ad9870df5768653afe128db822301a05a1",
"size": 121787,
"url": "https://github.com/ninja-build/ninja/releases/download/v1.12.1/ninja-linux-aarch64.zip",
},
}
def _patch_tools_json_for_linux_arm64(framework_path: Path) -> None:
"""Inject ninja linux-arm64 entries into the framework's tools.json on aarch64.
Idempotent: a tools.json that already has the entry, or a host that
isn't aarch64, is a no-op. Applied unconditionally on every install
check so a build dir extracted before the backport got fixed up
without forcing a clean.
"""
if platform.machine() != "aarch64":
return
tools_json = framework_path / "tools" / "tools.json"
if not tools_json.is_file():
return
try:
with open(tools_json, encoding="utf-8") as f:
data = json.load(f)
except (json.JSONDecodeError, OSError) as e:
_LOGGER.warning(
"Could not parse %s for linux-arm64 backport (%s); "
"skipping. A clean reinstall of the framework directory "
"may be needed.",
tools_json,
e,
)
return
changed = False
for tool in data.get("tools", []):
if tool.get("name") != "ninja":
continue
for ver in tool.get("versions", []):
entry = _NINJA_ARM64_BACKPORT.get(ver.get("name"))
if entry is None or ver.get("linux-arm64"):
continue
ver["linux-arm64"] = entry
changed = True
if changed:
# write_file_if_changed stages a tempfile in the destination dir
# and atomically replaces — safe against mid-write interruption
# and concurrent invocations.
write_file_if_changed(tools_json, json.dumps(data, indent=2) + "\n")
_LOGGER.info(
"Patched %s to add ninja linux-arm64 download "
"(espressif/esp-idf#18272 backport).",
tools_json,
)
def _check_esphome_idf_framework_install(
version: str,
targets: list[str],
tools: list[str],
force: bool = False,
env: dict[str, str] | None = None,
source_url: str | None = None,
) -> tuple[Path, bool]:
"""
Check and install ESP-IDF framework.
@@ -894,11 +796,6 @@ def _check_esphome_idf_framework_install(
tools: list of tools to install
force: If True, force reinstallation
env: Optional dictionary of environment variables to set
source_url: Optional override URL for the framework tarball. Supports
the same ``{VERSION}`` / ``{MAJOR}`` / ``{MINOR}`` / ``{PATCH}`` /
``{EXTRA}`` substitutions as ESPHOME_IDF_FRAMEWORK_MIRRORS. When
set, it replaces the default mirror list no implicit fallback,
so a misspelled URL fails loudly.
Returns:
tuple of (framework_path, install_flag)
@@ -920,10 +817,6 @@ def _check_esphome_idf_framework_install(
env_stamp_file = framework_path / ESPHOME_STAMP_FILE
idf_tools_path = framework_path / "tools" / "idf_tools.py"
_LOGGER.info("Checking ESP-IDF %s framework ...", version)
# Logged every invocation (not just on install) so the user can verify the
# override. A changed URL needs ``esphome clean`` to force a re-download.
if source_url:
_LOGGER.info("Using framework source override: %s", source_url)
# 2. Download and extract the framework if not already extracted.
# The marker is written last after extraction succeeds, so its presence
@@ -951,23 +844,14 @@ def _check_esphome_idf_framework_install(
except ValueError:
pass
mirrors = [source_url] if source_url else ESPHOME_IDF_FRAMEWORK_MIRRORS
download_from_mirrors(mirrors, substitutions, tmp.file)
download_from_mirrors(
ESPHOME_IDF_FRAMEWORK_MIRRORS, substitutions, tmp.file
)
_LOGGER.info("Extracting ESP-IDF %s framework ...", version)
archive_extract_all(tmp.file, framework_path, progress_header="Extracting")
extracted_marker.touch()
# Idempotent post-extract patch: written every invocation so a build
# dir extracted before this fix gets the file too, without forcing a
# clean. Skips when version.txt already exists.
_write_idf_version_txt(framework_path, version)
# Apply the ninja linux-arm64 backport on every invocation, not just on
# fresh extracts — idempotent and cheap, and lets a build dir carrying
# a pre-patch tools.json get fixed up without forcing a clean.
_patch_tools_json_for_linux_arm64(framework_path)
# 3. Check if the framework tools are the same and correctly installed
if not install:
install = True
@@ -1124,7 +1008,6 @@ def check_esp_idf_install(
tools: list[str] | None = None,
features: list[str] | None = None,
force: bool = False,
source_url: str | None = None,
) -> tuple[Path, Path]:
"""
Check and install ESP-IDF framework and Python environment.
@@ -1135,10 +1018,6 @@ def check_esp_idf_install(
tools: list of tools to install
features: Features to install
force: If True, force reinstallation
source_url: Optional override URL for the framework tarball. When
set, it replaces the default mirror list (no fallback). Forwarded
to ``_check_esphome_idf_framework_install``; supports the same URL
substitutions.
Returns:
tuple of (framework_path, python_env_path)
@@ -1161,7 +1040,7 @@ def check_esp_idf_install(
# 1) Framework
framework_path, installed = _check_esphome_idf_framework_install(
version, targets, tools, force=force, env=env, source_url=source_url
version, targets, tools, force=force, env=env
)
features = features or ESPHOME_IDF_DEFAULT_FEATURES
-6
View File
@@ -66,12 +66,6 @@ FILTER_IDF_LINES: list[str] = [
# Drop the blank line rich emits after the note so the build log
# doesn't end with an orphan gap before ESPHome's own status lines.
r"\s*$",
# ESP-IDF shells out to ``git rev-parse`` to embed a commit hash;
# esphome-libs strips ``.git`` from the tarball so those probes fail
# noisily without affecting the build.
r"-- git rev-parse returned ",
r"fatal: not a git repository",
r"Stopping at filesystem boundary",
]
+3 -4
View File
@@ -94,10 +94,9 @@ def print_summary(size_json: Path, partitions_csv: Path | None) -> None:
_LOGGER.debug("Skipping size summary: %s", e)
return
memory_types = data.get("memory_types", {})
ram_region = memory_types.get("DRAM") or memory_types.get("DIRAM") or {}
ram_used = ram_region.get("used")
ram_total = ram_region.get("size")
dram = data.get("memory_types", {}).get("DRAM") or {}
ram_used = dram.get("used")
ram_total = dram.get("size")
if ram_total and ram_used is not None:
print(f"RAM: {_format_bar(ram_used, ram_total)}")
+1 -16
View File
@@ -10,7 +10,6 @@ import shutil
import subprocess
from esphome.components.esp32.const import KEY_ESP32, KEY_FLASH_SIZE, KEY_IDF_VERSION
from esphome.const import CONF_FRAMEWORK, CONF_SOURCE
from esphome.core import CORE, EsphomeError
from esphome.espidf.framework import check_esp_idf_install, get_framework_env
from esphome.espidf.size_summary import print_summary
@@ -38,27 +37,13 @@ def _get_core_framework_version():
return str(CORE.data[KEY_ESP32][KEY_IDF_VERSION])
def _get_framework_source_override() -> str | None:
"""Return the user-supplied esp32.framework.source override, if any.
The override lets a user point the IDF tarball download at a custom URL
(mirror, fork, local server). Substitutions like ``{VERSION}`` /
``{MAJOR}`` etc. work the same as in the default mirror list.
"""
if CORE.config is None:
return None
return CORE.config.get(KEY_ESP32, {}).get(CONF_FRAMEWORK, {}).get(CONF_SOURCE)
def _get_esphome_esp_idf_paths(
version: str | None = None,
) -> tuple[os.PathLike, os.PathLike]:
version = version or _get_core_framework_version()
paths = _cache().paths
if version not in paths:
paths[version] = check_esp_idf_install(
version, source_url=_get_framework_source_override()
)
paths[version] = check_esp_idf_install(version)
return paths[version]
-87
View File
@@ -6,7 +6,6 @@ import logging
from pathlib import Path
import re
import subprocess
import sys
import urllib.parse
import esphome.config_validation as cv
@@ -94,92 +93,6 @@ def _compute_destination_path(key: str, domain: str) -> Path:
return base_dir / h.hexdigest()[:8]
def resolve_symlink_stub(repo_dir: Path, file_path: Path) -> Path | None:
"""Return the symlink target if ``file_path`` is a Windows-checked-out symlink stub.
On Windows, when ``core.symlinks=false`` (the default unless the user has
SeCreateSymbolicLinkPrivilege i.e. Developer Mode or running elevated),
git materializes files with tree mode ``120000`` as plain text files
whose content is the literal symlink target path. Opening such a file
yields the target path string instead of the target's content.
If ``file_path`` is one of those stubs, return the resolved target Path
inside ``repo_dir``. Otherwise return ``None`` and the caller should use
``file_path`` as-is.
Designed to be called *only* when normal access has already produced an
unexpected result (e.g. YAML parsed as a top-level scalar), so the
per-file ``git ls-files`` subprocess cost is paid only on the failure
path. Returns ``None`` on any error or check failure it's purely a
best-effort recovery, never raises.
"""
# On non-Windows, git creates real symlinks; ordinary file access already
# transparently follows them.
if sys.platform != "win32":
return None
if file_path.is_symlink():
return None
if not file_path.is_file():
return None
try:
rel = file_path.relative_to(repo_dir)
except ValueError:
return None
try:
# ``git ls-files -s <path>`` prints "<mode> <sha> <stage>\t<path>"
# for that single entry, or empty if untracked.
out = run_git_command(
["git", "ls-files", "-s", "--", rel.as_posix()],
git_dir=repo_dir,
)
except GitException:
return None
parts = out.split()
if not parts or parts[0] != "120000":
return None
# Stubs are short ASCII relative paths. Decode defensively, and only
# strip the trailing newline git's checkout may append — preserving any
# whitespace that could be part of a valid target name.
try:
raw = file_path.read_bytes()
except OSError:
return None
try:
target_str = raw.decode("utf-8").rstrip("\r\n")
except UnicodeDecodeError:
return None
# ``Path()`` and ``Path.resolve()`` can raise on malformed inputs (e.g.
# embedded NUL bytes from a hostile symlink blob, paths too long for the
# OS, or temporary I/O errors). Catch broadly — this helper is purely a
# best-effort recovery and must never raise.
try:
target_path = (file_path.parent / target_str).resolve()
repo_root_resolved = repo_dir.resolve()
except (OSError, ValueError, RuntimeError):
return None
# ``Path.resolve()`` follows ``..``; re-verify containment afterwards.
try:
target_path.relative_to(repo_root_resolved)
except ValueError:
_LOGGER.warning(
"Refusing to follow symlink %s -> %s (escapes repository)",
file_path,
target_str,
)
return None
if not target_path.is_file():
return None
return target_path
def clone_or_update(
*,
url: str,
+1 -1
View File
@@ -100,6 +100,6 @@ dependencies:
esp32async/asynctcp:
version: 3.4.91
sendspin/sendspin-cpp:
version: 0.6.1
version: 0.5.0
lvgl/lvgl:
version: 9.5.0
+1 -39
View File
@@ -14,7 +14,6 @@ from esphome.const import (
KEY_CORE,
KEY_TARGET_FRAMEWORK,
KEY_TARGET_PLATFORM,
Toolchain,
)
from esphome.core import CORE
from esphome.helpers import write_file_if_changed
@@ -99,8 +98,6 @@ class StorageJSON:
no_mdns: bool,
framework: str | None = None,
core_platform: str | None = None,
toolchain: str | None = None,
area: str | None = None,
) -> None:
# Version of the storage JSON schema
assert storage_version is None or isinstance(storage_version, int)
@@ -137,10 +134,6 @@ class StorageJSON:
self.framework = framework
# The core platform of this firmware. Like "esp32", "rp2040", "host" etc.
self.core_platform = core_platform
# The toolchain used for the build ("platformio" / "esp-idf")
self.toolchain = toolchain
# The area of the node
self.area = area
def as_dict(self):
return {
@@ -160,8 +153,6 @@ class StorageJSON:
"no_mdns": self.no_mdns,
"framework": self.framework,
"core_platform": self.core_platform,
"toolchain": self.toolchain,
"area": self.area,
}
def to_json(self):
@@ -198,8 +189,6 @@ class StorageJSON:
),
framework=esph.target_framework,
core_platform=esph.target_platform,
toolchain=esph.toolchain.value if esph.toolchain is not None else None,
area=esph.area,
)
@staticmethod
@@ -247,8 +236,6 @@ class StorageJSON:
no_mdns = storage.get("no_mdns", False)
framework = storage.get("framework")
core_platform = storage.get("core_platform")
toolchain = storage.get("toolchain")
area = storage.get("area")
return StorageJSON(
storage_version,
name,
@@ -266,8 +253,6 @@ class StorageJSON:
no_mdns,
framework,
core_platform,
toolchain,
area,
)
@staticmethod
@@ -288,33 +273,10 @@ class StorageJSON:
"""
CORE.name = self.name
CORE.build_path = self.build_path
# Restore toolchain so upload/logs picks the right firmware_bin path.
# An unknown value (corrupt sidecar, or written by a newer ESPHome)
# just leaves CORE.toolchain None — the fallback then picks PlatformIO.
if self.toolchain and CORE.toolchain is None:
try:
CORE.toolchain = Toolchain(self.toolchain)
except ValueError:
_LOGGER.debug(
"Ignoring unknown toolchain %r from %s",
self.toolchain,
storage_path(),
)
target_platform = self.core_platform or self.target_platform.lower()
CORE.data[KEY_CORE] = {
KEY_TARGET_PLATFORM: target_platform,
KEY_TARGET_PLATFORM: self.core_platform or self.target_platform.lower(),
KEY_TARGET_FRAMEWORK: self.framework,
}
# The compile pipeline populates CORE.data[KEY_ESP32] when esp32's
# validator runs; on the cache fast path that validator is skipped,
# so populate the variant upload_using_esptool reads via
# esp32.get_esp32_variant(). target_platform on disk is the variant
# (e.g. "ESP32S3"); core_platform is the family (e.g. "esp32").
if target_platform == const.PLATFORM_ESP32:
from esphome.components.esp32.const import KEY_ESP32
from esphome.const import KEY_VARIANT
CORE.data[KEY_ESP32] = {KEY_VARIANT: self.target_platform}
def __eq__(self, o) -> bool:
return isinstance(o, StorageJSON) and self.as_dict() == o.as_dict()
-23
View File
@@ -87,23 +87,6 @@ def replace_file_content(text, pattern, repl):
def storage_should_clean(old: StorageJSON | None, new: StorageJSON) -> bool:
"""Return True when the build tree must be wiped before reuse.
Predicate is True when *old* is missing (first build),
``src_version`` differs, ``build_path`` differs, the build
``toolchain`` differs (e.g. switching between the PlatformIO and
native ESP-IDF toolchains, which produce incompatible build trees),
or a previously loaded integration was removed in *new*. Adding
integrations or changing unrelated fields (friendly name, esphome
version, etc.) does not trigger a clean.
Used by esphome-device-builder (esphome/device-builder) to gate
its remote-build artifact materialiser so a local remote local
cycle preserves PlatformIO's local object cache instead of wiping
it on every cycle. The signature, semantics, and ``None`` handling
for *old* are part of the public contract; keep them stable so the
offloader's wipe decision tracks core's.
"""
if old is None:
return True
@@ -111,8 +94,6 @@ def storage_should_clean(old: StorageJSON | None, new: StorageJSON) -> bool:
return True
if old.build_path != new.build_path:
return True
if old.toolchain != new.toolchain:
return True
# Check if any components have been removed
return bool(old.loaded_integrations - new.loaded_integrations)
@@ -509,10 +490,6 @@ def clean_build(clear_pio_cache: bool = True):
if dependencies_lock.is_file():
_LOGGER.info("Deleting %s", dependencies_lock)
dependencies_lock.unlink()
idedata_cache = CORE.relative_internal_path("idedata", f"{CORE.name}.json")
if idedata_cache.is_file():
_LOGGER.info("Deleting %s", idedata_cache)
idedata_cache.unlink()
# Native ESP-IDF toolchain artifacts: the IDF CMake/ninja build dir
# and the Component Manager's fetched managed components live under
# the project's build path, not under .pioenvs / .piolibdeps.
+2 -2
View File
@@ -12,8 +12,8 @@ platformio==6.1.19
esptool==5.2.0
click==8.3.3
esphome-dashboard==20260425.0
aioesphomeapi==45.0.4
zeroconf==0.149.16
aioesphomeapi==45.0.0
zeroconf==0.148.0
puremagic==1.30
ruamel.yaml==0.19.1 # dashboard_import
ruamel.yaml.clib==0.2.15 # dashboard_import
+1 -1
View File
@@ -1,6 +1,6 @@
pylint==4.0.5
flake8==7.3.0 # also change in .pre-commit-config.yaml when updating
ruff==0.15.12 # also change in .pre-commit-config.yaml when updating
ruff==0.15.13 # also change in .pre-commit-config.yaml when updating
pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating
pre-commit
+60 -19
View File
@@ -1062,22 +1062,42 @@ def main() -> None:
parser.add_argument(
"-b", "--branch", help="Branch to compare changed files against"
)
parser.add_argument(
"--force-all",
action="store_true",
help=(
"Force every job to run regardless of what changed. Used by CI "
"when the ci-run-all label is applied to a PR (escape hatch for "
"changes that need full-matrix validation but don't touch enough "
"files to trigger it organically)."
),
)
args = parser.parse_args()
# Determine what should run
integration_run_all, integration_test_files = determine_integration_tests(
args.branch
)
if args.force_all:
integration_run_all, integration_test_files = True, []
run_clang_tidy = True
run_clang_format = True
run_python_linters = True
run_import_time = True
run_device_builder = True
native_idf_components = sorted(NATIVE_IDF_TEST_COMPONENTS)
run_native_idf = True
else:
integration_run_all, integration_test_files = determine_integration_tests(
args.branch
)
run_clang_tidy = should_run_clang_tidy(args.branch)
run_clang_format = should_run_clang_format(args.branch)
run_python_linters = should_run_python_linters(args.branch)
run_import_time = should_run_import_time(args.branch)
run_device_builder = should_run_device_builder(args.branch)
native_idf_components = native_idf_components_to_test(args.branch)
run_native_idf = bool(native_idf_components)
run_integration, integration_test_buckets = _compute_integration_test_buckets(
integration_run_all, integration_test_files
)
run_clang_tidy = should_run_clang_tidy(args.branch)
run_clang_format = should_run_clang_format(args.branch)
run_python_linters = should_run_python_linters(args.branch)
run_import_time = should_run_import_time(args.branch)
run_device_builder = should_run_device_builder(args.branch)
native_idf_components = native_idf_components_to_test(args.branch)
run_native_idf = bool(native_idf_components)
changed_cpp_file_count = count_changed_cpp_files(args.branch)
# Get changed components
@@ -1106,11 +1126,27 @@ def main() -> None:
changed_components = changed_components_result
is_core_change = False
# Filter to only components that have test files
# Components without tests shouldn't generate CI test jobs
changed_components_with_tests = [
component for component in changed_components if _component_has_tests(component)
]
if args.force_all:
# Force every component with tests into the CI matrix. Each disk entry
# under tests/components/<name> is treated as a component; filtered
# below by _component_has_tests so components without YAML tests are
# still excluded.
tests_root = Path(root_path) / ESPHOME_TESTS_COMPONENTS_PATH
all_components = sorted(d.name for d in tests_root.iterdir() if d.is_dir())
changed_components_with_tests = [
component for component in all_components if _component_has_tests(component)
]
# Treat as a core change so downstream logic (clang-tidy full scan,
# dep expansion) sees the same world as when esphome/core/ changes.
is_core_change = True
else:
# Filter to only components that have test files
# Components without tests shouldn't generate CI test jobs
changed_components_with_tests = [
component
for component in changed_components
if _component_has_tests(component)
]
# Get directly changed components with tests (for isolated testing)
# These will be tested WITHOUT --testing-mode in CI to enable full validation
@@ -1143,8 +1179,10 @@ def main() -> None:
memory_impact = detect_memory_impact_config(args.branch)
# Determine clang-tidy mode based on actual files that will be checked
is_full_scan = False
if run_clang_tidy:
# Full scan needed if: hash changed OR core files changed
# (is_core_change is forced True under --force-all)
is_full_scan = _is_clang_tidy_full_scan() or is_core_change
if is_full_scan:
@@ -1177,10 +1215,12 @@ def main() -> None:
# Build output
# Determine which C++ unit tests to run
cpp_run_all, cpp_components = determine_cpp_unit_tests(args.branch)
# Determine if benchmarks should run
run_benchmarks = should_run_benchmarks(args.branch)
if args.force_all:
cpp_run_all, cpp_components = True, []
run_benchmarks = True
else:
cpp_run_all, cpp_components = determine_cpp_unit_tests(args.branch)
run_benchmarks = should_run_benchmarks(args.branch)
# Split components into batches for CI testing
# This intelligently groups components with similar bus configurations
@@ -1219,6 +1259,7 @@ def main() -> None:
"integration_test_buckets": integration_test_buckets,
"clang_tidy": run_clang_tidy,
"clang_tidy_mode": clang_tidy_mode,
"clang_tidy_full_scan": is_full_scan,
"clang_format": run_clang_format,
"python_linters": run_python_linters,
"import_time": run_import_time,
@@ -1,8 +0,0 @@
substitutions:
i2s_bclk_pin: GPIO27
i2s_lrclk_pin: GPIO26
i2s_mclk_pin: GPIO25
i2s_dout_pin: GPIO12
spdif_data_pin: GPIO4
<<: !include common-spdif_mode.yaml
-1
View File
@@ -276,7 +276,6 @@ display:
auto_wake_on_touch: true
brightness: 80%
command_spacing: 5ms
dump_device_info: true
exit_reparse_on_start: true
lambda: |-
ESP_LOGD("display","Display is being tested!");
@@ -1,3 +1,13 @@
substitutions:
i2s_bclk_pin: GPIO27
i2s_lrclk_pin: GPIO26
i2s_mclk_pin: GPIO25
i2s_dout_pin: GPIO12
spdif_data_pin: GPIO4
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
i2s_audio:
- id: i2s_output
@@ -10,5 +20,6 @@ speaker:
use_apll: true
timeout: 2s
sample_rate: 48000
bits_per_sample: 16bit
channel: stereo
i2s_mode: primary
+3 -8
View File
@@ -1503,18 +1503,13 @@ async def test_websocket_refresh_command(
) -> None:
"""Test WebSocket refresh command triggers dashboard update."""
with patch("esphome.dashboard.web_server.DASHBOARD_SUBSCRIBER") as mock_subscriber:
# Signal an asyncio.Event when request_refresh is invoked so the
# test can deterministically wait for the server-side handler to run
# instead of relying on a fixed sleep (flaky on Windows CI under load).
called = asyncio.Event()
mock_subscriber.request_refresh = Mock(side_effect=called.set)
mock_subscriber.request_refresh = Mock()
# Send refresh command
await websocket_client.write_message(json.dumps({"event": "refresh"}))
# Wait for the server to process the message and invoke request_refresh
async with asyncio.timeout(5):
await called.wait()
# Give it a moment to process
await asyncio.sleep(0.01)
# Verify request_refresh was called
mock_subscriber.request_refresh.assert_called_once()
+139
View File
@@ -2602,3 +2602,142 @@ def test_main_validate_only_excludes_transitive_components(
# Only foo (directly changed, validate-only). bar is a transitive dep
# and still needs compile despite no source change of its own.
assert output["validate_only_components"] == ["foo"]
def test_main_force_all_overrides_detection(
mock_determine_integration_tests: Mock,
mock_should_run_clang_tidy: Mock,
mock_should_run_clang_format: Mock,
mock_should_run_python_linters: Mock,
mock_should_run_import_time: Mock,
mock_should_run_device_builder: Mock,
mock_native_idf_components_to_test: Mock,
mock_determine_cpp_unit_tests: Mock,
mock_changed_files: Mock,
capsys: pytest.CaptureFixture[str],
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""--force-all bypasses per-feature detection and runs every job.
Detection mocks all return False/empty (which would normally skip
everything) -- the flag must override them. Also verifies clang-tidy
goes to ``split`` (full scan) and the component-test matrix is
populated from disk rather than from changed-files.
"""
monkeypatch.delenv("GITHUB_ACTIONS", raising=False)
mock_determine_integration_tests.return_value = (False, [])
mock_should_run_clang_tidy.return_value = False
mock_should_run_clang_format.return_value = False
mock_should_run_python_linters.return_value = False
mock_should_run_import_time.return_value = False
mock_should_run_device_builder.return_value = False
mock_native_idf_components_to_test.return_value = []
mock_determine_cpp_unit_tests.return_value = (False, [])
mock_changed_files.return_value = []
with (
patch("sys.argv", ["determine-jobs.py", "--force-all"]),
patch.object(determine_jobs, "get_changed_components", return_value=[]),
patch.object(
determine_jobs, "filter_component_and_test_files", return_value=False
),
patch.object(
determine_jobs, "get_components_with_dependencies", return_value=[]
),
patch.object(
determine_jobs,
"detect_memory_impact_config",
return_value={"should_run": "false"},
),
patch.object(determine_jobs, "should_run_benchmarks", return_value=False),
):
determine_jobs.main()
output = json.loads(capsys.readouterr().out)
assert output["integration_tests"] is True
assert output["clang_tidy"] is True
assert output["clang_tidy_mode"] == "split"
assert output["clang_tidy_full_scan"] is True
assert output["clang_format"] is True
assert output["python_linters"] is True
assert output["import_time"] is True
assert output["device_builder"] is True
assert output["native_idf"] is True
# native_idf_components is a CSV of NATIVE_IDF_TEST_COMPONENTS
assert "esp32" in output["native_idf_components"].split(",")
assert output["cpp_unit_tests_run_all"] is True
assert output["cpp_unit_tests_components"] == []
assert output["benchmarks"] is True
# Detection helpers must not be consulted when --force-all is set
mock_determine_integration_tests.assert_not_called()
mock_should_run_clang_tidy.assert_not_called()
mock_should_run_clang_format.assert_not_called()
mock_should_run_python_linters.assert_not_called()
mock_should_run_import_time.assert_not_called()
mock_should_run_device_builder.assert_not_called()
mock_native_idf_components_to_test.assert_not_called()
mock_determine_cpp_unit_tests.assert_not_called()
# Component matrix is populated from disk (tests/components/ in the repo)
assert output["component_test_count"] > 0
assert len(output["component_test_batches"]) > 0
def test_main_force_all_off_uses_detection(
mock_determine_integration_tests: Mock,
mock_should_run_clang_tidy: Mock,
mock_should_run_clang_format: Mock,
mock_should_run_python_linters: Mock,
mock_should_run_import_time: Mock,
mock_should_run_device_builder: Mock,
mock_native_idf_components_to_test: Mock,
mock_determine_cpp_unit_tests: Mock,
mock_changed_files: Mock,
capsys: pytest.CaptureFixture[str],
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Without --force-all, detection helpers drive the decision (regression guard)."""
monkeypatch.delenv("GITHUB_ACTIONS", raising=False)
mock_determine_integration_tests.return_value = (False, [])
mock_should_run_clang_tidy.return_value = False
mock_should_run_clang_format.return_value = False
mock_should_run_python_linters.return_value = False
mock_should_run_import_time.return_value = False
mock_should_run_device_builder.return_value = False
mock_native_idf_components_to_test.return_value = []
mock_determine_cpp_unit_tests.return_value = (False, [])
mock_changed_files.return_value = []
with (
patch("sys.argv", ["determine-jobs.py"]),
patch.object(determine_jobs, "get_changed_components", return_value=[]),
patch.object(
determine_jobs, "filter_component_and_test_files", return_value=False
),
patch.object(
determine_jobs, "get_components_with_dependencies", return_value=[]
),
patch.object(
determine_jobs,
"detect_memory_impact_config",
return_value={"should_run": "false"},
),
patch.object(
determine_jobs, "create_intelligent_batches", return_value=([], {})
),
patch.object(determine_jobs, "should_run_benchmarks", return_value=False),
):
determine_jobs.main()
output = json.loads(capsys.readouterr().out)
assert output["integration_tests"] is False
assert output["clang_tidy"] is False
assert output["clang_format"] is False
assert output["python_linters"] is False
assert output["native_idf"] is False
assert output["component_test_count"] == 0
mock_determine_integration_tests.assert_called_once()
mock_should_run_clang_tidy.assert_called_once()
@@ -7,9 +7,6 @@ esp32:
variant: ESP32S3
framework:
type: esp-idf
# Use custom partition table with larger app partition (3MB)
# Default IDF partitions only allow 1.75MB which is too small for grouped tests
partitions: ../partitions_testing.csv
logger:
level: VERY_VERBOSE
+2 -76
View File
@@ -3,20 +3,8 @@
import pytest
from esphome.components.esp32 import const
from esphome.components.wifi import (
check_placeholder_credentials,
has_native_wifi,
variant_has_wifi,
)
from esphome.const import (
CONF_AP,
CONF_NETWORKS,
CONF_SSID,
CONF_WIFI,
PLACEHOLDER_WIFI_SSID,
Platform,
)
from esphome.core import EsphomeError, Lambda
from esphome.components.wifi import has_native_wifi, variant_has_wifi
from esphome.const import Platform
@pytest.mark.parametrize(
@@ -135,65 +123,3 @@ def test_has_native_wifi_esp32_without_variant_assumes_wifi() -> None:
def test_has_native_wifi_rp2040_without_board_assumes_wifi() -> None:
"""RP2040 without a board id falls open to True (custom-board default)."""
assert has_native_wifi(platform=Platform.RP2040) is True
def _wifi_config(
*,
networks: list[dict] | None = None,
ap: dict | None = None,
) -> dict:
"""Build a minimal config dict matching the post-validation shape."""
wifi: dict = {}
if networks is not None:
wifi[CONF_NETWORKS] = networks
if ap is not None:
wifi[CONF_AP] = ap
return {CONF_WIFI: wifi}
def test_check_placeholder_credentials_passes_with_real_ssid() -> None:
"""A real SSID compiles without complaint."""
config = _wifi_config(networks=[{CONF_SSID: "home_network"}])
assert check_placeholder_credentials(config) is None
def test_check_placeholder_credentials_refuses_placeholder_ssid() -> None:
"""The placeholder SSID is rejected with an actionable message."""
config = _wifi_config(networks=[{CONF_SSID: PLACEHOLDER_WIFI_SSID}])
with pytest.raises(EsphomeError) as exc_info:
check_placeholder_credentials(config)
message = str(exc_info.value)
assert "wifi.networks[0].ssid" in message
assert "secrets.yaml" in message
def test_check_placeholder_credentials_refuses_placeholder_in_second_network() -> None:
"""Index reporting picks the placeholder out of a mixed network list."""
config = _wifi_config(
networks=[
{CONF_SSID: "home_network"},
{CONF_SSID: PLACEHOLDER_WIFI_SSID},
],
)
with pytest.raises(EsphomeError) as exc_info:
check_placeholder_credentials(config)
assert "wifi.networks[1].ssid" in str(exc_info.value)
def test_check_placeholder_credentials_refuses_placeholder_ap_ssid() -> None:
"""An AP using the placeholder broadcast name is also refused."""
config = _wifi_config(ap={CONF_SSID: PLACEHOLDER_WIFI_SSID})
with pytest.raises(EsphomeError) as exc_info:
check_placeholder_credentials(config)
assert "wifi.ap.ssid" in str(exc_info.value)
def test_check_placeholder_credentials_no_wifi_passes() -> None:
"""Ethernet-only / wifi-less configs skip the check entirely."""
assert check_placeholder_credentials({}) is None
def test_check_placeholder_credentials_skips_template_ssid() -> None:
"""A templated (Lambda) SSID is not a string and is skipped."""
config = _wifi_config(networks=[{CONF_SSID: Lambda('return "x";')}])
assert check_placeholder_credentials(config) is None
-27
View File
@@ -140,33 +140,6 @@ def test_multiple_areas_and_devices(yaml_file: Callable[[str], str]) -> None:
}
@pytest.mark.asyncio
@pytest.mark.filterwarnings("ignore::RuntimeWarning")
@pytest.mark.parametrize(
("fixture", "expected_area"),
[
("legacy_string_area.yaml", "Living Room"),
("multiple_areas_devices.yaml", "Main Area"),
],
)
async def test_to_code_records_core_area(
yaml_file: Callable[[str], Path],
fixture: str,
expected_area: str,
) -> None:
"""``to_code`` records the node's area name on CORE for StorageJSON."""
result = load_config_from_fixture(yaml_file, fixture, FIXTURES_DIR)
assert result is not None
assert CORE.area is None
with patch("esphome.core.config.cg") as mock_cg:
mock_cg.RawStatement.side_effect = lambda *args, **kwargs: MagicMock()
mock_cg.RawExpression.side_effect = lambda *args, **kwargs: MagicMock()
await config.to_code(result[CONF_ESPHOME])
assert CORE.area == expected_area
def test_legacy_string_area(
yaml_file: Callable[[str], str], caplog: pytest.LogCaptureFixture
) -> None:
+3 -153
View File
@@ -22,7 +22,6 @@ from esphome.const import (
KEY_CORE,
KEY_TARGET_FRAMEWORK,
KEY_TARGET_PLATFORM,
KEY_VARIANT,
)
from esphome.core import CORE
@@ -48,12 +47,7 @@ wifi:
"""
def _write_storage(
storage_path: Path,
*,
esp_platform: str = "ESP32",
core_platform: str | None = "esp32",
) -> None:
def _write_storage(storage_path: Path) -> None:
"""Write a vanilla StorageJSON sidecar for the cache tests."""
storage_path.parent.mkdir(parents=True, exist_ok=True)
data = {
@@ -65,14 +59,14 @@ def _write_storage(
"src_version": 1,
"address": "192.168.1.42",
"web_port": None,
"esp_platform": esp_platform,
"esp_platform": "ESP32",
"build_path": "/build/lite_test",
"firmware_bin_path": "/build/lite_test/firmware.bin",
"loaded_integrations": ["api", "logger", "ota", "wifi"],
"loaded_platforms": [],
"no_mdns": False,
"framework": "arduino",
"core_platform": core_platform,
"core_platform": "esp32",
}
storage_path.write_text(json.dumps(data))
@@ -129,50 +123,6 @@ def test_load_compiled_config_happy_path(fresh_cache_files: Path) -> None:
assert CORE.build_path == Path("/build/lite_test")
assert CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] == "esp32"
assert CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] == "arduino"
# upload_using_esptool reads get_esp32_variant() off CORE.data[KEY_ESP32].
from esphome.components.esp32.const import KEY_ESP32
assert CORE.data[KEY_ESP32][KEY_VARIANT] == "ESP32"
def test_load_compiled_config_populates_esp32_variant(tmp_path: Path) -> None:
"""ESP32 variants survive the cache fast path so esptool gets the right --chip."""
from esphome.components.esp32.const import KEY_ESP32
yaml_path = tmp_path / "lite_test.yaml"
yaml_path.write_text("esphome:\n name: lite_test\n")
CORE.config_path = yaml_path
storage_dir = tmp_path / ".esphome" / "storage"
_write_storage(storage_dir / "lite_test.yaml.json", esp_platform="ESP32S3")
cache = _write_cache(storage_dir / "lite_test.yaml.validated.yaml")
_set_cache_mtime(cache, yaml_path, offset=5)
assert load_compiled_config(yaml_path) is not None
assert CORE.data[KEY_ESP32][KEY_VARIANT] == "ESP32S3"
def test_load_compiled_config_skips_esp32_block_for_other_platforms(
tmp_path: Path,
) -> None:
"""Non-esp32 targets shouldn't fabricate an esp32 data block."""
from esphome.components.esp32.const import KEY_ESP32
yaml_path = tmp_path / "lite_test.yaml"
yaml_path.write_text("esphome:\n name: lite_test\n")
CORE.config_path = yaml_path
storage_dir = tmp_path / ".esphome" / "storage"
_write_storage(
storage_dir / "lite_test.yaml.json",
esp_platform="ESP8266",
core_platform="esp8266",
)
cache = _write_cache(storage_dir / "lite_test.yaml.validated.yaml")
_set_cache_mtime(cache, yaml_path, offset=5)
assert load_compiled_config(yaml_path) is not None
assert KEY_ESP32 not in CORE.data
@pytest.mark.parametrize(
@@ -253,106 +203,6 @@ def test_run_esphome_upload_and_logs_fall_back_when_no_cache(
mock_read.assert_called_once()
def test_run_esphome_upload_does_not_refresh_cache_without_sidecar(
tmp_path: Path,
) -> None:
"""Without a StorageJSON sidecar (no compile has run), the fallback
skips the cache write -- load_compiled_config requires the sidecar,
so writing the rendered (secret-resolved) YAML would be inert and
leak secrets to disk for nothing."""
yaml_path = tmp_path / "lite_test.yaml"
yaml_path.write_text("esphome:\n name: lite_test\n")
CORE.config_path = yaml_path
with (
patch(
"esphome.__main__.read_config",
return_value={"esphome": {"name": "lite_test"}},
),
patch("esphome.compiled_config.save_compiled_config") as mock_save,
patch.dict(
"esphome.__main__.POST_CONFIG_ACTIONS",
{"upload": lambda args, config: 0},
),
):
run_esphome(["esphome", "upload", str(yaml_path)])
mock_save.assert_not_called()
@pytest.mark.parametrize("command", ["upload", "logs"])
def test_run_esphome_upload_and_logs_refresh_cache_on_fallback(
tmp_path: Path, command: str
) -> None:
"""A stale-cache fallback rewrites the cache so the next call hits
the fast path. Without this, every upload/logs after a YAML edit
pays for read_config() until the next compile rewrites the cache."""
yaml_path = tmp_path / "lite_test.yaml"
yaml_path.write_text("esphome:\n name: lite_test\n")
CORE.config_path = yaml_path
storage_dir = tmp_path / ".esphome" / "storage"
_write_storage(storage_dir / "lite_test.yaml.json")
cache = _write_cache(storage_dir / "lite_test.yaml.validated.yaml")
_set_cache_mtime(cache, yaml_path, offset=-60) # stale
fresh_config = {"esphome": {"name": "lite_test"}, "logger": {}}
with (
patch("esphome.__main__.read_config", return_value=fresh_config),
patch(
"esphome.compiled_config.save_compiled_config", wraps=save_compiled_config
) as mock_save,
patch.dict(
"esphome.__main__.POST_CONFIG_ACTIONS",
{command: lambda args, config: 0},
),
):
assert run_esphome(["esphome", command, str(yaml_path)]) == 0
mock_save.assert_called_once_with(fresh_config)
# mtime is now newer than the source YAML, so a follow-up call hits
# the fast path instead of repeating read_config.
assert cache.stat().st_mtime >= yaml_path.stat().st_mtime
def test_run_esphome_upload_with_substitution_does_not_refresh_cache(
fresh_cache_files: Path,
) -> None:
"""`-s` substitutions skip the cache on both read and write -- saving
here would clobber the cache with a substitution-specific config."""
with (
patch("esphome.__main__.read_config", return_value={"esphome": {}}),
patch("esphome.compiled_config.save_compiled_config") as mock_save,
patch.dict(
"esphome.__main__.POST_CONFIG_ACTIONS",
{"upload": lambda args, config: 0},
),
):
run_esphome(["esphome", "-s", "var", "val", "upload", str(fresh_cache_files)])
mock_save.assert_not_called()
def test_run_esphome_compile_does_not_refresh_cache_via_fallback(
fresh_cache_files: Path,
) -> None:
"""Compile writes the cache through update_storage_json, not via the
upload/logs fallback path -- the fallback save would skip the
storage_should_clean check."""
with (
patch("esphome.__main__.read_config", return_value={"esphome": {}}),
patch("esphome.compiled_config.save_compiled_config") as mock_save,
patch.dict(
"esphome.__main__.POST_CONFIG_ACTIONS",
{"compile": lambda args, config: 0},
),
):
run_esphome(["esphome", "compile", str(fresh_cache_files)])
mock_save.assert_not_called()
def test_run_esphome_upload_with_substitution_skips_cache(
fresh_cache_files: Path,
) -> None:
+11 -141
View File
@@ -203,7 +203,7 @@ def test_generate_idf_component_yml_basic(tmp_component):
tmp_component.data = {"description": "test", "repository": {"url": "http://aaa"}}
result = generate_idf_component_yml(tmp_component)
assert result == "description: test\nrepository: http://aaa\n"
assert result == "description: test\nversion: 1.0.0\nrepository: http://aaa\n"
def test_generate_idf_component_yml_with_dependencies(tmp_component, tmp_path):
@@ -217,16 +217,18 @@ def test_generate_idf_component_yml_with_dependencies(tmp_component, tmp_path):
assert (
result
== f"""dependencies:
== f"""version: 1.0.0
dependencies:
dep:
version: '1.0'
override_path: {dep.path}
"""
)
def test_generate_idf_component_yml_missing_path_raises(tmp_component):
# A dep without a path is a contract violation — every dep is expected
# to have been downloaded before YAML generation. Raise loudly.
def test_generate_idf_component_yml_missing_path_reraises(tmp_component):
# A dep without a path and without a recognised source should re-raise
# the underlying RuntimeError instead of silently producing a bad manifest.
dep = IDFComponent("foo/bar", "1.0", source=None)
tmp_component.dependencies = [dep]
@@ -420,37 +422,15 @@ def test_convert_library_with_repository():
result = _convert_library_to_component(lib)
assert result.name == "foo/bar"
assert result.version == "*"
assert result.version == "1.2.3"
assert isinstance(result.source, GitSource)
assert result.source.ref == "v1.2.3"
def test_convert_library_with_branch_ref():
lib = Library("name", None, "https://github.com/foo/bar.git#some-branch")
result = _convert_library_to_component(lib)
assert result.name == "foo/bar"
assert result.version == "*"
assert isinstance(result.source, GitSource)
assert result.source.ref == "some-branch"
def test_convert_library_missing_ref_uses_default_branch():
"""A bare URL with no #ref clones the remote's default branch.
Matches PIO's lib_deps behavior and external_components handling --
git.clone_or_update with ref=None leaves the depth-1 clone on
whatever branch the remote HEAD points at.
"""
def test_convert_library_missing_ref():
lib = Library("name", None, "https://github.com/foo/bar.git")
result = _convert_library_to_component(lib)
assert result.name == "foo/bar"
assert result.version == "*"
assert isinstance(result.source, GitSource)
assert result.source.ref is None
with pytest.raises(ValueError):
_convert_library_to_component(lib)
def test_convert_library_registry(monkeypatch):
@@ -505,113 +485,3 @@ def test_process_dependencies_skips_invalid(tmp_component):
_process_dependencies(tmp_component)
assert tmp_component.dependencies == []
def test_process_dependencies_dict_form(tmp_component, monkeypatch):
"""PIO library.json shorthand ``{"owner/Name": "version"}`` is honored.
Iterating a dict gives string keys, which would silently fail the
``"name" in dependency`` substring check. Normalize to list-of-dicts
first so the dict form (used by e.g. tesla-ble for its nanopb dep)
is treated the same as the verbose list form.
"""
captured: list[Library] = []
def fake_generate(library):
captured.append(library)
return IDFComponent(
library.name, library.version, source=URLSource("http://dummy.com")
)
tmp_component.data = {
"dependencies": {
"nanopb/Nanopb": "^0.4.91",
"BareName": "1.2.3",
}
}
monkeypatch.setattr(
esphome.espidf.component, "_generate_idf_component", fake_generate
)
monkeypatch.setattr(esphome.espidf.component, "_check_library_data", lambda x: None)
_process_dependencies(tmp_component)
assert len(tmp_component.dependencies) == 2
names = sorted(lib.name for lib in captured)
versions = sorted(lib.version for lib in captured)
assert names == ["BareName", "nanopb/Nanopb"]
assert versions == ["1.2.3", "^0.4.91"]
def test_process_dependencies_dict_form_with_url_value(tmp_component, monkeypatch):
"""A dict-value that's a URL gets routed to ``repository`` like the list form."""
captured: list[Library] = []
def fake_generate(library):
captured.append(library)
return IDFComponent(library.name, "*", source=URLSource("http://dummy.com"))
tmp_component.data = {
"dependencies": {
"foo/Bar": "https://github.com/foo/bar.git#main",
}
}
monkeypatch.setattr(
esphome.espidf.component, "_generate_idf_component", fake_generate
)
monkeypatch.setattr(esphome.espidf.component, "_check_library_data", lambda x: None)
_process_dependencies(tmp_component)
assert len(captured) == 1
assert captured[0].name == "foo/Bar"
assert captured[0].version is None
assert captured[0].repository == "https://github.com/foo/bar.git#main"
def test_process_dependencies_dict_form_with_nested_spec(tmp_component, monkeypatch):
"""A dict-value that's itself a dict is merged into the entry.
PIO's library.json allows ``{"owner/Name": {"version": "...", ...}}``
for entries that need fields beyond just a version (platforms,
frameworks, etc.). The extra fields flow into _check_library_data
via the entry merge.
"""
captured: list[Library] = []
checked: list[dict] = []
def fake_generate(library):
captured.append(library)
return IDFComponent(
library.name, library.version, source=URLSource("http://dummy.com")
)
tmp_component.data = {
"dependencies": {
"nanopb/Nanopb": {"version": "^0.4.91", "platforms": "espidf"},
}
}
monkeypatch.setattr(
esphome.espidf.component, "_generate_idf_component", fake_generate
)
monkeypatch.setattr(
esphome.espidf.component,
"_check_library_data",
checked.append,
)
_process_dependencies(tmp_component)
assert len(captured) == 1
assert captured[0].name == "nanopb/Nanopb"
assert captured[0].version == "^0.4.91"
# Extra spec fields reach _check_library_data so platform/framework
# gating still applies.
assert checked == [
{
"name": "Nanopb",
"owner": "nanopb",
"version": "^0.4.91",
"platforms": "espidf",
}
]
-58
View File
@@ -1,58 +0,0 @@
"""Tests for esphome.espidf.toolchain helpers."""
# pylint: disable=protected-access
from unittest.mock import patch
from esphome.const import CONF_FRAMEWORK, CONF_SOURCE
from esphome.core import CORE
from esphome.espidf import toolchain
def test_get_framework_source_override_no_config():
"""When CORE.config hasn't been set, no override is returned."""
CORE.config = None
assert toolchain._get_framework_source_override() is None
def test_get_framework_source_override_no_esp32_section():
"""A config without an esp32 section yields no override."""
CORE.config = {}
assert toolchain._get_framework_source_override() is None
def test_get_framework_source_override_no_framework_source():
"""An esp32 section without framework.source yields no override."""
CORE.config = {"esp32": {CONF_FRAMEWORK: {}}}
assert toolchain._get_framework_source_override() is None
def test_get_framework_source_override_returns_value():
"""A user-supplied framework source is returned verbatim."""
url = "https://example.com/esp-idf-v{VERSION}.tar.xz"
CORE.config = {"esp32": {CONF_FRAMEWORK: {CONF_SOURCE: url}}}
assert toolchain._get_framework_source_override() == url
def test_get_esphome_esp_idf_paths_forwards_source_override():
"""_get_esphome_esp_idf_paths threads the override into check_esp_idf_install."""
url = "https://my-mirror/esp-idf-v{VERSION}.tar.xz"
CORE.config = {"esp32": {CONF_FRAMEWORK: {CONF_SOURCE: url}}}
# Hit a fresh cache key so check_esp_idf_install is actually called.
toolchain._cache().paths.clear()
with patch.object(
toolchain, "check_esp_idf_install", return_value=("/fw", "/penv")
) as mock_install:
toolchain._get_esphome_esp_idf_paths("5.5.4")
mock_install.assert_called_once_with("5.5.4", source_url=url)
def test_get_esphome_esp_idf_paths_no_override():
"""When no source override is configured, source_url=None is passed."""
CORE.config = {}
toolchain._cache().paths.clear()
with patch.object(
toolchain, "check_esp_idf_install", return_value=("/fw", "/penv")
) as mock_install:
toolchain._get_esphome_esp_idf_paths("5.5.4")
mock_install.assert_called_once_with("5.5.4", source_url=None)
+1 -302
View File
@@ -4,7 +4,7 @@ from datetime import datetime, timedelta
import os
from pathlib import Path
from typing import Any
from unittest.mock import Mock, patch
from unittest.mock import Mock
import pytest
@@ -1001,304 +1001,3 @@ def test_refresh_picks_up_new_remote_commits(
"--hard",
"old_sha",
]
def test_resolve_symlink_stub_returns_none_on_non_windows(
tmp_path: Path, mock_run_git_command: Mock
) -> None:
"""On non-Windows, resolve_symlink_stub returns None without calling git."""
repo_dir = tmp_path / "repo"
repo_dir.mkdir()
stub = repo_dir / "file.yaml"
stub.write_text("static/file.yaml")
with patch("esphome.git.sys.platform", "linux"):
result = git.resolve_symlink_stub(repo_dir, stub)
assert result is None
mock_run_git_command.assert_not_called()
def test_resolve_symlink_stub_returns_target_for_mode_120000(
tmp_path: Path, mock_run_git_command: Mock
) -> None:
"""A mode-120000 file is recognised as a stub; its target Path is returned."""
repo_dir = tmp_path / "repo"
repo_dir.mkdir()
(repo_dir / "static").mkdir()
target = repo_dir / "static" / "real.yaml"
target.write_text("esphome:\n name: real\n")
stub = repo_dir / "real.yaml"
stub.write_text("static/real.yaml")
mock_run_git_command.return_value = "120000 abc123 0\treal.yaml"
with patch("esphome.git.sys.platform", "win32"):
result = git.resolve_symlink_stub(repo_dir, stub)
assert result == target.resolve()
# Stub file itself was not modified — only inspected.
assert stub.read_text() == "static/real.yaml"
def test_resolve_symlink_stub_resolves_relative_parent_paths(
tmp_path: Path, mock_run_git_command: Mock
) -> None:
"""Symlink targets with ``..`` segments resolve correctly within the repo."""
repo_dir = tmp_path / "repo"
(repo_dir / "subdir").mkdir(parents=True)
(repo_dir / "static").mkdir()
target = repo_dir / "static" / "shared.yaml"
target.write_text("shared content")
stub = repo_dir / "subdir" / "shared.yaml"
stub.write_text("../static/shared.yaml")
mock_run_git_command.return_value = "120000 abc123 0\tsubdir/shared.yaml"
with patch("esphome.git.sys.platform", "win32"):
result = git.resolve_symlink_stub(repo_dir, stub)
assert result == target.resolve()
def test_resolve_symlink_stub_refuses_escape_outside_repo(
tmp_path: Path, mock_run_git_command: Mock
) -> None:
"""A symlink pointing outside the repository is not followed."""
outside = tmp_path / "outside.yaml"
outside.write_text("sensitive")
repo_dir = tmp_path / "repo"
repo_dir.mkdir()
stub = repo_dir / "escape.yaml"
stub.write_text("../outside.yaml")
mock_run_git_command.return_value = "120000 abc123 0\tescape.yaml"
with patch("esphome.git.sys.platform", "win32"):
result = git.resolve_symlink_stub(repo_dir, stub)
assert result is None
def test_resolve_symlink_stub_returns_none_for_real_symlink(
tmp_path: Path, mock_run_git_command: Mock
) -> None:
"""A real symlink already opens transparently, so the helper short-circuits.
Skipped on Windows where symlink creation requires
SeCreateSymbolicLinkPrivilege.
"""
if os.name == "nt":
pytest.skip("Requires symlink-creation privilege on Windows")
repo_dir = tmp_path / "repo"
repo_dir.mkdir()
target = repo_dir / "real.yaml"
target.write_text("real content")
real_link = repo_dir / "link.yaml"
real_link.symlink_to("real.yaml")
with patch("esphome.git.sys.platform", "win32"):
result = git.resolve_symlink_stub(repo_dir, real_link)
assert result is None
# No git call needed for real symlinks.
mock_run_git_command.assert_not_called()
def test_resolve_symlink_stub_returns_none_for_regular_file(
tmp_path: Path, mock_run_git_command: Mock
) -> None:
"""A regular file (mode 100644) whose content looks path-shaped is not
followed."""
repo_dir = tmp_path / "repo"
repo_dir.mkdir()
regular = repo_dir / "looks_like_path.txt"
regular.write_text("static/something.yaml")
mock_run_git_command.return_value = "100644 abc123 0\tlooks_like_path.txt"
with patch("esphome.git.sys.platform", "win32"):
result = git.resolve_symlink_stub(repo_dir, regular)
assert result is None
def test_resolve_symlink_stub_returns_none_when_git_fails(
tmp_path: Path, mock_run_git_command: Mock
) -> None:
"""If ``git ls-files`` fails (e.g. not a repo), the helper returns None."""
repo_dir = tmp_path / "repo"
repo_dir.mkdir()
stub = repo_dir / "real.yaml"
stub.write_text("static/real.yaml")
mock_run_git_command.side_effect = GitCommandError("ls-files exploded")
with patch("esphome.git.sys.platform", "win32"):
result = git.resolve_symlink_stub(repo_dir, stub)
assert result is None
def test_resolve_symlink_stub_returns_none_for_non_utf8_content(
tmp_path: Path, mock_run_git_command: Mock
) -> None:
"""A file whose bytes are not valid UTF-8 must not raise — return None."""
repo_dir = tmp_path / "repo"
repo_dir.mkdir()
stub = repo_dir / "binary.bin"
stub.write_bytes(b"\xff\xfe\x00\xff")
mock_run_git_command.return_value = "120000 abc123 0\tbinary.bin"
with patch("esphome.git.sys.platform", "win32"):
result = git.resolve_symlink_stub(repo_dir, stub)
assert result is None
def test_resolve_symlink_stub_preserves_whitespace_in_target(
tmp_path: Path, mock_run_git_command: Mock
) -> None:
"""Only trailing CR/LF is stripped — internal whitespace is preserved."""
repo_dir = tmp_path / "repo"
repo_dir.mkdir()
target_dir = repo_dir / "dir with spaces"
target_dir.mkdir()
target = target_dir / "real.yaml"
target.write_text("hello")
stub = repo_dir / "link.yaml"
# Trailing newline (as git's checkout may append) is stripped, but
# whitespace inside the target path itself must survive.
stub.write_bytes(b"dir with spaces/real.yaml\n")
mock_run_git_command.return_value = "120000 abc123 0\tlink.yaml"
with patch("esphome.git.sys.platform", "win32"):
result = git.resolve_symlink_stub(repo_dir, stub)
assert result == target.resolve()
def test_resolve_symlink_stub_returns_none_for_directory_target(
tmp_path: Path, mock_run_git_command: Mock
) -> None:
"""A symlink pointing at a directory has no file content to load."""
repo_dir = tmp_path / "repo"
repo_dir.mkdir()
(repo_dir / "dir_target").mkdir()
stub = repo_dir / "link_to_dir"
stub.write_text("dir_target")
mock_run_git_command.return_value = "120000 abc123 0\tlink_to_dir"
with patch("esphome.git.sys.platform", "win32"):
result = git.resolve_symlink_stub(repo_dir, stub)
assert result is None
def test_resolve_symlink_stub_returns_none_when_resolve_raises(
tmp_path: Path, mock_run_git_command: Mock
) -> None:
"""Path.resolve() raising (e.g. on a malformed target) must not propagate."""
repo_dir = tmp_path / "repo"
repo_dir.mkdir()
stub = repo_dir / "broken.yaml"
stub.write_text("ignored")
mock_run_git_command.return_value = "120000 abc123 0\tbroken.yaml"
with (
patch("esphome.git.sys.platform", "win32"),
patch.object(Path, "resolve", side_effect=OSError("bad path")),
):
result = git.resolve_symlink_stub(repo_dir, stub)
assert result is None
def test_resolve_symlink_stub_returns_none_when_file_missing(
tmp_path: Path, mock_run_git_command: Mock
) -> None:
"""A file path that doesn't exist is rejected before git is consulted."""
repo_dir = tmp_path / "repo"
repo_dir.mkdir()
missing = repo_dir / "ghost.yaml" # not created
with patch("esphome.git.sys.platform", "win32"):
result = git.resolve_symlink_stub(repo_dir, missing)
assert result is None
mock_run_git_command.assert_not_called()
def test_resolve_symlink_stub_returns_none_when_path_outside_repo(
tmp_path: Path, mock_run_git_command: Mock
) -> None:
"""A file path that isn't under repo_dir is rejected (ValueError from relative_to)."""
repo_dir = tmp_path / "repo"
repo_dir.mkdir()
outside = tmp_path / "stray.yaml"
outside.write_text("something")
with patch("esphome.git.sys.platform", "win32"):
result = git.resolve_symlink_stub(repo_dir, outside)
assert result is None
mock_run_git_command.assert_not_called()
def test_resolve_symlink_stub_returns_none_when_untracked(
tmp_path: Path, mock_run_git_command: Mock
) -> None:
"""Empty `git ls-files` output (untracked file) makes the helper return None."""
repo_dir = tmp_path / "repo"
repo_dir.mkdir()
stub = repo_dir / "untracked.yaml"
stub.write_text("static/foo.yaml")
mock_run_git_command.return_value = ""
with patch("esphome.git.sys.platform", "win32"):
result = git.resolve_symlink_stub(repo_dir, stub)
assert result is None
def test_resolve_symlink_stub_returns_none_when_read_bytes_raises(
tmp_path: Path, mock_run_git_command: Mock
) -> None:
"""An OSError from read_bytes() (e.g. file vanished mid-call) must not propagate."""
repo_dir = tmp_path / "repo"
repo_dir.mkdir()
stub = repo_dir / "racy.yaml"
stub.write_text("static/racy.yaml")
mock_run_git_command.return_value = "120000 abc123 0\tracy.yaml"
with (
patch("esphome.git.sys.platform", "win32"),
patch.object(Path, "read_bytes", side_effect=OSError("vanished")),
):
result = git.resolve_symlink_stub(repo_dir, stub)
assert result is None
-128
View File
@@ -1,128 +0,0 @@
"""Tests for esphome.espidf.size_summary.print_summary."""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from esphome.espidf.size_summary import print_summary
def _write_size_json(tmp_path: Path, data: dict) -> Path:
"""Drop a fake esp_idf_size.json under ``tmp_path`` and return the path."""
out = tmp_path / "esp_idf_size.json"
out.write_text(json.dumps(data))
return out
def _esp32_size_data() -> dict:
"""Synthetic esp_idf_size.json for the original ESP32 (split IRAM/DRAM)."""
return {
"image_size": 827455,
"memory_types": {
"DRAM": {
"size": 180736,
"used": 47332,
"sections": {
".dram0.bss": {"abbrev_name": ".bss", "size": 30616},
".dram0.data": {"abbrev_name": ".data", "size": 16716},
},
},
"IRAM": {
"size": 131072,
"used": 80351,
"sections": {
".iram0.text": {"abbrev_name": ".text", "size": 79323},
".iram0.vectors": {"abbrev_name": ".vectors", "size": 1028},
},
},
},
}
def _s3_size_data() -> dict:
"""Synthetic esp_idf_size.json for ESP32-S3 (unified DIRAM)."""
return {
"image_size": 724215,
"memory_types": {
"DIRAM": {
"size": 341760,
"used": 104999,
"sections": {
".iram0.text": {"abbrev_name": ".text", "size": 58051},
".dram0.bss": {"abbrev_name": ".bss", "size": 27088},
".dram0.data": {"abbrev_name": ".data", "size": 19708},
".noinit": {"abbrev_name": ".noinit", "size": 152},
},
},
"IRAM": {
"size": 16384,
"used": 16384,
"sections": {
".iram0.text": {"abbrev_name": ".text", "size": 15356},
".iram0.vectors": {"abbrev_name": ".vectors", "size": 1028},
},
},
},
}
def test_print_summary_esp32_uses_dram(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""Original ESP32: DRAM has no ``.text``, so RAM = DRAM.used / DRAM.size unchanged."""
size_json = _write_size_json(tmp_path, _esp32_size_data())
print_summary(size_json, partitions_csv=None)
out = capsys.readouterr().out
assert "RAM:" in out
assert "used 47332 bytes from 180736 bytes" in out
def test_print_summary_s3_falls_back_to_diram(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""ESP32-S3 with no DRAM key falls back to DIRAM and reports raw region usage."""
size_json = _write_size_json(tmp_path, _s3_size_data())
print_summary(size_json, partitions_csv=None)
out = capsys.readouterr().out
assert "used 104999 bytes from 341760 bytes" in out
def test_print_summary_skips_when_diram_total_collapses(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""A zero-size region drops the RAM line rather than divide by zero."""
size_json = _write_size_json(
tmp_path,
{
"memory_types": {
"DIRAM": {
"size": 0,
"used": 0,
"sections": {},
},
},
},
)
print_summary(size_json, partitions_csv=None)
out = capsys.readouterr().out
assert "RAM:" not in out
def test_print_summary_handles_missing_json(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""Missing size json is non-fatal and prints nothing."""
print_summary(tmp_path / "does_not_exist.json", partitions_csv=None)
assert capsys.readouterr().out == ""
def test_print_summary_handles_no_memory_types(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""A size json without ``memory_types`` still doesn't crash."""
size_json = _write_size_json(tmp_path, {"image_size": 0})
print_summary(size_json, partitions_csv=None)
assert capsys.readouterr().out == ""
+1 -110
View File
@@ -9,7 +9,7 @@ from unittest.mock import MagicMock, Mock, patch
import pytest
from esphome import storage_json
from esphome.const import CONF_DISABLED, CONF_MDNS, Toolchain
from esphome.const import CONF_DISABLED, CONF_MDNS
from esphome.core import CORE
@@ -205,7 +205,6 @@ def test_storage_json_as_dict() -> None:
no_mdns=True,
framework="arduino",
core_platform="esp32",
area="Living Room",
)
result = storage.as_dict()
@@ -234,7 +233,6 @@ def test_storage_json_as_dict() -> None:
assert result["no_mdns"] is True
assert result["framework"] == "arduino"
assert result["core_platform"] == "esp32"
assert result["area"] == "Living Room"
def test_storage_json_to_json() -> None:
@@ -310,8 +308,6 @@ def test_storage_json_from_esphome_core(setup_core: Path) -> None:
mock_core.loaded_platforms = {"sensor"}
mock_core.config = {CONF_MDNS: {CONF_DISABLED: True}}
mock_core.target_framework = "esp-idf"
mock_core.toolchain = Toolchain.ESP_IDF
mock_core.area = "Living Room"
with patch("esphome.components.esp32.get_esp32_variant") as mock_variant:
mock_variant.return_value = "ESP32-C3"
@@ -331,8 +327,6 @@ def test_storage_json_from_esphome_core(setup_core: Path) -> None:
assert result.no_mdns is True
assert result.framework == "esp-idf"
assert result.core_platform == "esp32"
assert result.toolchain == "esp-idf"
assert result.area == "Living Room"
def test_storage_json_from_esphome_core_mdns_enabled(setup_core: Path) -> None:
@@ -351,12 +345,10 @@ def test_storage_json_from_esphome_core_mdns_enabled(setup_core: Path) -> None:
mock_core.loaded_platforms = set()
mock_core.config = {} # No MDNS config means enabled
mock_core.target_framework = "arduino"
mock_core.toolchain = None
result = storage_json.StorageJSON.from_esphome_core(mock_core, old=None)
assert result.no_mdns is False
assert result.toolchain is None
def test_storage_json_load_valid_file(tmp_path: Path) -> None:
@@ -478,73 +470,6 @@ def test_storage_json_equality() -> None:
assert storage1 != "not a storage object"
def _make_storage_with_toolchain(
toolchain: str | None,
) -> storage_json.StorageJSON:
return storage_json.StorageJSON(
storage_version=1,
name="dev",
friendly_name=None,
comment=None,
esphome_version="2024.1.0",
src_version=1,
address="dev.local",
web_port=None,
target_platform="ESP32",
build_path=Path("/build"),
firmware_bin_path=Path("/build/firmware.bin"),
loaded_integrations=set(),
loaded_platforms=set(),
no_mdns=False,
framework="esp-idf",
core_platform="esp32",
toolchain=toolchain,
)
def test_storage_json_toolchain_round_trip(setup_core: Path) -> None:
"""Sidecar toolchain survives save -> load -> apply_to_core."""
storage = _make_storage_with_toolchain("esp-idf")
path = setup_core / "storage.json"
path.write_text(storage.to_json())
# Serialization key is stable -- device-builder relies on it.
assert json.loads(path.read_text())["toolchain"] == "esp-idf"
loaded = storage_json.StorageJSON.load(path)
assert loaded is not None
assert loaded.toolchain == "esp-idf"
CORE.toolchain = None
with patch("esphome.components.esp32.get_esp32_variant"):
loaded.apply_to_core()
assert CORE.toolchain == Toolchain.ESP_IDF
def test_storage_json_apply_to_core_preserves_cli_toolchain(
setup_core: Path,
) -> None:
"""A CLI-set CORE.toolchain wins over the sidecar value."""
loaded = _make_storage_with_toolchain("esp-idf")
CORE.toolchain = Toolchain.PLATFORMIO
with patch("esphome.components.esp32.get_esp32_variant"):
loaded.apply_to_core()
assert CORE.toolchain == Toolchain.PLATFORMIO
def test_storage_json_apply_to_core_ignores_unknown_toolchain(
setup_core: Path,
) -> None:
"""Unknown enum values (corrupt sidecar / newer ESPHome) fall through to None."""
loaded = _make_storage_with_toolchain("gcc")
CORE.toolchain = None
with patch("esphome.components.esp32.get_esp32_variant"):
loaded.apply_to_core()
assert CORE.toolchain is None
def test_esphome_storage_json_as_dict() -> None:
"""Test EsphomeStorageJSON.as_dict returns correct dictionary."""
storage = storage_json.EsphomeStorageJSON(
@@ -733,37 +658,3 @@ def test_storage_json_load_legacy_esphomeyaml_version(tmp_path: Path) -> None:
assert result is not None
assert result.esphome_version == "1.14.0" # Should map to esphome_version
def test_storage_json_load_area(tmp_path: Path) -> None:
"""``area`` round-trips through load; absence loads as None."""
file_path = tmp_path / "with_area.json"
file_path.write_text(
json.dumps(
{
"storage_version": 1,
"name": "lamp",
"friendly_name": "Lamp",
"esp_platform": "ESP32",
"area": "Living Room",
}
)
)
result = storage_json.StorageJSON.load(file_path)
assert result is not None
assert result.area == "Living Room"
legacy_path = tmp_path / "no_area.json"
legacy_path.write_text(
json.dumps(
{
"storage_version": 1,
"name": "lamp",
"friendly_name": "Lamp",
"esp_platform": "ESP32",
}
)
)
legacy = storage_json.StorageJSON.load(legacy_path)
assert legacy is not None
assert legacy.area is None
-83
View File
@@ -838,86 +838,3 @@ def test_include_vars_applied_to_lambda_value(tmp_path: Path) -> None:
assert isinstance(result["value"], Lambda)
assert result["value"].value == 'return "bar";'
@patch("esphome.git.resolve_symlink_stub")
@patch("esphome.git.clone_or_update")
def test_remote_package_symlink_stub_is_followed(
mock_clone_or_update: MagicMock,
mock_resolve_symlink_stub: MagicMock,
tmp_path: Path,
) -> None:
"""When a package YAML is a scalar (symlink stub) and resolve_symlink_stub
returns a target, the loader follows the target and uses its content."""
CORE.config_path = tmp_path / "test.yaml"
repo_dir = tmp_path / "repo"
repo_dir.mkdir()
(repo_dir / "static").mkdir()
# Stub file: content is the target path string (simulating Windows behavior).
stub = repo_dir / "file1.yaml"
stub.write_text("static/file1.yaml")
# Real target with valid YAML mapping.
target = repo_dir / "static" / "file1.yaml"
target.write_text("substitutions:\n hello: world\n")
mock_clone_or_update.return_value = (repo_dir, None)
mock_resolve_symlink_stub.return_value = target
config: dict[str, Any] = {
"packages": {
"test_package": {
"url": "https://github.com/esphome/repo1",
"ref": "main",
"files": ["file1.yaml"],
}
}
}
# Must succeed (does not raise the helpful cv.Invalid) because the stub
# was followed and a valid mapping was loaded from the target.
do_packages_pass(config)
assert mock_resolve_symlink_stub.called
@patch("esphome.git.clone_or_update")
def test_remote_package_scalar_yaml_raises_helpful_error(
mock_clone_or_update: MagicMock, tmp_path: Path
) -> None:
"""A remote package YAML that is a top-level scalar (e.g. an unmaterialized
git symlink on Windows) raises a clear cv.Invalid, not AttributeError.
Regression test for the case where a repo containing a YAML symlink,
checked out on Windows without symlink privilege, lands as a short text
file containing the symlink target path. PyYAML parses that as a bare
string scalar; the package loader must reject it with a human-readable
error instead of dying inside ``.get()``.
"""
CORE.config_path = tmp_path / "test.yaml"
repo_dir = tmp_path / "repo"
repo_dir.mkdir()
# Simulate the broken-symlink state: a YAML file whose entire content is
# the symlink target string. PyYAML parses this as a top-level scalar.
(repo_dir / "file1.yaml").write_text("static/file1.yaml")
mock_clone_or_update.return_value = (repo_dir, None)
config: dict[str, Any] = {
"packages": {
"test_package": {
"url": "https://github.com/esphome/repo1",
"ref": "main",
"files": ["file1.yaml"],
}
}
}
with pytest.raises(cv.Invalid) as exc_info:
do_packages_pass(config)
msg = str(exc_info.value)
assert "mapping at the top level" in msg
assert "file1.yaml" in msg
-25
View File
@@ -75,7 +75,6 @@ def create_storage() -> Callable[..., StorageJSON]:
no_mdns=kwargs.get("no_mdns", False),
framework=kwargs.get("framework", "arduino"),
core_platform=kwargs.get("core_platform", "esp32"),
toolchain=kwargs.get("toolchain", "platformio"),
)
return _create
@@ -107,20 +106,6 @@ def test_storage_should_clean_when_build_path_changes(
assert storage_should_clean(old, new) is True
def test_storage_should_clean_when_toolchain_changes(
create_storage: Callable[..., StorageJSON],
) -> None:
"""Test that clean is triggered when the build toolchain changes.
Switching between the PlatformIO and native ESP-IDF toolchains produces
incompatible build trees (and toolchain-specific idedata), so the build
must be wiped.
"""
old = create_storage(loaded_integrations=["api", "wifi"], toolchain="platformio")
new = create_storage(loaded_integrations=["api", "wifi"], toolchain="esp-idf")
assert storage_should_clean(old, new) is True
def test_storage_should_clean_when_component_removed(
create_storage: Callable[..., StorageJSON],
) -> None:
@@ -458,11 +443,6 @@ def test_clean_build(
dependencies_lock = tmp_path / "dependencies.lock"
dependencies_lock.write_text("lock file")
# idedata cache lives under the data dir, not the build path.
idedata_cache = tmp_path / "idedata" / "test.json"
idedata_cache.parent.mkdir()
idedata_cache.write_text("{}")
# Native ESP-IDF toolchain artifacts.
idf_build_dir = tmp_path / "build"
idf_build_dir.mkdir()
@@ -483,14 +463,11 @@ def test_clean_build(
mock_core.relative_pioenvs_path.return_value = pioenvs_dir
mock_core.relative_piolibdeps_path.return_value = piolibdeps_dir
mock_core.relative_build_path.side_effect = lambda name: tmp_path / name
mock_core.name = "test"
mock_core.relative_internal_path.side_effect = tmp_path.joinpath
# Verify all exist before
assert pioenvs_dir.exists()
assert piolibdeps_dir.exists()
assert dependencies_lock.exists()
assert idedata_cache.exists()
assert idf_build_dir.exists()
assert managed_components_dir.exists()
assert platformio_cache_dir.exists()
@@ -515,7 +492,6 @@ def test_clean_build(
assert not pioenvs_dir.exists()
assert not piolibdeps_dir.exists()
assert not dependencies_lock.exists()
assert not idedata_cache.exists()
assert not idf_build_dir.exists()
assert not managed_components_dir.exists()
assert not platformio_cache_dir.exists()
@@ -525,7 +501,6 @@ def test_clean_build(
assert ".pioenvs" in caplog.text
assert ".piolibdeps" in caplog.text
assert "dependencies.lock" in caplog.text
assert str(idedata_cache) in caplog.text
assert str(idf_build_dir) in caplog.text
assert str(managed_components_dir) in caplog.text
assert "PlatformIO cache" in caplog.text