mirror of
https://github.com/esphome/esphome.git
synced 2026-06-29 12:06:13 +00:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5ce8c324b0 | |||
| 4e70e0b4d7 | |||
| e63cb94f94 | |||
| ff968a4629 | |||
| d832ce51cd | |||
| d663d80fde | |||
| c5c627d534 | |||
| d046dd7276 | |||
| 56983f414f | |||
| a92b607754 | |||
| 313d974983 | |||
| 1d86d856d1 | |||
| 1bb191aa77 | |||
| 5d9d6e83f7 | |||
| f3d7743460 | |||
| f291dc8d2f | |||
| a8e69a15e4 | |||
| 7436d1c199 | |||
| 348b92910e | |||
| f89a6f4f9c | |||
| c3ee962b83 | |||
| e593cb6efc | |||
| d2107e40c8 | |||
| 78b60ac6fa |
@@ -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
@@ -1 +1 @@
|
||||
593fd53fa09944a59af3f38521e31d87fe10b60326b8d82bb76413c5149b312c
|
||||
27aaab4e0ebfc10491720345aa746fc2dffa6a3985f73ec111b12dd99078d46f
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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_();
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 1–100 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,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,
|
||||
|
||||
@@ -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()}")
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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_();
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
|
||||
@@ -50,8 +50,6 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CODEOWNERS = ["@luar123", "@tomaszduda23"]
|
||||
|
||||
CONFLICTS_WITH = ["openthread"]
|
||||
|
||||
BASE_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_REPORT): cv.All(
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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)}")
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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,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
@@ -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
|
||||
@@ -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!");
|
||||
|
||||
+11
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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 == ""
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user