From d759f1a56751207689f5db024181739c96d94646 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Thu, 23 Apr 2026 16:53:52 -0400 Subject: [PATCH 01/68] [audio_http] Add a media source for playing audio from HTTP URLs (#15741) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- CODEOWNERS | 1 + esphome/components/audio_http/__init__.py | 0 .../audio_http/audio_http_media_source.cpp | 163 ++++++++++++++++++ .../audio_http/audio_http_media_source.h | 59 +++++++ esphome/components/audio_http/media_source.py | 59 +++++++ tests/components/audio_http/common.yaml | 7 + .../components/audio_http/test.esp32-idf.yaml | 1 + 7 files changed, 290 insertions(+) create mode 100644 esphome/components/audio_http/__init__.py create mode 100644 esphome/components/audio_http/audio_http_media_source.cpp create mode 100644 esphome/components/audio_http/audio_http_media_source.h create mode 100644 esphome/components/audio_http/media_source.py create mode 100644 tests/components/audio_http/common.yaml create mode 100644 tests/components/audio_http/test.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 69f2cb1d178..be835aae3d9 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -56,6 +56,7 @@ esphome/components/audio_adc/* @kbx81 esphome/components/audio_dac/* @kbx81 esphome/components/audio_file/* @kahrendt esphome/components/audio_file/media_source/* @kahrendt +esphome/components/audio_http/* @kahrendt esphome/components/axs15231/* @clydebarrow esphome/components/b_parasite/* @rbaron esphome/components/ballu/* @bazuchan diff --git a/esphome/components/audio_http/__init__.py b/esphome/components/audio_http/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/esphome/components/audio_http/audio_http_media_source.cpp b/esphome/components/audio_http/audio_http_media_source.cpp new file mode 100644 index 00000000000..04b7d046e6b --- /dev/null +++ b/esphome/components/audio_http/audio_http_media_source.cpp @@ -0,0 +1,163 @@ +#include "audio_http_media_source.h" + +#ifdef USE_ESP32 + +#include "esphome/core/log.h" + +#include +#include + +#include + +namespace esphome::audio_http { + +static const char *const TAG = "audio_http_media_source"; + +// Decoder task / buffer tuning. Kept here as constants so the header stays free of magic numbers. +static constexpr size_t DEFAULT_TRANSFER_BUFFER_SIZE = 8 * 1024; // Staging buffer between HTTP reader and decoder +static constexpr uint32_t HTTP_TIMEOUT_MS = 5000; // HTTP connect/read timeout +static constexpr uint32_t AUDIO_WRITE_TIMEOUT_MS = 50; // Max blocking time per on_audio_write() call +static constexpr uint32_t READER_WRITE_TIMEOUT_MS = 50; // Max blocking time when writing into the ring buffer +static constexpr uint8_t READER_TASK_PRIORITY = 2; +static constexpr uint8_t DECODER_TASK_PRIORITY = 2; +static constexpr size_t READER_TASK_STACK_SIZE = 4096; +static constexpr size_t DECODER_TASK_STACK_SIZE = 5120; +static constexpr uint32_t PAUSE_POLL_DELAY_MS = 20; +static constexpr const char *const HTTP_URI_PREFIX = "http://"; +static constexpr const char *const HTTPS_URI_PREFIX = "https://"; + +void AudioHTTPMediaSource::dump_config() { + ESP_LOGCONFIG(TAG, + "Audio HTTP Media Source:\n" + " Buffer Size: %zu bytes\n" + " Decoder Task Stack in PSRAM: %s", + this->buffer_size_, YESNO(this->decoder_task_stack_in_psram_)); +} + +void AudioHTTPMediaSource::setup() { + this->disable_loop(); + + micro_decoder::DecoderConfig config; + config.ring_buffer_size = this->buffer_size_; + // Keep the transfer buffer smaller than the ring buffer so the reader can top up the ring + // while the decoder is still draining it, instead of oscillating between empty and full. + config.transfer_buffer_size = std::min(DEFAULT_TRANSFER_BUFFER_SIZE, this->buffer_size_ / 2); + config.http_timeout_ms = HTTP_TIMEOUT_MS; + config.audio_write_timeout_ms = AUDIO_WRITE_TIMEOUT_MS; + config.reader_write_timeout_ms = READER_WRITE_TIMEOUT_MS; + config.reader_priority = READER_TASK_PRIORITY; + config.decoder_priority = DECODER_TASK_PRIORITY; + config.reader_stack_size = READER_TASK_STACK_SIZE; + config.decoder_stack_size = DECODER_TASK_STACK_SIZE; + config.decoder_stack_in_psram = this->decoder_task_stack_in_psram_; + + this->decoder_ = std::make_unique(config); + if (this->decoder_ == nullptr) { + ESP_LOGE(TAG, "Failed to allocate decoder"); + this->mark_failed(); + return; + } + this->decoder_->set_listener(this); // We inherit from micro_decoder::DecoderListener +} + +void AudioHTTPMediaSource::loop() { this->decoder_->loop(); } + +bool AudioHTTPMediaSource::can_handle(const std::string &uri) const { + return uri.starts_with(HTTP_URI_PREFIX) || uri.starts_with(HTTPS_URI_PREFIX); +} + +// Called from the orchestrator's main loop, so no synchronization needed with loop() +bool AudioHTTPMediaSource::play_uri(const std::string &uri) { + if (!this->is_ready() || this->is_failed() || this->status_has_error() || !this->has_listener()) { + return false; + } + + // Check if source is already playing + if (this->get_state() != media_source::MediaSourceState::IDLE) { + ESP_LOGE(TAG, "Cannot play '%s': source is busy", uri.c_str()); + return false; + } + + // Validate URI starts with "http://" or "https://" + if (!uri.starts_with(HTTP_URI_PREFIX) && !uri.starts_with(HTTPS_URI_PREFIX)) { + ESP_LOGE(TAG, "Invalid URI: '%s'", uri.c_str()); + return false; + } + + if (this->decoder_->play_url(uri)) { + this->pause_.store(false, std::memory_order_relaxed); + this->enable_loop(); + return true; + } + + ESP_LOGE(TAG, "Failed to start playback of '%s'", uri.c_str()); + return false; +} + +// Called from the orchestrator's main loop, so no synchronization needed with loop() +void AudioHTTPMediaSource::handle_command(media_source::MediaSourceCommand command) { + switch (command) { + case media_source::MediaSourceCommand::STOP: + this->decoder_->stop(); + break; + case media_source::MediaSourceCommand::PAUSE: + // Only valid while actively playing; ignoring from IDLE/ERROR/PAUSED prevents the state + // machine from getting stuck in PAUSED when no playback is active (which would block the + // next play_uri() call via its IDLE-state precondition). + if (this->get_state() != media_source::MediaSourceState::PLAYING) + break; + // PAUSE does not stop the decoder task. Instead, on_audio_write() returns 0 and temporarily + // yields, which fills the ring buffer and applies back pressure that effectively pauses both + // the decoder and HTTP reader tasks. + this->set_state_(media_source::MediaSourceState::PAUSED); + this->pause_.store(true, std::memory_order_relaxed); + break; + case media_source::MediaSourceCommand::PLAY: + // Only resume from PAUSED; don't fabricate a PLAYING state from IDLE/ERROR. + if (this->get_state() != media_source::MediaSourceState::PAUSED) + break; + this->set_state_(media_source::MediaSourceState::PLAYING); + this->pause_.store(false, std::memory_order_relaxed); + break; + default: + break; + } +} + +// Called from the decoder task. Forwards to the orchestrator's listener, which is responsible for +// being thread-safe with respect to its own audio writer. +size_t AudioHTTPMediaSource::on_audio_write(const uint8_t *data, size_t length, uint32_t timeout_ms) { + if (this->pause_.load(std::memory_order_relaxed)) { + vTaskDelay(pdMS_TO_TICKS(PAUSE_POLL_DELAY_MS)); + return 0; + } + return this->write_output(data, length, timeout_ms, this->stream_info_); +} + +// Called from the decoder task before the first on_audio_write(). +void AudioHTTPMediaSource::on_stream_info(const micro_decoder::AudioStreamInfo &info) { + this->stream_info_ = audio::AudioStreamInfo(info.get_bits_per_sample(), info.get_channels(), info.get_sample_rate()); +} + +// microDecoder invokes on_state_change() from inside decoder_->loop(), so this runs on the main +// loop thread and it's safe to call set_state_() directly. +void AudioHTTPMediaSource::on_state_change(micro_decoder::DecoderState state) { + switch (state) { + case micro_decoder::DecoderState::IDLE: + this->set_state_(media_source::MediaSourceState::IDLE); + this->disable_loop(); + break; + case micro_decoder::DecoderState::PLAYING: + this->set_state_(media_source::MediaSourceState::PLAYING); + break; + case micro_decoder::DecoderState::FAILED: + this->set_state_(media_source::MediaSourceState::ERROR); + break; + default: + break; + } +} + +} // namespace esphome::audio_http + +#endif // USE_ESP32 diff --git a/esphome/components/audio_http/audio_http_media_source.h b/esphome/components/audio_http/audio_http_media_source.h new file mode 100644 index 00000000000..e4bd69e9e6f --- /dev/null +++ b/esphome/components/audio_http/audio_http_media_source.h @@ -0,0 +1,59 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_ESP32 + +#include "esphome/components/audio/audio.h" +#include "esphome/components/media_source/media_source.h" +#include "esphome/core/component.h" + +#include +#include + +#include +#include +#include + +namespace esphome::audio_http { + +// Inherits from two unrelated listener-style interfaces: +// - media_source::MediaSource: this source reports state and writes audio *to* an orchestrator +// (the orchestrator calls set_listener() on us with a MediaSourceListener*). +// - micro_decoder::DecoderListener: the underlying decoder calls back *into* us with decoded +// audio and state changes (we call decoder_->set_listener(this) in setup()). +// The two set_listener() methods live on different base classes and serve opposite directions. +class AudioHTTPMediaSource : public Component, public media_source::MediaSource, public micro_decoder::DecoderListener { + public: + void setup() override; + void loop() override; + void dump_config() override; + + void set_buffer_size(size_t buffer_size) { this->buffer_size_ = buffer_size; } + void set_task_stack_in_psram(bool task_stack_in_psram) { this->decoder_task_stack_in_psram_ = task_stack_in_psram; } + + // MediaSource interface implementation + bool play_uri(const std::string &uri) override; + void handle_command(media_source::MediaSourceCommand command) override; + bool can_handle(const std::string &uri) const override; + + // DecoderListener interface implementation + size_t on_audio_write(const uint8_t *data, size_t length, uint32_t timeout_ms) override; + void on_stream_info(const micro_decoder::AudioStreamInfo &info) override; + void on_state_change(micro_decoder::DecoderState state) override; + + protected: + std::unique_ptr decoder_; + audio::AudioStreamInfo stream_info_; + + size_t buffer_size_{50000}; + + // Written from the main loop in handle_command(), read from the decoder task in + // on_audio_write(). Must be atomic to avoid a data race. + std::atomic pause_{false}; + bool decoder_task_stack_in_psram_{false}; +}; + +} // namespace esphome::audio_http + +#endif // USE_ESP32 diff --git a/esphome/components/audio_http/media_source.py b/esphome/components/audio_http/media_source.py new file mode 100644 index 00000000000..519d8df698e --- /dev/null +++ b/esphome/components/audio_http/media_source.py @@ -0,0 +1,59 @@ +from typing import Any + +import esphome.codegen as cg +from esphome.components import audio, esp32, media_source, psram +import esphome.config_validation as cv +from esphome.const import CONF_BUFFER_SIZE, CONF_ID, CONF_TASK_STACK_IN_PSRAM +from esphome.types import ConfigType + +CODEOWNERS = ["@kahrendt"] +AUTO_LOAD = ["audio"] + +audio_http_ns = cg.esphome_ns.namespace("audio_http") +AudioHTTPMediaSource = audio_http_ns.class_( + "AudioHTTPMediaSource", cg.Component, media_source.MediaSource +) + + +def _request_micro_decoder(config: ConfigType) -> ConfigType: + audio.request_micro_decoder_support() + return config + + +def _validate_task_stack_in_psram(value: Any) -> bool: + # Only require the psram component when actually enabling PSRAM stacks; validating + # the boolean first means `false` doesn't trigger the requires_component check. + if value := cv.boolean(value): + return cv.requires_component(psram.DOMAIN)(value) + return value + + +CONFIG_SCHEMA = cv.All( + media_source.media_source_schema( + AudioHTTPMediaSource, + ) + .extend( + { + cv.Optional(CONF_BUFFER_SIZE, default=50000): cv.int_range( + min=5000, max=1000000 + ), + cv.Optional(CONF_TASK_STACK_IN_PSRAM): _validate_task_stack_in_psram, + } + ) + .extend(cv.COMPONENT_SCHEMA), + cv.only_on_esp32, + _request_micro_decoder, +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await media_source.register_media_source(var, config) + + if config.get(CONF_TASK_STACK_IN_PSRAM): + cg.add(var.set_task_stack_in_psram(True)) + esp32.add_idf_sdkconfig_option( + "CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY", True + ) + cg.add(var.set_buffer_size(config[CONF_BUFFER_SIZE])) diff --git a/tests/components/audio_http/common.yaml b/tests/components/audio_http/common.yaml new file mode 100644 index 00000000000..b7457165a59 --- /dev/null +++ b/tests/components/audio_http/common.yaml @@ -0,0 +1,7 @@ +psram: + +media_source: + - platform: audio_http + id: audio_http_source + buffer_size: 100000 + task_stack_in_psram: true diff --git a/tests/components/audio_http/test.esp32-idf.yaml b/tests/components/audio_http/test.esp32-idf.yaml new file mode 100644 index 00000000000..dade44d145b --- /dev/null +++ b/tests/components/audio_http/test.esp32-idf.yaml @@ -0,0 +1 @@ +<<: !include common.yaml From 90d7bfe02ea4f5fa61fdcec4a0645aa95da88b53 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Apr 2026 16:36:32 -0500 Subject: [PATCH 02/68] [ci] Auto-close PRs opened from a fork's default branch (#15957) --- .../close-pr-from-fork-default-branch.yml | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 .github/workflows/close-pr-from-fork-default-branch.yml diff --git a/.github/workflows/close-pr-from-fork-default-branch.yml b/.github/workflows/close-pr-from-fork-default-branch.yml new file mode 100644 index 00000000000..1cd70f5efcd --- /dev/null +++ b/.github/workflows/close-pr-from-fork-default-branch.yml @@ -0,0 +1,72 @@ +name: Close PR From Fork Default Branch + +on: + # pull_request_target is required so we have permission to comment and close PRs from forks. + pull_request_target: + types: [opened, reopened] + +permissions: + pull-requests: write + issues: write + +jobs: + close: + name: Close PR opened from fork's default branch + runs-on: ubuntu-latest + if: >- + github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name + && github.event.pull_request.head.ref == github.event.repository.default_branch + steps: + - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { owner, repo } = context.repo; + const prNumber = context.payload.pull_request.number; + const author = context.payload.pull_request.user.login; + const defaultBranch = context.payload.repository.default_branch; + const headRepo = context.payload.pull_request.head.repo.full_name; + + const body = [ + `Hi @${author}, thanks for opening a pull request! :tada:`, + ``, + `It looks like this PR was opened from the \`${defaultBranch}\` branch of your fork (\`${headRepo}\`), which is the same name as this repository's default branch. Working directly on \`${defaultBranch}\` in your fork causes a few problems:`, + ``, + `- Your fork's \`${defaultBranch}\` branch will permanently diverge from \`esphome/esphome:${defaultBranch}\`, making it hard to keep your fork up to date.`, + `- Any additional commits you push to \`${defaultBranch}\` will be added to this PR, so you can't easily work on multiple changes at once.`, + `- Pushing maintainer fixes to your branch is awkward, since it means committing directly to your fork's default branch.`, + `- It makes local collaboration painful — \`${defaultBranch}\` in a checkout becomes ambiguous between upstream and your fork, and maintainers end up with naming collisions when fetching your branch.`, + ``, + `Please re-open this as a new PR from a dedicated feature branch. The usual flow looks like:`, + ``, + `\`\`\`bash`, + `# Make sure your fork's ${defaultBranch} is up to date with upstream`, + `git remote add upstream https://github.com/${owner}/${repo}.git # if you haven't already`, + `git fetch upstream`, + `git checkout ${defaultBranch}`, + `git reset --hard upstream/${defaultBranch}`, + `git push --force-with-lease origin ${defaultBranch}`, + ``, + `# Create a new branch for your change and cherry-pick / re-apply your commits there`, + `git checkout -b my-feature-branch upstream/${defaultBranch}`, + `# ...re-apply your changes, then:`, + `git push origin my-feature-branch`, + `\`\`\``, + ``, + `Then open a new pull request from \`my-feature-branch\` into \`${owner}/${repo}:${defaultBranch}\`.`, + ``, + `Closing this PR for now — sorry for the friction, and thanks again for contributing! :heart:`, + ].join('\n'); + + await github.rest.issues.createComment({ + owner, + repo, + issue_number: prNumber, + body, + }); + + await github.rest.pulls.update({ + owner, + repo, + pull_number: prNumber, + state: 'closed', + }); From ddf1426f8622daf68e4be187d243fcdf410bda28 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Thu, 23 Apr 2026 18:09:36 -0400 Subject: [PATCH 03/68] [sendspin] Add initial Sendspin hub component (PR1) (#15924) Co-authored-by: Copilot --- CODEOWNERS | 1 + esphome/components/sendspin/__init__.py | 146 ++++++++++++++++++ esphome/components/sendspin/sendspin_hub.cpp | 143 +++++++++++++++++ esphome/components/sendspin/sendspin_hub.h | 138 +++++++++++++++++ esphome/core/defines.h | 5 + esphome/idf_component.yml | 2 + tests/components/sendspin/common.yaml | 9 ++ tests/components/sendspin/test.esp32-idf.yaml | 1 + 8 files changed, 445 insertions(+) create mode 100644 esphome/components/sendspin/__init__.py create mode 100644 esphome/components/sendspin/sendspin_hub.cpp create mode 100644 esphome/components/sendspin/sendspin_hub.h create mode 100644 tests/components/sendspin/common.yaml create mode 100644 tests/components/sendspin/test.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index be835aae3d9..facfdb1705e 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -440,6 +440,7 @@ esphome/components/sen0321/* @notjj esphome/components/sen21231/* @shreyaskarnik esphome/components/sen5x/* @martgras esphome/components/sen6x/* @martgras @mebner86 @mikelawrence @tuct +esphome/components/sendspin/* @kahrendt esphome/components/sensirion_common/* @martgras esphome/components/sensor/* @esphome/core esphome/components/serial_proxy/* @kbx81 diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py new file mode 100644 index 00000000000..d86c5d6dab2 --- /dev/null +++ b/esphome/components/sendspin/__init__.py @@ -0,0 +1,146 @@ +from dataclasses import dataclass + +import esphome.codegen as cg +from esphome.components import esp32, network, psram, socket, wifi +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_TASK_STACK_IN_PSRAM +from esphome.core import CORE +from esphome.types import ConfigType + +# mdns for autodiscovery +AUTO_LOAD = ["mdns"] +CODEOWNERS = ["@kahrendt"] +DEPENDENCIES = ["network"] +DOMAIN = "sendspin" + +# Trailing underscore avoids clashing with sendspin-cpp's global `sendspin` namespace. +# Analysis tools strip the trailing underscore (same pattern as `template_`). +sendspin_ns = cg.esphome_ns.namespace("sendspin_") +SendspinHub = sendspin_ns.class_( + "SendspinHub", + cg.Component, +) + + +@dataclass +class SendspinConfiguration: + artwork_support: bool = False + controller_support: bool = False + metadata_support: bool = False + player_support: bool = False + visualizer_support: bool = False + + +def _get_data() -> SendspinConfiguration: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = SendspinConfiguration() + return CORE.data[DOMAIN] + + +def request_artwork_support() -> None: + """Request artwork role support for Sendspin.""" + _get_data().artwork_support = True + + +def request_controller_support() -> None: + """Request controller role support for Sendspin.""" + _get_data().controller_support = True + + +def request_metadata_support() -> None: + """Request metadata role support for Sendspin.""" + _get_data().metadata_support = True + + +def request_player_support() -> None: + """Request player role support for Sendspin.""" + _get_data().player_support = True + + +def request_visualizer_support() -> None: + """Request visualizer role support for Sendspin.""" + _get_data().visualizer_support = True + + +def _validate_task_stack_in_psram(value): + value = cv.boolean(value) + if value: + return cv.requires_component(psram.DOMAIN)(value) + return value + + +def _request_high_performance_networking(config: ConfigType) -> ConfigType: + """Request high performance networking for Sendspin streaming. + + Also enables wake_loop_threadsafe support for fast defer() callbacks + from background threads (WebSocket handler, image decoder). + """ + network.require_high_performance_networking() + # Socket consumption varies by mode: + # - Server mode: 1 listening socket + 2 client connections (for handoff) + # - Client mode: 1 outbound connection + socket.consume_sockets( + 1, "sendspin_websocket_server", socket.SocketType.TCP_LISTEN + )(config) + socket.consume_sockets(2, "sendspin_websocket_server")(config) + socket.consume_sockets(1, "sendspin_websocket_client")(config) + + wifi.enable_runtime_power_save_control() + return config + + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(SendspinHub), + cv.Optional(CONF_TASK_STACK_IN_PSRAM): _validate_task_stack_in_psram, + } + ), + cv.only_on_esp32, + _request_high_performance_networking, +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + + if config.get(CONF_TASK_STACK_IN_PSRAM): + cg.add(var.set_task_stack_in_psram(True)) + esp32.add_idf_sdkconfig_option( + "CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY", True + ) + + # sendspin-cpp library + esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.3.0") + + cg.add_define("USE_SENDSPIN", True) # for MDNS + + data = _get_data() + + # Configure Sendspin roles based on requested features (ESPHome internally via USE_SENDSPIN_*) + # and disable building unused code paths in the sendspin-cpp library (IDF SDKConfig via CONFIG_SENDSPIN_ENABLE_*). + if data.artwork_support: + cg.add_define("USE_SENDSPIN_ARTWORK", True) + else: + esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_ARTWORK", False) + + if data.controller_support: + cg.add_define("USE_SENDSPIN_CONTROLLER", True) + else: + esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_CONTROLLER", False) + + if data.metadata_support: + cg.add_define("USE_SENDSPIN_METADATA", True) + else: + esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_METADATA", False) + + if data.player_support: + cg.add_define("USE_SENDSPIN_PLAYER", True) + else: + esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_PLAYER", False) + + if data.visualizer_support: + cg.add_define("USE_SENDSPIN_VISUALIZER", True) + else: + esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_VISUALIZER", False) diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp new file mode 100644 index 00000000000..94338887946 --- /dev/null +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -0,0 +1,143 @@ +#include "sendspin_hub.h" + +#ifdef USE_ESP32 + +#include "esphome/components/network/util.h" +#ifdef USE_WIFI +#include "esphome/components/wifi/wifi_component.h" +#endif + +#include "esphome/core/application.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" +#include "esphome/core/version.h" + +#include + +namespace esphome::sendspin_ { + +static const char *const TAG = "sendspin.hub"; + +void SendspinHub::setup() { + auto config = this->build_client_config_(); + this->client_ = std::make_unique(std::move(config)); + + // Set up persistence (preferences must be initialized before providers are added to the client) + this->last_played_server_pref_ = + global_preferences->make_preference(fnv1a_hash("sendspin_last_played")); + + // Wire providers and client listener + this->client_->set_listener(this); + this->client_->set_network_provider(this); + this->client_->set_persistence_provider(this); + + if (!this->client_->start_server()) { + ESP_LOGE(TAG, "Failed to start Sendspin server"); + this->mark_failed(); + return; + } +} + +void SendspinHub::loop() { this->client_->loop(); } + +void SendspinHub::dump_config() { + char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + ESP_LOGCONFIG(TAG, + "Sendspin Hub:\n" + " Client ID: %s\n" + " Task stack in PSRAM: %s", + get_mac_address_pretty_into_buffer(mac_buf), YESNO(this->task_stack_in_psram_)); +} + +// --- Delegating methods --- + +// THREAD CONTEXT: Main loop (invoked from Sendspin components) +void SendspinHub::connect_to_server(const std::string &url) { + if (this->is_ready()) { + this->client_->connect_to(url); + } +} + +// THREAD CONTEXT: Main loop (invoked from Sendspin components) +void SendspinHub::disconnect_from_server(sendspin::SendspinGoodbyeReason reason) { + if (this->is_ready()) { + this->client_->disconnect(reason); + } +} + +// THREAD CONTEXT: Main loop (invoked from Sendspin components) +void SendspinHub::update_state(sendspin::SendspinClientState state) { + if (this->is_ready()) { + this->client_->update_state(state); + } +} + +sendspin::SendspinClientConfig SendspinHub::build_client_config_() { + sendspin::SendspinClientConfig config; + + char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + config.client_id = get_mac_address_pretty_into_buffer(mac_buf); + config.name = App.get_friendly_name(); + config.product_name = App.get_name(); + config.manufacturer = "ESPHome"; + config.software_version = ESPHOME_VERSION; + config.httpd_psram_stack = this->task_stack_in_psram_; + + return config; +} + +// --- SendspinClientListener overrides --- +// THREAD CONTEXT: Main loop (fired from client_->loop()) + +void SendspinHub::on_group_update(const sendspin::GroupUpdateObject &group) { + this->group_update_callbacks_.call(group); +} + +void SendspinHub::on_request_high_performance() { +#ifdef USE_WIFI + if (wifi::global_wifi_component != nullptr) { + wifi::global_wifi_component->request_high_performance(); + } +#endif +} + +void SendspinHub::on_release_high_performance() { +#ifdef USE_WIFI + if (wifi::global_wifi_component != nullptr) { + wifi::global_wifi_component->release_high_performance(); + } +#endif +} + +// --- SendspinNetworkProvider override --- + +// THREAD CONTEXT: Main loop (polled by client_->loop()) +bool SendspinHub::is_network_ready() { return network::is_connected(); } + +// --- SendspinPersistenceProvider overrides --- + +// THREAD CONTEXT: Main loop (invoked by client_->loop() during lifecycle events) +bool SendspinHub::save_last_server_hash(uint32_t hash) { + LastPlayedServerPref pref{.server_id_hash = hash}; + bool ok = this->last_played_server_pref_.save(&pref); + if (ok) { + ESP_LOGD(TAG, "Persisted last played server hash: 0x%08X", hash); + } else { + ESP_LOGW(TAG, "Failed to persist last played server hash"); + } + return ok; +} + +// THREAD CONTEXT: Main loop (invoked by client_->loop() during lifecycle events) +std::optional SendspinHub::load_last_server_hash() { + LastPlayedServerPref pref{}; + if (this->last_played_server_pref_.load(&pref)) { + ESP_LOGI(TAG, "Loaded last played server hash: 0x%08X", pref.server_id_hash); + return pref.server_id_hash; + } + return std::nullopt; +} + +} // namespace esphome::sendspin_ + +#endif // USE_ESP32 diff --git a/esphome/components/sendspin/sendspin_hub.h b/esphome/components/sendspin/sendspin_hub.h new file mode 100644 index 00000000000..4402d25fbd1 --- /dev/null +++ b/esphome/components/sendspin/sendspin_hub.h @@ -0,0 +1,138 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_ESP32 + +#include "esphome/core/automation.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" +#include "esphome/core/preferences.h" + +#include +#include +#include + +#include +#include +#include + +namespace esphome::sendspin_ { + +/// @brief Setup priorities for the sendspin hub and its child components. +/// +/// Centralized here so every sendspin component orders itself relative to the hub +/// without each subcomponent having to pick a priority independently. Children run +/// one step later than hub so they can assume hub's setup() has already completed. +namespace sendspin_priority { +inline constexpr float HUB = esphome::setup_priority::PROCESSOR; +inline constexpr float CHILD = HUB - 1.0f; +} // namespace sendspin_priority + +/// @brief Persistent storage structure for last played server hash. +struct LastPlayedServerPref { + uint32_t server_id_hash; +}; + +/// @brief Thin adapter over sendspin::SendspinClient. +/// +/// The hub owns a SendspinClient instance and bridges its listener/provider interfaces to ESPHome's CallbackManager for +/// fan-out to child components. +/// - Provides persistence via ESPPreferenceObject and WiFi power management integration. +/// - Handles Sendspin roles that apply to multiple child components (artwork, controller, metadata) so their events +/// can be fanned out. Roles specific to a single component (player) are configured by the hub but owned by the +/// child thereafter, since no fan-out is needed. +/// +/// The sendspin-cpp library follows this design: +/// - Core and role configuration are passed at client/role construction time as structs. Built in our `setup()`. +/// - Library -> user code communication happens via two interface types the user implements and registers in our +/// `setup()`: listener interfaces (for events the library pushes; e.g., group updates) and provider interfaces +/// (for services the library pulls; e.g., persistence, network readiness). +/// - User -> library communication uses exposed functions on the client and role objects that the user calls. +class SendspinHub final : public Component, + public sendspin::SendspinClientListener, + public sendspin::SendspinNetworkProvider, + public sendspin::SendspinPersistenceProvider { + public: + float get_setup_priority() const override { return sendspin_priority::HUB; } + void setup() override; + void loop() override; + void dump_config() override; + + /// @brief Connects the underlying client to the given Sendspin server. + /// + /// No-op if the hub's client is not ready (e.g. setup() has not completed). + /// Must be called from the main loop thread. + /// @param url WebSocket URL of the Sendspin server, starting with `ws://` (e.g. `ws://host:port/path`). + void connect_to_server(const std::string &url); + + /// @brief Disconnects the underlying client from the current server. + /// + /// Sends a `client/goodbye` message with the given reason before closing the connection. + /// No-op if the hub's client is not ready. Must be called from the main loop thread. + /// @param reason Reason reported to the server: + /// - `ANOTHER_SERVER`: client is switching to another server. + /// - `SHUTDOWN`: client is shutting down. + /// - `RESTART`: client is restarting. + /// - `USER_REQUEST`: user explicitly requested disconnect. + void disconnect_from_server(sendspin::SendspinGoodbyeReason reason); + + /// @brief Updates the client's reported playback state on the server. + /// + /// No-op if the hub's client is not ready. Must be called from the main loop thread. + /// @param state New client state: + /// - `SYNCHRONIZED`: client is synchronized and playing from the server. + /// - `ERROR`: client encountered a playback error. + /// - `EXTERNAL_SOURCE`: client is playing from a non-Sendspin source. + void update_state(sendspin::SendspinClientState state); + + // --- Configuration setters (called from codegen) --- + + template void add_group_update_callback(F &&callback) { + this->group_update_callbacks_.add(std::forward(callback)); + } + + void set_task_stack_in_psram(bool task_stack_in_psram) { this->task_stack_in_psram_ = task_stack_in_psram; } + + protected: + /// @brief Builds the SendspinClientConfig from ESPHome configuration and platform info. + sendspin::SendspinClientConfig build_client_config_(); + + // --- SendspinClientListener overrides --- + void on_group_update(const sendspin::GroupUpdateObject &group) override; + + void on_request_high_performance() override; + + void on_release_high_performance() override; + + // --- SendspinNetworkProvider override --- + bool is_network_ready() override; + + // --- SendspinPersistenceProvider overrides --- + bool save_last_server_hash(uint32_t hash) override; + std::optional load_last_server_hash() override; + + ESPPreferenceObject last_played_server_pref_; + + std::unique_ptr client_; + + // Callback fan-out to child components + CallbackManager group_update_callbacks_{}; + + bool task_stack_in_psram_{false}; +}; + +/// @brief Base class for all sendspin subcomponents. +/// +/// Consolidates the Component + Parented inheritance and pins the setup +/// priority so the hub's setup() always runs before any child. Subcomponents should +/// inherit from this instead of listing Component/Parented individually and must not +/// override get_setup_priority(). +class SendspinChild : public Component, public Parented { + public: + float get_setup_priority() const override { return sendspin_priority::CHILD; } +}; + +} // namespace esphome::sendspin_ + +#endif // USE_ESP32 diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 9b751dd8c0e..80247f69da1 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -257,6 +257,11 @@ #define USE_MICROPHONE #define USE_PSRAM #define USE_SENDSPIN +#define USE_SENDSPIN_ARTWORK +#define USE_SENDSPIN_CONTROLLER +#define USE_SENDSPIN_METADATA +#define USE_SENDSPIN_PLAYER +#define USE_SENDSPIN_VISUALIZER #define USE_SENDSPIN_PORT 8928 // NOLINT #define USE_SOCKET_IMPL_BSD_SOCKETS #define USE_LWIP_FAST_SELECT diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index c590f73642a..f422d94097d 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -91,5 +91,7 @@ dependencies: - if: "idf_version >=6.0.0 && target in [esp32s2, esp32s3, esp32p4]" esp32async/asynctcp: version: 3.4.91 + sendspin/sendspin-cpp: + version: 0.3.0 lvgl/lvgl: version: 9.5.0 diff --git a/tests/components/sendspin/common.yaml b/tests/components/sendspin/common.yaml new file mode 100644 index 00000000000..9d7da76758a --- /dev/null +++ b/tests/components/sendspin/common.yaml @@ -0,0 +1,9 @@ +wifi: + ap: + +psram: + mode: quad + +sendspin: + id: sendspin_hub_id + task_stack_in_psram: true diff --git a/tests/components/sendspin/test.esp32-idf.yaml b/tests/components/sendspin/test.esp32-idf.yaml new file mode 100644 index 00000000000..dade44d145b --- /dev/null +++ b/tests/components/sendspin/test.esp32-idf.yaml @@ -0,0 +1 @@ +<<: !include common.yaml From b4a86e46b256020be129a67c59b48ccf3e5c3311 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Thu, 23 Apr 2026 21:22:47 -0400 Subject: [PATCH 04/68] [sendspin] Add controller role and sendspin.switch action (PR2) (#15929) Co-authored-by: Copilot --- esphome/components/sendspin/__init__.py | 46 ++++++++++++++++++- esphome/components/sendspin/automation.h | 25 ++++++++++ esphome/components/sendspin/sendspin_hub.cpp | 22 +++++++++ esphome/components/sendspin/sendspin_hub.h | 31 +++++++++++++ tests/components/sendspin/common-action.yaml | 8 ++++ .../sendspin/test-action.esp32-idf.yaml | 1 + 6 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 esphome/components/sendspin/automation.h create mode 100644 tests/components/sendspin/common-action.yaml create mode 100644 tests/components/sendspin/test-action.esp32-idf.yaml diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index d86c5d6dab2..166d3fd70da 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -1,10 +1,12 @@ from dataclasses import dataclass +from esphome import automation import esphome.codegen as cg from esphome.components import esp32, network, psram, socket, wifi import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_TASK_STACK_IN_PSRAM -from esphome.core import CORE +from esphome.core import CORE, ID +from esphome.cpp_generator import TemplateArgsType from esphome.types import ConfigType # mdns for autodiscovery @@ -22,6 +24,13 @@ SendspinHub = sendspin_ns.class_( ) +SendspinSwitchCommandAction = sendspin_ns.class_( + "SendspinSwitchCommandAction", + automation.Action, + cg.Parented.template(SendspinHub), +) + + @dataclass class SendspinConfiguration: artwork_support: bool = False @@ -101,6 +110,41 @@ CONFIG_SCHEMA = cv.All( ) +def _request_controller_role(config: ConfigType) -> ConfigType: + """Request the controller role for the sendspin.switch action.""" + request_controller_support() + return config + + +SENDSPIN_SIMPLE_ACTION_SCHEMA = cv.All( + automation.maybe_simple_id( + cv.Schema( + { + cv.GenerateID(): cv.use_id(SendspinHub), + } + ) + ), + _request_controller_role, +) + + +@automation.register_action( + "sendspin.switch", + SendspinSwitchCommandAction, + SENDSPIN_SIMPLE_ACTION_SCHEMA, + synchronous=True, +) +async def sendspin_switch_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +): + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + return var + + async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/sendspin/automation.h b/esphome/components/sendspin/automation.h new file mode 100644 index 00000000000..be3b1eb39d3 --- /dev/null +++ b/esphome/components/sendspin/automation.h @@ -0,0 +1,25 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_ESP32 + +#include "esphome/core/automation.h" +#include "sendspin_hub.h" + +namespace esphome::sendspin_ { + +#ifdef USE_SENDSPIN_CONTROLLER +template class SendspinSwitchCommandAction : public Action, public Parented { + public: + void play(const Ts &...x) override { + // Clear any EXTERNAL_SOURCE state so the switch command is followed + this->parent_->update_state(sendspin::SendspinClientState::SYNCHRONIZED); + this->parent_->send_client_command(sendspin::SendspinControllerCommand::SWITCH); + } +}; +#endif // USE_SENDSPIN_CONTROLLER + +} // namespace esphome::sendspin_ + +#endif // USE_ESP32 diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp index 94338887946..ec419f77412 100644 --- a/esphome/components/sendspin/sendspin_hub.cpp +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -31,6 +31,11 @@ void SendspinHub::setup() { this->client_->set_network_provider(this); this->client_->set_persistence_provider(this); +#ifdef USE_SENDSPIN_CONTROLLER + this->controller_role_ = &this->client_->add_controller(); + this->controller_role_->set_listener(this); +#endif + if (!this->client_->start_server()) { ESP_LOGE(TAG, "Failed to start Sendspin server"); this->mark_failed(); @@ -138,6 +143,23 @@ std::optional SendspinHub::load_last_server_hash() { return std::nullopt; } +// --- Sendspin role specific methods/overrides --- + +#ifdef USE_SENDSPIN_CONTROLLER +// THREAD CONTEXT: Main loop (invoked from ESPHome actions / other components) +void SendspinHub::send_client_command(sendspin::SendspinControllerCommand command, std::optional volume, + std::optional mute) { + if (this->is_ready()) { + this->controller_role_->send_command(command, volume, mute); + } +} + +// THREAD CONTEXT: Main loop (ControllerRoleListener override, fired from client_->loop()) +void SendspinHub::on_controller_state(const sendspin::ServerStateControllerObject &state) { + this->controller_state_callbacks_.call(state); +} +#endif + } // namespace esphome::sendspin_ #endif // USE_ESP32 diff --git a/esphome/components/sendspin/sendspin_hub.h b/esphome/components/sendspin/sendspin_hub.h index 4402d25fbd1..1e217e0ea2e 100644 --- a/esphome/components/sendspin/sendspin_hub.h +++ b/esphome/components/sendspin/sendspin_hub.h @@ -13,6 +13,10 @@ #include #include +#ifdef USE_SENDSPIN_CONTROLLER +#include +#endif + #include #include #include @@ -50,6 +54,9 @@ struct LastPlayedServerPref { /// (for services the library pulls; e.g., persistence, network readiness). /// - User -> library communication uses exposed functions on the client and role objects that the user calls. class SendspinHub final : public Component, +#ifdef USE_SENDSPIN_CONTROLLER + public sendspin::ControllerRoleListener, +#endif public sendspin::SendspinClientListener, public sendspin::SendspinNetworkProvider, public sendspin::SendspinPersistenceProvider { @@ -94,6 +101,17 @@ class SendspinHub final : public Component, void set_task_stack_in_psram(bool task_stack_in_psram) { this->task_stack_in_psram_ = task_stack_in_psram; } + // --- Sendspin role specific methods --- + +#ifdef USE_SENDSPIN_CONTROLLER + void send_client_command(sendspin::SendspinControllerCommand command, std::optional volume = std::nullopt, + std::optional mute = std::nullopt); + + template void add_controller_state_callback(F &&callback) { + this->controller_state_callbacks_.add(std::forward(callback)); + } +#endif + protected: /// @brief Builds the SendspinClientConfig from ESPHome configuration and platform info. sendspin::SendspinClientConfig build_client_config_(); @@ -112,6 +130,19 @@ class SendspinHub final : public Component, bool save_last_server_hash(uint32_t hash) override; std::optional load_last_server_hash() override; + // --- Sendspin role specific methods/overrides/member variables --- + +#ifdef USE_SENDSPIN_CONTROLLER + sendspin::ControllerRole *controller_role_{nullptr}; + + void on_controller_state(const sendspin::ServerStateControllerObject &state) override; + + // Callback fan-out to child components; they filter as needed + CallbackManager controller_state_callbacks_{}; +#endif + + // --- Core member variables --- + ESPPreferenceObject last_played_server_pref_; std::unique_ptr client_; diff --git a/tests/components/sendspin/common-action.yaml b/tests/components/sendspin/common-action.yaml new file mode 100644 index 00000000000..16f19ad7d17 --- /dev/null +++ b/tests/components/sendspin/common-action.yaml @@ -0,0 +1,8 @@ +# `sendspin.switch` action enables the controller role, so we use a standalone test +packages: + base: !include common.yaml + +wifi: + on_connect: + then: + - sendspin.switch: diff --git a/tests/components/sendspin/test-action.esp32-idf.yaml b/tests/components/sendspin/test-action.esp32-idf.yaml new file mode 100644 index 00000000000..70a7ee1bade --- /dev/null +++ b/tests/components/sendspin/test-action.esp32-idf.yaml @@ -0,0 +1 @@ +<<: !include common-action.yaml From 3ccaa771a7423f95cc1bd4cb1b5a77d5b7f04324 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Thu, 23 Apr 2026 21:46:25 -0400 Subject: [PATCH 05/68] [sendspin] Add a group media player controller (PR3) (#15948) Co-authored-by: Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/sendspin/__init__.py | 2 + .../sendspin/media_player/__init__.py | 45 +++++ .../media_player/sendspin_media_player.cpp | 165 ++++++++++++++++++ .../media_player/sendspin_media_player.h | 33 ++++ .../sendspin/common-media_player.yaml | 5 + .../sendspin/test-media_player.esp32-idf.yaml | 1 + 7 files changed, 252 insertions(+) create mode 100644 esphome/components/sendspin/media_player/__init__.py create mode 100644 esphome/components/sendspin/media_player/sendspin_media_player.cpp create mode 100644 esphome/components/sendspin/media_player/sendspin_media_player.h create mode 100644 tests/components/sendspin/common-media_player.yaml create mode 100644 tests/components/sendspin/test-media_player.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index facfdb1705e..65db6ca25ed 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -441,6 +441,7 @@ esphome/components/sen21231/* @shreyaskarnik esphome/components/sen5x/* @martgras esphome/components/sen6x/* @martgras @mebner86 @mikelawrence @tuct esphome/components/sendspin/* @kahrendt +esphome/components/sendspin/media_player/* @kahrendt esphome/components/sensirion_common/* @martgras esphome/components/sensor/* @esphome/core esphome/components/serial_proxy/* @kbx81 diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index 166d3fd70da..2d053903789 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -15,6 +15,8 @@ CODEOWNERS = ["@kahrendt"] DEPENDENCIES = ["network"] DOMAIN = "sendspin" +CONF_SENDSPIN_ID = "sendspin_id" + # Trailing underscore avoids clashing with sendspin-cpp's global `sendspin` namespace. # Analysis tools strip the trailing underscore (same pattern as `template_`). sendspin_ns = cg.esphome_ns.namespace("sendspin_") diff --git a/esphome/components/sendspin/media_player/__init__.py b/esphome/components/sendspin/media_player/__init__.py new file mode 100644 index 00000000000..4aaee8cd897 --- /dev/null +++ b/esphome/components/sendspin/media_player/__init__.py @@ -0,0 +1,45 @@ +import esphome.codegen as cg +from esphome.components import media_player +from esphome.components.const import CONF_VOLUME_INCREMENT +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.types import ConfigType + +from .. import CONF_SENDSPIN_ID, SendspinHub, request_controller_support, sendspin_ns + +CODEOWNERS = ["@kahrendt"] +DEPENDENCIES = ["sendspin"] + +SendspinMediaPlayer = sendspin_ns.class_( + "SendspinMediaPlayer", + media_player.MediaPlayer, + cg.Component, +) + + +def _request_roles(config: ConfigType) -> ConfigType: + """Request the necessary Sendspin roles for the media player.""" + request_controller_support() + + return config + + +CONFIG_SCHEMA = cv.All( + media_player.media_player_schema(SendspinMediaPlayer).extend( + { + cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub), + cv.Optional(CONF_VOLUME_INCREMENT, default=0.05): cv.percentage, + } + ), + cv.only_on_esp32, + _request_roles, +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await cg.register_parented(var, config[CONF_SENDSPIN_ID]) + await media_player.register_media_player(var, config) + + cg.add(var.set_volume_increment(config[CONF_VOLUME_INCREMENT])) diff --git a/esphome/components/sendspin/media_player/sendspin_media_player.cpp b/esphome/components/sendspin/media_player/sendspin_media_player.cpp new file mode 100644 index 00000000000..beb20286896 --- /dev/null +++ b/esphome/components/sendspin/media_player/sendspin_media_player.cpp @@ -0,0 +1,165 @@ +#include "sendspin_media_player.h" + +#if defined(USE_ESP32) && defined(USE_MEDIA_PLAYER) && defined(USE_SENDSPIN_CONTROLLER) + +#include "esphome/core/application.h" +#include "esphome/core/log.h" + +#include + +#include +#include +#include +#include + +#include + +namespace esphome::sendspin_ { + +static const char *const TAG = "sendspin.media_player"; + +// THREAD CONTEXT: Main loop. The callbacks registered here also fire on the main loop, +// since SendspinHub dispatches group updates and controller state from client_->loop(). +void SendspinMediaPlayer::setup() { + // Register for group updates to sync playback state + this->parent_->add_group_update_callback([this](const sendspin::GroupUpdateObject &group_obj) { + if (group_obj.playback_state.has_value()) { + media_player::MediaPlayerState new_state; + switch (group_obj.playback_state.value()) { + case sendspin::SendspinPlaybackState::PLAYING: + new_state = media_player::MEDIA_PLAYER_STATE_PLAYING; + break; + case sendspin::SendspinPlaybackState::STOPPED: + default: + new_state = media_player::MEDIA_PLAYER_STATE_IDLE; + break; + } + if (this->state != new_state) { + this->state = new_state; + this->publish_state(); + ESP_LOGD(TAG, "State changed to %s", media_player::media_player_state_to_string(this->state)); + } + } + }); + + this->parent_->add_controller_state_callback([this](const sendspin::ServerStateControllerObject &state) { + float new_volume = static_cast(state.volume) / 100.0f; + bool new_muted = state.muted; + if ((new_volume != this->volume) || (new_muted != this->muted_)) { + this->volume = new_volume; + this->muted_ = new_muted; + this->publish_state(); + } + }); + + // Publish an initial state + this->state = media_player::MEDIA_PLAYER_STATE_IDLE; + this->publish_state(); +} + +// THREAD CONTEXT: Main loop (invoked by the media_player framework) +media_player::MediaPlayerTraits SendspinMediaPlayer::get_traits() { + auto traits = media_player::MediaPlayerTraits(); + + // By default, the base media player always enables these traits, but they are not actually supported by this media + // player + traits.clear_feature_flags(media_player::MediaPlayerEntityFeature::PLAY_MEDIA | + media_player::MediaPlayerEntityFeature::BROWSE_MEDIA | + media_player::MediaPlayerEntityFeature::MEDIA_ANNOUNCE); + + traits.add_feature_flags( + media_player::MediaPlayerEntityFeature::PLAY | media_player::MediaPlayerEntityFeature::PAUSE | + media_player::MediaPlayerEntityFeature::STOP | media_player::MediaPlayerEntityFeature::VOLUME_STEP | + media_player::MediaPlayerEntityFeature::VOLUME_SET | media_player::MediaPlayerEntityFeature::VOLUME_MUTE); + + // NEXT_TRACK, PREVIOUS_TRACK, SHUFFLE_SET, and REPEAT_SET are intentionally not advertised: the ESPHome native API + // does not implement the corresponding media player commands, so Home Assistant cannot actually send them even if + // we expose the capability. They remain accessible via ESPHome YAML automations. + + return traits; +} + +// THREAD CONTEXT: Main loop (invoked by the media_player framework) +void SendspinMediaPlayer::control(const media_player::MediaPlayerCall &call) { + if (!this->is_ready()) { + // Ignore any commands sent before the media player is setup + return; + } + + auto volume = call.get_volume(); + if (volume.has_value()) { + uint8_t new_volume = static_cast(std::roundf(volume.value() * 100.0f)); + this->parent_->send_client_command(sendspin::SendspinControllerCommand::VOLUME, new_volume, std::nullopt); + } + + auto command = call.get_command(); + if (!command.has_value()) { + return; + } + switch (command.value()) { + case media_player::MEDIA_PLAYER_COMMAND_TOGGLE: + if (this->state == media_player::MediaPlayerState::MEDIA_PLAYER_STATE_PLAYING) { + this->parent_->send_client_command(sendspin::SendspinControllerCommand::PAUSE); + } else { + this->parent_->send_client_command(sendspin::SendspinControllerCommand::PLAY); + } + break; + case media_player::MEDIA_PLAYER_COMMAND_PLAY: + this->parent_->send_client_command(sendspin::SendspinControllerCommand::PLAY); + break; + case media_player::MEDIA_PLAYER_COMMAND_PAUSE: + this->parent_->send_client_command(sendspin::SendspinControllerCommand::PAUSE); + break; + case media_player::MEDIA_PLAYER_COMMAND_STOP: + this->parent_->send_client_command(sendspin::SendspinControllerCommand::STOP); + break; + case media_player::MEDIA_PLAYER_COMMAND_REPEAT_OFF: + this->parent_->send_client_command(sendspin::SendspinControllerCommand::REPEAT_OFF); + break; + case media_player::MEDIA_PLAYER_COMMAND_REPEAT_ONE: + this->parent_->send_client_command(sendspin::SendspinControllerCommand::REPEAT_ONE); + break; + case media_player::MEDIA_PLAYER_COMMAND_REPEAT_ALL: + this->parent_->send_client_command(sendspin::SendspinControllerCommand::REPEAT_ALL); + break; + case media_player::MEDIA_PLAYER_COMMAND_SHUFFLE: + this->parent_->send_client_command(sendspin::SendspinControllerCommand::SHUFFLE); + break; + case media_player::MEDIA_PLAYER_COMMAND_UNSHUFFLE: + this->parent_->send_client_command(sendspin::SendspinControllerCommand::UNSHUFFLE); + break; + case media_player::MEDIA_PLAYER_COMMAND_NEXT: + this->parent_->send_client_command(sendspin::SendspinControllerCommand::NEXT); + break; + case media_player::MEDIA_PLAYER_COMMAND_PREVIOUS: + this->parent_->send_client_command(sendspin::SendspinControllerCommand::PREVIOUS); + break; + case media_player::MEDIA_PLAYER_COMMAND_VOLUME_UP: + this->parent_->send_client_command( + sendspin::SendspinControllerCommand::VOLUME, + static_cast(std::roundf(std::min(1.0f, this->volume + this->volume_increment_) * 100.0f)), + std::nullopt); + break; + case media_player::MEDIA_PLAYER_COMMAND_VOLUME_DOWN: + this->parent_->send_client_command( + sendspin::SendspinControllerCommand::VOLUME, + static_cast(std::roundf(std::max(0.0f, this->volume - this->volume_increment_) * 100.0f)), + std::nullopt); + break; + case media_player::MEDIA_PLAYER_COMMAND_MUTE: + this->parent_->send_client_command(sendspin::SendspinControllerCommand::MUTE, std::nullopt, true); + break; + case media_player::MEDIA_PLAYER_COMMAND_UNMUTE: + this->parent_->send_client_command(sendspin::SendspinControllerCommand::MUTE, std::nullopt, false); + break; + default: + break; + } +} + +void SendspinMediaPlayer::dump_config() { + ESP_LOGCONFIG(TAG, "Sendspin Media Player: volume_increment=%.2f", this->volume_increment_); +} + +} // namespace esphome::sendspin_ +#endif diff --git a/esphome/components/sendspin/media_player/sendspin_media_player.h b/esphome/components/sendspin/media_player/sendspin_media_player.h new file mode 100644 index 00000000000..52786d6d7b3 --- /dev/null +++ b/esphome/components/sendspin/media_player/sendspin_media_player.h @@ -0,0 +1,33 @@ +#pragma once + +#include "esphome/core/defines.h" + +#if defined(USE_ESP32) && defined(USE_MEDIA_PLAYER) && defined(USE_SENDSPIN_CONTROLLER) + +#include "esphome/components/media_player/media_player.h" +#include "esphome/components/sendspin/sendspin_hub.h" + +namespace esphome::sendspin_ { + +class SendspinMediaPlayer : public SendspinChild, public media_player::MediaPlayer { + public: + void setup() override; + void dump_config() override; + + // MediaPlayer implementations + media_player::MediaPlayerTraits get_traits() override; + + void set_volume_increment(float volume_increment) { this->volume_increment_ = volume_increment; } + + bool is_muted() const override { return this->muted_; } + + protected: + // Receives commands from HA + void control(const media_player::MediaPlayerCall &call) override; + + float volume_increment_{0.05f}; + bool muted_{false}; +}; + +} // namespace esphome::sendspin_ +#endif diff --git a/tests/components/sendspin/common-media_player.yaml b/tests/components/sendspin/common-media_player.yaml new file mode 100644 index 00000000000..d3792cf4708 --- /dev/null +++ b/tests/components/sendspin/common-media_player.yaml @@ -0,0 +1,5 @@ +<<: !include common.yaml + +media_player: + - platform: sendspin + id: media_player_id diff --git a/tests/components/sendspin/test-media_player.esp32-idf.yaml b/tests/components/sendspin/test-media_player.esp32-idf.yaml new file mode 100644 index 00000000000..cbbdb07c77c --- /dev/null +++ b/tests/components/sendspin/test-media_player.esp32-idf.yaml @@ -0,0 +1 @@ +<<: !include common-media_player.yaml From 404620b99cc805225c328ce49d81a0fe4e07dff1 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Fri, 24 Apr 2026 04:31:46 +0200 Subject: [PATCH 06/68] [deep_sleep][logger][zephyr][zigbee] add deep sleep support with zigbee wakeup (#13950) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/components/deep_sleep/__init__.py | 6 +- .../deep_sleep/deep_sleep_bk72xx.cpp | 2 + .../deep_sleep/deep_sleep_component.cpp | 27 +++++++-- .../deep_sleep/deep_sleep_component.h | 16 +++++ .../deep_sleep/deep_sleep_esp32.cpp | 2 + .../deep_sleep/deep_sleep_esp8266.cpp | 2 + .../deep_sleep/deep_sleep_zephyr.cpp | 60 +++++++++++++++++++ esphome/components/logger/__init__.py | 17 +++--- esphome/components/logger/logger_zephyr.cpp | 2 + esphome/components/zephyr/__init__.py | 26 +++++++- esphome/components/zephyr/const.py | 1 + esphome/components/zigbee/__init__.py | 4 ++ esphome/components/zigbee/const_zephyr.py | 1 + esphome/components/zigbee/zigbee_zephyr.cpp | 25 +++++++- esphome/components/zigbee/zigbee_zephyr.h | 4 ++ esphome/components/zigbee/zigbee_zephyr.py | 8 +++ .../deep_sleep/test.nrf52-adafruit.yaml | 12 ++++ .../zigbee/test.nrf52-xiao-ble.yaml | 1 + 18 files changed, 196 insertions(+), 20 deletions(-) create mode 100644 esphome/components/deep_sleep/deep_sleep_zephyr.cpp create mode 100644 tests/components/deep_sleep/test.nrf52-adafruit.yaml diff --git a/esphome/components/deep_sleep/__init__.py b/esphome/components/deep_sleep/__init__.py index 16329bb0fa0..8184f954c74 100644 --- a/esphome/components/deep_sleep/__init__.py +++ b/esphome/components/deep_sleep/__init__.py @@ -14,6 +14,7 @@ from esphome.components.esp32 import ( VARIANT_ESP32S3, get_esp32_variant, ) +from esphome.components.zephyr import zephyr_add_prj_conf from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv from esphome.const import ( @@ -33,6 +34,7 @@ from esphome.const import ( PLATFORM_BK72XX, PLATFORM_ESP32, PLATFORM_ESP8266, + PLATFORM_NRF52, PlatformFramework, ) from esphome.core import CORE @@ -304,7 +306,7 @@ CONFIG_SCHEMA = cv.All( ), } ).extend(cv.COMPONENT_SCHEMA), - cv.only_on([PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_BK72XX]), + cv.only_on([PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_BK72XX, PLATFORM_NRF52]), validate_config, ) @@ -369,6 +371,8 @@ async def to_code(config): if CONF_TOUCH_WAKEUP in config: cg.add(var.set_touch_wakeup(config[CONF_TOUCH_WAKEUP])) + if CORE.using_zephyr and "zigbee" not in CORE.loaded_integrations: + zephyr_add_prj_conf("POWEROFF", True) cg.add_define("USE_DEEP_SLEEP") diff --git a/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp b/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp index b5fadd7230e..8dca32689bd 100644 --- a/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp +++ b/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp @@ -59,6 +59,8 @@ void DeepSleepComponent::deep_sleep_() { lt_deep_sleep_enter(); } +bool DeepSleepComponent::should_teardown_() { return true; } + } // namespace esphome::deep_sleep #endif // USE_BK72XX diff --git a/esphome/components/deep_sleep/deep_sleep_component.cpp b/esphome/components/deep_sleep/deep_sleep_component.cpp index 3dd1b709308..d2c5db54b39 100644 --- a/esphome/components/deep_sleep/deep_sleep_component.cpp +++ b/esphome/components/deep_sleep/deep_sleep_component.cpp @@ -9,11 +9,22 @@ static const char *const TAG = "deep_sleep"; // 5 seconds for deep sleep to ensure clean disconnect from Home Assistant static const uint32_t TEARDOWN_TIMEOUT_DEEP_SLEEP_MS = 5000; -bool global_has_deep_sleep = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +bool global_has_deep_sleep = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +std::atomic global_deep_sleep; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) void DeepSleepComponent::setup() { +#ifdef USE_ZEPHYR + k_sem_init(&this->wakeup_sem_, 0, 1); +#endif global_has_deep_sleep = true; + this->schedule_sleep_(); + // It can be used from another thread for waking up the device. + // It should be called as last item in setup. + global_deep_sleep.store(this); +} +void DeepSleepComponent::schedule_sleep_() { + this->next_enter_deep_sleep_ = false; const optional run_duration = get_run_duration_(); if (run_duration.has_value()) { ESP_LOGI(TAG, "Scheduling in %" PRIu32 " ms", *run_duration); @@ -58,13 +69,17 @@ void DeepSleepComponent::begin_sleep(bool manual) { if (this->sleep_duration_.has_value()) { ESP_LOGI(TAG, "Sleeping for %" PRId64 "us", *this->sleep_duration_); } - App.run_safe_shutdown_hooks(); - // It's critical to teardown components cleanly for deep sleep to ensure - // Home Assistant sees a clean disconnect instead of marking the device unavailable - App.teardown_components(TEARDOWN_TIMEOUT_DEEP_SLEEP_MS); - App.run_powerdown_hooks(); + + if (this->should_teardown_()) { + App.run_safe_shutdown_hooks(); + // It's critical to teardown components cleanly for deep sleep to ensure + // Home Assistant sees a clean disconnect instead of marking the device unavailable + App.teardown_components(TEARDOWN_TIMEOUT_DEEP_SLEEP_MS); + App.run_powerdown_hooks(); + } this->deep_sleep_(); + this->schedule_sleep_(); } float DeepSleepComponent::get_setup_priority() const { return setup_priority::LATE; } diff --git a/esphome/components/deep_sleep/deep_sleep_component.h b/esphome/components/deep_sleep/deep_sleep_component.h index 9090f91876a..854ab152a16 100644 --- a/esphome/components/deep_sleep/deep_sleep_component.h +++ b/esphome/components/deep_sleep/deep_sleep_component.h @@ -4,6 +4,7 @@ #include "esphome/core/component.h" #include "esphome/core/hal.h" #include "esphome/core/helpers.h" +#include #ifdef USE_ESP32 #include @@ -14,6 +15,10 @@ #include "esphome/core/time.h" #endif +#ifdef USE_ZEPHYR +#include +#endif + #include namespace esphome { @@ -120,6 +125,9 @@ class DeepSleepComponent : public Component { void prevent_deep_sleep(); void allow_deep_sleep(); +#ifdef USE_ZEPHYR + void wakeup(); +#endif protected: // Returns nullopt if no run duration is set. Otherwise, returns the run @@ -129,6 +137,8 @@ class DeepSleepComponent : public Component { void dump_config_platform_(); bool prepare_to_sleep_(); void deep_sleep_(); + void schedule_sleep_(); + bool should_teardown_(); #ifdef USE_BK72XX bool pin_prevents_sleep_(WakeUpPinItem &pinItem) const; @@ -157,6 +167,9 @@ class DeepSleepComponent : public Component { optional run_duration_; bool next_enter_deep_sleep_{false}; bool prevent_{false}; +#ifdef USE_ZEPHYR + k_sem wakeup_sem_; +#endif }; extern bool global_has_deep_sleep; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) @@ -243,5 +256,8 @@ template class AllowDeepSleepAction : public Action, publ void play(const Ts &...x) override { this->parent_->allow_deep_sleep(); } }; +extern std::atomic + global_deep_sleep; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + } // namespace deep_sleep } // namespace esphome diff --git a/esphome/components/deep_sleep/deep_sleep_esp32.cpp b/esphome/components/deep_sleep/deep_sleep_esp32.cpp index 4f4d262d30d..80a218e9133 100644 --- a/esphome/components/deep_sleep/deep_sleep_esp32.cpp +++ b/esphome/components/deep_sleep/deep_sleep_esp32.cpp @@ -165,6 +165,8 @@ void DeepSleepComponent::deep_sleep_() { esp_deep_sleep_start(); } +bool DeepSleepComponent::should_teardown_() { return true; } + } // namespace deep_sleep } // namespace esphome #endif // USE_ESP32 diff --git a/esphome/components/deep_sleep/deep_sleep_esp8266.cpp b/esphome/components/deep_sleep/deep_sleep_esp8266.cpp index efbd45c34e7..42c153c2f38 100644 --- a/esphome/components/deep_sleep/deep_sleep_esp8266.cpp +++ b/esphome/components/deep_sleep/deep_sleep_esp8266.cpp @@ -18,6 +18,8 @@ void DeepSleepComponent::deep_sleep_() { ESP.deepSleep(this->sleep_duration_.value_or(0)); // NOLINT(readability-static-accessed-through-instance) } +bool DeepSleepComponent::should_teardown_() { return true; } + } // namespace deep_sleep } // namespace esphome #endif diff --git a/esphome/components/deep_sleep/deep_sleep_zephyr.cpp b/esphome/components/deep_sleep/deep_sleep_zephyr.cpp new file mode 100644 index 00000000000..82d6d8c7ded --- /dev/null +++ b/esphome/components/deep_sleep/deep_sleep_zephyr.cpp @@ -0,0 +1,60 @@ +#include "deep_sleep_component.h" +#ifdef USE_ZEPHYR +#include "esphome/core/log.h" +#include +#include +#include +#include + +namespace esphome::deep_sleep { + +static const char *const TAG = "deep_sleep"; + +void DeepSleepComponent::wakeup() { k_sem_give(&this->wakeup_sem_); } + +optional DeepSleepComponent::get_run_duration_() const { return this->run_duration_; } + +void DeepSleepComponent::dump_config_platform_() {} + +bool DeepSleepComponent::prepare_to_sleep_() { return true; } + +void DeepSleepComponent::deep_sleep_() { + k_timeout_t sleep_duration = K_FOREVER; + if (this->sleep_duration_.has_value()) { + sleep_duration = K_USEC(*this->sleep_duration_); + } else { +#ifndef USE_ZIGBEE + // the device can be woken up through one of the following signals: + // - The DETECT signal, optionally generated by the GPIO peripheral. + // - The ANADETECT signal, optionally generated by the LPCOMP module. + // - The SENSE signal, optionally generated by the NFC module to wake-on-field. + // - Detecting a valid USB voltage on the VBUS pin (VBUS,DETECT). + // - A reset. + // + // The system is reset when it wakes up from System OFF mode. + sys_poweroff(); +#endif + } + // It might wake up immediately if k_sem_give was called again after wake up + int ret = k_sem_take(&this->wakeup_sem_, sleep_duration); + if (ret == 0) { + ESP_LOGD(TAG, "Woken up by another thread"); + } else { + ESP_LOGD(TAG, "Timeout expired (normal sleep)"); + } +} + +bool DeepSleepComponent::should_teardown_() { + if (this->sleep_duration_.has_value()) { + return false; + } +#ifdef USE_ZIGBEE + return false; +#else + return true; +#endif +} + +} // namespace esphome::deep_sleep + +#endif diff --git a/esphome/components/logger/__init__.py b/esphome/components/logger/__init__.py index 4144543b89a..9d7dc8d92c1 100644 --- a/esphome/components/logger/__init__.py +++ b/esphome/components/logger/__init__.py @@ -472,14 +472,15 @@ async def _late_logger_init(config: ConfigType) -> None: # esphome implement own fatal error handler which save PC/LR before reset zephyr_add_prj_conf("RESET_ON_FATAL_ERROR", False) zephyr_add_prj_conf("THREAD_LOCAL_STORAGE", True) - if config[CONF_HARDWARE_UART] == UART0: - zephyr_add_overlay("""&uart0 { status = "okay";};""") - if config[CONF_HARDWARE_UART] == UART1: - zephyr_add_overlay("""&uart1 { status = "okay";};""") - if config[CONF_HARDWARE_UART] == USB_CDC: - cg.add_define("USE_LOGGER_UART_SELECTION_USB_CDC") - zephyr_add_prj_conf("UART_LINE_CTRL", True) - zephyr_add_cdc_acm(config, 0) + if has_serial_logging: + if config[CONF_HARDWARE_UART] == UART0: + zephyr_add_overlay("""&uart0 { status = "okay";};""") + if config[CONF_HARDWARE_UART] == UART1: + zephyr_add_overlay("""&uart1 { status = "okay";};""") + if config[CONF_HARDWARE_UART] == USB_CDC: + cg.add_define("USE_LOGGER_UART_SELECTION_USB_CDC") + zephyr_add_prj_conf("UART_LINE_CTRL", True) + zephyr_add_cdc_acm(config, 0) # Register at end for safe mode await cg.register_component(log, config) diff --git a/esphome/components/logger/logger_zephyr.cpp b/esphome/components/logger/logger_zephyr.cpp index 6b46b93c61e..7fa9e42c6a0 100644 --- a/esphome/components/logger/logger_zephyr.cpp +++ b/esphome/components/logger/logger_zephyr.cpp @@ -65,10 +65,12 @@ void Logger::pre_setup() { break; #ifdef USE_LOGGER_USB_CDC case UART_SELECTION_USB_CDC: +#ifdef CONFIG_USB_DEVICE_STACK uart_dev = DEVICE_DT_GET_OR_NULL(DT_NODELABEL(cdc_acm_uart0)); if (device_is_ready(uart_dev)) { usb_enable(nullptr); } +#endif break; #endif } diff --git a/esphome/components/zephyr/__init__.py b/esphome/components/zephyr/__init__.py index d3cc6b2cf45..5dccecc0974 100644 --- a/esphome/components/zephyr/__init__.py +++ b/esphome/components/zephyr/__init__.py @@ -15,6 +15,7 @@ from .const import ( KEY_BOARD, KEY_BOOTLOADER, KEY_EXTRA_BUILD_FILES, + KEY_KCONFIG, KEY_OVERLAY, KEY_PM_STATIC, KEY_PRJ_CONF, @@ -54,6 +55,7 @@ class ZephyrData(TypedDict): extra_build_files: dict[str, Path] pm_static: list[Section] user: dict[str, list[str]] + kconfig: str def zephyr_set_core_data(config: ConfigType) -> None: @@ -65,6 +67,7 @@ def zephyr_set_core_data(config: ConfigType) -> None: extra_build_files={}, pm_static=[], user={}, + kconfig="", ) @@ -185,8 +188,12 @@ def zephyr_add_cdc_acm(config: ConfigType, id: int) -> None: ) -def zephyr_add_pm_static(section: Section): - CORE.data[KEY_ZEPHYR][KEY_PM_STATIC].extend(section) +def zephyr_add_kconfig(kconfig: str) -> None: + zephyr_data()[KEY_KCONFIG] += textwrap.dedent(kconfig) + "\n" + + +def zephyr_add_pm_static(sections: list[Section]) -> None: + zephyr_data()[KEY_PM_STATIC].extend(sections) def zephyr_add_user(key, value): @@ -273,3 +280,18 @@ def copy_files(): write_file_if_changed( CORE.relative_build_path("zephyr/pm_static.yml"), pm_static ) + + kconfig = zephyr_data()[KEY_KCONFIG] + if kconfig: + kconfig = ( + textwrap.dedent( + """ + menu "Zephyr" + source "Kconfig.zephyr" + endmenu + """ + ) + + "\n" + + kconfig + ) + write_file_if_changed(CORE.relative_build_path("zephyr/Kconfig"), kconfig) diff --git a/esphome/components/zephyr/const.py b/esphome/components/zephyr/const.py index f67b058ed78..f2de861e314 100644 --- a/esphome/components/zephyr/const.py +++ b/esphome/components/zephyr/const.py @@ -8,6 +8,7 @@ KEY_BOOTLOADER: Final = "bootloader" KEY_EXTRA_BUILD_FILES: Final = "extra_build_files" KEY_OVERLAY: Final = "overlay" KEY_PM_STATIC: Final = "pm_static" +KEY_KCONFIG: Final = "kconfig" KEY_PRJ_CONF: Final = "prj_conf" KEY_ZEPHYR = "zephyr" KEY_BOARD: Final = "board" diff --git a/esphome/components/zigbee/__init__.py b/esphome/components/zigbee/__init__.py index 126e3aa2cd1..0bb5f95bb68 100644 --- a/esphome/components/zigbee/__init__.py +++ b/esphome/components/zigbee/__init__.py @@ -32,6 +32,7 @@ from .const import ( from .const_zephyr import ( CONF_IEEE802154_VENDOR_OUI, CONF_MAX_EP_NUMBER, + CONF_SLEEPY, CONF_ZIGBEE_ID, KEY_EP_NUMBER, ) @@ -107,6 +108,9 @@ CONFIG_SCHEMA = cv.All( ), cv.requires_component("nrf52"), ), + cv.OnlyWith(CONF_SLEEPY, "nrf52", default=False): cv.All( + cv.boolean, + ), } ).extend(cv.COMPONENT_SCHEMA), zigbee_require_vfs_select, diff --git a/esphome/components/zigbee/const_zephyr.py b/esphome/components/zigbee/const_zephyr.py index 103ef01a3d8..63d03c7952b 100644 --- a/esphome/components/zigbee/const_zephyr.py +++ b/esphome/components/zigbee/const_zephyr.py @@ -4,6 +4,7 @@ CONF_ZIGBEE_BINARY_SENSOR = "zigbee_binary_sensor" CONF_ZIGBEE_SENSOR = "zigbee_sensor" CONF_ZIGBEE_SWITCH = "zigbee_switch" CONF_ZIGBEE_NUMBER = "zigbee_number" +CONF_SLEEPY = "sleepy" CONF_IEEE802154_VENDOR_OUI = "ieee802154_vendor_oui" # Keys for CORE.data storage diff --git a/esphome/components/zigbee/zigbee_zephyr.cpp b/esphome/components/zigbee/zigbee_zephyr.cpp index 047c30300e2..90bb66c91d5 100644 --- a/esphome/components/zigbee/zigbee_zephyr.cpp +++ b/esphome/components/zigbee/zigbee_zephyr.cpp @@ -4,6 +4,9 @@ #include #include #include "esphome/core/hal.h" +#ifdef USE_DEEP_SLEEP +#include "esphome/components/deep_sleep/deep_sleep_component.h" +#endif extern "C" { #include @@ -116,6 +119,12 @@ void ZigbeeComponent::zcl_device_cb(zb_bufid_t bufid) { /* Set default response value. */ p_device_cb_param->status = RET_OK; +#ifdef USE_DEEP_SLEEP + if (auto *ds = deep_sleep::global_deep_sleep.load()) { + ds->wakeup(); + } +#endif + // endpoints are enumerated from 1 if (global_zigbee->callbacks_.size() >= endpoint) { const auto &cb = global_zigbee->callbacks_[endpoint - 1]; @@ -181,9 +190,11 @@ void ZigbeeComponent::setup() { ESP_LOGE(TAG, "Cannot load settings, err: %d", err); return; } + zigbee_configure_sleepy_behavior(this->sleepy_); zigbee_enable(); } +#ifdef ESPHOME_LOG_HAS_CONFIG static const char *role() { switch (zb_get_network_role()) { case ZB_NWK_DEVICE_TYPE_COORDINATOR: @@ -207,6 +218,7 @@ static const char *get_wipe_on_boot() { return "NO"; #endif } +#endif void ZigbeeComponent::dump_config() { char ieee_addr_buf[IEEE_ADDR_BUF_SIZE] = {0}; @@ -222,6 +234,7 @@ void ZigbeeComponent::dump_config() { " Wipe on boot: %s\n" " Device is joined to the network: %s\n" " Sleep time: %us\n" + " RX ON when idle: %s\n" " Current channel: %d\n" " Current page: %d\n" " Sleep threshold: %ums\n" @@ -230,9 +243,9 @@ void ZigbeeComponent::dump_config() { " Short addr: 0x%04X\n" " Long pan id: 0x%s\n" " Short pan id: 0x%04X", - get_wipe_on_boot(), YESNO(zb_zdo_joined()), this->sleep_time_, zb_get_current_channel(), - zb_get_current_page(), zb_get_sleep_threshold(), role(), ieee_addr_buf, zb_get_short_address(), - extended_pan_id_buf, zb_get_pan_id()); + get_wipe_on_boot(), YESNO(zb_zdo_joined()), this->sleep_time_, YESNO(zb_get_rx_on_when_idle()), + zb_get_current_channel(), zb_get_current_page(), zb_get_sleep_threshold(), role(), ieee_addr_buf, + zb_get_short_address(), extended_pan_id_buf, zb_get_pan_id()); dump_reporting_(); } @@ -302,6 +315,12 @@ void ZigbeeComponent::after_reporting_info(zb_zcl_configure_reporting_req_t *con extern "C" { void zboss_signal_handler(zb_uint8_t param) { esphome::zigbee::global_zigbee->zboss_signal_handler_esphome(param); } +void zb_osif_serial_put_bytes(const zb_uint8_t *buf, zb_short_t len) { + (void) buf; + (void) len; +} +void zb_osif_serial_flush() {} +void zb_osif_serial_init() {} // NOLINTBEGIN(readability-identifier-naming,bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp) extern zb_ret_t __real_zb_zcl_put_reporting_info_from_req(zb_zcl_configure_reporting_req_t *config_rep_req, diff --git a/esphome/components/zigbee/zigbee_zephyr.h b/esphome/components/zigbee/zigbee_zephyr.h index eeb142eff17..0a189ac1e04 100644 --- a/esphome/components/zigbee/zigbee_zephyr.h +++ b/esphome/components/zigbee/zigbee_zephyr.h @@ -81,6 +81,7 @@ class ZigbeeComponent : public Component { Trigger<> *get_join_trigger() { return &this->join_trigger_; }; void force_report(); void loop() override; + void set_sleepy(bool sleepy) { this->sleepy_ = sleepy; } protected: static void zcl_device_cb(zb_bufid_t bufid); @@ -95,6 +96,7 @@ class ZigbeeComponent : public Component { bool force_report_{false}; uint32_t sleep_time_{}; uint32_t sleep_remainder_{}; + bool sleepy_{}; }; class ZigbeeEntity { @@ -107,5 +109,7 @@ class ZigbeeEntity { ZigbeeComponent *parent_{nullptr}; }; +extern ZigbeeComponent *global_zigbee; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + } // namespace esphome::zigbee #endif diff --git a/esphome/components/zigbee/zigbee_zephyr.py b/esphome/components/zigbee/zigbee_zephyr.py index f6e3e88c63a..7d904b6081d 100644 --- a/esphome/components/zigbee/zigbee_zephyr.py +++ b/esphome/components/zigbee/zigbee_zephyr.py @@ -63,6 +63,7 @@ from .const import ( ) from .const_zephyr import ( CONF_IEEE802154_VENDOR_OUI, + CONF_SLEEPY, CONF_ZIGBEE_BINARY_SENSOR, CONF_ZIGBEE_ID, CONF_ZIGBEE_NUMBER, @@ -169,6 +170,11 @@ async def zephyr_to_code(config: ConfigType) -> None: zephyr_add_prj_conf("NET_IP_ADDR_CHECK", False) zephyr_add_prj_conf("NET_UDP", False) + # disable all extra to reduce power and save flash + zephyr_add_prj_conf("ZIGBEE_HAVE_SERIAL", False) + zephyr_add_prj_conf("ZBOSS_ERROR_PRINT_TO_LOG", False) + zephyr_add_prj_conf("DK_LIBRARY", False) + cg.add_build_flag("-Wl,--wrap=zb_zcl_put_reporting_info_from_req") if CONF_IEEE802154_VENDOR_OUI in config: @@ -200,6 +206,8 @@ async def zephyr_to_code(config: ConfigType) -> None: CORE.add_job(_ctx_to_code, config) + cg.add(var.set_sleepy(config[CONF_SLEEPY])) + async def _attr_to_code(config: ConfigType) -> None: # Create the basic attributes structure and attribute list diff --git a/tests/components/deep_sleep/test.nrf52-adafruit.yaml b/tests/components/deep_sleep/test.nrf52-adafruit.yaml new file mode 100644 index 00000000000..6362142be2e --- /dev/null +++ b/tests/components/deep_sleep/test.nrf52-adafruit.yaml @@ -0,0 +1,12 @@ +deep_sleep: + run_duration: 10s + sleep_duration: 50s + +<<: !include common.yaml + +zigbee: + +sensor: + - platform: template + name: "Temperature" + id: temperature_sensor diff --git a/tests/components/zigbee/test.nrf52-xiao-ble.yaml b/tests/components/zigbee/test.nrf52-xiao-ble.yaml index 83d949b4ddc..acfbc9e9961 100644 --- a/tests/components/zigbee/test.nrf52-xiao-ble.yaml +++ b/tests/components/zigbee/test.nrf52-xiao-ble.yaml @@ -4,3 +4,4 @@ zigbee: wipe_on_boot: once power_source: battery ieee802154_vendor_oui: 0x231 + sleepy: true From eceb534895dcaa1c9c77c906500799fefeb4f6de Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Apr 2026 02:19:59 -0500 Subject: [PATCH 07/68] [deep_sleep] Fix sleep_duration codegen type to uint32_t (#15965) --- esphome/components/deep_sleep/__init__.py | 2 +- tests/components/deep_sleep/common.yaml | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/esphome/components/deep_sleep/__init__.py b/esphome/components/deep_sleep/__init__.py index 8184f954c74..0ca557bd6d8 100644 --- a/esphome/components/deep_sleep/__init__.py +++ b/esphome/components/deep_sleep/__init__.py @@ -417,7 +417,7 @@ async def deep_sleep_enter_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) if CONF_SLEEP_DURATION in config: - template_ = await cg.templatable(config[CONF_SLEEP_DURATION], args, cg.int32) + template_ = await cg.templatable(config[CONF_SLEEP_DURATION], args, cg.uint32) cg.add(var.set_sleep_duration(template_)) if CONF_UNTIL in config: diff --git a/tests/components/deep_sleep/common.yaml b/tests/components/deep_sleep/common.yaml index c090cb83e2d..7a1a709965b 100644 --- a/tests/components/deep_sleep/common.yaml +++ b/tests/components/deep_sleep/common.yaml @@ -4,3 +4,9 @@ esphome: - deep_sleep.prevent - delay: 1s - deep_sleep.allow + - if: + condition: + lambda: 'return false;' + then: + - deep_sleep.enter: + sleep_duration: 60min From ae02ab38656f484e55468302015016a9a59440a0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Apr 2026 03:42:36 -0500 Subject: [PATCH 08/68] [wifi] Fix stale wifi.connected after state transition (#15966) --- esphome/components/wifi/wifi_component.cpp | 2 ++ esphome/components/wifi/wifi_component_esp8266.cpp | 2 ++ esphome/components/wifi/wifi_component_esp_idf.cpp | 2 ++ esphome/components/wifi/wifi_component_libretiny.cpp | 2 ++ esphome/components/wifi/wifi_component_pico_w.cpp | 2 ++ 5 files changed, 10 insertions(+) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 481846085c2..f7c70b1147a 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -1579,6 +1579,8 @@ void WiFiComponent::check_connecting_finished(uint32_t now) { #endif this->state_ = WIFI_COMPONENT_STATE_STA_CONNECTED; + // Refresh is_connected() cache; loop()'s refresh ran before this transition. + this->update_connected_state_(); this->num_retried_ = 0; this->print_connect_params_(); diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index e56a8df350b..bf3a0d29497 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -951,6 +951,8 @@ void WiFiComponent::process_pending_callbacks_() { #ifdef USE_WIFI_CONNECT_STATE_LISTENERS if (this->pending_.disconnect) { this->pending_.disconnect = false; + // Refresh is_connected() cache here, not in the SDK callback (sys context). + this->update_connected_state_(); this->notify_disconnect_state_listeners_(); } #endif diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index c790742c797..29d135ce900 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -804,6 +804,8 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { s_sta_connected = false; s_sta_connecting = false; error_from_callback_ = true; + // Refresh is_connected() cache; error_from_callback_ makes it false. + this->update_connected_state_(); #ifdef USE_WIFI_CONNECT_STATE_LISTENERS this->notify_disconnect_state_listeners_(); #endif diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index 6588e93e167..59efa4f8425 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -530,6 +530,8 @@ void WiFiComponent::wifi_process_event_(LTWiFiEvent *event) { this->error_from_callback_ = true; } + // Refresh is_connected() cache; sta_state_/error_from_callback_ make it false. + this->update_connected_state_(); #ifdef USE_WIFI_CONNECT_STATE_LISTENERS this->notify_disconnect_state_listeners_(); #endif diff --git a/esphome/components/wifi/wifi_component_pico_w.cpp b/esphome/components/wifi/wifi_component_pico_w.cpp index 4e1e0395c03..596fd2729b3 100644 --- a/esphome/components/wifi/wifi_component_pico_w.cpp +++ b/esphome/components/wifi/wifi_component_pico_w.cpp @@ -342,6 +342,8 @@ bool WiFiComponent::wifi_loop_() { s_sta_was_connected = false; s_sta_had_ip = false; ESP_LOGV(TAG, "Disconnected"); + // Refresh is_connected() cache; driver link status reports disconnected. + this->update_connected_state_(); #ifdef USE_WIFI_CONNECT_STATE_LISTENERS this->notify_disconnect_state_listeners_(); #endif From bc7f35b569c0dcec0364f7bf5f53fa19857ce572 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Fri, 24 Apr 2026 06:00:22 -0400 Subject: [PATCH 09/68] [sendspin] Add a Sendspin media source component for playing audio (PR4) (#15950) Co-authored-by: Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/sendspin/__init__.py | 78 ++++++- .../sendspin/media_source/__init__.py | 134 ++++++++++++ .../sendspin/media_source/automations.h | 26 +++ .../media_source/sendspin_media_source.cpp | 207 ++++++++++++++++++ .../media_source/sendspin_media_source.h | 72 ++++++ esphome/components/sendspin/sendspin_hub.cpp | 40 ++++ esphome/components/sendspin/sendspin_hub.h | 28 +++ .../sendspin/common-media_source.yaml | 9 + .../sendspin/test-media_source.esp32-idf.yaml | 1 + 10 files changed, 595 insertions(+), 1 deletion(-) create mode 100644 esphome/components/sendspin/media_source/__init__.py create mode 100644 esphome/components/sendspin/media_source/automations.h create mode 100644 esphome/components/sendspin/media_source/sendspin_media_source.cpp create mode 100644 esphome/components/sendspin/media_source/sendspin_media_source.h create mode 100644 tests/components/sendspin/common-media_source.yaml create mode 100644 tests/components/sendspin/test-media_source.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 65db6ca25ed..822b0e973c1 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -442,6 +442,7 @@ esphome/components/sen5x/* @martgras esphome/components/sen6x/* @martgras @mebner86 @mikelawrence @tuct esphome/components/sendspin/* @kahrendt esphome/components/sendspin/media_player/* @kahrendt +esphome/components/sendspin/media_source/* @kahrendt esphome/components/sensirion_common/* @martgras esphome/components/sensor/* @esphome/core esphome/components/serial_proxy/* @kbx81 diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index 2d053903789..6f5ccddb86d 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -4,7 +4,12 @@ from esphome import automation import esphome.codegen as cg from esphome.components import esp32, network, psram, socket, wifi import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_TASK_STACK_IN_PSRAM +from esphome.const import ( + CONF_BUFFER_SIZE, + CONF_ID, + CONF_SAMPLE_RATE, + CONF_TASK_STACK_IN_PSRAM, +) from esphome.core import CORE, ID from esphome.cpp_generator import TemplateArgsType from esphome.types import ConfigType @@ -17,6 +22,23 @@ DOMAIN = "sendspin" CONF_SENDSPIN_ID = "sendspin_id" +CONF_INITIAL_STATIC_DELAY = "initial_static_delay" +CONF_FIXED_DELAY = "fixed_delay" + +# sendspin-cpp library lives in the global `sendspin` namespace. +sendspin_library_ns = cg.global_ns.namespace("sendspin") + +# Library Enums +SendspinCodecFormat = sendspin_library_ns.enum("SendspinCodecFormat", is_class=True) +CODEC_FORMAT_FLAC = SendspinCodecFormat.enum("FLAC") +CODEC_FORMAT_OPUS = SendspinCodecFormat.enum("OPUS") +CODEC_FORMAT_PCM = SendspinCodecFormat.enum("PCM") +CODEC_FORMAT_UNSUPPORTED = SendspinCodecFormat.enum("UNSUPPORTED") + +# Library Structs +AudioSupportedFormatObject = sendspin_library_ns.struct("AudioSupportedFormatObject") +PlayerRoleConfig = sendspin_library_ns.struct("PlayerRoleConfig") + # Trailing underscore avoids clashing with sendspin-cpp's global `sendspin` namespace. # Analysis tools strip the trailing underscore (same pattern as `template_`). sendspin_ns = cg.esphome_ns.namespace("sendspin_") @@ -41,6 +63,8 @@ class SendspinConfiguration: player_support: bool = False visualizer_support: bool = False + player_config: ConfigType | None = None + def _get_data() -> SendspinConfiguration: if DOMAIN not in CORE.data: @@ -73,6 +97,17 @@ def request_visualizer_support() -> None: _get_data().visualizer_support = True +def register_player_config(config: ConfigType) -> None: + """Register the player role config from the media source subcomponent.""" + data = _get_data() + request_player_support() + if data.player_config is not None: + raise cv.Invalid( + "Only one sendspin media_source player configuration is supported" + ) + data.player_config = config + + def _validate_task_stack_in_psram(value): value = cv.boolean(value) if value: @@ -183,6 +218,47 @@ async def to_code(config: ConfigType) -> None: if data.player_support: cg.add_define("USE_SENDSPIN_PLAYER", True) + + # Configures the player role. We always assume support for 16 bits per sample mono and stereo FLAC, Opus, and PCM at the configured sample rate + # (with Opus only supported at 48 kHz since that's the only sample rate it supports). Users can configure the specific formats via the Sendspin server + player_cfg = data.player_config + sample_rate = player_cfg[CONF_SAMPLE_RATE] + + # OPUS only supports 48 kHz audio + codecs = [CODEC_FORMAT_FLAC] + if sample_rate == 48000: + codecs.append(CODEC_FORMAT_OPUS) + codecs.append(CODEC_FORMAT_PCM) + + def _audio_format(codec, channels): + return cg.StructInitializer( + AudioSupportedFormatObject, + ("codec", codec), + ("channels", channels), + ("sample_rate", sample_rate), + ("bit_depth", 16), + ) + + audio_format_structs = [ + _audio_format(codec, channels) for codec in codecs for channels in (2, 1) + ] + + psram_stack = player_cfg.get(CONF_TASK_STACK_IN_PSRAM, False) + if psram_stack: + esp32.add_idf_sdkconfig_option( + "CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY", True + ) + + player_config_struct = cg.StructInitializer( + PlayerRoleConfig, + ("audio_formats", audio_format_structs), + ("audio_buffer_capacity", player_cfg[CONF_BUFFER_SIZE]), + ("fixed_delay_us", player_cfg[CONF_FIXED_DELAY]), + ("initial_static_delay_ms", player_cfg[CONF_INITIAL_STATIC_DELAY]), + ("psram_stack", psram_stack), + ("priority", 2), + ) + cg.add(var.set_player_config(player_config_struct)) else: esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_PLAYER", False) diff --git a/esphome/components/sendspin/media_source/__init__.py b/esphome/components/sendspin/media_source/__init__.py new file mode 100644 index 00000000000..6d61a8a6361 --- /dev/null +++ b/esphome/components/sendspin/media_source/__init__.py @@ -0,0 +1,134 @@ +from esphome import automation +import esphome.codegen as cg +from esphome.components import media_source +import esphome.config_validation as cv +from esphome.const import ( + CONF_BUFFER_SIZE, + CONF_ID, + CONF_SAMPLE_RATE, + CONF_TASK_STACK_IN_PSRAM, +) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType + +from .. import ( + CONF_FIXED_DELAY, + CONF_INITIAL_STATIC_DELAY, + CONF_SENDSPIN_ID, + SendspinHub, + _validate_task_stack_in_psram, + register_player_config, + request_controller_support, + sendspin_ns, +) + +AUTO_LOAD = ["audio"] +CODEOWNERS = ["@kahrendt"] + +CONF_STATIC_DELAY_ADJUSTABLE = "static_delay_adjustable" + + +SendspinMediaSource = sendspin_ns.class_( + "SendspinMediaSource", + cg.Component, + media_source.MediaSource, +) + +EnableStaticDelayAdjustmentAction = sendspin_ns.class_( + "EnableStaticDelayAdjustmentAction", + automation.Action, + cg.Parented.template(SendspinMediaSource), +) + +DisableStaticDelayAdjustmentAction = sendspin_ns.class_( + "DisableStaticDelayAdjustmentAction", + automation.Action, + cg.Parented.template(SendspinMediaSource), +) + + +def _register(config: ConfigType) -> ConfigType: + request_controller_support() + register_player_config( + { + CONF_SAMPLE_RATE: config[CONF_SAMPLE_RATE], + CONF_BUFFER_SIZE: config[CONF_BUFFER_SIZE], + CONF_INITIAL_STATIC_DELAY: config[CONF_INITIAL_STATIC_DELAY], + CONF_FIXED_DELAY: config[CONF_FIXED_DELAY], + CONF_TASK_STACK_IN_PSRAM: config.get(CONF_TASK_STACK_IN_PSRAM, False), + } + ) + return config + + +CONFIG_SCHEMA = cv.All( + media_source.media_source_schema( + SendspinMediaSource, + ).extend( + { + cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub), + cv.Optional(CONF_TASK_STACK_IN_PSRAM): _validate_task_stack_in_psram, + cv.Optional(CONF_BUFFER_SIZE, default=1000000): cv.int_range(min=25000), + cv.Optional(CONF_INITIAL_STATIC_DELAY, default="0ms"): cv.All( + cv.positive_time_period_milliseconds, + cv.Range(max=cv.TimePeriod(milliseconds=5000)), + ), + cv.Optional(CONF_STATIC_DELAY_ADJUSTABLE, default=False): cv.boolean, + cv.Optional(CONF_FIXED_DELAY, default="0us"): cv.All( + cv.positive_time_period_microseconds, + cv.Range(max=cv.TimePeriod(microseconds=10000)), + ), + cv.Optional(CONF_SAMPLE_RATE, default=48000): cv.int_range( + min=16000, max=96000 + ), + } + ), + cv.only_on_esp32, + _register, +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await media_source.register_media_source(var, config) + + sendspin_hub = await cg.get_variable(config[CONF_SENDSPIN_ID]) + await cg.register_parented(var, sendspin_hub) + + cg.add(sendspin_hub.set_listener(var)) + + cg.add(var.set_static_delay_adjustable(config[CONF_STATIC_DELAY_ADJUSTABLE])) + + +SENDSPIN_MEDIA_SOURCE_ACTION_SCHEMA = automation.maybe_simple_id( + cv.Schema( + { + cv.GenerateID(): cv.use_id(SendspinMediaSource), + } + ) +) + + +@automation.register_action( + "sendspin.media_source.enable_static_delay_adjustment", + EnableStaticDelayAdjustmentAction, + SENDSPIN_MEDIA_SOURCE_ACTION_SCHEMA, + synchronous=True, +) +@automation.register_action( + "sendspin.media_source.disable_static_delay_adjustment", + DisableStaticDelayAdjustmentAction, + SENDSPIN_MEDIA_SOURCE_ACTION_SCHEMA, + synchronous=True, +) +async def sendspin_static_delay_adjustment_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + return var diff --git a/esphome/components/sendspin/media_source/automations.h b/esphome/components/sendspin/media_source/automations.h new file mode 100644 index 00000000000..08d2b2004b1 --- /dev/null +++ b/esphome/components/sendspin/media_source/automations.h @@ -0,0 +1,26 @@ +#pragma once + +#include "esphome/core/defines.h" + +#if defined(USE_ESP32) && defined(USE_SENDSPIN_PLAYER) && defined(USE_SENDSPIN_CONTROLLER) + +#include "esphome/core/automation.h" +#include "sendspin_media_source.h" + +namespace esphome::sendspin_ { + +template +class EnableStaticDelayAdjustmentAction : public Action, public Parented { + public: + void play(const Ts &...x) override { this->parent_->set_static_delay_adjustable(true); } +}; + +template +class DisableStaticDelayAdjustmentAction : public Action, public Parented { + public: + void play(const Ts &...x) override { this->parent_->set_static_delay_adjustable(false); } +}; + +} // namespace esphome::sendspin_ + +#endif diff --git a/esphome/components/sendspin/media_source/sendspin_media_source.cpp b/esphome/components/sendspin/media_source/sendspin_media_source.cpp new file mode 100644 index 00000000000..0fdfb01c55f --- /dev/null +++ b/esphome/components/sendspin/media_source/sendspin_media_source.cpp @@ -0,0 +1,207 @@ +#include "sendspin_media_source.h" + +#if defined(USE_ESP32) && defined(USE_SENDSPIN_CONTROLLER) && defined(USE_SENDSPIN_PLAYER) + +#include "esphome/components/audio/audio.h" +#include "esphome/core/log.h" + +#include + +namespace esphome::sendspin_ { + +static const char *const TAG = "sendspin.media_source"; + +static constexpr char URI_PREFIX[] = "sendspin://"; + +void SendspinMediaSource::setup() { + this->player_role_ = this->parent_->get_player_role(); + if (!this->player_role_) { + ESP_LOGE(TAG, "Failed to get player role from hub"); + this->mark_failed(); + return; + } + + // Push cached states to player role. They may have been set before setup() ran. + this->player_role_->update_volume(std::roundf(this->cached_volume_ * 100.0f)); + this->player_role_->update_muted(this->cached_muted_); + this->player_role_->set_static_delay_adjustable(this->static_delay_adjustable_); +} + +void SendspinMediaSource::dump_config() { + ESP_LOGCONFIG(TAG, "Sendspin Media Source: static_delay_adjustable=%s", YESNO(this->static_delay_adjustable_)); +} + +// THREAD CONTEXT: Main loop (invoked from ESPHome actions / config) +void SendspinMediaSource::set_static_delay_adjustable(bool adjustable) { + this->static_delay_adjustable_ = adjustable; + if (this->player_role_) { + this->player_role_->set_static_delay_adjustable(adjustable); + } +} + +// --- MediaSource interface --- + +bool SendspinMediaSource::can_handle(const std::string &uri) const { return uri.starts_with(URI_PREFIX); } + +// THREAD CONTEXT: Main loop (media_source.h documents play_uri as main-loop only) +bool SendspinMediaSource::play_uri(const std::string &uri) { + if (!this->is_ready() || this->is_failed() || !this->has_listener()) { + return false; + } + + if (this->get_state() != media_source::MediaSourceState::IDLE) { + ESP_LOGE(TAG, "Cannot play '%s': source is busy", uri.c_str()); + return false; + } + + if (!uri.starts_with(URI_PREFIX)) { + ESP_LOGE(TAG, "Invalid URI: '%s'", uri.c_str()); + return false; + } + + std::string sendspin_id = uri.substr(sizeof(URI_PREFIX) - 1); + + if (sendspin_id.empty()) { + ESP_LOGE(TAG, "Invalid URI: '%s'", uri.c_str()); + return false; + } + + ESP_LOGD(TAG, "sendspin_id: %s", sendspin_id.c_str()); + + if (sendspin_id != "current") { + // Connect to a new server as a websocket client + this->parent_->connect_to_server("ws://" + sendspin_id); + } + + // Tell the orchestrator we're now playing so it routes audio output from us + this->pending_start_ = false; + this->set_state_(media_source::MediaSourceState::PLAYING); + + return true; +} + +// THREAD CONTEXT: Main loop (media_source.h documents handle_command as main-loop only) +void SendspinMediaSource::handle_command(media_source::MediaSourceCommand command) { + switch (command) { + case media_source::MediaSourceCommand::STOP: { + if (!this->pending_start_) { + // Ignore stop commands if we have a pending start, since the orchestrator may send a stop command before + // play_uri + ESP_LOGD(TAG, "Received STOP command, updating Sendspin state to EXTERNAL_SOURCE"); + this->parent_->update_state(sendspin::SendspinClientState::EXTERNAL_SOURCE); + } + break; + } + case media_source::MediaSourceCommand::PLAY: // NOLINT(bugprone-branch-clone) + this->parent_->send_client_command(sendspin::SendspinControllerCommand::PLAY, std::nullopt, std::nullopt); + break; + case media_source::MediaSourceCommand::PAUSE: + this->parent_->send_client_command(sendspin::SendspinControllerCommand::PAUSE, std::nullopt, std::nullopt); + break; + case media_source::MediaSourceCommand::NEXT: + this->parent_->send_client_command(sendspin::SendspinControllerCommand::NEXT, std::nullopt, std::nullopt); + break; + case media_source::MediaSourceCommand::PREVIOUS: + this->parent_->send_client_command(sendspin::SendspinControllerCommand::PREVIOUS, std::nullopt, std::nullopt); + break; + case media_source::MediaSourceCommand::REPEAT_ALL: + this->parent_->send_client_command(sendspin::SendspinControllerCommand::REPEAT_ALL, std::nullopt, std::nullopt); + break; + case media_source::MediaSourceCommand::REPEAT_ONE: + this->parent_->send_client_command(sendspin::SendspinControllerCommand::REPEAT_ONE, std::nullopt, std::nullopt); + break; + case media_source::MediaSourceCommand::REPEAT_OFF: + this->parent_->send_client_command(sendspin::SendspinControllerCommand::REPEAT_OFF, std::nullopt, std::nullopt); + break; + case media_source::MediaSourceCommand::SHUFFLE: + this->parent_->send_client_command(sendspin::SendspinControllerCommand::SHUFFLE, std::nullopt, std::nullopt); + break; + case media_source::MediaSourceCommand::UNSHUFFLE: + this->parent_->send_client_command(sendspin::SendspinControllerCommand::UNSHUFFLE, std::nullopt, std::nullopt); + break; + default: + break; + } +} + +// THREAD CONTEXT: Main loop (orchestrator -> source notification) +void SendspinMediaSource::notify_volume_changed(float volume) { + this->cached_volume_ = volume; + if (this->player_role_) { + this->player_role_->update_volume(std::roundf(volume * 100.0f)); + } +} + +// THREAD CONTEXT: Main loop (orchestrator -> source notification) +void SendspinMediaSource::notify_mute_changed(bool is_muted) { + this->cached_muted_ = is_muted; + if (this->player_role_) { + this->player_role_->update_muted(is_muted); + } +} + +// THREAD CONTEXT: Speaker playback callback thread (forwarded from the speaker). +// PlayerRole::notify_audio_played() is documented as thread-safe for this use. +void SendspinMediaSource::notify_audio_played(uint32_t frames, int64_t timestamp) { + if (this->player_role_) { + this->player_role_->notify_audio_played(frames, timestamp); + } +} + +// --- Sendspin PlayerRoleListener overrides --- + +// THREAD CONTEXT: Sendspin sync task background thread. May block up to timeout_ms. +size_t SendspinMediaSource::on_audio_write(uint8_t *data, size_t length, uint32_t timeout_ms) { + if (!this->has_listener() || (this->get_state() != media_source::MediaSourceState::PLAYING)) { + vTaskDelay(pdMS_TO_TICKS(timeout_ms)); + return 0; + } + + // PlayerRole::get_current_stream_params() is safe to call from the sync task. + auto ¶ms = this->player_role_->get_current_stream_params(); + if (!params.bit_depth.has_value() || !params.channels.has_value() || !params.sample_rate.has_value()) { + vTaskDelay(pdMS_TO_TICKS(timeout_ms)); + return 0; + } + audio::AudioStreamInfo stream_info(*params.bit_depth, *params.channels, *params.sample_rate); + + return this->write_output(data, length, timeout_ms, stream_info); +} + +// THREAD CONTEXT: Main loop (PlayerRoleListener lifecycle callback) +void SendspinMediaSource::on_stream_start() { + this->parent_->update_state(sendspin::SendspinClientState::SYNCHRONIZED); + + if (!this->pending_start_) { + // Dedup rapid on_stream_start() calls + this->pending_start_ = true; + // Request the orchestrator to start this source + this->request_play_uri_("sendspin://current"); + } +} + +// THREAD CONTEXT: Main loop (PlayerRoleListener lifecycle callback) +void SendspinMediaSource::on_stream_end() { + if (this->get_state() != media_source::MediaSourceState::IDLE) { + // Only set to IDLE if we were previously in a non-IDLE state, to avoid duplicate state changes + this->set_state_(media_source::MediaSourceState::IDLE); + } +} + +// THREAD CONTEXT: Main loop (PlayerRoleListener lifecycle callback) +void SendspinMediaSource::on_stream_clear() { + if (this->get_state() != media_source::MediaSourceState::IDLE) { + // Only set to IDLE if we were previously in a non-IDLE state, to avoid duplicate state changes + this->set_state_(media_source::MediaSourceState::IDLE); + } +} + +// THREAD CONTEXT: Main loop (PlayerRoleListener callback) +void SendspinMediaSource::on_volume_changed(uint8_t volume) { this->request_volume_(volume / 100.0f); } + +// THREAD CONTEXT: Main loop (PlayerRoleListener callback) +void SendspinMediaSource::on_mute_changed(bool muted) { this->request_mute_(muted); } + +} // namespace esphome::sendspin_ + +#endif // USE_ESP32 && USE_SENDSPIN_PLAYER && USE_SENDSPIN_CONTROLLER diff --git a/esphome/components/sendspin/media_source/sendspin_media_source.h b/esphome/components/sendspin/media_source/sendspin_media_source.h new file mode 100644 index 00000000000..3b31716127c --- /dev/null +++ b/esphome/components/sendspin/media_source/sendspin_media_source.h @@ -0,0 +1,72 @@ +#pragma once + +#include "esphome/core/defines.h" + +#if defined(USE_ESP32) && defined(USE_SENDSPIN_CONTROLLER) && defined(USE_SENDSPIN_PLAYER) + +#include "esphome/components/sendspin/sendspin_hub.h" + +#include "esphome/components/media_source/media_source.h" + +#include + +namespace esphome::sendspin_ { + +/// @brief Thin adapter media source for Sendspin. +/// +/// Implements PlayerRoleListener to receive audio data from the sendspin-cpp library's +/// SyncTask and bridges it to ESPHome's MediaSource output pipeline. Also forwards +/// transport commands to the hub's controller role. +class SendspinMediaSource : public SendspinChild, + public media_source::MediaSource, + public sendspin::PlayerRoleListener { + public: + void setup() override; + void dump_config() override; + + void set_static_delay_adjustable(bool adjustable); + + // MediaSource interface implementation + bool play_uri(const std::string &uri) override; + void handle_command(media_source::MediaSourceCommand command) override; + bool can_handle(const std::string &uri) const override; + bool has_internal_playlist() const override { return true; } + + void notify_volume_changed(float volume) override; + void notify_mute_changed(bool is_muted) override; + void notify_audio_played(uint32_t frames, int64_t timestamp) override; + + protected: + // --- Sendspin PlayerRoleListener overrides --- + + /// @brief Writes decoded PCM audio to ESPHome's media source output pipeline. + /// Called from the sync task's background thread. + size_t on_audio_write(uint8_t *data, size_t length, uint32_t timeout_ms) override; + + /// @brief Called when a new audio stream starts (main loop thread). + void on_stream_start() override; + + /// @brief Called when the audio stream ends (main loop thread). + void on_stream_end() override; + + /// @brief Called when the audio stream is cleared (main loop thread). + void on_stream_clear() override; + + /// @brief Called when volume changes (main loop thread). + void on_volume_changed(uint8_t volume) override; + + /// @brief Called when mute state changes (main loop thread). + void on_mute_changed(bool muted) override; + + sendspin::PlayerRole *player_role_{nullptr}; + + float cached_volume_{0.0f}; + + bool cached_muted_{false}; + bool pending_start_{false}; + bool static_delay_adjustable_{false}; +}; + +} // namespace esphome::sendspin_ + +#endif diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp index ec419f77412..25e541a4938 100644 --- a/esphome/components/sendspin/sendspin_hub.cpp +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -25,6 +25,9 @@ void SendspinHub::setup() { // Set up persistence (preferences must be initialized before providers are added to the client) this->last_played_server_pref_ = global_preferences->make_preference(fnv1a_hash("sendspin_last_played")); +#ifdef USE_SENDSPIN_PLAYER + this->static_delay_pref_ = global_preferences->make_preference(fnv1a_hash("sendspin_static_delay")); +#endif // Wire providers and client listener this->client_->set_listener(this); @@ -36,6 +39,10 @@ void SendspinHub::setup() { this->controller_role_->set_listener(this); #endif +#ifdef USE_SENDSPIN_PLAYER + this->client_->add_player(this->player_config_).set_listener(this->player_listener_); +#endif + if (!this->client_->start_server()) { ESP_LOGE(TAG, "Failed to start Sendspin server"); this->mark_failed(); @@ -160,6 +167,39 @@ void SendspinHub::on_controller_state(const sendspin::ServerStateControllerObjec } #endif +#ifdef USE_SENDSPIN_PLAYER +// THREAD CONTEXT: Main loop, called from child component setup() after player role is created and configured +sendspin::PlayerRole *SendspinHub::get_player_role() { + if (this->is_ready()) { + return this->client_->player(); + } + return nullptr; +} + +// THREAD CONTEXT: Main loop (SendspinPersistenceProvider override) +bool SendspinHub::save_static_delay(uint16_t delay_ms) { + StaticDelayPref pref{.delay_ms = delay_ms}; + bool ok = this->static_delay_pref_.save(&pref); + if (ok) { + ESP_LOGD(TAG, "Persisted static delay: %u ms", delay_ms); + } else { + ESP_LOGW(TAG, "Failed to persist static delay"); + } + return ok; +} + +// THREAD CONTEXT: Main loop (SendspinPersistenceProvider override) +std::optional SendspinHub::load_static_delay() { + StaticDelayPref pref{}; + if (this->static_delay_pref_.load(&pref)) { + ESP_LOGI(TAG, "Loaded static delay: %u ms", pref.delay_ms); + return pref.delay_ms; + } + return std::nullopt; +} + +#endif + } // namespace esphome::sendspin_ #endif // USE_ESP32 diff --git a/esphome/components/sendspin/sendspin_hub.h b/esphome/components/sendspin/sendspin_hub.h index 1e217e0ea2e..c9266bd4d1e 100644 --- a/esphome/components/sendspin/sendspin_hub.h +++ b/esphome/components/sendspin/sendspin_hub.h @@ -16,6 +16,9 @@ #ifdef USE_SENDSPIN_CONTROLLER #include #endif +#ifdef USE_SENDSPIN_PLAYER +#include +#endif #include #include @@ -38,6 +41,13 @@ struct LastPlayedServerPref { uint32_t server_id_hash; }; +#ifdef USE_SENDSPIN_PLAYER +/// @brief Persistent storage structure for player static delay. +struct StaticDelayPref { + uint16_t delay_ms; +}; +#endif + /// @brief Thin adapter over sendspin::SendspinClient. /// /// The hub owns a SendspinClient instance and bridges its listener/provider interfaces to ESPHome's CallbackManager for @@ -112,6 +122,14 @@ class SendspinHub final : public Component, } #endif +#ifdef USE_SENDSPIN_PLAYER + void set_listener(sendspin::PlayerRoleListener *listener) { this->player_listener_ = listener; } + void set_player_config(const sendspin::PlayerRoleConfig &config) { this->player_config_ = config; } + + /// @brief Child components call this to get the PlayerRole instance after setup, so they can push updates to it. + sendspin::PlayerRole *get_player_role(); +#endif + protected: /// @brief Builds the SendspinClientConfig from ESPHome configuration and platform info. sendspin::SendspinClientConfig build_client_config_(); @@ -141,6 +159,16 @@ class SendspinHub final : public Component, CallbackManager controller_state_callbacks_{}; #endif +#ifdef USE_SENDSPIN_PLAYER + sendspin::PlayerRoleListener *player_listener_{nullptr}; + sendspin::PlayerRoleConfig player_config_{}; + + // Part of SendspinPersistenceProvider overrides + ESPPreferenceObject static_delay_pref_; + std::optional load_static_delay() override; + bool save_static_delay(uint16_t delay_ms) override; +#endif + // --- Core member variables --- ESPPreferenceObject last_played_server_pref_; diff --git a/tests/components/sendspin/common-media_source.yaml b/tests/components/sendspin/common-media_source.yaml new file mode 100644 index 00000000000..4a7cd79c67d --- /dev/null +++ b/tests/components/sendspin/common-media_source.yaml @@ -0,0 +1,9 @@ +<<: !include common.yaml + +media_source: + - platform: sendspin + id: media_source_id + buffer_size: 500000 + initial_static_delay: 5ms + static_delay_adjustable: true + fixed_delay: 480us diff --git a/tests/components/sendspin/test-media_source.esp32-idf.yaml b/tests/components/sendspin/test-media_source.esp32-idf.yaml new file mode 100644 index 00000000000..47aeb2257c4 --- /dev/null +++ b/tests/components/sendspin/test-media_source.esp32-idf.yaml @@ -0,0 +1 @@ +<<: !include common-media_source.yaml From ac7f0f0b74549d4add4f97cf53186f289b01cbe9 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Fri, 24 Apr 2026 07:07:00 -0400 Subject: [PATCH 10/68] [sendspin] Add a metadata text sensor component (#15969) --- CODEOWNERS | 1 + esphome/components/sendspin/sendspin_hub.cpp | 11 +++ esphome/components/sendspin/sendspin_hub.h | 19 +++++ .../sendspin/text_sensor/__init__.py | 55 ++++++++++++ .../text_sensor/sendspin_text_sensor.cpp | 85 +++++++++++++++++++ .../text_sensor/sendspin_text_sensor.h | 35 ++++++++ .../sendspin/common-text_sensor.yaml | 21 +++++ .../sendspin/test-text_sensor.esp32-idf.yaml | 1 + 8 files changed, 228 insertions(+) create mode 100644 esphome/components/sendspin/text_sensor/__init__.py create mode 100644 esphome/components/sendspin/text_sensor/sendspin_text_sensor.cpp create mode 100644 esphome/components/sendspin/text_sensor/sendspin_text_sensor.h create mode 100644 tests/components/sendspin/common-text_sensor.yaml create mode 100644 tests/components/sendspin/test-text_sensor.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 822b0e973c1..f4b288b23d6 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -443,6 +443,7 @@ esphome/components/sen6x/* @martgras @mebner86 @mikelawrence @tuct esphome/components/sendspin/* @kahrendt esphome/components/sendspin/media_player/* @kahrendt esphome/components/sendspin/media_source/* @kahrendt +esphome/components/sendspin/text_sensor/* @kahrendt esphome/components/sensirion_common/* @martgras esphome/components/sensor/* @esphome/core esphome/components/serial_proxy/* @kbx81 diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp index 25e541a4938..da298feb86d 100644 --- a/esphome/components/sendspin/sendspin_hub.cpp +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -39,6 +39,10 @@ void SendspinHub::setup() { this->controller_role_->set_listener(this); #endif +#ifdef USE_SENDSPIN_METADATA + this->client_->add_metadata().set_listener(this); +#endif + #ifdef USE_SENDSPIN_PLAYER this->client_->add_player(this->player_config_).set_listener(this->player_listener_); #endif @@ -167,6 +171,13 @@ void SendspinHub::on_controller_state(const sendspin::ServerStateControllerObjec } #endif +#ifdef USE_SENDSPIN_METADATA +// THREAD CONTEXT: Main loop (MetadataRoleListener override, fired from client_->loop()) +void SendspinHub::on_metadata(const sendspin::ServerMetadataStateObject &metadata) { + this->metadata_update_callbacks_.call(metadata); +} +#endif + #ifdef USE_SENDSPIN_PLAYER // THREAD CONTEXT: Main loop, called from child component setup() after player role is created and configured sendspin::PlayerRole *SendspinHub::get_player_role() { diff --git a/esphome/components/sendspin/sendspin_hub.h b/esphome/components/sendspin/sendspin_hub.h index c9266bd4d1e..8d9c58a3abb 100644 --- a/esphome/components/sendspin/sendspin_hub.h +++ b/esphome/components/sendspin/sendspin_hub.h @@ -16,6 +16,9 @@ #ifdef USE_SENDSPIN_CONTROLLER #include #endif +#ifdef USE_SENDSPIN_METADATA +#include +#endif #ifdef USE_SENDSPIN_PLAYER #include #endif @@ -66,6 +69,9 @@ struct StaticDelayPref { class SendspinHub final : public Component, #ifdef USE_SENDSPIN_CONTROLLER public sendspin::ControllerRoleListener, +#endif +#ifdef USE_SENDSPIN_METADATA + public sendspin::MetadataRoleListener, #endif public sendspin::SendspinClientListener, public sendspin::SendspinNetworkProvider, @@ -122,6 +128,12 @@ class SendspinHub final : public Component, } #endif +#ifdef USE_SENDSPIN_METADATA + template void add_metadata_update_callback(F &&callback) { + this->metadata_update_callbacks_.add(std::forward(callback)); + } +#endif + #ifdef USE_SENDSPIN_PLAYER void set_listener(sendspin::PlayerRoleListener *listener) { this->player_listener_ = listener; } void set_player_config(const sendspin::PlayerRoleConfig &config) { this->player_config_ = config; } @@ -159,6 +171,13 @@ class SendspinHub final : public Component, CallbackManager controller_state_callbacks_{}; #endif +#ifdef USE_SENDSPIN_METADATA + void on_metadata(const sendspin::ServerMetadataStateObject &metadata) override; + + // Callback fan-out to child components; they filter as needed + CallbackManager metadata_update_callbacks_{}; +#endif + #ifdef USE_SENDSPIN_PLAYER sendspin::PlayerRoleListener *player_listener_{nullptr}; sendspin::PlayerRoleConfig player_config_{}; diff --git a/esphome/components/sendspin/text_sensor/__init__.py b/esphome/components/sendspin/text_sensor/__init__.py new file mode 100644 index 00000000000..b7f216ca0ce --- /dev/null +++ b/esphome/components/sendspin/text_sensor/__init__.py @@ -0,0 +1,55 @@ +import esphome.codegen as cg +from esphome.components import text_sensor +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_TYPE +from esphome.types import ConfigType + +from .. import CONF_SENDSPIN_ID, SendspinHub, request_metadata_support, sendspin_ns + +CODEOWNERS = ["@kahrendt"] +DEPENDENCIES = ["sendspin"] + +SendspinTextSensor = sendspin_ns.class_( + "SendspinTextSensor", + text_sensor.TextSensor, + cg.Component, +) + +SendspinTextMetadataTypes = sendspin_ns.enum("SendspinTextMetadataTypes", is_class=True) +SENDSPIN_TEXT_METADATA_TYPES = { + "title": SendspinTextMetadataTypes.TITLE, + "artist": SendspinTextMetadataTypes.ARTIST, + "album": SendspinTextMetadataTypes.ALBUM, + "album_artist": SendspinTextMetadataTypes.ALBUM_ARTIST, + "year": SendspinTextMetadataTypes.YEAR, + "track": SendspinTextMetadataTypes.TRACK, +} + + +def _request_roles(config: ConfigType) -> ConfigType: + """Request the necessary Sendspin roles for the text sensor.""" + request_metadata_support() + + return config + + +CONFIG_SCHEMA = cv.All( + text_sensor.text_sensor_schema().extend( + { + cv.GenerateID(): cv.declare_id(SendspinTextSensor), + cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub), + cv.Required(CONF_TYPE): cv.enum(SENDSPIN_TEXT_METADATA_TYPES), + } + ), + cv.only_on_esp32, + _request_roles, +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await cg.register_parented(var, config[CONF_SENDSPIN_ID]) + await text_sensor.register_text_sensor(var, config) + + cg.add(var.set_metadata_type(config[CONF_TYPE])) diff --git a/esphome/components/sendspin/text_sensor/sendspin_text_sensor.cpp b/esphome/components/sendspin/text_sensor/sendspin_text_sensor.cpp new file mode 100644 index 00000000000..d16d51f63c6 --- /dev/null +++ b/esphome/components/sendspin/text_sensor/sendspin_text_sensor.cpp @@ -0,0 +1,85 @@ +#include "sendspin_text_sensor.h" + +#if defined(USE_ESP32) && defined(USE_SENDSPIN_METADATA) && defined(USE_TEXT_SENSOR) + +#include "esphome/core/helpers.h" + +#include + +#include + +namespace esphome::sendspin_ { + +static const char *const TAG = "sendspin.text_sensor"; + +void SendspinTextSensor::dump_config() { LOG_TEXT_SENSOR("", "Sendspin", this); } + +// THREAD CONTEXT: Main loop. The registered metadata callback also fires on the main loop +// (SendspinHub dispatches metadata from client_->loop()). +void SendspinTextSensor::setup() { + switch (this->metadata_type_) { + case SendspinTextMetadataTypes::TITLE: { + this->parent_->add_metadata_update_callback([this](const sendspin::ServerMetadataStateObject &metadata) { + if (metadata.title.has_value()) { + this->publish_if_changed_(metadata.title.value().c_str()); + } + }); + break; + } + case SendspinTextMetadataTypes::ARTIST: { + this->parent_->add_metadata_update_callback([this](const sendspin::ServerMetadataStateObject &metadata) { + if (metadata.artist.has_value()) { + this->publish_if_changed_(metadata.artist.value().c_str()); + } + }); + break; + } + case SendspinTextMetadataTypes::ALBUM: { + this->parent_->add_metadata_update_callback([this](const sendspin::ServerMetadataStateObject &metadata) { + if (metadata.album.has_value()) { + this->publish_if_changed_(metadata.album.value().c_str()); + } + }); + break; + } + case SendspinTextMetadataTypes::ALBUM_ARTIST: { + this->parent_->add_metadata_update_callback([this](const sendspin::ServerMetadataStateObject &metadata) { + if (metadata.album_artist.has_value()) { + this->publish_if_changed_(metadata.album_artist.value().c_str()); + } + }); + break; + } + case SendspinTextMetadataTypes::YEAR: { + this->parent_->add_metadata_update_callback([this](const sendspin::ServerMetadataStateObject &metadata) { + if (metadata.year.has_value() && metadata.year.value() <= 9999) { + char buf[UINT32_MAX_STR_SIZE]; + uint32_to_str(buf, metadata.year.value()); + this->publish_if_changed_(buf); + } + }); + break; + } + case SendspinTextMetadataTypes::TRACK: { + this->parent_->add_metadata_update_callback([this](const sendspin::ServerMetadataStateObject &metadata) { + if (metadata.track.has_value() && metadata.track.value() <= 9999) { + char buf[UINT32_MAX_STR_SIZE]; + uint32_to_str(buf, metadata.track.value()); + this->publish_if_changed_(buf); + } + }); + break; + } + } +} + +// Dedup to avoid frontend churn; TextSensor::publish_state already dedups the string assign but still notifies. +void SendspinTextSensor::publish_if_changed_(const char *value) { + if (this->get_raw_state() != value) { + this->publish_state(value); + } +} + +} // namespace esphome::sendspin_ + +#endif diff --git a/esphome/components/sendspin/text_sensor/sendspin_text_sensor.h b/esphome/components/sendspin/text_sensor/sendspin_text_sensor.h new file mode 100644 index 00000000000..d9ef49c938c --- /dev/null +++ b/esphome/components/sendspin/text_sensor/sendspin_text_sensor.h @@ -0,0 +1,35 @@ +#pragma once + +#include "esphome/core/defines.h" + +#if defined(USE_ESP32) && defined(USE_SENDSPIN_METADATA) && defined(USE_TEXT_SENSOR) + +#include "esphome/components/sendspin/sendspin_hub.h" +#include "esphome/components/text_sensor/text_sensor.h" + +namespace esphome::sendspin_ { + +enum class SendspinTextMetadataTypes { + TITLE, + ARTIST, + ALBUM, + ALBUM_ARTIST, + YEAR, + TRACK, +}; + +class SendspinTextSensor : public SendspinChild, public text_sensor::TextSensor { + public: + void dump_config() override; + void setup() override; + + void set_metadata_type(SendspinTextMetadataTypes metadata_type) { this->metadata_type_ = metadata_type; } + + protected: + void publish_if_changed_(const char *value); + + SendspinTextMetadataTypes metadata_type_; +}; + +} // namespace esphome::sendspin_ +#endif diff --git a/tests/components/sendspin/common-text_sensor.yaml b/tests/components/sendspin/common-text_sensor.yaml new file mode 100644 index 00000000000..0bfbf457574 --- /dev/null +++ b/tests/components/sendspin/common-text_sensor.yaml @@ -0,0 +1,21 @@ +<<: !include common.yaml + +text_sensor: + - platform: sendspin + name: "Title" + type: title + - platform: sendspin + name: "Artist" + type: artist + - platform: sendspin + name: "Album" + type: album + - platform: sendspin + name: "Album Artist" + type: album_artist + - platform: sendspin + name: "Year" + type: year + - platform: sendspin + name: "Track Number" + type: track diff --git a/tests/components/sendspin/test-text_sensor.esp32-idf.yaml b/tests/components/sendspin/test-text_sensor.esp32-idf.yaml new file mode 100644 index 00000000000..8998b8896ef --- /dev/null +++ b/tests/components/sendspin/test-text_sensor.esp32-idf.yaml @@ -0,0 +1 @@ +<<: !include common-text_sensor.yaml From 773b4d887bf25d8b564aab10f1c3ddd27ee65676 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Apr 2026 08:11:29 -0500 Subject: [PATCH 11/68] [core] Scheduler: don't sleep while defer queue is non-empty (#15968) --- esphome/core/scheduler.cpp | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index a6f1558e4a4..d83d67d6e42 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -414,8 +414,27 @@ bool HOT Scheduler::cancel_retry(Component *component, uint32_t id) { optional HOT Scheduler::next_schedule_in(uint32_t now) { // IMPORTANT: This method should only be called from the main thread (loop task). - // It performs cleanup and accesses items_[0] without holding a lock, which is only - // safe when called from the main thread. Other threads must not call this method. + // Accesses items_[0] and the fast-path empty checks without holding a lock, which + // is only safe from the main thread. Other threads must not call this method. + // + // Note: cleanup_() is only invoked on the items_[0] path below. The early returns + // skip it because they don't read items_[0], and Scheduler::call() at the top of + // every loop iteration already performs its own cleanup before the next sleep- + // duration computation happens. + +#ifndef ESPHOME_THREAD_SINGLE + // defer() items live in a separate queue that is drained at the top of every + // loop tick via process_defer_queue_(). If any are pending, the next loop + // iteration has work to do right now -- don't let the caller sleep. + if (!this->defer_empty_()) + return 0; +#else + // On single-threaded builds, defer() routes through set_timeout(..., 0) which + // stages in to_add_. process_to_add() runs at the top of every scheduler.call(), + // so anything in to_add_ becomes runnable on the next iteration; don't sleep. + if (!this->to_add_empty_()) + return 0; +#endif // If no items, return empty optional if (!this->cleanup_()) From baa6d5f96b85ff28f34af1a718e5bbe71bef3e2f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Apr 2026 08:11:47 -0500 Subject: [PATCH 12/68] [web_server_idf] Fix cross-thread race on SSE session state (#15967) --- .../web_server_idf/web_server_idf.cpp | 70 ++++++++++++++----- .../web_server_idf/web_server_idf.h | 16 ++++- 2 files changed, 65 insertions(+), 21 deletions(-) diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 8f464ae9121..e1d3e4bf34f 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -472,24 +472,36 @@ void AsyncResponseStream::printf(const char *fmt, ...) { #ifdef USE_WEBSERVER AsyncEventSource::~AsyncEventSource() { - for (auto *ses : this->sessions_) { - delete ses; // NOLINT(cppcoreguidelines-owning-memory) + LockGuard guard{this->pending_mutex_}; + for (auto *vec : {&this->sessions_, &this->pending_sessions_}) { + for (auto *ses : *vec) { + delete ses; // NOLINT(cppcoreguidelines-owning-memory) + } } } void AsyncEventSource::handleRequest(AsyncWebServerRequest *request) { + // Httpd task: set up the live httpd_req_t and park the session; main loop does the rest. // NOLINTNEXTLINE(cppcoreguidelines-owning-memory,clang-analyzer-cplusplus.NewDeleteLeaks) auto *rsp = new AsyncEventSourceResponse(request, this, this->web_server_); - if (this->on_connect_) { - this->on_connect_(rsp); + { + LockGuard guard{this->pending_mutex_}; + this->pending_sessions_.push_back(rsp); + this->has_pending_sessions_.store(true, std::memory_order_release); } - this->sessions_.push_back(rsp); - // Wake up WebServer::loop() to drain deferred event queues for this client. - // Safe from httpd task context via the pending_enable_loop_ flag. this->web_server_->enable_loop_soon_any_context(); } +// clang-analyzer traces a false-positive leak path from loop() through +// adopt_pending_sessions_main_loop_() into start_session_main_loop_() and +// finally ArduinoJson. Suppress along the entire in-our-code call chain. +// NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) bool AsyncEventSource::loop() { + // Fast path: one atomic load per tick. Slow path is out-of-line on connect. + if (this->has_pending_sessions_.load(std::memory_order_acquire)) { + this->adopt_pending_sessions_main_loop_(); + } + // Clean up dead sessions safely // This follows the ESP-IDF pattern where free_ctx marks resources as dead // and the main loop handles the actual cleanup to avoid race conditions @@ -497,7 +509,7 @@ bool AsyncEventSource::loop() { auto *ses = this->sessions_[i]; // If the session has a dead socket (marked by destroy callback) if (ses->fd_.load() == 0) { - ESP_LOGD(TAG, "Removing dead event source session"); + // destroy() already logged the close with the fd; don't double-log here. delete ses; // NOLINT(cppcoreguidelines-owning-memory) // Remove by swapping with last element (O(1) removal, order doesn't matter for sessions) this->sessions_[i] = this->sessions_.back(); @@ -510,6 +522,30 @@ bool AsyncEventSource::loop() { return !this->sessions_.empty(); } +void AsyncEventSource::adopt_pending_sessions_main_loop_() { + std::vector incoming; + { + LockGuard guard{this->pending_mutex_}; + incoming.swap(this->pending_sessions_); + this->has_pending_sessions_.store(false, std::memory_order_relaxed); + } + for (auto *rsp : incoming) { + // Already disconnected? Drop it; skip on_connect_/session start on a dead session. + if (rsp->fd_.load() == 0) { + delete rsp; // NOLINT(cppcoreguidelines-owning-memory) + continue; + } + this->sessions_.push_back(rsp); + // Prime first so on_connect_ observes a session that has already sent its + // initial ping/config/sorting_groups, matching the pre-refactor ordering. + rsp->start_session_main_loop_(); + if (this->on_connect_) { + this->on_connect_(rsp); + } + } +} +// NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks) + void AsyncEventSource::try_send_nodefer(const char *message, const char *event, uint32_t id, uint32_t reconnect) { for (auto *ses : this->sessions_) { if (ses->fd_.load() != 0) { // Skip dead sessions @@ -534,6 +570,7 @@ AsyncEventSourceResponse::AsyncEventSourceResponse(const AsyncWebServerRequest * esphome::web_server_idf::AsyncEventSource *server, esphome::web_server::WebServer *ws) : server_(server), web_server_(ws), entities_iterator_(ws, server) { + // Httpd task only. start_session_main_loop_() handles event_buffer_ / iterator setup. httpd_req_t *req = *request; httpd_resp_set_status(req, HTTPD_200); @@ -555,21 +592,23 @@ AsyncEventSourceResponse::AsyncEventSourceResponse(const AsyncWebServerRequest * // Use non-blocking send to prevent watchdog timeouts when TCP buffers are full httpd_sess_set_send_override(this->hd_, this->fd_.load(), nonblocking_send); +} - // Configure reconnect timeout and send config - // this should always go through since the tcp send buffer is empty on connect +// NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson +void AsyncEventSourceResponse::start_session_main_loop_() { + auto *ws = this->web_server_; + + // tcp send buffer is empty on connect, so these should always go through auto message = ws->get_config_json(); this->try_send_nodefer(message.c_str(), "ping", millis(), 30000); #ifdef USE_WEBSERVER_SORTING for (auto &group : ws->sorting_groups_) { - // NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson json::JsonBuilder builder; JsonObject root = builder.root(); root["name"] = group.second.name; root["sorting_weight"] = group.second.weight; message = builder.serialize(); - // NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks) // a (very) large number of these should be able to be queued initially without defer // since the only thing in the send buffer at this point is the initial ping/config @@ -578,13 +617,8 @@ AsyncEventSourceResponse::AsyncEventSourceResponse(const AsyncWebServerRequest * #endif this->entities_iterator_.begin(ws->include_internal_); - - // just dump them all up-front and take advantage of the deferred queue - // on second thought that takes too long, but leaving the commented code here for debug purposes - // while(!this->entities_iterator_.completed()) { - // this->entities_iterator_.advance(); - //} } +// NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks) void AsyncEventSourceResponse::destroy(void *ptr) { auto *rsp = static_cast(ptr); diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index f2931fb5079..cdb58c2f046 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -299,6 +299,9 @@ class AsyncEventSourceResponse { AsyncEventSourceResponse(const AsyncWebServerRequest *request, esphome::web_server_idf::AsyncEventSource *server, esphome::web_server::WebServer *ws); + // Main-loop only: sends initial ping/config/sorting_groups, starts entity iterator. + void start_session_main_loop_(); + void deq_push_back_with_dedup_(void *source, message_generator_t *message_generator); void process_deferred_queue_(); void process_buffer_(); @@ -335,6 +338,8 @@ class AsyncEventSource : public AsyncWebHandler { } // NOLINTNEXTLINE(readability-identifier-naming) void handleRequest(AsyncWebServerRequest *request) override; + // Callback runs on the main loop (not the httpd task) after the session's + // initial ping/config/sorting_groups have been sent. // NOLINTNEXTLINE(readability-identifier-naming) void onConnect(connect_handler_t &&cb) { this->on_connect_ = std::move(cb); } @@ -347,13 +352,18 @@ class AsyncEventSource : public AsyncWebHandler { size_t count() const { return this->sessions_.size(); } protected: + // Cold path: move sessions from pending_sessions_ into sessions_ and greet each one. + void __attribute__((noinline, cold)) adopt_pending_sessions_main_loop_(); + std::string url_; - // Use vector instead of set: SSE sessions are typically 1-5 connections (browsers, dashboards). - // Linear search is faster than red-black tree overhead for this small dataset. - // Only operations needed: add session, remove session, iterate sessions - no need for sorted order. + // Main-loop only. Vector: SSE sessions are 1-5 connections, linear search beats set. std::vector sessions_; + // Httpd-task intake; guarded by pending_mutex_, gated by has_pending_sessions_. + std::vector pending_sessions_; + Mutex pending_mutex_; connect_handler_t on_connect_{}; esphome::web_server::WebServer *web_server_; + std::atomic has_pending_sessions_{false}; }; #endif // USE_WEBSERVER From f132b7dc07f2f402eab87a8ee445a64c5b403e22 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Fri, 24 Apr 2026 10:09:03 -0400 Subject: [PATCH 13/68] [media_player][speaker][speaker_source] Centralize preferred format codegen (#14771) --- esphome/components/media_player/__init__.py | 111 +++++++++++- .../speaker/media_player/__init__.py | 160 ++++-------------- .../components/speaker_source/media_player.py | 83 +-------- .../speaker/common-media_player.yaml | 2 +- 4 files changed, 156 insertions(+), 200 deletions(-) diff --git a/esphome/components/media_player/__init__.py b/esphome/components/media_player/__init__.py index 1c2c4746451..d1db868ace4 100644 --- a/esphome/components/media_player/__init__.py +++ b/esphome/components/media_player/__init__.py @@ -1,20 +1,31 @@ +from collections.abc import Callable + from esphome import automation import esphome.codegen as cg +from esphome.components import audio import esphome.config_validation as cv from esphome.const import ( CONF_ENTITY_CATEGORY, + CONF_FORMAT, CONF_ICON, CONF_ID, + CONF_NUM_CHANNELS, CONF_ON_IDLE, CONF_ON_STATE, CONF_ON_TURN_OFF, CONF_ON_TURN_ON, + CONF_SAMPLE_RATE, CONF_VOLUME, ) from esphome.core import CORE -from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity +from esphome.core.entity_helpers import ( + entity_duplicate_validator, + inherit_property_from, + setup_entity, +) from esphome.coroutine import CoroPriority, coroutine_with_priority -from esphome.cpp_generator import MockObjClass +from esphome.cpp_generator import MockObj, MockObjClass +from esphome.types import ConfigType CODEOWNERS = ["@jesserockz"] @@ -34,6 +45,102 @@ MEDIA_PLAYER_FORMAT_PURPOSE_ENUM = { "announcement": MediaPlayerFormatPurpose.PURPOSE_ANNOUNCEMENT, } +# Public API for external components. Do not remove. +FORMAT_MAPPING = { + "FLAC": "flac", + "MP3": "mp3", + "OPUS": "opus", + "WAV": "wav", +} + + +def build_supported_format_struct( + format_config: ConfigType, purpose: MockObj +) -> cg.StructInitializer: + """Build a MediaPlayerSupportedFormat struct from a format config and purpose. + + Public API for external components. Do not remove. + """ + args = [ + MediaPlayerSupportedFormat, + ("format", FORMAT_MAPPING[format_config[CONF_FORMAT]]), + ("sample_rate", format_config[CONF_SAMPLE_RATE]), + ("num_channels", format_config[CONF_NUM_CHANNELS]), + ("purpose", purpose), + ] + + # Omit sample_bytes for MP3: ffmpeg transcoding in Home Assistant fails + # if the number of bytes per sample is specified for MP3. + if format_config[CONF_FORMAT] != "MP3": + args.append(("sample_bytes", 2)) + + return cg.StructInitializer(*args) + + +def validate_preferred_format( + component_name: str, audio_device_key: str +) -> Callable[[ConfigType], ConfigType]: + """Return a validator that inherits audio device settings and validates format constraints. + + Public API for external components. Do not remove. + """ + + def validator(config: ConfigType) -> ConfigType: + # Inherit settings from audio device if not manually set + inherit_property_from(CONF_NUM_CHANNELS, audio_device_key)(config) + inherit_property_from(CONF_SAMPLE_RATE, audio_device_key)(config) + + # Opus only supports 48 kHz + if config.get(CONF_FORMAT) == "OPUS" and config.get(CONF_SAMPLE_RATE) != 48000: + raise cv.Invalid("Opus only supports a sample rate of 48000 Hz") + + # Validate the settings are compatible with the audio device + audio.final_validate_audio_schema( + component_name, + audio_device=audio_device_key, + bits_per_sample=16, + channels=config.get(CONF_NUM_CHANNELS), + sample_rate=config.get(CONF_SAMPLE_RATE), + )(config) + + return config + + return validator + + +def request_codecs_for_format_configs( + config: ConfigType, format_config_keys: list[str] +) -> None: + """Scan format configs for configured formats and request the needed codec support. + + If any config uses "NONE" (accepts any format), all codecs are requested. + + Public API for external components. Do not remove. + """ + needed_formats: set[str] = set() + need_all = False + + for key in format_config_keys: + if format_config := config.get(key): + fmt = format_config[CONF_FORMAT] + if fmt == "NONE": + need_all = True + else: + needed_formats.add(fmt) + + if need_all: + audio.request_flac_support() + audio.request_mp3_support() + audio.request_opus_support() + else: + if "FLAC" in needed_formats: + audio.request_flac_support() + if "MP3" in needed_formats: + audio.request_mp3_support() + if "OPUS" in needed_formats: + audio.request_opus_support() + + # Local config key constants CONF_ANNOUNCEMENT = "announcement" CONF_ON_PLAY = "on_play" diff --git a/esphome/components/speaker/media_player/__init__.py b/esphome/components/speaker/media_player/__init__.py index 9b496637da1..abfd599808f 100644 --- a/esphome/components/speaker/media_player/__init__.py +++ b/esphome/components/speaker/media_player/__init__.py @@ -32,7 +32,6 @@ from esphome.const import ( CONF_URL, ) from esphome.core import CORE, HexInt -from esphome.core.entity_helpers import inherit_property_from from esphome.external_files import download_content _LOGGER = logging.getLogger(__name__) @@ -44,16 +43,12 @@ DEPENDENCIES = ["network"] CODEOWNERS = ["@kahrendt", "@synesthesiam"] DOMAIN = "media_player" -CODEC_SUPPORT_ALL = "all" -CODEC_SUPPORT_NEEDED = "needed" -CODEC_SUPPORT_NONE = "none" - TYPE_LOCAL = "local" TYPE_WEB = "web" CONF_ANNOUNCEMENT = "announcement" CONF_ANNOUNCEMENT_PIPELINE = "announcement_pipeline" -CONF_CODEC_SUPPORT_ENABLED = "codec_support_enabled" +CONF_CODEC_SUPPORT_ENABLED = "codec_support_enabled" # Remove before 2026.10.0 CONF_ENQUEUE = "enqueue" CONF_MEDIA_FILE = "media_file" CONF_MEDIA_PIPELINE = "media_pipeline" @@ -106,43 +101,10 @@ def _download_web_file(value): return value -# Returns a media_player.MediaPlayerSupportedFormat struct with the configured -# format, sample rate, number of channels, purpose, and bytes per sample -def _get_supported_format_struct(pipeline, type): - args = [ - media_player.MediaPlayerSupportedFormat, - ] - - if pipeline[CONF_FORMAT] == "FLAC": - args.append(("format", "flac")) - elif pipeline[CONF_FORMAT] == "MP3": - args.append(("format", "mp3")) - elif pipeline[CONF_FORMAT] == "OPUS": - args.append(("format", "opus")) - elif pipeline[CONF_FORMAT] == "WAV": - args.append(("format", "wav")) - - args.append(("sample_rate", pipeline[CONF_SAMPLE_RATE])) - args.append(("num_channels", pipeline[CONF_NUM_CHANNELS])) - - if type == "MEDIA": - args.append( - ( - "purpose", - media_player.MEDIA_PLAYER_FORMAT_PURPOSE_ENUM["default"], - ) - ) - elif type == "ANNOUNCEMENT": - args.append( - ( - "purpose", - media_player.MEDIA_PLAYER_FORMAT_PURPOSE_ENUM["announcement"], - ) - ) - if pipeline[CONF_FORMAT] != "MP3": - args.append(("sample_bytes", 2)) - - return cg.StructInitializer(*args) +_PURPOSE_MAP = { + "MEDIA": media_player.MEDIA_PLAYER_FORMAT_PURPOSE_ENUM["default"], + "ANNOUNCEMENT": media_player.MEDIA_PLAYER_FORMAT_PURPOSE_ENUM["announcement"], +} def _file_schema(value): @@ -210,25 +172,9 @@ def _validate_file_shorthand(value): ) -def _validate_pipeline(config): - # Inherit transcoder settings from speaker if not manually set - inherit_property_from(CONF_NUM_CHANNELS, CONF_SPEAKER)(config) - inherit_property_from(CONF_SAMPLE_RATE, CONF_SPEAKER)(config) - - # Opus only supports 48 kHz - if config.get(CONF_FORMAT) == "OPUS" and config.get(CONF_SAMPLE_RATE) != 48000: - raise cv.Invalid("Opus only supports a sample rate of 48000 Hz") - - # Validate the transcoder settings is compatible with the speaker - audio.final_validate_audio_schema( - "speaker media_player", - audio_device=CONF_SPEAKER, - bits_per_sample=16, - channels=config.get(CONF_NUM_CHANNELS), - sample_rate=config.get(CONF_SAMPLE_RATE), - )(config) - - return config +_validate_pipeline = media_player.validate_preferred_format( + "speaker media_player", CONF_SPEAKER +) def _validate_repeated_speaker(config): @@ -245,59 +191,34 @@ def _validate_repeated_speaker(config): def _final_validate(config): - # Normalize boolean values to string equivalents - codec_mode = config[CONF_CODEC_SUPPORT_ENABLED] - if codec_mode is True: - codec_mode = CODEC_SUPPORT_ALL - elif codec_mode is False: - codec_mode = CODEC_SUPPORT_NONE + # Remove before 2026.10.0 + if CONF_CODEC_SUPPORT_ENABLED in config: + _LOGGER.warning( + "'%s' is deprecated and will be removed in 2026.10.0. " + "Codec support is now automatically determined from the pipeline " + "'format' setting. Set format to 'NONE' to enable all codecs.", + CONF_CODEC_SUPPORT_ENABLED, + ) - use_codec = codec_mode != CODEC_SUPPORT_NONE - - # In "needed" mode, collect formats from pipelines and files - needed_formats = set() - need_all = False - if codec_mode == CODEC_SUPPORT_NEEDED: - for pipeline_key in (CONF_ANNOUNCEMENT_PIPELINE, CONF_MEDIA_PIPELINE): - if pipeline := config.get(pipeline_key): - fmt = pipeline[CONF_FORMAT] - if fmt == "NONE": - # No preferred format means any format could arrive - need_all = True - else: - needed_formats.add(fmt) + # Request codecs based on pipeline formats + media_player.request_codecs_for_format_configs( + config, [CONF_ANNOUNCEMENT_PIPELINE, CONF_MEDIA_PIPELINE] + ) + # Validate local files and request any additional codecs they need for file_config in config.get(CONF_FILES, []): _, media_file_type = _read_audio_file_and_type(file_config) if str(media_file_type) == str(audio.AUDIO_FILE_TYPE_ENUM["NONE"]): raise cv.Invalid("Unsupported local media file") - if not use_codec and str(media_file_type) != str( - audio.AUDIO_FILE_TYPE_ENUM["WAV"] - ): - # Only wav files are supported - raise cv.Invalid( - f"Unsupported local media file type, set {CONF_CODEC_SUPPORT_ENABLED} to true or convert the media file to wav" - ) - # In "needed" mode, add file format to needed codecs - if codec_mode == CODEC_SUPPORT_NEEDED: - for fmt_name, fmt_enum in audio.AUDIO_FILE_TYPE_ENUM.items(): - if str(media_file_type) == str(fmt_enum): - if fmt_name not in ("WAV", "NONE"): - needed_formats.add(fmt_name) - break - - # Request codec support - if codec_mode == CODEC_SUPPORT_ALL or need_all: - audio.request_flac_support() - audio.request_mp3_support() - audio.request_opus_support() - elif codec_mode == CODEC_SUPPORT_NEEDED: - if "FLAC" in needed_formats: - audio.request_flac_support() - if "MP3" in needed_formats: - audio.request_mp3_support() - if "OPUS" in needed_formats: - audio.request_opus_support() + for fmt_name, fmt_enum in audio.AUDIO_FILE_TYPE_ENUM.items(): + if str(media_file_type) == str(fmt_enum): + if fmt_name == "FLAC": + audio.request_flac_support() + elif fmt_name == "MP3": + audio.request_mp3_support() + elif fmt_name == "OPUS": + audio.request_opus_support() + break return config @@ -362,17 +283,8 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_BUFFER_SIZE, default=1000000): cv.int_range( min=4000, max=4000000 ), - cv.Optional( - CONF_CODEC_SUPPORT_ENABLED, default=CODEC_SUPPORT_NEEDED - ): cv.Any( - cv.boolean, - cv.one_of( - CODEC_SUPPORT_ALL, - CODEC_SUPPORT_NEEDED, - CODEC_SUPPORT_NONE, - lower=True, - ), - ), + # Remove before 2026.10.0 + cv.Optional(CONF_CODEC_SUPPORT_ENABLED): cv.Any(cv.boolean, cv.string), cv.Optional(CONF_FILES): cv.ensure_list(MEDIA_FILE_TYPE_SCHEMA), cv.Optional(CONF_TASK_STACK_IN_PSRAM): cv.All( cv.boolean, cv.requires_component(psram.DOMAIN) @@ -432,8 +344,8 @@ async def to_code(config): if announcement_pipeline_config[CONF_FORMAT] != "NONE": cg.add( var.set_announcement_format( - _get_supported_format_struct( - announcement_pipeline_config, "ANNOUNCEMENT" + media_player.build_supported_format_struct( + announcement_pipeline_config, _PURPOSE_MAP["ANNOUNCEMENT"] ) ) ) @@ -444,7 +356,9 @@ async def to_code(config): if media_pipeline_config[CONF_FORMAT] != "NONE": cg.add( var.set_media_format( - _get_supported_format_struct(media_pipeline_config, "MEDIA") + media_player.build_supported_format_struct( + media_pipeline_config, _PURPOSE_MAP["MEDIA"] + ) ) ) diff --git a/esphome/components/speaker_source/media_player.py b/esphome/components/speaker_source/media_player.py index 70feeac3180..b6653fe5433 100644 --- a/esphome/components/speaker_source/media_player.py +++ b/esphome/components/speaker_source/media_player.py @@ -17,7 +17,6 @@ from esphome.const import ( CONF_SPEAKER, ) from esphome.core import ID -from esphome.core.entity_helpers import inherit_property_from from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType @@ -65,53 +64,9 @@ SetPlaylistDelayAction = speaker_source_ns.class_( ) -FORMAT_MAPPING = { - "FLAC": "flac", - "MP3": "mp3", - "OPUS": "opus", - "WAV": "wav", -} - - -# Returns a media_player.MediaPlayerSupportedFormat struct with the configured -# format, sample rate, number of channels, purpose, and bytes per sample -def _get_supported_format_struct(pipeline: ConfigType, purpose: MockObj): - args = [ - media_player.MediaPlayerSupportedFormat, - ] - - args.append(("format", FORMAT_MAPPING[pipeline[CONF_FORMAT]])) - - args.append(("sample_rate", pipeline[CONF_SAMPLE_RATE])) - args.append(("num_channels", pipeline[CONF_NUM_CHANNELS])) - args.append(("purpose", purpose)) - - # Omit sample_bytes for MP3: ffmpeg transcoding in Home Assistant fails - # if the number of bytes per sample is specified for MP3. - if pipeline[CONF_FORMAT] != "MP3": - args.append(("sample_bytes", 2)) - - return cg.StructInitializer(*args) - - -def _validate_pipeline(config: ConfigType) -> ConfigType: - # Inherit settings from speaker if not manually set - inherit_property_from(CONF_NUM_CHANNELS, CONF_SPEAKER)(config) - inherit_property_from(CONF_SAMPLE_RATE, CONF_SPEAKER)(config) - - # Opus only supports 48 kHz - if config.get(CONF_FORMAT) == "OPUS" and config.get(CONF_SAMPLE_RATE) != 48000: - raise cv.Invalid("Opus only supports a sample rate of 48000 Hz") - - audio.final_validate_audio_schema( - "speaker_source media_player", - audio_device=CONF_SPEAKER, - bits_per_sample=16, - channels=config.get(CONF_NUM_CHANNELS), - sample_rate=config.get(CONF_SAMPLE_RATE), - )(config) - - return config +_validate_pipeline = media_player.validate_preferred_format( + "speaker_source media_player", CONF_SPEAKER +) PIPELINE_SCHEMA = cv.Schema( @@ -198,31 +153,9 @@ CONFIG_SCHEMA = cv.All( def _final_validate_codecs(config: ConfigType) -> ConfigType: - # "NONE" means the pipeline accepts any format at runtime, so all optional codecs must be available. - # When a specific format is set, only that codec is requested. - needed_formats: set[str] = set() - need_all = False - - for pipeline_key in (CONF_ANNOUNCEMENT_PIPELINE, CONF_MEDIA_PIPELINE): - if pipeline := config.get(pipeline_key): - fmt = pipeline[CONF_FORMAT] - if fmt == "NONE": - need_all = True - else: - needed_formats.add(fmt) - - if need_all: - audio.request_flac_support() - audio.request_mp3_support() - audio.request_opus_support() - else: - if "FLAC" in needed_formats: - audio.request_flac_support() - if "MP3" in needed_formats: - audio.request_mp3_support() - if "OPUS" in needed_formats: - audio.request_opus_support() - + media_player.request_codecs_for_format_configs( + config, [CONF_ANNOUNCEMENT_PIPELINE, CONF_MEDIA_PIPELINE] + ) return config @@ -264,7 +197,9 @@ async def to_code(config: ConfigType) -> None: cg.add( var.set_format( pipeline_enum, - _get_supported_format_struct(pipeline_config, purpose), + media_player.build_supported_format_struct( + pipeline_config, purpose + ), ) ) diff --git a/tests/components/speaker/common-media_player.yaml b/tests/components/speaker/common-media_player.yaml index c958c0d9120..a849e04b33e 100644 --- a/tests/components/speaker/common-media_player.yaml +++ b/tests/components/speaker/common-media_player.yaml @@ -11,9 +11,9 @@ media_player: id: speaker_media_player_id announcement_pipeline: speaker: speaker_id + format: NONE buffer_size: 1000000 volume_increment: 0.02 volume_max: 0.95 volume_min: 0.0 task_stack_in_psram: true - codec_support_enabled: all From 55bcf33446dfc8c001482c57fcdbc2754d332058 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Fri, 24 Apr 2026 10:32:47 -0400 Subject: [PATCH 14/68] [sendspin] Add metadata sensor component (#15971) --- CODEOWNERS | 1 + esphome/components/sendspin/sendspin_hub.cpp | 11 ++- esphome/components/sendspin/sendspin_hub.h | 15 +++ .../components/sendspin/sensor/__init__.py | 98 +++++++++++++++++++ .../sendspin/sensor/sendspin_sensor.cpp | 98 +++++++++++++++++++ .../sendspin/sensor/sendspin_sensor.h | 42 ++++++++ tests/components/sendspin/common-sensor.yaml | 15 +++ .../sendspin/test-sensor.esp32-idf.yaml | 1 + 8 files changed, 280 insertions(+), 1 deletion(-) create mode 100644 esphome/components/sendspin/sensor/__init__.py create mode 100644 esphome/components/sendspin/sensor/sendspin_sensor.cpp create mode 100644 esphome/components/sendspin/sensor/sendspin_sensor.h create mode 100644 tests/components/sendspin/common-sensor.yaml create mode 100644 tests/components/sendspin/test-sensor.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index f4b288b23d6..20c19a7dfa0 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -443,6 +443,7 @@ esphome/components/sen6x/* @martgras @mebner86 @mikelawrence @tuct esphome/components/sendspin/* @kahrendt esphome/components/sendspin/media_player/* @kahrendt esphome/components/sendspin/media_source/* @kahrendt +esphome/components/sendspin/sensor/* @kahrendt esphome/components/sendspin/text_sensor/* @kahrendt esphome/components/sensirion_common/* @martgras esphome/components/sensor/* @esphome/core diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp index da298feb86d..d27c5672eb6 100644 --- a/esphome/components/sendspin/sendspin_hub.cpp +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -40,7 +40,8 @@ void SendspinHub::setup() { #endif #ifdef USE_SENDSPIN_METADATA - this->client_->add_metadata().set_listener(this); + this->metadata_role_ = &this->client_->add_metadata(); + this->metadata_role_->set_listener(this); #endif #ifdef USE_SENDSPIN_PLAYER @@ -176,6 +177,14 @@ void SendspinHub::on_controller_state(const sendspin::ServerStateControllerObjec void SendspinHub::on_metadata(const sendspin::ServerMetadataStateObject &metadata) { this->metadata_update_callbacks_.call(metadata); } + +// THREAD CONTEXT: Main loop (invoked from Sendspin components) +uint32_t SendspinHub::get_track_progress_ms() const { + if (this->is_ready()) { + return this->metadata_role_->get_track_progress_ms(); + } + return 0; +} #endif #ifdef USE_SENDSPIN_PLAYER diff --git a/esphome/components/sendspin/sendspin_hub.h b/esphome/components/sendspin/sendspin_hub.h index 8d9c58a3abb..12fbf156ea6 100644 --- a/esphome/components/sendspin/sendspin_hub.h +++ b/esphome/components/sendspin/sendspin_hub.h @@ -132,6 +132,9 @@ class SendspinHub final : public Component, template void add_metadata_update_callback(F &&callback) { this->metadata_update_callbacks_.add(std::forward(callback)); } + + /// @brief Returns the interpolated track progress in milliseconds, or 0 if the hub is not yet ready. + uint32_t get_track_progress_ms() const; #endif #ifdef USE_SENDSPIN_PLAYER @@ -172,6 +175,8 @@ class SendspinHub final : public Component, #endif #ifdef USE_SENDSPIN_METADATA + sendspin::MetadataRole *metadata_role_{nullptr}; + void on_metadata(const sendspin::ServerMetadataStateObject &metadata) override; // Callback fan-out to child components; they filter as needed @@ -211,6 +216,16 @@ class SendspinChild : public Component, public Parented { float get_setup_priority() const override { return sendspin_priority::CHILD; } }; +/// @brief Base class for sendspin subcomponents that need polling behavior. +/// +/// Same purpose as SendspinChild but inherits from PollingComponent for subcomponents +/// that poll on a fixed interval. Subcomponents should inherit from this instead of +/// listing PollingComponent/Parented individually and must not override get_setup_priority(). +class SendspinPollingChild : public PollingComponent, public Parented { + public: + float get_setup_priority() const override { return sendspin_priority::CHILD; } +}; + } // namespace esphome::sendspin_ #endif // USE_ESP32 diff --git a/esphome/components/sendspin/sensor/__init__.py b/esphome/components/sendspin/sensor/__init__.py new file mode 100644 index 00000000000..dc9b86c2a36 --- /dev/null +++ b/esphome/components/sendspin/sensor/__init__.py @@ -0,0 +1,98 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + CONF_TYPE, + CONF_YEAR, + STATE_CLASS_MEASUREMENT, + UNIT_MILLISECOND, +) +from esphome.types import ConfigType + +from .. import CONF_SENDSPIN_ID, SendspinHub, request_metadata_support, sendspin_ns + +CODEOWNERS = ["@kahrendt"] +DEPENDENCIES = ["sendspin"] + +CONF_TRACK = "track" +CONF_TRACK_PROGRESS = "track_progress" +CONF_TRACK_DURATION = "track_duration" + +SendspinTrackProgressSensor = sendspin_ns.class_( + "SendspinTrackProgressSensor", + sensor.Sensor, + cg.PollingComponent, +) +SendspinMetadataSensor = sendspin_ns.class_( + "SendspinMetadataSensor", + sensor.Sensor, + cg.Component, +) + +SendspinNumericMetadataTypes = sendspin_ns.enum( + "SendspinNumericMetadataTypes", is_class=True +) +_METADATA_TYPE_ENUM = { + CONF_TRACK_DURATION: SendspinNumericMetadataTypes.TRACK_DURATION, + CONF_YEAR: SendspinNumericMetadataTypes.YEAR, + CONF_TRACK: SendspinNumericMetadataTypes.TRACK, +} + + +def _request_roles(config: ConfigType) -> ConfigType: + """Request the necessary Sendspin roles for the sensor.""" + request_metadata_support() + + return config + + +_HUB_ID_SCHEMA = cv.Schema({cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub)}) + + +def _metadata_schema(**sensor_kwargs): + """Schema for event-driven numeric metadata sensors (duration/year/track).""" + return ( + sensor.sensor_schema( + SendspinMetadataSensor, + accuracy_decimals=0, + **sensor_kwargs, + ) + .extend(_HUB_ID_SCHEMA) + .extend(cv.COMPONENT_SCHEMA) + ) + + +CONFIG_SCHEMA = cv.All( + cv.typed_schema( + { + CONF_TRACK_PROGRESS: sensor.sensor_schema( + SendspinTrackProgressSensor, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + unit_of_measurement=UNIT_MILLISECOND, + ) + .extend(_HUB_ID_SCHEMA) + .extend(cv.polling_component_schema("1s")), + CONF_TRACK_DURATION: _metadata_schema( + state_class=STATE_CLASS_MEASUREMENT, + unit_of_measurement=UNIT_MILLISECOND, + ), + CONF_YEAR: _metadata_schema(), + CONF_TRACK: _metadata_schema(), + }, + key=CONF_TYPE, + ), + cv.only_on_esp32, + _request_roles, +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await cg.register_parented(var, config[CONF_SENDSPIN_ID]) + await sensor.register_sensor(var, config) + + if (metadata_type := _METADATA_TYPE_ENUM.get(config[CONF_TYPE])) is not None: + cg.add(var.set_metadata_type(metadata_type)) diff --git a/esphome/components/sendspin/sensor/sendspin_sensor.cpp b/esphome/components/sendspin/sensor/sendspin_sensor.cpp new file mode 100644 index 00000000000..68848a6f3e1 --- /dev/null +++ b/esphome/components/sendspin/sensor/sendspin_sensor.cpp @@ -0,0 +1,98 @@ +#include "sendspin_sensor.h" + +#if defined(USE_ESP32) && defined(USE_SENDSPIN_METADATA) && defined(USE_SENSOR) + +#include + +namespace esphome::sendspin_ { + +static const char *const TAG = "sendspin.sensor"; + +// --- SendspinTrackProgressSensor --- + +void SendspinTrackProgressSensor::dump_config() { + LOG_SENSOR("", "Track Progress", this); + LOG_UPDATE_INTERVAL(this); +} + +// THREAD CONTEXT: Main loop. The registered metadata callback also fires on the main loop +// (SendspinHub dispatches metadata from client_->loop()). +void SendspinTrackProgressSensor::setup() { + this->parent_->add_metadata_update_callback([this](const sendspin::ServerMetadataStateObject &metadata) { + if (!metadata.progress.has_value()) { + return; + } + const auto &progress = metadata.progress.value(); + if (progress.playback_speed == 0) { + // Paused: freeze progress at the reported position and stop polling to save cycles. + this->stop_poller(); + this->publish_state(progress.track_progress); + } else { + // Resumed: publish the fresh interpolated position immediately so the frontend doesn't show a stale + // paused value until the next poll tick. + this->publish_state(this->parent_->get_track_progress_ms()); + this->start_poller(); + } + }); +} + +// THREAD CONTEXT: Main loop. +// Sendspin only pushes progress on state changes (play/pause/seek/speed change), not continuously during +// playback. The hub helper interpolates the current position from the last server update and the playback +// speed, giving us a fresh value on every poll. +void SendspinTrackProgressSensor::update() { this->publish_state(this->parent_->get_track_progress_ms()); } + +// --- SendspinMetadataSensor --- + +void SendspinMetadataSensor::dump_config() { + switch (this->metadata_type_) { + case SendspinNumericMetadataTypes::TRACK_DURATION: + LOG_SENSOR("", "Track Duration", this); + break; + case SendspinNumericMetadataTypes::YEAR: + LOG_SENSOR("", "Year", this); + break; + case SendspinNumericMetadataTypes::TRACK: + LOG_SENSOR("", "Track", this); + break; + } +} + +std::optional SendspinMetadataSensor::extract_value_(const sendspin::ServerMetadataStateObject &metadata) const { + switch (this->metadata_type_) { + case SendspinNumericMetadataTypes::TRACK_DURATION: + if (metadata.progress.has_value()) + return metadata.progress.value().track_duration; + return std::nullopt; + case SendspinNumericMetadataTypes::YEAR: + if (metadata.year.has_value()) + return metadata.year.value(); + return std::nullopt; + case SendspinNumericMetadataTypes::TRACK: + if (metadata.track.has_value()) + return metadata.track.value(); + return std::nullopt; + } + return std::nullopt; +} + +// THREAD CONTEXT: Main loop. The registered metadata callback also fires on the main loop +// (SendspinHub dispatches metadata from client_->loop()). +void SendspinMetadataSensor::setup() { + this->parent_->add_metadata_update_callback([this](const sendspin::ServerMetadataStateObject &metadata) { + if (auto value = this->extract_value_(metadata)) { + this->publish_if_changed_(*value); + } + }); +} + +// Dedup to avoid frontend churn; Sensor::publish_state always notifies without checking for changes. +void SendspinMetadataSensor::publish_if_changed_(float value) { + if (this->get_raw_state() != value) { + this->publish_state(value); + } +} + +} // namespace esphome::sendspin_ + +#endif diff --git a/esphome/components/sendspin/sensor/sendspin_sensor.h b/esphome/components/sendspin/sensor/sendspin_sensor.h new file mode 100644 index 00000000000..cbfe1742c95 --- /dev/null +++ b/esphome/components/sendspin/sensor/sendspin_sensor.h @@ -0,0 +1,42 @@ +#pragma once + +#include "esphome/core/defines.h" + +#if defined(USE_ESP32) && defined(USE_SENDSPIN_METADATA) && defined(USE_SENSOR) + +#include "esphome/components/sendspin/sendspin_hub.h" +#include "esphome/components/sensor/sensor.h" + +#include + +namespace esphome::sendspin_ { + +class SendspinTrackProgressSensor : public sensor::Sensor, public SendspinPollingChild { + public: + void dump_config() override; + void setup() override; + void update() override; +}; + +enum class SendspinNumericMetadataTypes { + TRACK_DURATION, + YEAR, + TRACK, +}; + +class SendspinMetadataSensor : public sensor::Sensor, public SendspinChild { + public: + void dump_config() override; + void setup() override; + + void set_metadata_type(SendspinNumericMetadataTypes metadata_type) { this->metadata_type_ = metadata_type; } + + protected: + std::optional extract_value_(const sendspin::ServerMetadataStateObject &metadata) const; + void publish_if_changed_(float value); + + SendspinNumericMetadataTypes metadata_type_; +}; + +} // namespace esphome::sendspin_ +#endif diff --git a/tests/components/sendspin/common-sensor.yaml b/tests/components/sendspin/common-sensor.yaml new file mode 100644 index 00000000000..6d9745cff94 --- /dev/null +++ b/tests/components/sendspin/common-sensor.yaml @@ -0,0 +1,15 @@ +<<: !include common.yaml + +sensor: + - platform: sendspin + name: "Sendspin Track Progress" + type: track_progress + - platform: sendspin + name: "Sendspin Track Duration" + type: track_duration + - platform: sendspin + name: "Sendspin Year" + type: year + - platform: sendspin + name: "Sendspin Track" + type: track diff --git a/tests/components/sendspin/test-sensor.esp32-idf.yaml b/tests/components/sendspin/test-sensor.esp32-idf.yaml new file mode 100644 index 00000000000..f9127d47bc0 --- /dev/null +++ b/tests/components/sendspin/test-sensor.esp32-idf.yaml @@ -0,0 +1 @@ +<<: !include common-sensor.yaml From 94e300389c8accb7387d342e6d9ce75cad694fa7 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Fri, 24 Apr 2026 11:35:32 -0400 Subject: [PATCH 15/68] [sendspin] remove year and track number text sensors and refactor (#15975) --- .../sendspin/text_sensor/__init__.py | 2 - .../text_sensor/sendspin_text_sensor.cpp | 81 ++++++------------- .../text_sensor/sendspin_text_sensor.h | 5 +- .../sendspin/common-text_sensor.yaml | 6 -- 4 files changed, 29 insertions(+), 65 deletions(-) diff --git a/esphome/components/sendspin/text_sensor/__init__.py b/esphome/components/sendspin/text_sensor/__init__.py index b7f216ca0ce..87f6c9b9362 100644 --- a/esphome/components/sendspin/text_sensor/__init__.py +++ b/esphome/components/sendspin/text_sensor/__init__.py @@ -21,8 +21,6 @@ SENDSPIN_TEXT_METADATA_TYPES = { "artist": SendspinTextMetadataTypes.ARTIST, "album": SendspinTextMetadataTypes.ALBUM, "album_artist": SendspinTextMetadataTypes.ALBUM_ARTIST, - "year": SendspinTextMetadataTypes.YEAR, - "track": SendspinTextMetadataTypes.TRACK, } diff --git a/esphome/components/sendspin/text_sensor/sendspin_text_sensor.cpp b/esphome/components/sendspin/text_sensor/sendspin_text_sensor.cpp index d16d51f63c6..9843fb966ec 100644 --- a/esphome/components/sendspin/text_sensor/sendspin_text_sensor.cpp +++ b/esphome/components/sendspin/text_sensor/sendspin_text_sensor.cpp @@ -2,8 +2,6 @@ #if defined(USE_ESP32) && defined(USE_SENDSPIN_METADATA) && defined(USE_TEXT_SENSOR) -#include "esphome/core/helpers.h" - #include #include @@ -14,63 +12,36 @@ static const char *const TAG = "sendspin.text_sensor"; void SendspinTextSensor::dump_config() { LOG_TEXT_SENSOR("", "Sendspin", this); } +const char *SendspinTextSensor::extract_value_(const sendspin::ServerMetadataStateObject &metadata) const { + switch (this->metadata_type_) { + case SendspinTextMetadataTypes::TITLE: + if (metadata.title.has_value()) + return metadata.title.value().c_str(); + return nullptr; + case SendspinTextMetadataTypes::ARTIST: + if (metadata.artist.has_value()) + return metadata.artist.value().c_str(); + return nullptr; + case SendspinTextMetadataTypes::ALBUM: + if (metadata.album.has_value()) + return metadata.album.value().c_str(); + return nullptr; + case SendspinTextMetadataTypes::ALBUM_ARTIST: + if (metadata.album_artist.has_value()) + return metadata.album_artist.value().c_str(); + return nullptr; + } + return nullptr; +} + // THREAD CONTEXT: Main loop. The registered metadata callback also fires on the main loop // (SendspinHub dispatches metadata from client_->loop()). void SendspinTextSensor::setup() { - switch (this->metadata_type_) { - case SendspinTextMetadataTypes::TITLE: { - this->parent_->add_metadata_update_callback([this](const sendspin::ServerMetadataStateObject &metadata) { - if (metadata.title.has_value()) { - this->publish_if_changed_(metadata.title.value().c_str()); - } - }); - break; + this->parent_->add_metadata_update_callback([this](const sendspin::ServerMetadataStateObject &metadata) { + if (const char *value = this->extract_value_(metadata)) { + this->publish_if_changed_(value); } - case SendspinTextMetadataTypes::ARTIST: { - this->parent_->add_metadata_update_callback([this](const sendspin::ServerMetadataStateObject &metadata) { - if (metadata.artist.has_value()) { - this->publish_if_changed_(metadata.artist.value().c_str()); - } - }); - break; - } - case SendspinTextMetadataTypes::ALBUM: { - this->parent_->add_metadata_update_callback([this](const sendspin::ServerMetadataStateObject &metadata) { - if (metadata.album.has_value()) { - this->publish_if_changed_(metadata.album.value().c_str()); - } - }); - break; - } - case SendspinTextMetadataTypes::ALBUM_ARTIST: { - this->parent_->add_metadata_update_callback([this](const sendspin::ServerMetadataStateObject &metadata) { - if (metadata.album_artist.has_value()) { - this->publish_if_changed_(metadata.album_artist.value().c_str()); - } - }); - break; - } - case SendspinTextMetadataTypes::YEAR: { - this->parent_->add_metadata_update_callback([this](const sendspin::ServerMetadataStateObject &metadata) { - if (metadata.year.has_value() && metadata.year.value() <= 9999) { - char buf[UINT32_MAX_STR_SIZE]; - uint32_to_str(buf, metadata.year.value()); - this->publish_if_changed_(buf); - } - }); - break; - } - case SendspinTextMetadataTypes::TRACK: { - this->parent_->add_metadata_update_callback([this](const sendspin::ServerMetadataStateObject &metadata) { - if (metadata.track.has_value() && metadata.track.value() <= 9999) { - char buf[UINT32_MAX_STR_SIZE]; - uint32_to_str(buf, metadata.track.value()); - this->publish_if_changed_(buf); - } - }); - break; - } - } + }); } // Dedup to avoid frontend churn; TextSensor::publish_state already dedups the string assign but still notifies. diff --git a/esphome/components/sendspin/text_sensor/sendspin_text_sensor.h b/esphome/components/sendspin/text_sensor/sendspin_text_sensor.h index d9ef49c938c..203b01d0248 100644 --- a/esphome/components/sendspin/text_sensor/sendspin_text_sensor.h +++ b/esphome/components/sendspin/text_sensor/sendspin_text_sensor.h @@ -7,6 +7,8 @@ #include "esphome/components/sendspin/sendspin_hub.h" #include "esphome/components/text_sensor/text_sensor.h" +#include + namespace esphome::sendspin_ { enum class SendspinTextMetadataTypes { @@ -14,8 +16,6 @@ enum class SendspinTextMetadataTypes { ARTIST, ALBUM, ALBUM_ARTIST, - YEAR, - TRACK, }; class SendspinTextSensor : public SendspinChild, public text_sensor::TextSensor { @@ -26,6 +26,7 @@ class SendspinTextSensor : public SendspinChild, public text_sensor::TextSensor void set_metadata_type(SendspinTextMetadataTypes metadata_type) { this->metadata_type_ = metadata_type; } protected: + const char *extract_value_(const sendspin::ServerMetadataStateObject &metadata) const; void publish_if_changed_(const char *value); SendspinTextMetadataTypes metadata_type_; diff --git a/tests/components/sendspin/common-text_sensor.yaml b/tests/components/sendspin/common-text_sensor.yaml index 0bfbf457574..fc6a56a21ad 100644 --- a/tests/components/sendspin/common-text_sensor.yaml +++ b/tests/components/sendspin/common-text_sensor.yaml @@ -13,9 +13,3 @@ text_sensor: - platform: sendspin name: "Album Artist" type: album_artist - - platform: sendspin - name: "Year" - type: year - - platform: sendspin - name: "Track Number" - type: track From 9caf9ee02336fb7754ead08a11fd2da10c77d74a Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Fri, 24 Apr 2026 12:53:03 -0400 Subject: [PATCH 16/68] [sendspin] Bumps sendspin-cpp library for a bugfix (#15976) --- esphome/components/sendspin/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index 6f5ccddb86d..58687ae8389 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -193,7 +193,7 @@ async def to_code(config: ConfigType) -> None: ) # sendspin-cpp library - esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.3.0") + esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.3.1") cg.add_define("USE_SENDSPIN", True) # for MDNS diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index f422d94097d..11531e6d7b4 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -92,6 +92,6 @@ dependencies: esp32async/asynctcp: version: 3.4.91 sendspin/sendspin-cpp: - version: 0.3.0 + version: 0.3.1 lvgl/lvgl: version: 9.5.0 From f36efbc762b08b51cf4766a2ac441c9ceee3abec Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 24 Apr 2026 19:27:12 +0000 Subject: [PATCH 17/68] Update tzdata requirement from >=2026.1 to >=2026.2 (#15980) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 90b06938408..71db6d34447 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,7 +6,7 @@ colorama==0.4.6 icmplib==3.0.4 tornado==6.5.5 tzlocal==5.3.1 # from time -tzdata>=2026.1 # from time +tzdata>=2026.2 # from time pyserial==3.5 platformio==6.1.19 esptool==5.2.0 From f62972c2c6aaa78c2f9a798b30bc52609f959d6b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 24 Apr 2026 19:34:00 +0000 Subject: [PATCH 18/68] Bump ruff from 0.15.11 to 0.15.12 (#15981) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- .pre-commit-config.yaml | 2 +- requirements_test.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d9b7df6ec53..ad82bd8e5d8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,7 +11,7 @@ ci: repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.15.11 + rev: v0.15.12 hooks: # Run the linter. - id: ruff diff --git a/requirements_test.txt b/requirements_test.txt index bb98375cb65..b35025fa04a 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==4.0.5 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating -ruff==0.15.11 # also change in .pre-commit-config.yaml when updating +ruff==0.15.12 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating pre-commit From c27f9e512b63ced298e3634eb2cc17be92d521b6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 25 Apr 2026 02:28:04 +0000 Subject: [PATCH 19/68] Bump aioesphomeapi from 44.21.0 to 44.22.0 (#15989) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 71db6d34447..c1838d3b512 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.19 esptool==5.2.0 click==8.3.3 esphome-dashboard==20260408.1 -aioesphomeapi==44.21.0 +aioesphomeapi==44.22.0 zeroconf==0.148.0 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import From a437b3086bed5e7dae2c448c09ff4d0df6320bf6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 25 Apr 2026 02:30:10 +0000 Subject: [PATCH 20/68] Bump cryptography from 46.0.7 to 47.0.0 (#15990) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index c1838d3b512..ba7adaa747c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -cryptography==46.0.7 +cryptography==47.0.0 voluptuous==0.16.0 PyYAML==6.0.3 paho-mqtt==1.6.1 From b5ccd55f4ed8cf9c756505a0b2c1767f5d957be5 Mon Sep 17 00:00:00 2001 From: Javier Peletier Date: Sat, 25 Apr 2026 19:06:58 +0200 Subject: [PATCH 21/68] [packages] Fix premature substitution of vars in remote package files (#15997) Co-authored-by: J. Nick Koston Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/components/packages/__init__.py | 21 ++- esphome/yaml_util.py | 11 +- .../component_tests/packages/test_packages.py | 130 ++++++++++++++++++ tests/unit_tests/test_yaml_util.py | 62 ++++++++- 4 files changed, 219 insertions(+), 5 deletions(-) diff --git a/esphome/components/packages/__init__.py b/esphome/components/packages/__init__.py index b6ec0067c93..1b9e03d88fc 100644 --- a/esphome/components/packages/__init__.py +++ b/esphome/components/packages/__init__.py @@ -378,9 +378,8 @@ def _substitute_package_definition( Local package contents are left untouched — they will be substituted later during the main substitution pass. """ - if isinstance(package_config, str) or ( - isinstance(package_config, dict) and is_remote_package(package_config) - ): + + def do_substitute(package_config: dict | str) -> dict | str: # Collect undefined-variable errors (rather than raising strict) so the # path walked through a remote-package dict is preserved and the user # sees which field (url / path / ref / ...) referenced the undefined @@ -394,6 +393,22 @@ def _substitute_package_definition( errors=errors, ) raise_first_undefined(errors, "package definition") + return package_config + + if isinstance(package_config, str): + return do_substitute(package_config) + + if isinstance(package_config, dict) and is_remote_package(package_config): + # Mark vars as literal to avoid substituting variables in the vars block itself, since they are meant to be + # passed as-is to the package YAML and may contain their own substitution expressions that should not + # be prematurely evaluated here. + if CONF_FILES in package_config: + for file_def in package_config[CONF_FILES]: + if isinstance(file_def, dict) and CONF_VARS in file_def: + file_def[CONF_VARS] = yaml_util.make_literal(file_def[CONF_VARS]) + + package_config = do_substitute(package_config) + return package_config diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index 42da27ec142..3cfc9c4b15d 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -113,6 +113,15 @@ def make_data_base( return value +def make_literal(value: Any) -> ESPLiteralValue | Any: + """Wrap a value in an ESPLiteralValue object.""" + try: + return add_class_to_obj(value, ESPLiteralValue) + except TypeError: + # Adding class failed, ignore error + return value + + def add_context(value: Any, context_vars: dict[str, Any] | None) -> Any: """Tags a list/string/dict value with context vars that must be applied to it and its children during the substitution pass. If no vars are given, no tagging is done. @@ -525,7 +534,7 @@ class ESPHomeLoaderMixin: obj = self.construct_sequence(node) elif isinstance(node, yaml.MappingNode): obj = self.construct_mapping(node) - return add_class_to_obj(obj, ESPLiteralValue) + return make_literal(obj) @_add_data_ref def construct_extend(self, node: yaml.Node) -> Extend: diff --git a/tests/component_tests/packages/test_packages.py b/tests/component_tests/packages/test_packages.py index af4b6db7961..13a6da9f2c6 100644 --- a/tests/component_tests/packages/test_packages.py +++ b/tests/component_tests/packages/test_packages.py @@ -1491,3 +1491,133 @@ def test_substitute_package_definition_includes_source_location(tmp_path: Path) line, col = int(match.group(1)), int(match.group(2)) assert line == 2, f"expected 1-based line 2, got {line} (err={err!r})" assert col >= 1, f"expected 1-based column ≥ 1, got {col} (err={err!r})" + + +def test_substitute_package_definition_vars_preserved_literally() -> None: + """``vars:`` blocks in remote-package files are not substituted prematurely. + + Variable references inside ``vars:`` may resolve to substitutions + contributed by sibling packages that have not yet been loaded, so they + must be passed through untouched and resolved later by the package YAML. + """ + pkg = { + CONF_URL: "https://github.com/esphome/non-existant-repo", + CONF_REF: "main", + CONF_FILES: [ + { + CONF_PATH: "common/somefile.yaml", + CONF_VARS: {"pin": "${PIN}"}, + }, + ], + } + # Note: PIN is intentionally NOT in the context — it is meant to + # be resolved later, when the package YAML is processed. + result = _substitute_package_definition(pkg, ContextVars()) + + assert result[CONF_FILES][0][CONF_VARS] == {"pin": "${PIN}"} + + +def test_substitute_package_definition_other_fields_still_substituted() -> None: + """Marking ``vars:`` literal does not stop substitution of url/ref/path.""" + ctx = ContextVars({"branch": "release", "org": "esphome"}) + pkg = { + CONF_URL: "https://github.com/${org}/firmware", + CONF_REF: "${branch}", + CONF_FILES: [ + { + CONF_PATH: "common/sensor.yaml", + CONF_VARS: {"pin": "${PIN}"}, + }, + ], + } + result = _substitute_package_definition(pkg, ctx) + + assert result[CONF_URL] == "https://github.com/esphome/firmware" + assert result[CONF_REF] == "release" + # vars passed through unchanged + assert result[CONF_FILES][0][CONF_VARS] == {"pin": "${PIN}"} + + +def test_substitute_package_definition_without_vars_unaffected() -> None: + """Files entries without a ``vars:`` block continue to work.""" + ctx = ContextVars({"branch": "main"}) + pkg = { + CONF_URL: "https://github.com/esphome/firmware", + CONF_REF: "${branch}", + CONF_FILES: [ + {CONF_PATH: "file1.yaml"}, + "file2.yaml", + ], + } + result = _substitute_package_definition(pkg, ctx) + + assert result[CONF_REF] == "main" + assert result[CONF_FILES][0] == {CONF_PATH: "file1.yaml"} + assert result[CONF_FILES][1] == "file2.yaml" + + +@patch("esphome.yaml_util.load_yaml") +@patch("pathlib.Path.is_file") +@patch("esphome.git.clone_or_update") +def test_remote_package_vars_resolved_against_sibling_package_substitutions( + mock_clone_or_update, mock_is_file, mock_load_yaml +) -> None: + """A ``vars:`` reference in one remote package can resolve to a + substitution defined in a sibling remote package. + + A higher-priority package declares ``substitutions:`` (e.g. ``SENSOR_PIN: 5``) and a + lower-priority package's ``files: -> vars:`` references that substitution. + Because packages are processed highest-priority first and ``vars:`` is now + preserved literally during package-definition processing, the substitution + is resolved correctly when the package YAML itself is loaded. + """ + mock_clone_or_update.return_value = (Path("/tmp/noexists"), MagicMock()) + mock_is_file.return_value = True + + # Two YAML files mocked from the "remote" repo: + # - platform.yaml exports a substitution ``SENSOR_PIN`` + # - sensor.yaml uses ``${pin}`` (which is bound from ``vars:`` to + # ``${SENSOR_PIN}`` and resolved against the merged substitutions). + mock_load_yaml.side_effect = [ + # Order matches reverse-priority traversal (highest priority first). + OrderedDict( + { + CONF_SUBSTITUTIONS: {"SENSOR_PIN": "GPIO5"}, + } + ), + OrderedDict( + { + CONF_SENSOR: [ + { + CONF_PLATFORM: TEST_SENSOR_PLATFORM_1, + CONF_NAME: TEST_SENSOR_NAME_1, + "pin": "${pin}", + } + ], + } + ), + ] + + config = { + CONF_PACKAGES: { + "special_sensor": { + CONF_URL: "https://github.com/esphome/non-existant-repo", + CONF_FILES: [ + { + CONF_PATH: "sensor.yaml", + CONF_VARS: {"pin": "${SENSOR_PIN}"}, + }, + ], + CONF_REFRESH: "1d", + }, + "platform": { + CONF_URL: "https://github.com/esphome/non-existant-repo", + CONF_FILES: ["platform.yaml"], + CONF_REFRESH: "1d", + }, + } + } + + actual = packages_pass(config) + + assert actual[CONF_SENSOR][0]["pin"] == "GPIO5" diff --git a/tests/unit_tests/test_yaml_util.py b/tests/unit_tests/test_yaml_util.py index e3aa2a16f56..3815ac1d752 100644 --- a/tests/unit_tests/test_yaml_util.py +++ b/tests/unit_tests/test_yaml_util.py @@ -11,7 +11,13 @@ from esphome.config_helpers import Extend, Remove import esphome.config_validation as cv from esphome.core import DocumentLocation, DocumentRange, EsphomeError from esphome.util import OrderedDict -from esphome.yaml_util import ESPHomeDataBase, format_path, make_data_base +from esphome.yaml_util import ( + ESPHomeDataBase, + ESPLiteralValue, + format_path, + make_data_base, + make_literal, +) @pytest.fixture(autouse=True) @@ -891,3 +897,57 @@ def test_format_path_empty_path_with_located_current_obj(): obj = _located("${var}", "main.yaml", 0, 0) result = format_path([], obj) assert result == "In: in main.yaml 1:1" + + +def test_make_literal_wraps_dict() -> None: + """A dict is wrapped so it becomes an ESPLiteralValue instance.""" + value = {"key": "${var}"} + result = make_literal(value) + assert isinstance(result, ESPLiteralValue) + assert isinstance(result, dict) + assert result == {"key": "${var}"} + + +def test_make_literal_wraps_list() -> None: + """A list is wrapped so it becomes an ESPLiteralValue instance.""" + value = ["${var}", "plain"] + result = make_literal(value) + assert isinstance(result, ESPLiteralValue) + assert isinstance(result, list) + assert result == ["${var}", "plain"] + + +def test_make_literal_wraps_string() -> None: + """A string is wrapped so it becomes an ESPLiteralValue instance.""" + result = make_literal("${var}") + assert isinstance(result, ESPLiteralValue) + assert result == "${var}" + + +def test_make_literal_returns_already_wrapped_value_unchanged() -> None: + """Wrapping a value that is already an ESPLiteralValue returns it as-is.""" + value = make_literal({"key": "value"}) + assert isinstance(value, ESPLiteralValue) + result = make_literal(value) + assert result is value + + +def test_make_literal_returns_none_unchanged() -> None: + """Values whose class cannot be augmented (e.g. ``None``) are returned as-is.""" + result = make_literal(None) + assert result is None + + +def test_make_literal_blocks_substitution() -> None: + """A value wrapped with make_literal is skipped by the substitution pass.""" + value = make_literal({"pin": "${PIN}"}) + result = substitutions.substitute( + value, + path=[], + parent_context=substitutions.ContextVars(), + strict_undefined=False, + ) + # The literal block must remain untouched, even though the variable is + # undefined in the context. + assert result == {"pin": "${PIN}"} + assert isinstance(result, ESPLiteralValue) From 4f8feb86f0fdea15ff09dbfee20b10ee899ae2e0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Apr 2026 15:43:05 -0500 Subject: [PATCH 22/68] [dashboard] Add --no-states support to logs WebSocket handler (#15993) --- esphome/dashboard/web_server.py | 6 +++- tests/dashboard/test_web_server.py | 58 ++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index b8e17244e53..d67245967c5 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -437,7 +437,11 @@ class EsphomePortCommandWebSocket(EsphomeCommandWebSocket): class EsphomeLogsHandler(EsphomePortCommandWebSocket): async def build_command(self, json_message: dict[str, Any]) -> list[str]: """Build the command to run.""" - return await self.build_device_command(["logs"], json_message) + cmd = await self.build_device_command(["logs"], json_message) + if json_message.get("no_states"): + cmd.append("--no-states") + _LOGGER.debug("Built command: %s", cmd) + return cmd class EsphomeRenameHandler(EsphomeCommandWebSocket): diff --git a/tests/dashboard/test_web_server.py b/tests/dashboard/test_web_server.py index daff3845158..1a62cfda904 100644 --- a/tests/dashboard/test_web_server.py +++ b/tests/dashboard/test_web_server.py @@ -1744,6 +1744,64 @@ def test_proc_on_exit_skips_when_already_closed() -> None: handler.close.assert_not_called() +@pytest.mark.asyncio +async def test_esphome_logs_handler_appends_no_states_when_set() -> None: + """Test --no-states is appended when no_states is truthy in the message.""" + handler = Mock(spec=web_server.EsphomeLogsHandler) + handler.build_device_command = AsyncMock( + return_value=["esphome", "logs", "device.yaml", "--device", "OTA"] + ) + + json_message = { + "configuration": "device.yaml", + "port": "OTA", + "no_states": True, + } + cmd = await web_server.EsphomeLogsHandler.build_command(handler, json_message) + + assert cmd == [ + "esphome", + "logs", + "device.yaml", + "--device", + "OTA", + "--no-states", + ] + handler.build_device_command.assert_awaited_once_with(["logs"], json_message) + + +@pytest.mark.asyncio +async def test_esphome_logs_handler_omits_no_states_when_missing() -> None: + """Test --no-states is not added when no_states is absent from the message.""" + handler = Mock(spec=web_server.EsphomeLogsHandler) + handler.build_device_command = AsyncMock( + return_value=["esphome", "logs", "device.yaml", "--device", "OTA"] + ) + + cmd = await web_server.EsphomeLogsHandler.build_command( + handler, {"configuration": "device.yaml", "port": "OTA"} + ) + + assert "--no-states" not in cmd + assert cmd == ["esphome", "logs", "device.yaml", "--device", "OTA"] + + +@pytest.mark.asyncio +async def test_esphome_logs_handler_omits_no_states_when_false() -> None: + """Test --no-states is not added when no_states is explicitly False.""" + handler = Mock(spec=web_server.EsphomeLogsHandler) + handler.build_device_command = AsyncMock( + return_value=["esphome", "logs", "device.yaml", "--device", "OTA"] + ) + + cmd = await web_server.EsphomeLogsHandler.build_command( + handler, + {"configuration": "device.yaml", "port": "OTA", "no_states": False}, + ) + + assert "--no-states" not in cmd + + def _make_auth_handler(auth_header: str | None = None) -> Mock: """Create a mock handler with the given Authorization header.""" handler = Mock() From 9ad820c9214a3b7d6e2d93b82e6d88d270d4ffd6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 25 Apr 2026 20:59:01 +0000 Subject: [PATCH 23/68] Bump esphome-dashboard from 20260408.1 to 20260425.0 (#16006) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index ba7adaa747c..abc8ac5dbb6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,7 +11,7 @@ pyserial==3.5 platformio==6.1.19 esptool==5.2.0 click==8.3.3 -esphome-dashboard==20260408.1 +esphome-dashboard==20260425.0 aioesphomeapi==44.22.0 zeroconf==0.148.0 puremagic==1.30 From 4cab262ef8bec892ea274e58a17e4d15a8784e4d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Apr 2026 16:18:21 -0500 Subject: [PATCH 24/68] [ci] Trigger CodSpeed benchmarks on host platform changes (#15995) --- script/determine-jobs.py | 11 +++++++++-- tests/script/test_determine_jobs.py | 16 ++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/script/determine-jobs.py b/script/determine-jobs.py index d94d472c9ed..f036447542d 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -402,8 +402,11 @@ def should_run_benchmarks(branch: str | None = None) -> bool: Benchmarks run when any of the following conditions are met: 1. Core C++ files changed (esphome/core/*) - 2. A directly changed component has benchmark files (no dependency expansion) - 3. Benchmark infrastructure changed (tests/benchmarks/*, script/cpp_benchmark.py, + 2. The host platform changed (esphome/components/host/*) — benchmarks + are built and run on the host platform, so its implementations of + ``millis()``/``micros()``/etc. affect every benchmark + 3. A directly changed component has benchmark files (no dependency expansion) + 4. Benchmark infrastructure changed (tests/benchmarks/*, script/cpp_benchmark.py, script/build_helpers.py, script/setup_codspeed_lib.py) Unlike unit tests, benchmarks do NOT expand to dependent components. @@ -420,6 +423,10 @@ def should_run_benchmarks(branch: str | None = None) -> bool: if core_changed(files): return True + # Host platform supplies the runtime that benchmarks execute on + if any(f.startswith("esphome/components/host/") for f in files): + return True + # Check if benchmark infrastructure changed if any( f.startswith("tests/benchmarks/") or f in BENCHMARK_INFRASTRUCTURE_FILES diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index de239ee0b55..2c726734fe7 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -1842,6 +1842,22 @@ def test_should_run_benchmarks_core_header_change() -> None: assert determine_jobs.should_run_benchmarks() is True +def test_should_run_benchmarks_host_platform_change() -> None: + """Test benchmarks trigger on host platform changes. + + Benchmarks build and run on the host platform, so changes to its + millis()/micros()/etc. implementations affect every benchmark. + """ + for host_file in [ + "esphome/components/host/core.cpp", + "esphome/components/host/__init__.py", + ]: + with patch.object(determine_jobs, "changed_files", return_value=[host_file]): + assert determine_jobs.should_run_benchmarks() is True, ( + f"Expected benchmarks to run for {host_file}" + ) + + def test_should_run_benchmarks_benchmark_infra_change() -> None: """Test benchmarks trigger on benchmark infrastructure changes.""" for infra_file in [ From bc33260c61f2f635f7240ea7e5e1380fee504e95 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Sat, 25 Apr 2026 22:33:02 -0500 Subject: [PATCH 25/68] [ir_rf_proxy] Extend for RF (#15744) Co-authored-by: J. Nick Koston --- .../components/ir_rf_proxy/ir_rf_proxy.cpp | 112 +++++++++++++++++- esphome/components/ir_rf_proxy/ir_rf_proxy.h | 37 ++++++ .../components/ir_rf_proxy/radio_frequency.py | 68 +++++++++++ .../components/radio_frequency/common-rx.yaml | 18 +++ .../components/radio_frequency/common-tx.yaml | 19 +++ tests/components/radio_frequency/common.yaml | 7 ++ .../radio_frequency/test.bk72xx-ard.yaml | 8 ++ .../radio_frequency/test.esp32-idf.yaml | 8 ++ .../radio_frequency/test.esp8266-ard.yaml | 8 ++ .../radio_frequency/test.rp2040-ard.yaml | 8 ++ 10 files changed, 292 insertions(+), 1 deletion(-) create mode 100644 esphome/components/ir_rf_proxy/radio_frequency.py create mode 100644 tests/components/radio_frequency/common-rx.yaml create mode 100644 tests/components/radio_frequency/common-tx.yaml create mode 100644 tests/components/radio_frequency/common.yaml create mode 100644 tests/components/radio_frequency/test.bk72xx-ard.yaml create mode 100644 tests/components/radio_frequency/test.esp32-idf.yaml create mode 100644 tests/components/radio_frequency/test.esp8266-ard.yaml create mode 100644 tests/components/radio_frequency/test.rp2040-ard.yaml diff --git a/esphome/components/ir_rf_proxy/ir_rf_proxy.cpp b/esphome/components/ir_rf_proxy/ir_rf_proxy.cpp index 5239a4667c0..60b0cd513bb 100644 --- a/esphome/components/ir_rf_proxy/ir_rf_proxy.cpp +++ b/esphome/components/ir_rf_proxy/ir_rf_proxy.cpp @@ -1,13 +1,73 @@ #include "ir_rf_proxy.h" + +#include + #include "esphome/core/log.h" namespace esphome::ir_rf_proxy { static const char *const TAG = "ir_rf_proxy"; +// ========== Shared transmit helper ========== +// Static template: all instantiations occur in this translation unit. + +template +static void transmit_raw_timings(remote_base::RemoteTransmitterBase *transmitter, uint32_t carrier_frequency, + const CallT &call) { + if (transmitter == nullptr) { + ESP_LOGW(TAG, "No transmitter configured"); + return; + } + + if (!call.has_raw_timings()) { + ESP_LOGE(TAG, "No raw timings provided"); + return; + } + + auto transmit_call = transmitter->transmit(); + auto *transmit_data = transmit_call.get_data(); + transmit_data->set_carrier_frequency(carrier_frequency); + + if (call.is_packed()) { + transmit_data->set_data_from_packed_sint32(call.get_packed_data(), call.get_packed_length(), + call.get_packed_count()); + ESP_LOGD(TAG, "Transmitting packed raw timings: count=%" PRIu16 ", repeat=%" PRIu32, call.get_packed_count(), + call.get_repeat_count()); + } else if (call.is_base64url()) { + if (!transmit_data->set_data_from_base64url(call.get_base64url_data())) { + ESP_LOGE(TAG, "Invalid base64url data"); + return; + } + constexpr int32_t max_timing_us = 500000; + for (int32_t timing : transmit_data->get_data()) { + int32_t abs_timing = timing < 0 ? -timing : timing; + if (abs_timing > max_timing_us) { + ESP_LOGE(TAG, "Invalid timing value: %" PRId32 " µs (max %" PRId32 ")", timing, max_timing_us); + return; + } + } + ESP_LOGD(TAG, "Transmitting base64url raw timings: count=%zu, repeat=%" PRIu32, transmit_data->get_data().size(), + call.get_repeat_count()); + } else { + transmit_data->set_data(call.get_raw_timings()); + ESP_LOGD(TAG, "Transmitting raw timings: count=%zu, repeat=%" PRIu32, call.get_raw_timings().size(), + call.get_repeat_count()); + } + + if (call.get_repeat_count() > 0) { + transmit_call.set_send_times(call.get_repeat_count()); + } + + transmit_call.perform(); +} + +// ========== IrRfProxy (Infrared platform) ========== + +#ifdef USE_IR_RF + void IrRfProxy::dump_config() { ESP_LOGCONFIG(TAG, - "IR/RF Proxy '%s'\n" + "IR Proxy '%s'\n" " Supports Transmitter: %s\n" " Supports Receiver: %s", this->get_name().c_str(), YESNO(this->traits_.get_supports_transmitter()), @@ -20,4 +80,54 @@ void IrRfProxy::dump_config() { } } +void IrRfProxy::control(const infrared::InfraredCall &call) { + uint32_t carrier = call.get_carrier_frequency().value_or(0); + transmit_raw_timings(this->transmitter_, carrier, call); +} + +#endif // USE_IR_RF + +// ========== RfProxy (Radio Frequency platform) ========== + +#ifdef USE_RADIO_FREQUENCY + +void RfProxy::setup() { + this->traits_.set_supports_transmitter(this->transmitter_ != nullptr); + this->traits_.set_supports_receiver(this->receiver_ != nullptr); + + // remote_transmitter/receiver always uses OOK (on-off keying) + this->traits_.add_supported_modulation(radio_frequency::RadioFrequencyModulation::RADIO_FREQUENCY_MODULATION_OOK); + + if (this->receiver_ != nullptr) { + this->receiver_->register_listener(this); + } +} + +void RfProxy::dump_config() { + ESP_LOGCONFIG(TAG, + "RF Proxy '%s'\n" + " Backend: remote_transmitter/receiver\n" + " Supports Transmitter: %s\n" + " Supports Receiver: %s", + this->get_name().c_str(), YESNO(this->traits_.get_supports_transmitter()), + YESNO(this->traits_.get_supports_receiver())); + + const auto &traits = this->traits_; + if (traits.get_frequency_min_hz() > 0) { + if (traits.get_frequency_min_hz() == traits.get_frequency_max_hz()) { + ESP_LOGCONFIG(TAG, " Frequency: %.3f MHz (fixed)", traits.get_frequency_min_hz() / 1e6f); + } else { + ESP_LOGCONFIG(TAG, " Frequency Range: %.3f - %.3f MHz", traits.get_frequency_min_hz() / 1e6f, + traits.get_frequency_max_hz() / 1e6f); + } + } +} + +void RfProxy::control(const radio_frequency::RadioFrequencyCall &call) { + // RF: no IR carrier modulation + transmit_raw_timings(this->transmitter_, 0, call); +} + +#endif // USE_RADIO_FREQUENCY + } // namespace esphome::ir_rf_proxy diff --git a/esphome/components/ir_rf_proxy/ir_rf_proxy.h b/esphome/components/ir_rf_proxy/ir_rf_proxy.h index 05b988f2877..973e9e20514 100644 --- a/esphome/components/ir_rf_proxy/ir_rf_proxy.h +++ b/esphome/components/ir_rf_proxy/ir_rf_proxy.h @@ -4,10 +4,19 @@ // without following the normal breaking changes policy. Use at your own risk. // Once the API is considered stable, this warning will be removed. +#include "esphome/components/remote_base/remote_base.h" + +#ifdef USE_IR_RF #include "esphome/components/infrared/infrared.h" +#endif + +#ifdef USE_RADIO_FREQUENCY +#include "esphome/components/radio_frequency/radio_frequency.h" +#endif namespace esphome::ir_rf_proxy { +#ifdef USE_IR_RF /// IrRfProxy - Infrared platform implementation using remote_transmitter/receiver as backend class IrRfProxy : public infrared::Infrared { public: @@ -26,8 +35,36 @@ class IrRfProxy : public infrared::Infrared { void set_receiver_frequency(uint32_t frequency_hz) { this->get_traits().set_receiver_frequency_hz(frequency_hz); } protected: + void control(const infrared::InfraredCall &call) override; + // RF frequency in kHz (Hz / 1000); 0 = infrared, non-zero = RF uint32_t frequency_khz_{0}; }; +#endif // USE_IR_RF + +#ifdef USE_RADIO_FREQUENCY +/// RfProxy - Radio Frequency platform implementation using remote_transmitter/receiver as backend +class RfProxy : public radio_frequency::RadioFrequency { + public: + RfProxy() = default; + + void setup() override; + void dump_config() override; + + /// Set the remote transmitter component + void set_transmitter(remote_base::RemoteTransmitterBase *transmitter) { this->transmitter_ = transmitter; } + /// Set the remote receiver component + void set_receiver(remote_base::RemoteReceiverBase *receiver) { this->receiver_ = receiver; } + + /// Set the fixed carrier frequency in Hz (metadata: advertised via traits, does not tune hardware) + void set_frequency_hz(uint32_t freq_hz) { this->traits_.set_fixed_frequency_hz(freq_hz); } + + protected: + void control(const radio_frequency::RadioFrequencyCall &call) override; + + remote_base::RemoteTransmitterBase *transmitter_{nullptr}; + remote_base::RemoteReceiverBase *receiver_{nullptr}; +}; +#endif // USE_RADIO_FREQUENCY } // namespace esphome::ir_rf_proxy diff --git a/esphome/components/ir_rf_proxy/radio_frequency.py b/esphome/components/ir_rf_proxy/radio_frequency.py new file mode 100644 index 00000000000..9982f5e4d10 --- /dev/null +++ b/esphome/components/ir_rf_proxy/radio_frequency.py @@ -0,0 +1,68 @@ +"""Radio Frequency platform implementation using remote_base (remote_transmitter/receiver).""" + +import esphome.codegen as cg +from esphome.components import radio_frequency, remote_receiver, remote_transmitter +import esphome.config_validation as cv +from esphome.const import CONF_CARRIER_DUTY_PERCENT, CONF_FREQUENCY +import esphome.final_validate as fv +from esphome.types import ConfigType + +from . import CONF_REMOTE_RECEIVER_ID, CONF_REMOTE_TRANSMITTER_ID, ir_rf_proxy_ns + +CODEOWNERS = ["@kbx81"] +DEPENDENCIES = ["radio_frequency"] + +RfProxy = ir_rf_proxy_ns.class_("RfProxy", radio_frequency.RadioFrequency) + +CONFIG_SCHEMA = cv.All( + radio_frequency.radio_frequency_schema(RfProxy).extend( + { + cv.Optional(CONF_FREQUENCY): cv.frequency, + cv.Optional(CONF_REMOTE_RECEIVER_ID): cv.use_id( + remote_receiver.RemoteReceiverComponent + ), + cv.Optional(CONF_REMOTE_TRANSMITTER_ID): cv.use_id( + remote_transmitter.RemoteTransmitterComponent + ), + } + ), + cv.has_exactly_one_key(CONF_REMOTE_RECEIVER_ID, CONF_REMOTE_TRANSMITTER_ID), +) + + +def _final_validate(config: ConfigType) -> None: + """Validate that RF transmitters have carrier duty set to 100%.""" + if CONF_REMOTE_TRANSMITTER_ID not in config: + return + + transmitter_id = config[CONF_REMOTE_TRANSMITTER_ID] + full_config = fv.full_config.get() + transmitter_path = full_config.get_path_for_id(transmitter_id)[:-1] + transmitter_config = full_config.get_config_for_path(transmitter_path) + + duty_percent = transmitter_config.get(CONF_CARRIER_DUTY_PERCENT) + if duty_percent is not None and duty_percent != 100: + raise cv.Invalid( + f"Transmitter '{transmitter_id}' must have '{CONF_CARRIER_DUTY_PERCENT}' " + "set to 100% for RF transmission. Dedicated RF hardware handles modulation; " + "applying a carrier duty cycle would corrupt the signal" + ) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + """Code generation for remote_base radio frequency platform.""" + var = await radio_frequency.new_radio_frequency(config) + + if CONF_FREQUENCY in config: + cg.add(var.set_frequency_hz(int(config[CONF_FREQUENCY]))) + + if CONF_REMOTE_TRANSMITTER_ID in config: + transmitter = await cg.get_variable(config[CONF_REMOTE_TRANSMITTER_ID]) + cg.add(var.set_transmitter(transmitter)) + + if CONF_REMOTE_RECEIVER_ID in config: + receiver = await cg.get_variable(config[CONF_REMOTE_RECEIVER_ID]) + cg.add(var.set_receiver(receiver)) diff --git a/tests/components/radio_frequency/common-rx.yaml b/tests/components/radio_frequency/common-rx.yaml new file mode 100644 index 00000000000..bcfa1f10c71 --- /dev/null +++ b/tests/components/radio_frequency/common-rx.yaml @@ -0,0 +1,18 @@ +remote_receiver: + id: rf_receiver + pin: ${rx_pin} + +# Test radio_frequency platform with receiver +radio_frequency: + # RF 900MHz receiver + - platform: ir_rf_proxy + id: rf_900_rx + name: "RF 900 Receiver" + frequency: 900 MHz + remote_receiver_id: rf_receiver + + # RF receiver (no frequency specified) + - platform: ir_rf_proxy + id: rf_rx + name: "RF Receiver" + remote_receiver_id: rf_receiver diff --git a/tests/components/radio_frequency/common-tx.yaml b/tests/components/radio_frequency/common-tx.yaml new file mode 100644 index 00000000000..778dd68d1ef --- /dev/null +++ b/tests/components/radio_frequency/common-tx.yaml @@ -0,0 +1,19 @@ +remote_transmitter: + id: rf_transmitter + pin: ${tx_pin} + carrier_duty_percent: 100% + +# Test radio_frequency platform with transmitter +radio_frequency: + # RF 433MHz transmitter + - platform: ir_rf_proxy + id: rf_433_tx + name: "RF 433 Transmitter" + frequency: 433 MHz + remote_transmitter_id: rf_transmitter + + # RF transmitter (no frequency specified) + - platform: ir_rf_proxy + id: rf_tx + name: "RF Transmitter" + remote_transmitter_id: rf_transmitter diff --git a/tests/components/radio_frequency/common.yaml b/tests/components/radio_frequency/common.yaml new file mode 100644 index 00000000000..53a0cd379a9 --- /dev/null +++ b/tests/components/radio_frequency/common.yaml @@ -0,0 +1,7 @@ +network: + +wifi: + ssid: MySSID + password: password1 + +api: diff --git a/tests/components/radio_frequency/test.bk72xx-ard.yaml b/tests/components/radio_frequency/test.bk72xx-ard.yaml new file mode 100644 index 00000000000..a0e145f4762 --- /dev/null +++ b/tests/components/radio_frequency/test.bk72xx-ard.yaml @@ -0,0 +1,8 @@ +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + +packages: + common: !include common.yaml + rx: !include common-rx.yaml + tx: !include common-tx.yaml diff --git a/tests/components/radio_frequency/test.esp32-idf.yaml b/tests/components/radio_frequency/test.esp32-idf.yaml new file mode 100644 index 00000000000..a0e145f4762 --- /dev/null +++ b/tests/components/radio_frequency/test.esp32-idf.yaml @@ -0,0 +1,8 @@ +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + +packages: + common: !include common.yaml + rx: !include common-rx.yaml + tx: !include common-tx.yaml diff --git a/tests/components/radio_frequency/test.esp8266-ard.yaml b/tests/components/radio_frequency/test.esp8266-ard.yaml new file mode 100644 index 00000000000..a0e145f4762 --- /dev/null +++ b/tests/components/radio_frequency/test.esp8266-ard.yaml @@ -0,0 +1,8 @@ +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + +packages: + common: !include common.yaml + rx: !include common-rx.yaml + tx: !include common-tx.yaml diff --git a/tests/components/radio_frequency/test.rp2040-ard.yaml b/tests/components/radio_frequency/test.rp2040-ard.yaml new file mode 100644 index 00000000000..a0e145f4762 --- /dev/null +++ b/tests/components/radio_frequency/test.rp2040-ard.yaml @@ -0,0 +1,8 @@ +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + +packages: + common: !include common.yaml + rx: !include common-rx.yaml + tx: !include common-tx.yaml From 58f6ad2d0ce727ae6df08d6a57eadedbfdc496a4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Apr 2026 00:01:21 -0500 Subject: [PATCH 26/68] [safe_mode] Use StaticCallbackManager for on_safe_mode (#16002) --- esphome/components/safe_mode/__init__.py | 3 ++- esphome/components/safe_mode/safe_mode.h | 2 +- esphome/core/defines.h | 1 + 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/esphome/components/safe_mode/__init__.py b/esphome/components/safe_mode/__init__.py index 6df0ba78b1f..578376258a1 100644 --- a/esphome/components/safe_mode/__init__.py +++ b/esphome/components/safe_mode/__init__.py @@ -76,8 +76,9 @@ async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - if config.get(CONF_ON_SAFE_MODE): + if on_safe_mode := config.get(CONF_ON_SAFE_MODE): cg.add_define("USE_SAFE_MODE_CALLBACK") + cg.add_define("ESPHOME_SAFE_MODE_CALLBACK_COUNT", len(on_safe_mode)) await automation.build_callback_automations( var, config, _CALLBACK_AUTOMATIONS ) diff --git a/esphome/components/safe_mode/safe_mode.h b/esphome/components/safe_mode/safe_mode.h index 2733054962e..b458a9a3021 100644 --- a/esphome/components/safe_mode/safe_mode.h +++ b/esphome/components/safe_mode/safe_mode.h @@ -57,7 +57,7 @@ class SafeModeComponent final : public Component { // Larger objects at the end ESPPreferenceObject rtc_; #ifdef USE_SAFE_MODE_CALLBACK - CallbackManager safe_mode_callback_{}; + StaticCallbackManager safe_mode_callback_{}; #endif static const uint32_t ENTER_SAFE_MODE_MAGIC = diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 80247f69da1..297bf081c5a 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -136,6 +136,7 @@ #define USE_PREFERENCES_SYNC_EVERY_LOOP #define USE_QR_CODE #define USE_SAFE_MODE_CALLBACK +#define ESPHOME_SAFE_MODE_CALLBACK_COUNT 1 #define USE_SELECT #define USE_SENSOR #define USE_SENSOR_FILTER From f092e619d8b37e71054636ba1b63848159c31438 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Apr 2026 00:03:59 -0500 Subject: [PATCH 27/68] [rtttl] Gate on_finished_playback callback storage behind define (#16003) --- esphome/components/rtttl/__init__.py | 4 +++- esphome/components/rtttl/rtttl.cpp | 2 ++ esphome/components/rtttl/rtttl.h | 6 ++++++ esphome/core/defines.h | 1 + tests/components/rtttl/common.yaml | 5 +++++ 5 files changed, 17 insertions(+), 1 deletion(-) diff --git a/esphome/components/rtttl/__init__.py b/esphome/components/rtttl/__init__.py index c661aad972a..4880f9ac41a 100644 --- a/esphome/components/rtttl/__init__.py +++ b/esphome/components/rtttl/__init__.py @@ -93,7 +93,9 @@ async def to_code(config): cg.add(var.set_gain(config[CONF_GAIN])) - await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) + if config.get(CONF_ON_FINISHED_PLAYBACK): + cg.add_define("USE_RTTTL_FINISHED_PLAYBACK_CALLBACK") + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) @automation.register_action( diff --git a/esphome/components/rtttl/rtttl.cpp b/esphome/components/rtttl/rtttl.cpp index 08d902b4be8..a5f8567c9da 100644 --- a/esphome/components/rtttl/rtttl.cpp +++ b/esphome/components/rtttl/rtttl.cpp @@ -424,7 +424,9 @@ void Rtttl::set_state_(State state) { // Clear loop_done when transitioning from `State::STOPPED` to any other state if (state == State::STOPPED) { this->disable_loop(); +#ifdef USE_RTTTL_FINISHED_PLAYBACK_CALLBACK this->on_finished_playback_callback_.call(); +#endif ESP_LOGD(TAG, "Playback finished"); } else if (old_state == State::STOPPED) { this->enable_loop(); diff --git a/esphome/components/rtttl/rtttl.h b/esphome/components/rtttl/rtttl.h index 98ed9ba1bf4..9dac92be2ad 100644 --- a/esphome/components/rtttl/rtttl.h +++ b/esphome/components/rtttl/rtttl.h @@ -2,6 +2,8 @@ #include "esphome/core/automation.h" #include "esphome/core/component.h" +#include "esphome/core/defines.h" +#include "esphome/core/helpers.h" #ifdef USE_OUTPUT #include "esphome/components/output/float_output.h" @@ -45,9 +47,11 @@ class Rtttl : public Component { bool is_playing() { return this->state_ != State::STOPPED; } +#ifdef USE_RTTTL_FINISHED_PLAYBACK_CALLBACK template void add_on_finished_playback_callback(F &&callback) { this->on_finished_playback_callback_.add(std::forward(callback)); } +#endif protected: inline uint16_t get_integer_() { @@ -106,8 +110,10 @@ class Rtttl : public Component { uint32_t samples_gap_{0}; #endif // USE_SPEAKER +#ifdef USE_RTTTL_FINISHED_PLAYBACK_CALLBACK /// The callback to call when playback is finished. CallbackManager on_finished_playback_callback_; +#endif }; template class PlayAction : public Action { diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 297bf081c5a..f929b224ca4 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -186,6 +186,7 @@ #define USE_MQTT #define USE_MQTT_COVER_JSON #define USE_NETWORK +#define USE_RTTTL_FINISHED_PLAYBACK_CALLBACK #define USE_RUNTIME_IMAGE_BMP #define USE_RUNTIME_IMAGE_PNG #define USE_RUNTIME_IMAGE_JPEG diff --git a/tests/components/rtttl/common.yaml b/tests/components/rtttl/common.yaml index 529713583be..a4d8f951f42 100644 --- a/tests/components/rtttl/common.yaml +++ b/tests/components/rtttl/common.yaml @@ -29,3 +29,8 @@ output: rtttl: output: rtttl_output + on_finished_playback: + - then: + - logger.log: "Playback finished 1" + - then: + - logger.log: "Playback finished 2" From dc57969afdc96dd546ceaca27474200f090e6c6e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Apr 2026 03:39:24 -0500 Subject: [PATCH 28/68] [host] Use integer math in millis()/micros() (#15994) --- esphome/components/host/core.cpp | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/esphome/components/host/core.cpp b/esphome/components/host/core.cpp index 0ade4274feb..b067ebbf6ef 100644 --- a/esphome/components/host/core.cpp +++ b/esphome/components/host/core.cpp @@ -8,7 +8,6 @@ #include #include #include -#include #include namespace { @@ -22,9 +21,7 @@ void HOT yield() { ::sched_yield(); } uint32_t IRAM_ATTR HOT millis() { struct timespec spec; clock_gettime(CLOCK_MONOTONIC, &spec); - time_t seconds = spec.tv_sec; - uint32_t ms = round(spec.tv_nsec / 1e6); - return ((uint32_t) seconds) * 1000U + ms; + return static_cast(spec.tv_sec * 1000ULL + spec.tv_nsec / 1000000); } uint64_t millis_64() { struct timespec spec; @@ -43,9 +40,7 @@ void HOT delay(uint32_t ms) { uint32_t IRAM_ATTR HOT micros() { struct timespec spec; clock_gettime(CLOCK_MONOTONIC, &spec); - time_t seconds = spec.tv_sec; - uint32_t us = round(spec.tv_nsec / 1e3); - return ((uint32_t) seconds) * 1000000U + us; + return static_cast(spec.tv_sec * 1000000ULL + spec.tv_nsec / 1000); } void IRAM_ATTR HOT delayMicroseconds(uint32_t us) { struct timespec ts; From 68625a1b76aafaa5a859cea3c090333dfa81ce40 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Sun, 26 Apr 2026 13:11:09 +0400 Subject: [PATCH 29/68] [core] Isolate generated build metadata (#16007) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/core/config.py | 8 ++- esphome/writer.py | 101 ++++++++++++++++++++------- tests/unit_tests/test_writer.py | 119 ++++++++++++++++++++++---------- 3 files changed, 167 insertions(+), 61 deletions(-) diff --git a/esphome/core/config.py b/esphome/core/config.py index bf210876dfb..018e05f17b4 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -242,6 +242,10 @@ PROJECT_MAX_LENGTH = 127 # Max board/model string length (must fit in single-byte varint for proto encoding) BOARD_MAX_LENGTH = 127 +# Keep in sync with ESPHOME_COMMENT_SIZE_MAX in esphome/core/application.h +# (C++ side includes the null terminator). +COMMENT_MAX_LEN = 255 + AREA_SCHEMA = cv.Schema( { cv.GenerateID(CONF_ID): cv.declare_id(Area), @@ -275,7 +279,9 @@ CONFIG_SCHEMA = cv.All( cv.string_no_slash, cv.ByteLength(max=FRIENDLY_NAME_MAX_LEN) ), cv.Optional(CONF_AREA): validate_area_config, - cv.Optional(CONF_COMMENT): cv.All(cv.string, cv.Length(max=255)), + cv.Optional(CONF_COMMENT): cv.All( + cv.string, cv.ByteLength(max=COMMENT_MAX_LEN) + ), cv.Required(CONF_BUILD_PATH): cv.string, cv.Optional(CONF_PLATFORMIO_OPTIONS, default={}): cv.Schema( { diff --git a/esphome/writer.py b/esphome/writer.py index 787ecac6f6e..816c57a0bc1 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -22,7 +22,6 @@ from esphome.helpers import ( read_file, rmtree, walk_files, - write_file, write_file_if_changed, ) from esphome.storage_json import StorageJSON, storage_path @@ -171,6 +170,7 @@ VERSION_H_FORMAT = """\ DEFINES_H_TARGET = "esphome/core/defines.h" VERSION_H_TARGET = "esphome/core/version.h" BUILD_INFO_DATA_H_TARGET = "esphome/core/build_info_data.h" +BUILD_INFO_DATA_CPP_TARGET = "esphome/core/build_info_data.cpp" ENTITY_TYPES_H_TARGET = "esphome/core/entity_types.h" ESPHOME_README_TXT = """ THIS DIRECTORY IS AUTO-GENERATED, DO NOT MODIFY @@ -209,13 +209,22 @@ def copy_src_tree(): source_files_copy = source_files_map.copy() ignore_targets = [ - Path(x) for x in (DEFINES_H_TARGET, VERSION_H_TARGET, BUILD_INFO_DATA_H_TARGET) + Path(x) + for x in ( + DEFINES_H_TARGET, + VERSION_H_TARGET, + BUILD_INFO_DATA_H_TARGET, + BUILD_INFO_DATA_CPP_TARGET, + ) ] for t in ignore_targets: source_files_copy.pop(t, None) # Files to exclude from sources_changed tracking (generated files) - generated_files = {Path("esphome/core/build_info_data.h")} + generated_files = { + Path("esphome/core/build_info_data.h"), + Path("esphome/core/build_info_data.cpp"), + } sources_changed = False for fname in walk_files(CORE.relative_src_path("esphome")): @@ -268,12 +277,15 @@ def copy_src_tree(): build_info_data_h_path = CORE.relative_src_path( "esphome", "core", "build_info_data.h" ) + build_info_data_cpp_path = CORE.relative_src_path( + "esphome", "core", "build_info_data.cpp" + ) build_info_json_path = CORE.relative_build_path("build_info.json") config_hash, build_time, build_time_str, comment = get_build_info() # Defensively force a rebuild if the build_info files don't exist, or if # there was a config change which didn't actually cause a source change - if not build_info_data_h_path.exists(): + if not build_info_data_h_path.exists() or not build_info_data_cpp_path.exists(): sources_changed = True else: try: @@ -288,13 +300,19 @@ def copy_src_tree(): # Write build_info header and JSON metadata if sources_changed: - write_file( + # write_file_if_changed avoids bumping mtime on identical content, + # which is what makes the stable header actually isolate metadata churn. + write_file_if_changed( build_info_data_h_path, - generate_build_info_data_h( + generate_build_info_data_h(), + ) + write_file_if_changed( + build_info_data_cpp_path, + generate_build_info_data_cpp( config_hash, build_time, build_time_str, comment ), ) - write_file( + write_file_if_changed( build_info_json_path, json.dumps( { @@ -345,27 +363,60 @@ def get_build_info() -> tuple[int, int, str, str]: return config_hash, build_time, build_time_str, comment -def generate_build_info_data_h( - config_hash: int, build_time: int, build_time_str: str, comment: str -) -> str: - """Generate build_info_data.h header with config hash, build time, and comment.""" - # cpp_string_escape returns '"escaped"', slice off the quotes since template has them - escaped_comment = cpp_string_escape(comment)[1:-1] - # +1 for null terminator - comment_size = len(comment) + 1 - return f"""#pragma once -// Auto-generated build_info data -#define ESPHOME_CONFIG_HASH 0x{config_hash:08x}U // NOLINT -#define ESPHOME_BUILD_TIME {build_time} // NOLINT -#define ESPHOME_COMMENT_SIZE {comment_size} // NOLINT +def generate_build_info_data_h() -> str: + """Generate stable declarations for build info provided by generated C++.""" + return """#pragma once +// Auto-generated build_info declarations +#include +#include +#include #ifdef USE_ESP8266 #include -static const char ESPHOME_BUILD_TIME_STR[] PROGMEM = "{build_time_str}"; -static const char ESPHOME_COMMENT_STR[] PROGMEM = "{escaped_comment}"; -#else -static const char ESPHOME_BUILD_TIME_STR[] = "{build_time_str}"; -static const char ESPHOME_COMMENT_STR[] = "{escaped_comment}"; #endif + +namespace esphome { +extern const uint32_t ESPHOME_CONFIG_HASH; +extern const time_t ESPHOME_BUILD_TIME; +extern const size_t ESPHOME_COMMENT_SIZE; +#ifdef USE_ESP8266 +extern const char ESPHOME_BUILD_TIME_STR[] PROGMEM; +extern const char ESPHOME_COMMENT_STR[] PROGMEM; +#else +extern const char ESPHOME_BUILD_TIME_STR[]; +extern const char ESPHOME_COMMENT_STR[]; +#endif +} // namespace esphome +""" + + +def generate_build_info_data_cpp( + config_hash: int, build_time: int, build_time_str: str, comment: str +) -> str: + """Generate build_info_data.cpp with config hash, build time, and comment.""" + from esphome.core.config import COMMENT_MAX_LEN + + # Defense-in-depth clamp; errors="ignore" drops a partial trailing UTF-8 + # sequence so the literal never decodes to a truncated codepoint. + encoded = comment.encode("utf-8")[:COMMENT_MAX_LEN] + comment = encoded.decode("utf-8", errors="ignore") + # cpp_string_escape wraps in quotes; strip them since the template has them. + escaped_comment = cpp_string_escape(comment)[1:-1] + comment_size = len(comment.encode("utf-8")) + 1 # +1 for NUL + return f"""// Auto-generated build_info data +#include "esphome/core/build_info_data.h" + +namespace esphome {{ +const uint32_t ESPHOME_CONFIG_HASH = 0x{config_hash:08x}U; // NOLINT +const time_t ESPHOME_BUILD_TIME = {build_time}; // NOLINT +const size_t ESPHOME_COMMENT_SIZE = {comment_size}; // NOLINT +#ifdef USE_ESP8266 +const char ESPHOME_BUILD_TIME_STR[] PROGMEM = "{build_time_str}"; +const char ESPHOME_COMMENT_STR[] PROGMEM = "{escaped_comment}"; +#else +const char ESPHOME_BUILD_TIME_STR[] = "{build_time_str}"; +const char ESPHOME_COMMENT_STR[] = "{escaped_comment}"; +#endif +}} // namespace esphome """ diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index 940a394c080..e76769e6a83 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -7,6 +7,7 @@ from datetime import datetime import json import os from pathlib import Path +import re import stat from typing import Any from unittest.mock import MagicMock, patch @@ -32,6 +33,7 @@ from esphome.writer import ( clean_build, clean_cmake_cache, copy_src_tree, + generate_build_info_data_cpp, generate_build_info_data_h, get_build_info, storage_should_clean, @@ -1615,49 +1617,62 @@ def test_get_build_info_build_time_str_format( def test_generate_build_info_data_h_format() -> None: """Test generate_build_info_data_h produces correct header content.""" - config_hash = 0x12345678 - build_time = 1700000000 - build_time_str = "2023-11-14 22:13:20 +0000" - comment = "Test comment" - - result = generate_build_info_data_h( - config_hash, build_time, build_time_str, comment - ) + result = generate_build_info_data_h() assert "#pragma once" in result - assert "#define ESPHOME_CONFIG_HASH 0x12345678U" in result - assert "#define ESPHOME_BUILD_TIME 1700000000" in result - assert "#define ESPHOME_COMMENT_SIZE 13" in result # len("Test comment") + 1 - assert 'ESPHOME_BUILD_TIME_STR[] = "2023-11-14 22:13:20 +0000"' in result - assert 'ESPHOME_COMMENT_STR[] = "Test comment"' in result + assert "extern const uint32_t ESPHOME_CONFIG_HASH;" in result + assert "extern const time_t ESPHOME_BUILD_TIME;" in result + assert "extern const size_t ESPHOME_COMMENT_SIZE;" in result + assert "extern const char ESPHOME_BUILD_TIME_STR[]" in result + assert "extern const char ESPHOME_COMMENT_STR[]" in result def test_generate_build_info_data_h_esp8266_progmem() -> None: """Test generate_build_info_data_h includes PROGMEM for ESP8266.""" - result = generate_build_info_data_h(0xABCDEF01, 1700000000, "test", "comment") + result = generate_build_info_data_h() # Should have ESP8266 PROGMEM conditional assert "#ifdef USE_ESP8266" in result assert "#include " in result assert "PROGMEM" in result - # Both build time and comment should have PROGMEM versions + + +def test_generate_build_info_data_cpp_format() -> None: + """Test generate_build_info_data_cpp produces correct data definitions.""" + result = generate_build_info_data_cpp( + 0x12345678, 1700000000, "2023-11-14 22:13:20 +0000", "Test comment" + ) + + assert '#include "esphome/core/build_info_data.h"' in result + assert "const uint32_t ESPHOME_CONFIG_HASH = 0x12345678U;" in result + assert "const time_t ESPHOME_BUILD_TIME = 1700000000;" in result + assert "const size_t ESPHOME_COMMENT_SIZE = 13;" in result + assert 'ESPHOME_BUILD_TIME_STR[] = "2023-11-14 22:13:20 +0000"' in result + assert 'ESPHOME_COMMENT_STR[] = "Test comment"' in result + + +def test_generate_build_info_data_cpp_esp8266_progmem() -> None: + """Test generate_build_info_data_cpp includes PROGMEM definitions.""" + result = generate_build_info_data_cpp(0xABCDEF01, 1700000000, "test", "comment") + + assert "#ifdef USE_ESP8266" in result assert 'ESPHOME_BUILD_TIME_STR[] PROGMEM = "test"' in result assert 'ESPHOME_COMMENT_STR[] PROGMEM = "comment"' in result -def test_generate_build_info_data_h_hash_formatting() -> None: - """Test generate_build_info_data_h formats hash with leading zeros.""" +def test_generate_build_info_data_cpp_hash_formatting() -> None: + """Test generate_build_info_data_cpp formats hash with leading zeros.""" # Test with small hash value that needs leading zeros - result = generate_build_info_data_h(0x00000001, 0, "test", "") - assert "#define ESPHOME_CONFIG_HASH 0x00000001U" in result + result = generate_build_info_data_cpp(0x00000001, 0, "test", "") + assert "const uint32_t ESPHOME_CONFIG_HASH = 0x00000001U;" in result # Test with larger hash value - result = generate_build_info_data_h(0xFFFFFFFF, 0, "test", "") - assert "#define ESPHOME_CONFIG_HASH 0xffffffffU" in result + result = generate_build_info_data_cpp(0xFFFFFFFF, 0, "test", "") + assert "const uint32_t ESPHOME_CONFIG_HASH = 0xffffffffU;" in result -def test_generate_build_info_data_h_comment_escaping() -> None: - r"""Test generate_build_info_data_h properly escapes special characters in comment. +def test_generate_build_info_data_cpp_comment_escaping() -> None: + r"""Test generate_build_info_data_cpp properly escapes special characters in comment. Uses cpp_string_escape which outputs octal escapes for special characters: - backslash (ASCII 92) -> \134 @@ -1665,26 +1680,52 @@ def test_generate_build_info_data_h_comment_escaping() -> None: - newline (ASCII 10) -> \012 """ # Test backslash escaping (ASCII 92 = octal 134) - result = generate_build_info_data_h(0, 0, "test", "backslash\\here") + result = generate_build_info_data_cpp(0, 0, "test", "backslash\\here") assert 'ESPHOME_COMMENT_STR[] = "backslash\\134here"' in result # Test quote escaping (ASCII 34 = octal 042) - result = generate_build_info_data_h(0, 0, "test", 'has "quotes"') + result = generate_build_info_data_cpp(0, 0, "test", 'has "quotes"') assert 'ESPHOME_COMMENT_STR[] = "has \\042quotes\\042"' in result # Test newline escaping (ASCII 10 = octal 012) - result = generate_build_info_data_h(0, 0, "test", "line1\nline2") + result = generate_build_info_data_cpp(0, 0, "test", "line1\nline2") assert 'ESPHOME_COMMENT_STR[] = "line1\\012line2"' in result -def test_generate_build_info_data_h_empty_comment() -> None: - """Test generate_build_info_data_h handles empty comment.""" - result = generate_build_info_data_h(0, 0, "test", "") +def test_generate_build_info_data_cpp_empty_comment() -> None: + """Test generate_build_info_data_cpp handles empty comment.""" + result = generate_build_info_data_cpp(0, 0, "test", "") - assert "#define ESPHOME_COMMENT_SIZE 1" in result # Just null terminator + assert "const size_t ESPHOME_COMMENT_SIZE = 1;" in result # Just null terminator assert 'ESPHOME_COMMENT_STR[] = ""' in result +def test_generate_build_info_data_cpp_comment_size_counts_utf8_bytes() -> None: + """Comment size is in encoded UTF-8 bytes, not characters.""" + # "héllo" = 6 UTF-8 bytes + NUL. + result = generate_build_info_data_cpp(0, 0, "test", "héllo") + assert "const size_t ESPHOME_COMMENT_SIZE = 7;" in result + + +def test_generate_build_info_data_cpp_comment_clamped_to_buffer() -> None: + """Generator clamps at byte level and never truncates mid-codepoint.""" + # 100 thermometer-with-VS-16 sequences = 700 bytes, past the 256 buffer. + result = generate_build_info_data_cpp(0, 0, "test", "🌡️" * 100) + + match = re.search(r"ESPHOME_COMMENT_SIZE = (\d+);", result) + assert match is not None + size = int(match.group(1)) + assert 1 < size <= 256 + + lit_match = re.search(r'ESPHOME_COMMENT_STR\[\] = "([^"]*)"', result) + assert lit_match is not None + raw = re.sub( + r"\\([0-7]{3})", lambda m: chr(int(m.group(1), 8)), lit_match.group(1) + ).encode("latin-1") + raw.decode("utf-8") # raises if truncation left a partial UTF-8 sequence + assert len(raw) == size - 1 + + @patch("esphome.writer.CORE") @patch("esphome.writer.iter_components") @patch("esphome.writer.walk_files") @@ -1758,15 +1799,21 @@ def test_copy_src_tree_writes_build_info_files( ): copy_src_tree() - # Verify build_info_data.h was written + # Verify build_info_data.h declarations and build_info_data.cpp values were written build_info_h_path = esphome_core_path / "build_info_data.h" assert build_info_h_path.exists() build_info_h_content = build_info_h_path.read_text() - assert "#define ESPHOME_CONFIG_HASH 0xdeadbeefU" in build_info_h_content - assert "#define ESPHOME_BUILD_TIME" in build_info_h_content + assert "extern const uint32_t ESPHOME_CONFIG_HASH;" in build_info_h_content assert "ESPHOME_BUILD_TIME_STR" in build_info_h_content - assert "#define ESPHOME_COMMENT_SIZE" in build_info_h_content + assert "extern const size_t ESPHOME_COMMENT_SIZE;" in build_info_h_content assert "ESPHOME_COMMENT_STR" in build_info_h_content + build_info_cpp_path = esphome_core_path / "build_info_data.cpp" + assert build_info_cpp_path.exists() + build_info_cpp_content = build_info_cpp_path.read_text() + assert "const uint32_t ESPHOME_CONFIG_HASH = 0xdeadbeefU;" in build_info_cpp_content + assert "const time_t ESPHOME_BUILD_TIME" in build_info_cpp_content + assert "const size_t ESPHOME_COMMENT_SIZE" in build_info_cpp_content + assert "ESPHOME_COMMENT_STR" in build_info_cpp_content # Verify build_info.json was written build_info_json_path = build_path / "build_info.json" @@ -1833,7 +1880,9 @@ def test_copy_src_tree_detects_config_hash_change( # Verify build_info files were updated due to config_hash change assert build_info_h_path.exists() - new_content = build_info_h_path.read_text() + build_info_cpp_path = esphome_core_path / "build_info_data.cpp" + assert build_info_cpp_path.exists() + new_content = build_info_cpp_path.read_text() assert "0xdeadbeef" in new_content.lower() new_json = json.loads(build_info_json_path.read_text()) From b084fa449008b4dbf4162d2e702d280cb25a9433 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Sun, 26 Apr 2026 15:31:32 +0400 Subject: [PATCH 30/68] [esp32] Make ESP-IDF builds reproducible (#16008) Co-authored-by: J. Nick Koston --- esphome/components/esp32/__init__.py | 4 ++++ .../esp32/config/reproducible_build.yaml | 8 ++++++++ tests/component_tests/esp32/test_esp32.py | 11 +++++++++++ 3 files changed, 23 insertions(+) create mode 100644 tests/component_tests/esp32/config/reproducible_build.yaml diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 1a7ae700c78..78a1715ccfd 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1729,6 +1729,10 @@ async def to_code(config): cg.add_build_flag("-DUSE_ESP32_FRAMEWORK_ESP_IDF") if use_platformio: cg.add_platformio_option("framework", "espidf") + # Strip volatile build path/time metadata from PlatformIO-managed + # ESP-IDF builds so equivalent projects can produce reproducible + # outputs and downstream tooling can safely reuse artifacts. + add_idf_sdkconfig_option("CONFIG_APP_REPRODUCIBLE_BUILD", True) # Wrap std::__throw_* functions to abort immediately, eliminating ~3KB of # exception class overhead. See throw_stubs.cpp for implementation. diff --git a/tests/component_tests/esp32/config/reproducible_build.yaml b/tests/component_tests/esp32/config/reproducible_build.yaml new file mode 100644 index 00000000000..eb9721b4320 --- /dev/null +++ b/tests/component_tests/esp32/config/reproducible_build.yaml @@ -0,0 +1,8 @@ +esphome: + name: test + +esp32: + board: esp32dev + variant: esp32 + framework: + type: esp-idf diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index ac492e27529..c39a4aafc88 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -232,3 +232,14 @@ def test_execute_from_psram_disabled_sdkconfig( assert "CONFIG_SPIRAM_FETCH_INSTRUCTIONS" not in sdkconfig assert "CONFIG_SPIRAM_RODATA" not in sdkconfig assert "CONFIG_SPIRAM_XIP_FROM_PSRAM" not in sdkconfig + + +def test_platformio_idf_enables_reproducible_build( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Test PlatformIO ESP-IDF builds enable reproducible app metadata.""" + generate_main(component_config_path("reproducible_build.yaml")) + + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert sdkconfig.get("CONFIG_APP_REPRODUCIBLE_BUILD") is True From c8d4420408c33680ab47c4576129fc4ad3dad1d7 Mon Sep 17 00:00:00 2001 From: Boris Krivonog Date: Sun, 26 Apr 2026 14:19:49 +0200 Subject: [PATCH 31/68] [mitsubishi_cn105] add support for half-degree temperature setpoint (#15919) --- .../components/mitsubishi_cn105/mitsubishi_cn105.cpp | 6 +++--- .../climate/mitsubishi_cn105_tests.cpp | 11 +++++++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp index 1a354956183..f04a5906c16 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp @@ -352,7 +352,7 @@ void MitsubishiCN105::set_target_temperature(float target_temperature) { ESP_LOGD(TAG, "Setting temperature out-of-range: %.1f", target_temperature); return; } - this->status_.target_temperature = std::round(target_temperature); + this->status_.target_temperature = target_temperature; this->pending_updates_.set(UpdateFlag::TEMPERATURE); } @@ -387,9 +387,9 @@ void MitsubishiCN105::apply_settings_() { if (this->pending_updates_.has(UpdateFlag::TEMPERATURE)) { payload[1] |= 0x04; if (this->use_temperature_encoding_b_) { - payload[14] = static_cast(this->status_.target_temperature * 2.0f + 128.0f); + payload[14] = static_cast(std::round(this->status_.target_temperature * 2.0f) + 128); } else { - payload[5] = static_cast(TARGET_TEMPERATURE_ENC_A_OFFSET - this->status_.target_temperature); + payload[5] = static_cast(TARGET_TEMPERATURE_ENC_A_OFFSET - std::round(this->status_.target_temperature)); } } diff --git a/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp b/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp index 7846a31193f..86faaeac784 100644 --- a/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp +++ b/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp @@ -341,6 +341,17 @@ TEST(MitsubishiCN105Tests, ApplySettingsTemperatureEncodedB) { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xB4, 0x00, 0xC5)); } +TEST(MitsubishiCN105Tests, ApplySettingsHalfDegreeTemperatureEncodedB) { + auto ctx = TestContext{}; + + ctx.sut.use_temperature_encoding_b_ = true; + ctx.sut.set_target_temperature(26.5f); + ctx.sut.apply_settings(); + + EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x41, 0x01, 0x30, 0x10, 0x01, 0x04, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xB5, 0x00, 0xC4)); +} + TEST(MitsubishiCN105Tests, ApplyModeCool) { auto ctx = TestContext{}; From df987a7ffb4580dd90f07a99e1544b447f9104b4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Apr 2026 07:22:34 -0500 Subject: [PATCH 32/68] [ci-custom] Suggest uint32_to_str/int8_to_str for integer formatting (#15970) --- script/ci-custom.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/script/ci-custom.py b/script/ci-custom.py index 02ec08bc318..4d71df74cfa 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -837,7 +837,16 @@ def lint_no_std_to_string(fname, match): f"{highlight('std::to_string()')} (including unqualified {highlight('to_string()')}) " f"allocates heap memory. On long-running embedded devices, repeated heap allocations " f"fragment memory over time.\n" - f"Please use {highlight('snprintf()')} with a stack buffer instead.\n" + f"\n" + f"For plain integer formatting, prefer the dedicated helpers in helpers.h over " + f"{highlight('snprintf()')} — they avoid pulling in printf formatting code and are " + f"smaller and faster:\n" + f" int8_t: {highlight('int8_to_str(buf, val)')} (buf >= 5 bytes)\n" + f" uint8_t/uint16_t/uint32_t: {highlight('uint32_to_str(buf, val)')} (buf = UINT32_MAX_STR_SIZE; smaller types auto-widen)\n" + f"Example: {highlight('char buf[UINT32_MAX_STR_SIZE]; uint32_to_str(buf, value);')}\n" + f"For sensor values, use {highlight('value_accuracy_to_buf()')} from helpers.h.\n" + f"\n" + f"Otherwise use {highlight('snprintf()')} with a stack buffer.\n" f"\n" f"Buffer sizes and format specifiers (sizes include sign and null terminator):\n" f" uint8_t: 4 chars - %u (or PRIu8)\n" @@ -851,7 +860,6 @@ def lint_no_std_to_string(fname, match): f" float/double: 24 chars - %.8g (15 digits + sign + decimal + e+XXX)\n" f" 317 chars - %f (for DBL_MAX: 309 int digits + decimal + 6 frac + sign)\n" f"\n" - f"For sensor values, use value_accuracy_to_buf() from helpers.h.\n" f'Example: char buf[11]; snprintf(buf, sizeof(buf), "%" PRIu32, value);\n' f"(If strictly necessary, add `{highlight('// NOLINT')}` to the end of the line)" ) From 4c0dfb0e0d302373786ccbffbd07bb43b673fe3d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Apr 2026 07:22:50 -0500 Subject: [PATCH 33/68] [core] Raise ESP32 WDT feed interval to 1/5 of configured timeout (#15984) --- esphome/core/application.h | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/esphome/core/application.h b/esphome/core/application.h index e9b386038ea..bc09f7d38c8 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -17,6 +17,10 @@ #include "esphome/core/string_ref.h" #include "esphome/core/version.h" +#ifdef USE_ESP32 +#include // for CONFIG_ESP_TASK_WDT_TIMEOUT_S (drives WDT_FEED_INTERVAL_MS) +#endif + #ifdef USE_DEVICES #include "esphome/core/device.h" #endif @@ -216,16 +220,30 @@ class Application { /// loops and scheduler items still feed after every op, so any op exceeding /// this threshold triggers a real feed naturally. /// Safety margins vs. platform watchdog timeouts: - /// - ESP32 task WDT default (5 s): ~16x - /// - ESP8266 soft WDT (~1.6 s): ~5x <-- floor case; any future change - /// must keep comfortable margin here - /// - ESP8266 HW WDT (~6 s): ~20x - /// - BK72xx HW WDT (10 s): ~5x <-- platform override below + /// - ESP32 task WDT (user-configurable): ~5x <-- auto-scaled below + /// - ESP8266 soft WDT (~1.6 s): ~5x <-- floor case; any future change + /// must keep comfortable margin here + /// - ESP8266 HW WDT (~6 s): ~20x + /// - BK72xx HW WDT (10 s): ~5x <-- platform override below #ifdef USE_BK72XX // BDK busy-waits 200us per WDT reload (sctrl_dpll_delay200us). LibreTiny // sets HW WDT to 10s; 2000ms keeps ~5x margin. See wdt_ctrl WCMD_RELOAD_PERIOD: // https://github.com/libretiny-eu/framework-beken-bdk/blob/44800e7451ea30fbcbd3bb6e905315de59349fee/beken378/driver/wdt/wdt.c#L75-L87 static constexpr uint32_t WDT_FEED_INTERVAL_MS = 2000; +#elif defined(USE_ESP32) + // Auto-scale to 1/5 of the configured ESP32 task WDT timeout so the safety + // margin stays constant when the user raises esp32.watchdog_timeout (default + // 5 s → 1000 ms feed; 10 s → 2000 ms; 60 s → 12000 ms). The esp32 component + // writes CONFIG_ESP_TASK_WDT_TIMEOUT_S into sdkconfig (range is validated + // to ≥ 5 s in esp32/__init__.py), giving us the value at compile time. + // esp_task_wdt_reset() takes a spinlock and walks the WDT task list, so + // each call costs tens of microseconds; longer intervals materially reduce + // the main-loop's wdt bucket. Component loops and scheduler items still + // feed after every op, so any op exceeding this threshold triggers a real + // feed naturally regardless of the rate-limit. + static_assert(CONFIG_ESP_TASK_WDT_TIMEOUT_S >= 5, + "CONFIG_ESP_TASK_WDT_TIMEOUT_S must be at least 5s for a safe WDT feed interval"); + static constexpr uint32_t WDT_FEED_INTERVAL_MS = (CONFIG_ESP_TASK_WDT_TIMEOUT_S * 1000U) / 5U; #else static constexpr uint32_t WDT_FEED_INTERVAL_MS = 300; #endif From 180105bb4b794fd9767714d4426a8a6c4402ce6f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Apr 2026 07:23:08 -0500 Subject: [PATCH 34/68] =?UTF-8?q?[bluetooth=5Fproxy]=20Partial=20revert=20?= =?UTF-8?q?of=20loop()=20=E2=86=92=20set=5Finterval=20migration=20(#15992)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../bluetooth_proxy/bluetooth_proxy.cpp | 32 +++++++++++-------- .../bluetooth_proxy/bluetooth_proxy.h | 4 +++ 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index c69163b1f74..45f848a2866 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -30,19 +30,6 @@ void BluetoothProxy::setup() { this->configured_scan_active_ = this->parent_->get_scan_active(); this->parent_->add_scanner_state_listener(this); - - this->set_interval(100, [this]() { - if (api::global_api_server->is_connected() && this->api_connection_ != nullptr) { - this->flush_pending_advertisements_(); - return; - } - for (uint8_t i = 0; i < this->connection_count_; i++) { - auto *connection = this->connections_[i]; - if (connection->get_address() != 0 && !connection->disconnect_pending()) { - connection->disconnect(); - } - } - }); } void BluetoothProxy::on_scanner_state(esp32_ble_tracker::ScannerState state) { @@ -133,6 +120,25 @@ void BluetoothProxy::dump_config() { YESNO(this->active_), this->connection_count_); } +void BluetoothProxy::loop() { + // Run advertisement flush / connection cleanup every 100ms + uint32_t now = App.get_loop_component_start_time(); + if (now - this->last_advertisement_flush_time_ < 100) + return; + this->last_advertisement_flush_time_ = now; + + if (api::global_api_server->is_connected() && this->api_connection_ != nullptr) { + this->flush_pending_advertisements_(); + return; + } + for (uint8_t i = 0; i < this->connection_count_; i++) { + auto *connection = this->connections_[i]; + if (connection->get_address() != 0 && !connection->disconnect_pending()) { + connection->disconnect(); + } + } +} + esp32_ble_tracker::AdvertisementParserType BluetoothProxy::get_advertisement_parser_type() { return esp32_ble_tracker::AdvertisementParserType::RAW_ADVERTISEMENTS; } diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index 6680ab0e840..10449f21f1e 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -65,6 +65,7 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener, bool parse_devices(const esp32_ble::BLEScanResult *scan_results, size_t count) override; void dump_config() override; void setup() override; + void loop() override; esp32_ble_tracker::AdvertisementParserType get_advertisement_parser_type() override; void register_connection(BluetoothConnection *connection) { @@ -176,6 +177,9 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener, // BLE advertisement batching api::BluetoothLERawAdvertisementsResponse response_; + // Group 3: 4-byte types + uint32_t last_advertisement_flush_time_{0}; + // Pre-allocated response message - always ready to send api::BluetoothConnectionsFreeResponse connections_free_response_; From 502c0104650e2f2d165896528069a635d22f95df Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Apr 2026 07:23:24 -0500 Subject: [PATCH 35/68] [bh1750] Downgrade per-reading Illuminance log to verbose (#16005) --- esphome/components/bh1750/bh1750.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/bh1750/bh1750.cpp b/esphome/components/bh1750/bh1750.cpp index 045fb7cf454..ab952895a88 100644 --- a/esphome/components/bh1750/bh1750.cpp +++ b/esphome/components/bh1750/bh1750.cpp @@ -154,7 +154,7 @@ void BH1750Sensor::loop() { break; } - ESP_LOGD(TAG, "'%s': Illuminance=%.1flx", this->get_name().c_str(), lx); + ESP_LOGV(TAG, "'%s': Illuminance=%.1flx", this->get_name().c_str(), lx); this->status_clear_warning(); this->publish_state(lx); this->state_ = IDLE; From 04d067196d05737ea66bf4b121fbfc02f2c12a18 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Apr 2026 07:23:41 -0500 Subject: [PATCH 36/68] [rotary_encoder][at581x] Fix templatable int field types (#16015) --- esphome/components/at581x/__init__.py | 8 ++++---- esphome/components/rotary_encoder/sensor.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/esphome/components/at581x/__init__.py b/esphome/components/at581x/__init__.py index 94b68db4b33..5031b72cceb 100644 --- a/esphome/components/at581x/__init__.py +++ b/esphome/components/at581x/__init__.py @@ -183,19 +183,19 @@ async def at581x_settings_to_code(config, action_id, template_arg, args): cg.add(var.set_sensing_distance(template_)) if selfcheck := config.get(CONF_POWERON_SELFCHECK_TIME): - template_ = await cg.templatable(selfcheck, args, cg.int32) + template_ = await cg.templatable(selfcheck, args, cg.int_) cg.add(var.set_poweron_selfcheck_time(template_)) if protect := config.get(CONF_PROTECT_TIME): - template_ = await cg.templatable(protect, args, cg.int32) + template_ = await cg.templatable(protect, args, cg.int_) cg.add(var.set_protect_time(template_)) if trig_base := config.get(CONF_TRIGGER_BASE): - template_ = await cg.templatable(trig_base, args, cg.int32) + template_ = await cg.templatable(trig_base, args, cg.int_) cg.add(var.set_trigger_base(template_)) if trig_keep := config.get(CONF_TRIGGER_KEEP): - template_ = await cg.templatable(trig_keep, args, cg.int32) + template_ = await cg.templatable(trig_keep, args, cg.int_) cg.add(var.set_trigger_keep(template_)) if (stage_gain := config.get(CONF_STAGE_GAIN)) is not None: diff --git a/esphome/components/rotary_encoder/sensor.py b/esphome/components/rotary_encoder/sensor.py index 21239863e45..0e5a03523df 100644 --- a/esphome/components/rotary_encoder/sensor.py +++ b/esphome/components/rotary_encoder/sensor.py @@ -129,6 +129,6 @@ async def to_code(config): async def sensor_template_publish_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_VALUE], args, cg.int32) + template_ = await cg.templatable(config[CONF_VALUE], args, cg.int_) cg.add(var.set_value(template_)) return var From 8950afc3c4c654eb77e45015f46883def1b37178 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Apr 2026 07:23:53 -0500 Subject: [PATCH 37/68] [bluetooth_proxy] Drop redundant remote_bda_ write in connect handler (#16000) --- esphome/components/bluetooth_proxy/bluetooth_proxy.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index 45f848a2866..c3461f9c519 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -207,7 +207,6 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest connection->set_connection_type(espbt::ConnectionType::V3_WITHOUT_CACHE); this->log_connection_info_(connection, "v3 without cache"); } - uint64_to_bd_addr(msg.address, connection->remote_bda_); connection->set_remote_addr_type(static_cast(msg.address_type)); connection->set_state(espbt::ClientState::DISCOVERED); this->send_connections_free(); From 8dbdcfc1284864cbc2321530794938fda908a93a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Apr 2026 07:24:07 -0500 Subject: [PATCH 38/68] [bk72xx] Prepare for BK7238 support (#16018) --- esphome/components/bk72xx/boards.py | 500 +++++++++++++++++++++++++- esphome/components/libretiny/const.py | 4 + 2 files changed, 502 insertions(+), 2 deletions(-) diff --git a/esphome/components/bk72xx/boards.py b/esphome/components/bk72xx/boards.py index 4bee69fe6da..f8bedce329b 100644 --- a/esphome/components/bk72xx/boards.py +++ b/esphome/components/bk72xx/boards.py @@ -16,6 +16,7 @@ from esphome.components.libretiny.const import ( FAMILY_BK7231N, FAMILY_BK7231Q, FAMILY_BK7231T, + FAMILY_BK7238, FAMILY_BK7251, ) @@ -24,16 +25,32 @@ BK72XX_BOARDS = { "name": "WB2L_M1 Wi-Fi Module", "family": FAMILY_BK7231N, }, + "xh-wb3s": { + "name": "NiceMCU XH-WB3S", + "family": FAMILY_BK7238, + }, "cbu": { "name": "CBU Wi-Fi Module", "family": FAMILY_BK7231N, }, + "t1-u": { + "name": "T1-U Wi-Fi Module", + "family": FAMILY_BK7238, + }, + "generic-bk7238-tuya": { + "name": "Generic - BK7238 (Tuya T1)", + "family": FAMILY_BK7238, + }, + "t1-m": { + "name": "T1-M Wi-Fi Module", + "family": FAMILY_BK7238, + }, "generic-bk7231t-qfn32-tuya": { - "name": "Generic - BK7231T (Tuya QFN32)", + "name": "Generic - BK7231T (Tuya)", "family": FAMILY_BK7231T, }, "generic-bk7231n-qfn32-tuya": { - "name": "Generic - BK7231N (Tuya QFN32)", + "name": "Generic - BK7231N (Tuya)", "family": FAMILY_BK7231N, }, "cb1s": { @@ -64,6 +81,10 @@ BK72XX_BOARDS = { "name": "Generic - BK7252", "family": FAMILY_BK7251, }, + "t1-3s": { + "name": "T1-3S Wi-Fi Module", + "family": FAMILY_BK7238, + }, "wb2l": { "name": "WB2L Wi-Fi Module", "family": FAMILY_BK7231T, @@ -80,6 +101,10 @@ BK72XX_BOARDS = { "name": "CB2S Wi-Fi Module", "family": FAMILY_BK7231N, }, + "generic-bk7238": { + "name": "Generic - BK7238", + "family": FAMILY_BK7238, + }, "wa2": { "name": "WA2 Wi-Fi Module", "family": FAMILY_BK7231Q, @@ -100,6 +125,10 @@ BK72XX_BOARDS = { "name": "WB3L Wi-Fi Module", "family": FAMILY_BK7231T, }, + "t1-2s": { + "name": "T1-2S Wi-Fi Module", + "family": FAMILY_BK7238, + }, "wb2s": { "name": "WB2S Wi-Fi Module", "family": FAMILY_BK7231T, @@ -158,6 +187,83 @@ BK72XX_BOARD_PINS = { "D12": 22, "A0": 23, }, + "xh-wb3s": { + "SPI0_CS": 15, + "SPI0_MISO": 17, + "SPI0_MOSI": 16, + "SPI0_SCK": 14, + "WIRE2_SCL_0": 15, + "WIRE2_SCL_1": 24, + "WIRE2_SDA_0": 17, + "WIRE2_SDA_1": 26, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC1": 26, + "ADC2": 24, + "ADC3": 20, + "ADC4": 28, + "ADC5": 1, + "ADC6": 10, + "CS": 15, + "MISO": 17, + "MOSI": 16, + "P0": 0, + "P1": 1, + "P6": 6, + "P7": 7, + "P8": 8, + "P9": 9, + "P10": 10, + "P11": 11, + "P14": 14, + "P15": 15, + "P16": 16, + "P17": 17, + "P20": 20, + "P21": 21, + "P22": 22, + "P23": 23, + "P24": 24, + "P26": 26, + "P28": 28, + "PWM0": 6, + "PWM1": 7, + "PWM2": 8, + "PWM3": 9, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCK": 14, + "TX1": 11, + "TX2": 0, + "D0": 7, + "D1": 23, + "D2": 14, + "D3": 26, + "D4": 24, + "D5": 6, + "D6": 9, + "D7": 0, + "D8": 1, + "D9": 8, + "D10": 10, + "D11": 11, + "D12": 16, + "D13": 20, + "D14": 21, + "D15": 22, + "D16": 15, + "D17": 17, + "A0": 28, + "A1": 26, + "A2": 24, + "A3": 1, + "A4": 10, + "A5": 20, + }, "cbu": { "SPI0_CS": 15, "SPI0_MISO": 17, @@ -230,6 +336,204 @@ BK72XX_BOARD_PINS = { "D18": 21, "A0": 23, }, + "t1-u": { + "SPI0_CS": 15, + "SPI0_MISO": 17, + "SPI0_MOSI": 16, + "SPI0_SCK": 14, + "WIRE2_SCL_0": 15, + "WIRE2_SCL_1": 24, + "WIRE2_SDA_0": 17, + "WIRE2_SDA_1": 26, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC1": 26, + "ADC2": 24, + "ADC3": 20, + "ADC4": 28, + "ADC5": 1, + "ADC6": 10, + "CS": 15, + "MISO": 17, + "MOSI": 16, + "P0": 0, + "P1": 1, + "P6": 6, + "P8": 8, + "P9": 9, + "P10": 10, + "P11": 11, + "P14": 14, + "P15": 15, + "P16": 16, + "P17": 17, + "P20": 20, + "P21": 21, + "P22": 22, + "P23": 23, + "P24": 24, + "P26": 26, + "P28": 28, + "PWM0": 6, + "PWM2": 8, + "PWM3": 9, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCK": 14, + "TX1": 11, + "TX2": 0, + "D0": 14, + "D1": 16, + "D2": 23, + "D3": 22, + "D4": 20, + "D5": 1, + "D6": 0, + "D7": 24, + "D8": 9, + "D9": 26, + "D10": 6, + "D11": 8, + "D12": 11, + "D13": 10, + "D14": 28, + "D15": 21, + "D16": 17, + "D17": 15, + "A0": 20, + "A1": 1, + "A2": 24, + "A3": 26, + "A4": 10, + "A5": 28, + }, + "generic-bk7238-tuya": { + "SPI0_CS": 15, + "SPI0_MISO": 17, + "SPI0_MOSI": 16, + "SPI0_SCK": 14, + "WIRE2_SCL_0": 15, + "WIRE2_SCL_1": 24, + "WIRE2_SDA_0": 17, + "WIRE2_SDA_1": 26, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC1": 26, + "ADC2": 24, + "ADC3": 20, + "ADC4": 28, + "ADC5": 1, + "ADC6": 10, + "CS": 15, + "MISO": 17, + "MOSI": 16, + "P0": 0, + "P1": 1, + "P6": 6, + "P7": 7, + "P8": 8, + "P9": 9, + "P10": 10, + "P11": 11, + "P14": 14, + "P15": 15, + "P16": 16, + "P17": 17, + "P20": 20, + "P21": 21, + "P22": 22, + "P23": 23, + "P24": 24, + "P26": 26, + "P28": 28, + "PWM0": 6, + "PWM1": 7, + "PWM2": 8, + "PWM3": 9, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCK": 14, + "TX1": 11, + "TX2": 0, + "D0": 0, + "D1": 1, + "D2": 6, + "D3": 7, + "D4": 8, + "D5": 9, + "D6": 10, + "D7": 11, + "D8": 14, + "D9": 15, + "D10": 16, + "D11": 17, + "D12": 20, + "D13": 21, + "D14": 22, + "D15": 23, + "D16": 24, + "D17": 26, + "D18": 28, + "A0": 1, + "A1": 10, + "A2": 20, + "A3": 24, + "A4": 26, + "A5": 28, + }, + "t1-m": { + "WIRE2_SCL": 24, + "WIRE2_SDA": 26, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC1": 26, + "ADC2": 24, + "ADC5": 1, + "ADC6": 10, + "P0": 0, + "P1": 1, + "P6": 6, + "P8": 8, + "P9": 9, + "P10": 10, + "P11": 11, + "P24": 24, + "P26": 26, + "PWM0": 6, + "PWM2": 8, + "PWM3": 9, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCL2": 24, + "SDA2": 26, + "TX1": 11, + "TX2": 0, + "D0": 26, + "D1": 6, + "D2": 8, + "D3": 1, + "D4": 10, + "D5": 11, + "D6": 9, + "D7": 24, + "D11": 0, + "A0": 26, + "A1": 10, + "A2": 1, + "A3": 24, + }, "generic-bk7231t-qfn32-tuya": { "SPI0_CS": 15, "SPI0_MISO": 17, @@ -781,6 +1085,75 @@ BK72XX_BOARD_PINS = { "A6": 12, "A7": 13, }, + "t1-3s": { + "SPI0_CS": 15, + "SPI0_MISO": 17, + "SPI0_MOSI": 16, + "SPI0_SCK": 14, + "WIRE2_SCL_0": 15, + "WIRE2_SCL_1": 24, + "WIRE2_SDA_0": 17, + "WIRE2_SDA_1": 26, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC1": 26, + "ADC2": 24, + "ADC3": 20, + "ADC5": 1, + "ADC6": 10, + "CS": 15, + "MISO": 17, + "MOSI": 16, + "P0": 0, + "P1": 1, + "P6": 6, + "P8": 8, + "P9": 9, + "P10": 10, + "P11": 11, + "P14": 14, + "P15": 15, + "P16": 16, + "P17": 17, + "P20": 20, + "P22": 22, + "P23": 23, + "P24": 24, + "P26": 26, + "PWM0": 6, + "PWM2": 8, + "PWM3": 9, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCK": 14, + "TX1": 11, + "TX2": 0, + "D0": 20, + "D1": 22, + "D2": 6, + "D3": 8, + "D4": 9, + "D5": 23, + "D6": 0, + "D7": 1, + "D8": 24, + "D9": 26, + "D10": 10, + "D11": 11, + "D12": 17, + "D13": 16, + "D14": 15, + "D15": 14, + "A0": 20, + "A1": 1, + "A2": 24, + "A3": 26, + "A4": 10, + }, "wb2l": { "WIRE1_SCL": 20, "WIRE1_SDA": 21, @@ -965,6 +1338,84 @@ BK72XX_BOARD_PINS = { "D10": 21, "A0": 23, }, + "generic-bk7238": { + "SPI0_CS": 15, + "SPI0_MISO": 17, + "SPI0_MOSI": 16, + "SPI0_SCK": 14, + "WIRE2_SCL_0": 15, + "WIRE2_SCL_1": 24, + "WIRE2_SDA_0": 17, + "WIRE2_SDA_1": 26, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC1": 26, + "ADC2": 24, + "ADC3": 20, + "ADC4": 28, + "ADC5": 1, + "ADC6": 10, + "CS": 15, + "MISO": 17, + "MOSI": 16, + "P0": 0, + "P1": 1, + "P6": 6, + "P7": 7, + "P8": 8, + "P9": 9, + "P10": 10, + "P11": 11, + "P14": 14, + "P15": 15, + "P16": 16, + "P17": 17, + "P20": 20, + "P21": 21, + "P22": 22, + "P23": 23, + "P24": 24, + "P26": 26, + "P28": 28, + "PWM0": 6, + "PWM1": 7, + "PWM2": 8, + "PWM3": 9, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCK": 14, + "TX1": 11, + "TX2": 0, + "D0": 0, + "D1": 1, + "D2": 6, + "D3": 7, + "D4": 8, + "D5": 9, + "D6": 10, + "D7": 11, + "D8": 14, + "D9": 15, + "D10": 16, + "D11": 17, + "D12": 20, + "D13": 21, + "D14": 22, + "D15": 23, + "D16": 24, + "D17": 26, + "D18": 28, + "A0": 1, + "A1": 10, + "A2": 20, + "A3": 24, + "A4": 26, + "A5": 28, + }, "wa2": { "WIRE1_SCL": 20, "WIRE1_SDA": 21, @@ -1235,6 +1686,51 @@ BK72XX_BOARD_PINS = { "D15": 1, "A0": 23, }, + "t1-2s": { + "WIRE2_SCL": 24, + "WIRE2_SDA": 26, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC1": 26, + "ADC2": 24, + "ADC5": 1, + "ADC6": 10, + "P0": 0, + "P1": 1, + "P6": 6, + "P8": 8, + "P9": 9, + "P10": 10, + "P11": 11, + "P24": 24, + "P26": 26, + "PWM0": 6, + "PWM2": 8, + "PWM3": 9, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCL2": 24, + "SDA2": 26, + "TX1": 11, + "TX2": 0, + "D0": 26, + "D1": 6, + "D2": 8, + "D3": 1, + "D4": 10, + "D5": 11, + "D6": 9, + "D7": 24, + "D11": 0, + "A0": 26, + "A1": 10, + "A2": 1, + "A3": 24, + }, "wb2s": { "WIRE1_SCL": 20, "WIRE1_SDA": 21, diff --git a/esphome/components/libretiny/const.py b/esphome/components/libretiny/const.py index 332be0de1dd..5de4a164b57 100644 --- a/esphome/components/libretiny/const.py +++ b/esphome/components/libretiny/const.py @@ -58,6 +58,7 @@ COMPONENT_RTL87XX = "rtl87xx" FAMILY_BK7231N = "BK7231N" FAMILY_BK7231Q = "BK7231Q" FAMILY_BK7231T = "BK7231T" +FAMILY_BK7238 = "BK7238" FAMILY_BK7251 = "BK7251" FAMILY_LN882H = "LN882H" FAMILY_RTL8710B = "RTL8710B" @@ -66,6 +67,7 @@ FAMILIES = [ FAMILY_BK7231N, FAMILY_BK7231Q, FAMILY_BK7231T, + FAMILY_BK7238, FAMILY_BK7251, FAMILY_LN882H, FAMILY_RTL8710B, @@ -75,6 +77,7 @@ FAMILY_FRIENDLY = { FAMILY_BK7231N: "BK7231N", FAMILY_BK7231Q: "BK7231Q", FAMILY_BK7231T: "BK7231T", + FAMILY_BK7238: "BK7238", FAMILY_BK7251: "BK7251", FAMILY_LN882H: "LN882H", FAMILY_RTL8710B: "RTL8710B", @@ -84,6 +87,7 @@ FAMILY_COMPONENT = { FAMILY_BK7231N: COMPONENT_BK72XX, FAMILY_BK7231Q: COMPONENT_BK72XX, FAMILY_BK7231T: COMPONENT_BK72XX, + FAMILY_BK7238: COMPONENT_BK72XX, FAMILY_BK7251: COMPONENT_BK72XX, FAMILY_LN882H: COMPONENT_LN882X, FAMILY_RTL8710B: COMPONENT_RTL87XX, From 0f25d91e68578878283c914c8cd9b6b9aa2b9978 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Apr 2026 07:24:33 -0500 Subject: [PATCH 39/68] [core] Unify `skip_external_update` and honor it in external_files for faster `esphome logs` (#16016) --- .../external_components/__init__.py | 19 ++- esphome/components/packages/__init__.py | 18 +-- esphome/config.py | 9 +- esphome/core/__init__.py | 4 + esphome/external_files.py | 5 +- esphome/git.py | 4 +- .../external_components/test_init.py | 121 +++++------------- tests/component_tests/packages/test_init.py | 115 +++++------------ tests/unit_tests/test_external_files.py | 46 +++++++ tests/unit_tests/test_git.py | 29 +++++ tests/unit_tests/test_substitutions.py | 4 +- 11 files changed, 170 insertions(+), 204 deletions(-) diff --git a/esphome/components/external_components/__init__.py b/esphome/components/external_components/__init__.py index ceb402c5b73..6eb577e5ade 100644 --- a/esphome/components/external_components/__init__.py +++ b/esphome/components/external_components/__init__.py @@ -1,5 +1,6 @@ import logging from pathlib import Path +from typing import Any from esphome import git, loader import esphome.config_validation as cv @@ -17,7 +18,7 @@ from esphome.const import ( TYPE_GIT, TYPE_LOCAL, ) -from esphome.core import CORE +from esphome.core import CORE, TimePeriodSeconds _LOGGER = logging.getLogger(__name__) @@ -35,17 +36,15 @@ CONFIG_SCHEMA = cv.ensure_list( ) -async def to_code(config): +async def to_code(config: dict[str, Any]) -> None: pass -def _process_git_config(config: dict, refresh, skip_update: bool = False) -> str: - # When skip_update is True, use NEVER_REFRESH to prevent updates - actual_refresh = git.NEVER_REFRESH if skip_update else refresh +def _process_git_config(config: dict[str, Any], refresh: TimePeriodSeconds) -> Path: repo_dir, _ = git.clone_or_update( url=config[CONF_URL], ref=config.get(CONF_REF), - refresh=actual_refresh, + refresh=refresh, domain=DOMAIN, username=config.get(CONF_USERNAME), password=config.get(CONF_PASSWORD), @@ -72,12 +71,12 @@ def _process_git_config(config: dict, refresh, skip_update: bool = False) -> str return components_dir -def _process_single_config(config: dict, skip_update: bool = False): +def _process_single_config(config: dict[str, Any]) -> None: conf = config[CONF_SOURCE] if conf[CONF_TYPE] == TYPE_GIT: with cv.prepend_path([CONF_SOURCE]): components_dir = _process_git_config( - config[CONF_SOURCE], config[CONF_REFRESH], skip_update + config[CONF_SOURCE], config[CONF_REFRESH] ) elif conf[CONF_TYPE] == TYPE_LOCAL: components_dir = Path(CORE.relative_config_path(conf[CONF_PATH])) @@ -107,7 +106,7 @@ def _process_single_config(config: dict, skip_update: bool = False): loader.install_meta_finder(components_dir, allowed_components=allowed_components) -def do_external_components_pass(config: dict, skip_update: bool = False) -> None: +def do_external_components_pass(config: dict[str, Any]) -> None: conf = config.get(DOMAIN) if conf is None: return @@ -115,4 +114,4 @@ def do_external_components_pass(config: dict, skip_update: bool = False) -> None conf = CONFIG_SCHEMA(conf) for i, c in enumerate(conf): with cv.prepend_path(i): - _process_single_config(c, skip_update) + _process_single_config(c) diff --git a/esphome/components/packages/__init__.py b/esphome/components/packages/__init__.py index 1b9e03d88fc..47a1fd20a75 100644 --- a/esphome/components/packages/__init__.py +++ b/esphome/components/packages/__init__.py @@ -205,7 +205,7 @@ CONFIG_SCHEMA = cv.Any( # under `packages:` we can have either: ) -def _process_remote_package(config: dict, skip_update: bool = False) -> dict: +def _process_remote_package(config: dict[str, Any]) -> dict[str, Any]: """Clone/update a git repo and load the YAML files listed in the package definition. Returns ``{"packages": {: , ...}}`` so the caller @@ -215,11 +215,10 @@ def _process_remote_package(config: dict, skip_update: bool = False) -> dict: If loading fails after cloning, attempts a revert and retry in case a prior cached checkout is stale. """ - actual_refresh = git.NEVER_REFRESH if skip_update else config[CONF_REFRESH] repo_dir, revert = git.clone_or_update( url=config[CONF_URL], ref=config.get(CONF_REF), - refresh=actual_refresh, + refresh=config[CONF_REFRESH], domain=DOMAIN, username=config.get(CONF_USERNAME), password=config.get(CONF_PASSWORD), @@ -456,11 +455,9 @@ class _PackageProcessor: self, substitutions: UserDict, command_line_substitutions: dict[str, Any] | None, - skip_update: bool, ) -> None: self.substitutions = substitutions self.parent_context = UserDict(command_line_substitutions or {}) - self.skip_update = skip_update def resolve_package( self, @@ -508,7 +505,7 @@ class _PackageProcessor: ) if is_remote_package(package_config): - package_config = _process_remote_package(package_config, self.skip_update) + package_config = _process_remote_package(package_config) return package_config def collect_substitutions(self, package_config: dict) -> None: @@ -552,11 +549,10 @@ class _PackageProcessor: def do_packages_pass( - config: dict, + config: dict[str, Any], *, command_line_substitutions: dict[str, Any] | None = None, - skip_update: bool = False, -) -> dict: +) -> dict[str, Any]: """Load, validate, and flatten all packages in the config. Returns the config with all packages loaded in-place (but not yet merged) @@ -571,9 +567,7 @@ def do_packages_pass( config.pop(CONF_SUBSTITUTIONS, {}), command_line_substitutions ) ) - processor = _PackageProcessor( - substitutions, command_line_substitutions, skip_update - ) + processor = _PackageProcessor(substitutions, command_line_substitutions) _update_substitutions_context(processor.parent_context, substitutions) context_vars = push_context( diff --git a/esphome/config.py b/esphome/config.py index 641b6ec1b48..6eb67af58b3 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -997,6 +997,8 @@ def validate_config( ) -> Config: result = Config() + CORE.skip_external_update = skip_external_update + loader.clear_component_meta_finders() loader.install_custom_components_meta_finder() @@ -1009,7 +1011,6 @@ def validate_config( config = do_packages_pass( config, command_line_substitutions=command_line_substitutions, - skip_update=skip_external_update, ) except vol.Invalid as err: result.update(config) @@ -1050,7 +1051,7 @@ def validate_config( result.add_output_path([CONF_EXTERNAL_COMPONENTS], CONF_EXTERNAL_COMPONENTS) try: - do_external_components_pass(config, skip_update=skip_external_update) + do_external_components_pass(config) except vol.Invalid as err: result.update(config) result.add_error(err) @@ -1341,7 +1342,9 @@ def strip_default_ids(config): return config -def read_config(command_line_substitutions, skip_external_update=False): +def read_config( + command_line_substitutions: dict[str, Any], skip_external_update: bool = False +) -> Config | None: _LOGGER.info("Reading configuration %s...", CORE.config_path) try: res = load_config(command_line_substitutions, skip_external_update) diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 009fef2f863..4fecebcd8d2 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -615,6 +615,9 @@ class EsphomeCore: self.address_cache: AddressCache | None = None # Cached config hash (computed lazily) self._config_hash: int | None = None + # When True, skip network freshness checks for cached external files + # (e.g. for `esphome logs`, where remote downloads aren't needed) + self.skip_external_update: bool = False def reset(self): from esphome.pins import PIN_SCHEMA_REGISTRY @@ -644,6 +647,7 @@ class EsphomeCore: self.current_component = None self.address_cache = None self._config_hash = None + self.skip_external_update = False PIN_SCHEMA_REGISTRY.reset() @contextmanager diff --git a/esphome/external_files.py b/esphome/external_files.py index 55711e1b790..b6f6149ebbb 100644 --- a/esphome/external_files.py +++ b/esphome/external_files.py @@ -81,7 +81,10 @@ def compute_local_file_dir(domain: str) -> Path: return base_directory -def download_content(url: str, path: Path, timeout=NETWORK_TIMEOUT) -> bytes: +def download_content(url: str, path: Path, timeout: int = NETWORK_TIMEOUT) -> bytes: + if CORE.skip_external_update and path.exists(): + _LOGGER.debug("Skipping update for %s (refresh disabled)", url) + return path.read_bytes() if not has_remote_file_changed(url, path): _LOGGER.debug("Remote file has not changed %s", url) return path.read_bytes() diff --git a/esphome/git.py b/esphome/git.py index 096ff483a71..4d6e14001a7 100644 --- a/esphome/git.py +++ b/esphome/git.py @@ -150,9 +150,7 @@ def clone_or_update( raise else: - # Check refresh needed - # Skip refresh if NEVER_REFRESH is specified - if refresh == NEVER_REFRESH: + if refresh == NEVER_REFRESH or CORE.skip_external_update: _LOGGER.debug("Skipping update for %s (refresh disabled)", key) return repo_dir, None diff --git a/tests/component_tests/external_components/test_init.py b/tests/component_tests/external_components/test_init.py index 905c0afa8b3..d3813ecc759 100644 --- a/tests/component_tests/external_components/test_init.py +++ b/tests/component_tests/external_components/test_init.py @@ -1,4 +1,4 @@ -"""Tests for the external_components skip_update functionality.""" +"""Tests for the external_components skip-update behavior driven by CORE.skip_external_update.""" from pathlib import Path from typing import Any @@ -12,25 +12,17 @@ from esphome.const import ( CONF_URL, TYPE_GIT, ) +from esphome.core import CORE, TimePeriodSeconds -def test_external_components_skip_update_true( - tmp_path: Path, mock_clone_or_update: MagicMock, mock_install_meta_finder: MagicMock -) -> None: - """Test that external components don't update when skip_update=True.""" - # Create a components directory structure +def _make_config(tmp_path: Path) -> dict[str, Any]: components_dir = tmp_path / "components" components_dir.mkdir() - - # Create a test component test_component_dir = components_dir / "test_component" test_component_dir.mkdir() (test_component_dir / "__init__.py").write_text("# Test component") - # Set up mock to return our tmp_path - mock_clone_or_update.return_value = (tmp_path, None) - - config: dict[str, Any] = { + return { CONF_EXTERNAL_COMPONENTS: [ { CONF_SOURCE: { @@ -43,92 +35,37 @@ def test_external_components_skip_update_true( ] } - # Call with skip_update=True - do_external_components_pass(config, skip_update=True) - # Verify clone_or_update was called with NEVER_REFRESH - mock_clone_or_update.assert_called_once() - call_args = mock_clone_or_update.call_args - from esphome import git - - assert call_args.kwargs["refresh"] == git.NEVER_REFRESH - - -def test_external_components_skip_update_false( - tmp_path: Path, mock_clone_or_update: MagicMock, mock_install_meta_finder: MagicMock +def test_external_components_skip_update_via_core_flag( + tmp_path: Path, + mock_clone_or_update: MagicMock, + mock_install_meta_finder: MagicMock, ) -> None: - """Test that external components update when skip_update=False.""" - # Create a components directory structure - components_dir = tmp_path / "components" - components_dir.mkdir() - - # Create a test component - test_component_dir = components_dir / "test_component" - test_component_dir.mkdir() - (test_component_dir / "__init__.py").write_text("# Test component") - - # Set up mock to return our tmp_path + """When CORE.skip_external_update is True, refresh is still passed through; + git.clone_or_update itself short-circuits the actual fetch.""" mock_clone_or_update.return_value = (tmp_path, None) + config = _make_config(tmp_path) + + CORE.skip_external_update = True + do_external_components_pass(config) + + mock_clone_or_update.assert_called_once() + call_args = mock_clone_or_update.call_args + # Refresh is passed through verbatim — the global flag is enforced inside git.clone_or_update. + assert call_args.kwargs["refresh"] == TimePeriodSeconds(days=1) + + +def test_external_components_normal_refresh( + tmp_path: Path, + mock_clone_or_update: MagicMock, + mock_install_meta_finder: MagicMock, +) -> None: + """When CORE.skip_external_update is False, the configured refresh value is used.""" + mock_clone_or_update.return_value = (tmp_path, None) + config = _make_config(tmp_path) - config: dict[str, Any] = { - CONF_EXTERNAL_COMPONENTS: [ - { - CONF_SOURCE: { - "type": TYPE_GIT, - CONF_URL: "https://github.com/test/components", - }, - CONF_REFRESH: "1d", - "components": "all", - } - ] - } - - # Call with skip_update=False - do_external_components_pass(config, skip_update=False) - - # Verify clone_or_update was called with actual refresh value - mock_clone_or_update.assert_called_once() - call_args = mock_clone_or_update.call_args - from esphome.core import TimePeriodSeconds - - assert call_args.kwargs["refresh"] == TimePeriodSeconds(days=1) - - -def test_external_components_default_no_skip( - tmp_path: Path, mock_clone_or_update: MagicMock, mock_install_meta_finder: MagicMock -) -> None: - """Test that external components update by default when skip_update not specified.""" - # Create a components directory structure - components_dir = tmp_path / "components" - components_dir.mkdir() - - # Create a test component - test_component_dir = components_dir / "test_component" - test_component_dir.mkdir() - (test_component_dir / "__init__.py").write_text("# Test component") - - # Set up mock to return our tmp_path - mock_clone_or_update.return_value = (tmp_path, None) - - config: dict[str, Any] = { - CONF_EXTERNAL_COMPONENTS: [ - { - CONF_SOURCE: { - "type": TYPE_GIT, - CONF_URL: "https://github.com/test/components", - }, - CONF_REFRESH: "1d", - "components": "all", - } - ] - } - - # Call without skip_update parameter do_external_components_pass(config) - # Verify clone_or_update was called with actual refresh value mock_clone_or_update.assert_called_once() call_args = mock_clone_or_update.call_args - from esphome.core import TimePeriodSeconds - assert call_args.kwargs["refresh"] == TimePeriodSeconds(days=1) diff --git a/tests/component_tests/packages/test_init.py b/tests/component_tests/packages/test_init.py index fd30c2433f2..19c7bd36692 100644 --- a/tests/component_tests/packages/test_init.py +++ b/tests/component_tests/packages/test_init.py @@ -1,4 +1,4 @@ -"""Tests for the packages component skip_update functionality.""" +"""Tests for the packages skip-update behavior driven by CORE.skip_external_update.""" from pathlib import Path from typing import Any @@ -6,24 +6,12 @@ from unittest.mock import MagicMock from esphome.components.packages import do_packages_pass from esphome.const import CONF_FILES, CONF_PACKAGES, CONF_REFRESH, CONF_URL +from esphome.core import CORE, TimePeriodSeconds from esphome.util import OrderedDict -def test_packages_skip_update_true( - tmp_path: Path, mock_clone_or_update: MagicMock, mock_load_yaml: MagicMock -) -> None: - """Test that packages don't update when skip_update=True.""" - # Set up mock to return our tmp_path - mock_clone_or_update.return_value = (tmp_path, None) - - # Create the test yaml file - test_file = tmp_path / "test.yaml" - test_file.write_text("sensor: []") - - # Set mock_load_yaml to return some valid config - mock_load_yaml.return_value = OrderedDict({"sensor": []}) - - config: dict[str, Any] = { +def _make_config() -> dict[str, Any]: + return { CONF_PACKAGES: { "test_package": { CONF_URL: "https://github.com/test/repo", @@ -33,82 +21,47 @@ def test_packages_skip_update_true( } } - # Call with skip_update=True - do_packages_pass(config, skip_update=True) - # Verify clone_or_update was called with NEVER_REFRESH - mock_clone_or_update.assert_called_once() - call_args = mock_clone_or_update.call_args - from esphome import git - - assert call_args.kwargs["refresh"] == git.NEVER_REFRESH - - -def test_packages_skip_update_false( - tmp_path: Path, mock_clone_or_update: MagicMock, mock_load_yaml: MagicMock +def test_packages_skip_update_via_core_flag( + tmp_path: Path, + mock_clone_or_update: MagicMock, + mock_load_yaml: MagicMock, ) -> None: - """Test that packages update when skip_update=False.""" - # Set up mock to return our tmp_path + """When CORE.skip_external_update is True, refresh is still passed through; + git.clone_or_update itself short-circuits the actual fetch.""" mock_clone_or_update.return_value = (tmp_path, None) - # Create the test yaml file test_file = tmp_path / "test.yaml" test_file.write_text("sensor: []") - - # Set mock_load_yaml to return some valid config mock_load_yaml.return_value = OrderedDict({"sensor": []}) - config: dict[str, Any] = { - CONF_PACKAGES: { - "test_package": { - CONF_URL: "https://github.com/test/repo", - CONF_FILES: ["test.yaml"], - CONF_REFRESH: "1d", - } - } - } + config = _make_config() + + CORE.skip_external_update = True + do_packages_pass(config, command_line_substitutions={}) + + mock_clone_or_update.assert_called_once() + call_args = mock_clone_or_update.call_args + # Refresh is passed through verbatim — the global flag is enforced inside git.clone_or_update. + assert call_args.kwargs["refresh"] == TimePeriodSeconds(days=1) + + +def test_packages_normal_refresh( + tmp_path: Path, + mock_clone_or_update: MagicMock, + mock_load_yaml: MagicMock, +) -> None: + """When CORE.skip_external_update is False, the configured refresh value is used.""" + mock_clone_or_update.return_value = (tmp_path, None) + + test_file = tmp_path / "test.yaml" + test_file.write_text("sensor: []") + mock_load_yaml.return_value = OrderedDict({"sensor": []}) + + config = _make_config() - # Call with skip_update=False (default) - do_packages_pass(config, command_line_substitutions={}, skip_update=False) - - # Verify clone_or_update was called with actual refresh value - mock_clone_or_update.assert_called_once() - call_args = mock_clone_or_update.call_args - from esphome.core import TimePeriodSeconds - - assert call_args.kwargs["refresh"] == TimePeriodSeconds(days=1) - - -def test_packages_default_no_skip( - tmp_path: Path, mock_clone_or_update: MagicMock, mock_load_yaml: MagicMock -) -> None: - """Test that packages update by default when skip_update not specified.""" - # Set up mock to return our tmp_path - mock_clone_or_update.return_value = (tmp_path, None) - - # Create the test yaml file - test_file = tmp_path / "test.yaml" - test_file.write_text("sensor: []") - - # Set mock_load_yaml to return some valid config - mock_load_yaml.return_value = OrderedDict({"sensor": []}) - - config: dict[str, Any] = { - CONF_PACKAGES: { - "test_package": { - CONF_URL: "https://github.com/test/repo", - CONF_FILES: ["test.yaml"], - CONF_REFRESH: "1d", - } - } - } - - # Call without skip_update parameter do_packages_pass(config, command_line_substitutions={}) - # Verify clone_or_update was called with actual refresh value mock_clone_or_update.assert_called_once() call_args = mock_clone_or_update.call_args - from esphome.core import TimePeriodSeconds - assert call_args.kwargs["refresh"] == TimePeriodSeconds(days=1) diff --git a/tests/unit_tests/test_external_files.py b/tests/unit_tests/test_external_files.py index a319fae83d4..4b0826db044 100644 --- a/tests/unit_tests/test_external_files.py +++ b/tests/unit_tests/test_external_files.py @@ -236,3 +236,49 @@ def test_download_content_with_network_error_no_cache_fails( with pytest.raises(Invalid, match="Could not download from.*Network error"): external_files.download_content(url, test_file) + + +@patch("esphome.external_files.requests.get") +@patch("esphome.external_files.has_remote_file_changed") +def test_download_content_skip_external_update_uses_cache( + mock_has_changed: MagicMock, + mock_get: MagicMock, + setup_core: Path, +) -> None: + """Test download_content skips network checks when CORE.skip_external_update is set.""" + test_file = setup_core / "cached.txt" + cached_content = b"cached content" + test_file.write_bytes(cached_content) + + CORE.skip_external_update = True + url = "https://example.com/file.txt" + result = external_files.download_content(url, test_file) + + assert result == cached_content + mock_has_changed.assert_not_called() + mock_get.assert_not_called() + + +@patch("esphome.external_files.requests.get") +@patch("esphome.external_files.has_remote_file_changed") +def test_download_content_skip_external_update_downloads_when_missing( + mock_has_changed: MagicMock, + mock_get: MagicMock, + setup_core: Path, +) -> None: + """Test download_content still downloads when file is missing, even with skip_external_update.""" + test_file = setup_core / "missing.txt" + new_content = b"fresh content" + + mock_has_changed.return_value = True + mock_response = MagicMock() + mock_response.content = new_content + mock_response.raise_for_status = MagicMock() + mock_get.return_value = mock_response + + CORE.skip_external_update = True + url = "https://example.com/file.txt" + result = external_files.download_content(url, test_file) + + assert result == new_content + assert test_file.read_bytes() == new_content diff --git a/tests/unit_tests/test_git.py b/tests/unit_tests/test_git.py index 745dfad487e..dd7d26cb714 100644 --- a/tests/unit_tests/test_git.py +++ b/tests/unit_tests/test_git.py @@ -236,6 +236,35 @@ def test_clone_or_update_with_never_refresh( assert revert is None +def test_clone_or_update_skips_when_core_skip_external_update( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """CORE.skip_external_update short-circuits the refresh for existing repos.""" + CORE.config_path = tmp_path / "test.yaml" + + url = "https://github.com/test/repo" + ref = None + domain = "test" + repo_dir = _compute_repo_dir(url, ref, domain) + + repo_dir.mkdir(parents=True) + git_dir = repo_dir / ".git" + git_dir.mkdir() + (git_dir / "FETCH_HEAD").write_text("test") + + CORE.skip_external_update = True + result_dir, revert = git.clone_or_update( + url=url, + ref=ref, + refresh=TimePeriodSeconds(days=1), + domain=domain, + ) + + mock_run_git_command.assert_not_called() + assert result_dir == repo_dir + assert revert is None + + def test_clone_or_update_with_refresh_updates_old_repo( tmp_path: Path, mock_run_git_command: Mock ) -> None: diff --git a/tests/unit_tests/test_substitutions.py b/tests/unit_tests/test_substitutions.py index 215ec291f9e..cf6d4adbf51 100644 --- a/tests/unit_tests/test_substitutions.py +++ b/tests/unit_tests/test_substitutions.py @@ -654,7 +654,7 @@ def test_resolve_package_max_depth_exceeded(tmp_path: Path) -> None: package_config = yaml_util.IncludeFile( parent, "test.yaml", None, always_returns_include ) - processor = _PackageProcessor({}, None, False) + processor = _PackageProcessor({}, None) with pytest.raises( cv.Invalid, match=f"Maximum include nesting depth \\({MAX_INCLUDE_DEPTH}\\) exceeded", @@ -776,7 +776,7 @@ def test_resolve_package_undefined_var_in_include_filename(tmp_path: Path) -> No package_config = yaml_util.IncludeFile( parent, "${undefined_var}.yaml", None, loader ) - processor = _PackageProcessor({}, None, False) + processor = _PackageProcessor({}, None) with pytest.raises(cv.Invalid, match="unresolved substitutions"): processor.resolve_package(package_config, substitutions.ContextVars(), []) From e87e78c5446f3bf256e2d9b5b53c0b3d31d6eebf Mon Sep 17 00:00:00 2001 From: Johan Henkens Date: Sun, 26 Apr 2026 05:58:14 -0700 Subject: [PATCH 40/68] [api] Expose TemperatureUnit in water heater and climate api (#15815) Co-authored-by: Claude Sonnet 4.6 Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/api/api.proto | 9 +++++++++ esphome/components/api/api_pb2.cpp | 4 ++++ esphome/components/api/api_pb2.h | 11 +++++++++-- esphome/components/api/api_pb2_dump.cpp | 14 ++++++++++++++ 4 files changed, 36 insertions(+), 2 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index c3e4c386334..1c33d92bea4 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -1025,6 +1025,13 @@ message CameraImageRequest { bool stream = 2; } +// ==================== TEMPERATURE UNIT ==================== +enum TemperatureUnit { + TEMPERATURE_UNIT_CELSIUS = 0; + TEMPERATURE_UNIT_FAHRENHEIT = 1; + TEMPERATURE_UNIT_KELVIN = 2; +} + // ==================== CLIMATE ==================== enum ClimateMode { CLIMATE_MODE_OFF = 0; @@ -1110,6 +1117,7 @@ message ListEntitiesClimateResponse { float visual_max_humidity = 25; uint32 device_id = 26 [(field_ifdef) = "USE_DEVICES"]; uint32 feature_flags = 27; + TemperatureUnit temperature_unit = 28; } message ClimateStateResponse { option (id) = 47; @@ -1203,6 +1211,7 @@ message ListEntitiesWaterHeaterResponse { repeated WaterHeaterMode supported_modes = 11 [(container_pointer_no_template) = "water_heater::WaterHeaterModeMask"]; // Bitmask of WaterHeaterFeature flags uint32 supported_features = 12; + TemperatureUnit temperature_unit = 13; } message WaterHeaterStateResponse { diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 3d124539395..f6ceee2296e 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -1439,6 +1439,7 @@ uint8_t *ListEntitiesClimateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCO ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 26, this->device_id); #endif ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 27, this->feature_flags); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 28, static_cast(this->temperature_unit)); return pos; } uint32_t ListEntitiesClimateResponse::calculate_size() const { @@ -1488,6 +1489,7 @@ uint32_t ListEntitiesClimateResponse::calculate_size() const { size += ProtoSize::calc_uint32(2, this->device_id); #endif size += ProtoSize::calc_uint32(2, this->feature_flags); + size += this->temperature_unit ? 3 : 0; return size; } uint8_t *ClimateStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { @@ -1645,6 +1647,7 @@ uint8_t *ListEntitiesWaterHeaterResponse::encode(ProtoWriteBuffer &buffer PROTO_ ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, static_cast(it), true); } ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 12, this->supported_features); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 13, static_cast(this->temperature_unit)); return pos; } uint32_t ListEntitiesWaterHeaterResponse::calculate_size() const { @@ -1667,6 +1670,7 @@ uint32_t ListEntitiesWaterHeaterResponse::calculate_size() const { size += this->supported_modes->size() * 2; } size += ProtoSize::calc_uint32(1, this->supported_features); + size += this->temperature_unit ? 2 : 0; return size; } uint8_t *WaterHeaterStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 5aa592e4fa8..a8e01c017fe 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -92,6 +92,11 @@ enum SupportsResponseType : uint32_t { SUPPORTS_RESPONSE_STATUS = 100, }; #endif +enum TemperatureUnit : uint32_t { + TEMPERATURE_UNIT_CELSIUS = 0, + TEMPERATURE_UNIT_FAHRENHEIT = 1, + TEMPERATURE_UNIT_KELVIN = 2, +}; #ifdef USE_CLIMATE enum ClimateMode : uint32_t { CLIMATE_MODE_OFF = 0, @@ -1372,7 +1377,7 @@ class CameraImageRequest final : public ProtoDecodableMessage { class ListEntitiesClimateResponse final : public InfoResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 46; - static constexpr uint8_t ESTIMATED_SIZE = 150; + static constexpr uint8_t ESTIMATED_SIZE = 153; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_climate_response"); } #endif @@ -1394,6 +1399,7 @@ class ListEntitiesClimateResponse final : public InfoResponseProtoMessage { float visual_min_humidity{0.0f}; float visual_max_humidity{0.0f}; uint32_t feature_flags{0}; + enums::TemperatureUnit temperature_unit{}; uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -1471,7 +1477,7 @@ class ClimateCommandRequest final : public CommandProtoMessage { class ListEntitiesWaterHeaterResponse final : public InfoResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 132; - static constexpr uint8_t ESTIMATED_SIZE = 63; + static constexpr uint8_t ESTIMATED_SIZE = 65; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_water_heater_response"); } #endif @@ -1480,6 +1486,7 @@ class ListEntitiesWaterHeaterResponse final : public InfoResponseProtoMessage { float target_temperature_step{0.0f}; const water_heater::WaterHeaterModeMask *supported_modes{}; uint32_t supported_features{0}; + enums::TemperatureUnit temperature_unit{}; uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index bdcb6d4146d..541f5d4d11c 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -297,6 +297,18 @@ template<> const char *proto_enum_to_string(enums:: } } #endif +template<> const char *proto_enum_to_string(enums::TemperatureUnit value) { + switch (value) { + case enums::TEMPERATURE_UNIT_CELSIUS: + return ESPHOME_PSTR("TEMPERATURE_UNIT_CELSIUS"); + case enums::TEMPERATURE_UNIT_FAHRENHEIT: + return ESPHOME_PSTR("TEMPERATURE_UNIT_FAHRENHEIT"); + case enums::TEMPERATURE_UNIT_KELVIN: + return ESPHOME_PSTR("TEMPERATURE_UNIT_KELVIN"); + default: + return ESPHOME_PSTR("UNKNOWN"); + } +} #ifdef USE_CLIMATE template<> const char *proto_enum_to_string(enums::ClimateMode value) { switch (value) { @@ -1539,6 +1551,7 @@ const char *ListEntitiesClimateResponse::dump_to(DumpBuffer &out) const { dump_field(out, ESPHOME_PSTR("device_id"), this->device_id); #endif dump_field(out, ESPHOME_PSTR("feature_flags"), this->feature_flags); + dump_field(out, ESPHOME_PSTR("temperature_unit"), static_cast(this->temperature_unit)); return out.c_str(); } const char *ClimateStateResponse::dump_to(DumpBuffer &out) const { @@ -1612,6 +1625,7 @@ const char *ListEntitiesWaterHeaterResponse::dump_to(DumpBuffer &out) const { dump_field(out, ESPHOME_PSTR("supported_modes"), static_cast(it), 4); } dump_field(out, ESPHOME_PSTR("supported_features"), this->supported_features); + dump_field(out, ESPHOME_PSTR("temperature_unit"), static_cast(this->temperature_unit)); return out.c_str(); } const char *WaterHeaterStateResponse::dump_to(DumpBuffer &out) const { From 2e096bb036abfefa0e86d3830a55b9e5aeecd270 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Apr 2026 21:54:15 -0500 Subject: [PATCH 41/68] [core] Combine set_component_source_ + register_component_ into one call (#16029) --- esphome/core/application.h | 6 +++++- esphome/cpp_helpers.py | 6 +++--- tests/component_tests/deep_sleep/test_deep_sleep.py | 2 +- tests/component_tests/ota/test_web_server_ota.py | 2 +- tests/unit_tests/test_cpp_helpers.py | 10 ++++++---- 5 files changed, 16 insertions(+), 10 deletions(-) diff --git a/esphome/core/application.h b/esphome/core/application.h index bc09f7d38c8..c0b2639bd18 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -382,7 +382,11 @@ class Application { /// Register a component, detecting loop() override at compile time. /// Uses HasLoopOverride which handles ambiguous &T::loop from multiple inheritance. - template void register_component_(T *comp) { + /// Optionally sets the component source index in the same call to avoid emitting + /// a separate set_component_source_() line in generated code. + template void register_component_(T *comp, uint8_t source_index = 0) { + if (source_index != 0) + comp->set_component_source_(source_index); this->register_component_impl_(comp, HasLoopOverride::value); } diff --git a/esphome/cpp_helpers.py b/esphome/cpp_helpers.py index f2bd3b92a31..b035e28a7ad 100644 --- a/esphome/cpp_helpers.py +++ b/esphome/cpp_helpers.py @@ -197,9 +197,9 @@ async def register_component(var, config): ) if name is not None: idx = register_component_source(name) - add(var.set_component_source_(idx)) - - add(App.register_component_(var)) + add(App.register_component_(var, idx)) + else: + add(App.register_component_(var)) # Collect C++ type for compile-time looping component count comp_entries = CORE.data.setdefault("looping_component_entries", []) diff --git a/tests/component_tests/deep_sleep/test_deep_sleep.py b/tests/component_tests/deep_sleep/test_deep_sleep.py index 8c1278a3323..84128d75d7c 100644 --- a/tests/component_tests/deep_sleep/test_deep_sleep.py +++ b/tests/component_tests/deep_sleep/test_deep_sleep.py @@ -12,7 +12,7 @@ def test_deep_sleep_setup(generate_main): in main_cpp ) assert "new(deepsleep) deep_sleep::DeepSleepComponent();" in main_cpp - assert "App.register_component_(deepsleep);" in main_cpp + assert "App.register_component_(deepsleep, " in main_cpp def test_deep_sleep_sleep_duration(generate_main): diff --git a/tests/component_tests/ota/test_web_server_ota.py b/tests/component_tests/ota/test_web_server_ota.py index 4b3a4c705c1..4b8b7540e84 100644 --- a/tests/component_tests/ota/test_web_server_ota.py +++ b/tests/component_tests/ota/test_web_server_ota.py @@ -27,7 +27,7 @@ def test_web_server_ota_generated(generate_main: Callable[[str], str]) -> None: assert "global_web_server_base" in main_cpp # Check component is registered - assert "App.register_component_(web_server_webserverotacomponent_id)" in main_cpp + assert "App.register_component_(web_server_webserverotacomponent_id" in main_cpp def test_web_server_ota_with_callbacks(generate_main: Callable[[str], str]) -> None: diff --git a/tests/unit_tests/test_cpp_helpers.py b/tests/unit_tests/test_cpp_helpers.py index a76ea21c23b..e389b56adac 100644 --- a/tests/unit_tests/test_cpp_helpers.py +++ b/tests/unit_tests/test_cpp_helpers.py @@ -34,8 +34,9 @@ async def test_register_component(monkeypatch): actual = await ch.register_component(var, {}) assert actual is var - assert add_mock.call_count == 2 - app_mock.register_component_.assert_called_with(var) + assert add_mock.call_count == 1 + app_mock.register_component_.assert_called_once() + assert app_mock.register_component_.call_args.args[0] is var assert core_mock.component_ids == [] @@ -77,8 +78,9 @@ async def test_register_component__with_setup_priority(monkeypatch): assert actual is var add_mock.assert_called() - assert add_mock.call_count == 4 - app_mock.register_component_.assert_called_with(var) + assert add_mock.call_count == 3 + app_mock.register_component_.assert_called_once() + assert app_mock.register_component_.call_args.args[0] is var assert core_mock.component_ids == [] From 112646a9c4ba92f49c3589e664d064f56193dee3 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Mon, 27 Apr 2026 05:02:09 +0200 Subject: [PATCH 42/68] [zigbee] add router for nrf52 (#16034) --- esphome/components/zigbee/__init__.py | 13 +++++++++---- esphome/components/zigbee/zigbee_zephyr.cpp | 2 ++ esphome/components/zigbee/zigbee_zephyr.py | 6 +++++- tests/components/zigbee/test.nrf52-mcumgr.yaml | 3 +++ 4 files changed, 19 insertions(+), 5 deletions(-) diff --git a/esphome/components/zigbee/__init__.py b/esphome/components/zigbee/__init__.py index 0bb5f95bb68..018dab73488 100644 --- a/esphome/components/zigbee/__init__.py +++ b/esphome/components/zigbee/__init__.py @@ -75,6 +75,13 @@ SENSOR_SCHEMA = cv.Schema({}).extend(zephyr_sensor) SWITCH_SCHEMA = cv.Schema({}).extend(zephyr_switch) NUMBER_SCHEMA = cv.Schema({}).extend(zephyr_number) + +def _validate_router_sleepy(config: ConfigType) -> ConfigType: + if config.get(CONF_ROUTER) and config.get(CONF_SLEEPY): + raise cv.Invalid("router and sleepy are mutually exclusive") + return config + + CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -82,10 +89,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_MODEL, default=CORE.name): cv.All( cv.string, cv.Length(max=31) ), - cv.OnlyWith(CONF_ROUTER, "esp32", default=False): cv.All( - cv.requires_component("esp32"), - cv.boolean, - ), + cv.Optional(CONF_ROUTER, default=False): cv.boolean, cv.Optional(CONF_ON_JOIN): cv.All( cv.requires_component("nrf52"), automation.validate_automation(single=True), @@ -113,6 +117,7 @@ CONFIG_SCHEMA = cv.All( ), } ).extend(cv.COMPONENT_SCHEMA), + _validate_router_sleepy, zigbee_require_vfs_select, zigbee_set_core_data, cv.Any( diff --git a/esphome/components/zigbee/zigbee_zephyr.cpp b/esphome/components/zigbee/zigbee_zephyr.cpp index 90bb66c91d5..dfffd1c91f4 100644 --- a/esphome/components/zigbee/zigbee_zephyr.cpp +++ b/esphome/components/zigbee/zigbee_zephyr.cpp @@ -190,7 +190,9 @@ void ZigbeeComponent::setup() { ESP_LOGE(TAG, "Cannot load settings, err: %d", err); return; } +#ifdef CONFIG_ZIGBEE_ROLE_END_DEVICE zigbee_configure_sleepy_behavior(this->sleepy_); +#endif zigbee_enable(); } diff --git a/esphome/components/zigbee/zigbee_zephyr.py b/esphome/components/zigbee/zigbee_zephyr.py index 7d904b6081d..b74074e50f8 100644 --- a/esphome/components/zigbee/zigbee_zephyr.py +++ b/esphome/components/zigbee/zigbee_zephyr.py @@ -52,6 +52,7 @@ from esphome.types import ConfigType from .const import ( CONF_ON_JOIN, CONF_POWER_SOURCE, + CONF_ROUTER, CONF_WIPE_ON_BOOT, KEY_ZIGBEE, POWER_SOURCE, @@ -160,7 +161,10 @@ zephyr_number = cv.Schema( async def zephyr_to_code(config: ConfigType) -> None: zephyr_add_prj_conf("ZIGBEE", True) zephyr_add_prj_conf("ZIGBEE_APP_UTILS", True) - zephyr_add_prj_conf("ZIGBEE_ROLE_END_DEVICE", True) + if config[CONF_ROUTER]: + zephyr_add_prj_conf("ZIGBEE_ROLE_ROUTER", True) + else: + zephyr_add_prj_conf("ZIGBEE_ROLE_END_DEVICE", True) zephyr_add_prj_conf("ZIGBEE_CHANNEL_SELECTION_MODE_MULTI", True) diff --git a/tests/components/zigbee/test.nrf52-mcumgr.yaml b/tests/components/zigbee/test.nrf52-mcumgr.yaml index bf3cb9cdd92..a81feea0691 100644 --- a/tests/components/zigbee/test.nrf52-mcumgr.yaml +++ b/tests/components/zigbee/test.nrf52-mcumgr.yaml @@ -1 +1,4 @@ <<: !include common_nrf52.yaml + +zigbee: + router: true From 79b741b8dc452de8430de732ae7ba316747456fb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Apr 2026 22:03:39 -0500 Subject: [PATCH 43/68] [core] Combine entity register + configure_entity_ into one call (#16030) --- .../alarm_control_panel/__init__.py | 8 +++-- esphome/components/binary_sensor/__init__.py | 3 +- esphome/components/button/__init__.py | 3 +- esphome/components/climate/__init__.py | 8 +++-- esphome/components/cover/__init__.py | 3 +- esphome/components/datetime/__init__.py | 8 +++-- esphome/components/event/__init__.py | 3 +- esphome/components/fan/__init__.py | 8 +++-- esphome/components/infrared/__init__.py | 4 +-- esphome/components/light/__init__.py | 8 +++-- esphome/components/lock/__init__.py | 8 +++-- esphome/components/media_player/__init__.py | 3 +- esphome/components/number/__init__.py | 3 +- .../components/radio_frequency/__init__.py | 4 +-- esphome/components/select/__init__.py | 8 +++-- esphome/components/sensor/__init__.py | 3 +- esphome/components/switch/__init__.py | 3 +- esphome/components/text/__init__.py | 8 +++-- esphome/components/text_sensor/__init__.py | 3 +- esphome/components/update/__init__.py | 3 +- esphome/components/valve/__init__.py | 3 +- esphome/components/water_heater/__init__.py | 8 +++-- esphome/core/application.h | 13 ++++++-- esphome/core/entity_base.h | 3 ++ esphome/core/entity_helpers.py | 32 +++++++++++++++++-- .../binary_sensor/test_binary_sensor.py | 2 +- tests/component_tests/button/test_button.py | 2 +- tests/component_tests/helpers.py | 20 +++++++++--- tests/component_tests/text/test_text.py | 2 +- .../text_sensor/test_text_sensor.py | 6 ++-- 30 files changed, 145 insertions(+), 48 deletions(-) diff --git a/esphome/components/alarm_control_panel/__init__.py b/esphome/components/alarm_control_panel/__init__.py index 9fcdf42ecb4..2f5d4c7c2bf 100644 --- a/esphome/components/alarm_control_panel/__init__.py +++ b/esphome/components/alarm_control_panel/__init__.py @@ -13,7 +13,11 @@ from esphome.const import ( CONF_WEB_SERVER, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority -from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity +from esphome.core.entity_helpers import ( + entity_duplicate_validator, + queue_entity_register, + setup_entity, +) from esphome.cpp_generator import MockObjClass CODEOWNERS = ["@grahambrown11", "@hwstar"] @@ -181,7 +185,7 @@ async def setup_alarm_control_panel_core_(var, config): async def register_alarm_control_panel(var, config): if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) - cg.add(cg.App.register_alarm_control_panel(var)) + queue_entity_register("alarm_control_panel", config) CORE.register_platform_component("alarm_control_panel", var) await setup_alarm_control_panel_core_(var, config) diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index 29ddbab02cd..1456e5bc663 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -62,6 +62,7 @@ from esphome.const import ( from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, + queue_entity_register, setup_device_class, setup_entity, ) @@ -624,7 +625,7 @@ async def setup_binary_sensor_core_(var, config): async def register_binary_sensor(var, config): if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) - cg.add(cg.App.register_binary_sensor(var)) + queue_entity_register("binary_sensor", config) CORE.register_platform_component("binary_sensor", var) await setup_binary_sensor_core_(var, config) diff --git a/esphome/components/button/__init__.py b/esphome/components/button/__init__.py index 2c19ea69b1a..dd4fde5705e 100644 --- a/esphome/components/button/__init__.py +++ b/esphome/components/button/__init__.py @@ -19,6 +19,7 @@ from esphome.const import ( from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, + queue_entity_register, setup_device_class, setup_entity, ) @@ -101,7 +102,7 @@ async def setup_button_core_(var, config): async def register_button(var, config): if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) - cg.add(cg.App.register_button(var)) + queue_entity_register("button", config) CORE.register_platform_component("button", var) await setup_button_core_(var, config) diff --git a/esphome/components/climate/__init__.py b/esphome/components/climate/__init__.py index df77fa5c1c9..0fdb18a92c8 100644 --- a/esphome/components/climate/__init__.py +++ b/esphome/components/climate/__init__.py @@ -49,7 +49,11 @@ from esphome.const import ( CONF_WEB_SERVER, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority -from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity +from esphome.core.entity_helpers import ( + entity_duplicate_validator, + queue_entity_register, + setup_entity, +) from esphome.cpp_generator import MockObjClass IS_PLATFORM_COMPONENT = True @@ -442,7 +446,7 @@ async def setup_climate_core_(var, config): async def register_climate(var, config): if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) - cg.add(cg.App.register_climate(var)) + queue_entity_register("climate", config) CORE.register_platform_component("climate", var) await setup_climate_core_(var, config) diff --git a/esphome/components/cover/__init__.py b/esphome/components/cover/__init__.py index fdfca55f0f8..41efd2ba7a5 100644 --- a/esphome/components/cover/__init__.py +++ b/esphome/components/cover/__init__.py @@ -39,6 +39,7 @@ from esphome.const import ( from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, + queue_entity_register, setup_device_class, setup_entity, ) @@ -232,7 +233,7 @@ async def setup_cover_core_(var, config): async def register_cover(var, config): if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) - cg.add(cg.App.register_cover(var)) + queue_entity_register("cover", config) CORE.register_platform_component("cover", var) await setup_cover_core_(var, config) diff --git a/esphome/components/datetime/__init__.py b/esphome/components/datetime/__init__.py index 895ac4e243e..87997daa3d9 100644 --- a/esphome/components/datetime/__init__.py +++ b/esphome/components/datetime/__init__.py @@ -22,7 +22,11 @@ from esphome.const import ( CONF_YEAR, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority -from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity +from esphome.core.entity_helpers import ( + entity_duplicate_validator, + queue_entity_register, + setup_entity, +) from esphome.cpp_generator import MockObjClass CODEOWNERS = ["@rfdarter", "@jesserockz"] @@ -160,7 +164,7 @@ async def register_datetime(var, config): if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) entity_type = config[CONF_TYPE].lower() - cg.add(getattr(cg.App, f"register_{entity_type}")(var)) + queue_entity_register(entity_type, config) CORE.register_platform_component(entity_type, var) await setup_datetime_core_(var, config) diff --git a/esphome/components/event/__init__.py b/esphome/components/event/__init__.py index 9c9dd025b18..4cab1bff9bb 100644 --- a/esphome/components/event/__init__.py +++ b/esphome/components/event/__init__.py @@ -19,6 +19,7 @@ from esphome.const import ( from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, + queue_entity_register, setup_device_class, setup_entity, ) @@ -108,7 +109,7 @@ async def setup_event_core_(var, config, *, event_types: list[str]): async def register_event(var, config, *, event_types: list[str]): if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) - cg.add(cg.App.register_event(var)) + queue_entity_register("event", config) CORE.register_platform_component("event", var) await setup_event_core_(var, config, event_types=event_types) diff --git a/esphome/components/fan/__init__.py b/esphome/components/fan/__init__.py index ce1e55d36b1..713f20fb95e 100644 --- a/esphome/components/fan/__init__.py +++ b/esphome/components/fan/__init__.py @@ -32,7 +32,11 @@ from esphome.const import ( CONF_WEB_SERVER, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority -from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity +from esphome.core.entity_helpers import ( + entity_duplicate_validator, + queue_entity_register, + setup_entity, +) IS_PLATFORM_COMPONENT = True @@ -292,7 +296,7 @@ async def setup_fan_core_(var, config): async def register_fan(var, config): if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) - cg.add(cg.App.register_fan(var)) + queue_entity_register("fan", config) CORE.register_platform_component("fan", var) await setup_fan_core_(var, config) diff --git a/esphome/components/infrared/__init__.py b/esphome/components/infrared/__init__.py index 6a2a72fa5d7..f8e77209b24 100644 --- a/esphome/components/infrared/__init__.py +++ b/esphome/components/infrared/__init__.py @@ -12,7 +12,7 @@ import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import CONF_ID from esphome.core import CORE, coroutine_with_priority -from esphome.core.entity_helpers import setup_entity +from esphome.core.entity_helpers import queue_entity_register, setup_entity from esphome.coroutine import CoroPriority from esphome.types import ConfigType @@ -54,8 +54,8 @@ async def register_infrared(var: cg.Pvariable, config: ConfigType) -> None: """Register an infrared device with the core.""" cg.add_define("USE_IR_RF") await cg.register_component(var, config) + queue_entity_register("infrared", config) await setup_infrared_core_(var, config) - cg.add(cg.App.register_infrared(var)) CORE.register_platform_component("infrared", var) diff --git a/esphome/components/light/__init__.py b/esphome/components/light/__init__.py index 5925afb472d..9540c644860 100644 --- a/esphome/components/light/__init__.py +++ b/esphome/components/light/__init__.py @@ -40,7 +40,11 @@ from esphome.const import ( CONF_WHITE, ) from esphome.core import CORE, ID, CoroPriority, HexInt, Lambda, coroutine_with_priority -from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity +from esphome.core.entity_helpers import ( + entity_duplicate_validator, + queue_entity_register, + setup_entity, +) from esphome.cpp_generator import MockObjClass import esphome.final_validate as fv from esphome.types import ConfigType @@ -405,7 +409,7 @@ async def setup_light_core_(light_var, config, output_var): async def register_light(output_var, config): light_var = cg.new_Pvariable(config[CONF_ID], output_var) - cg.add(cg.App.register_light(light_var)) + queue_entity_register("light", config) CORE.register_platform_component("light", light_var) await cg.register_component(light_var, config) await setup_light_core_(light_var, config, output_var) diff --git a/esphome/components/lock/__init__.py b/esphome/components/lock/__init__.py index a36d52a5d82..0a8ad58bc2d 100644 --- a/esphome/components/lock/__init__.py +++ b/esphome/components/lock/__init__.py @@ -13,7 +13,11 @@ from esphome.const import ( CONF_WEB_SERVER, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority -from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity +from esphome.core.entity_helpers import ( + entity_duplicate_validator, + queue_entity_register, + setup_entity, +) from esphome.cpp_generator import MockObjClass CODEOWNERS = ["@esphome/core"] @@ -112,7 +116,7 @@ async def _setup_lock_core(var, config): async def register_lock(var, config): if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) - cg.add(cg.App.register_lock(var)) + queue_entity_register("lock", config) CORE.register_platform_component("lock", var) await _setup_lock_core(var, config) diff --git a/esphome/components/media_player/__init__.py b/esphome/components/media_player/__init__.py index d1db868ace4..0024e3b9658 100644 --- a/esphome/components/media_player/__init__.py +++ b/esphome/components/media_player/__init__.py @@ -21,6 +21,7 @@ from esphome.core import CORE from esphome.core.entity_helpers import ( entity_duplicate_validator, inherit_property_from, + queue_entity_register, setup_entity, ) from esphome.coroutine import CoroPriority, coroutine_with_priority @@ -262,7 +263,7 @@ async def setup_media_player_core_(var, config): async def register_media_player(var, config): if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) - cg.add(cg.App.register_media_player(var)) + queue_entity_register("media_player", config) CORE.register_platform_component("media_player", var) await setup_media_player_core_(var, config) diff --git a/esphome/components/number/__init__.py b/esphome/components/number/__init__.py index f13ccc4c36d..ee2d53c65a4 100644 --- a/esphome/components/number/__init__.py +++ b/esphome/components/number/__init__.py @@ -82,6 +82,7 @@ from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.config import UNIT_OF_MEASUREMENT_MAX_LENGTH from esphome.core.entity_helpers import ( entity_duplicate_validator, + queue_entity_register, setup_device_class, setup_entity, setup_unit_of_measurement, @@ -301,7 +302,7 @@ async def register_number( ): if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) - cg.add(cg.App.register_number(var)) + queue_entity_register("number", config) CORE.register_platform_component("number", var) await setup_number_core_( var, config, min_value=min_value, max_value=max_value, step=step diff --git a/esphome/components/radio_frequency/__init__.py b/esphome/components/radio_frequency/__init__.py index b00590ceb58..a54ab6e2492 100644 --- a/esphome/components/radio_frequency/__init__.py +++ b/esphome/components/radio_frequency/__init__.py @@ -12,7 +12,7 @@ import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import CONF_ID from esphome.core import CORE, coroutine_with_priority -from esphome.core.entity_helpers import setup_entity +from esphome.core.entity_helpers import queue_entity_register, setup_entity from esphome.coroutine import CoroPriority from esphome.types import ConfigType @@ -55,8 +55,8 @@ async def register_radio_frequency(var: cg.Pvariable, config: ConfigType) -> Non """Register a radio frequency device with the core.""" cg.add_define("USE_RADIO_FREQUENCY") await cg.register_component(var, config) + queue_entity_register("radio_frequency", config) await setup_radio_frequency_core_(var, config) - cg.add(cg.App.register_radio_frequency(var)) CORE.register_platform_component("radio_frequency", var) diff --git a/esphome/components/select/__init__.py b/esphome/components/select/__init__.py index ba5214e550a..f561c030a49 100644 --- a/esphome/components/select/__init__.py +++ b/esphome/components/select/__init__.py @@ -19,7 +19,11 @@ from esphome.const import ( CONF_WEB_SERVER, ) from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority -from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity +from esphome.core.entity_helpers import ( + entity_duplicate_validator, + queue_entity_register, + setup_entity, +) from esphome.cpp_generator import MockObjClass, TemplateArguments from esphome.cpp_types import global_ns @@ -113,7 +117,7 @@ async def setup_select_core_(var, config, *, options: list[str]): async def register_select(var, config, *, options: list[str]): if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) - cg.add(cg.App.register_select(var)) + queue_entity_register("select", config) CORE.register_platform_component("select", var) await setup_select_core_(var, config, options=options) diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 43fbc989531..48b7d25d4df 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -109,6 +109,7 @@ from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.config import UNIT_OF_MEASUREMENT_MAX_LENGTH from esphome.core.entity_helpers import ( entity_duplicate_validator, + queue_entity_register, setup_device_class, setup_entity, setup_unit_of_measurement, @@ -982,7 +983,7 @@ async def setup_sensor_core_(var, config): async def register_sensor(var, config): if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) - cg.add(cg.App.register_sensor(var)) + queue_entity_register("sensor", config) CORE.register_platform_component("sensor", var) await setup_sensor_core_(var, config) diff --git a/esphome/components/switch/__init__.py b/esphome/components/switch/__init__.py index 9fa4a013ff8..1108652e993 100644 --- a/esphome/components/switch/__init__.py +++ b/esphome/components/switch/__init__.py @@ -23,6 +23,7 @@ from esphome.const import ( from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, + queue_entity_register, setup_device_class, setup_entity, ) @@ -166,7 +167,7 @@ async def setup_switch_core_(var, config): async def register_switch(var, config): if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) - cg.add(cg.App.register_switch(var)) + queue_entity_register("switch", config) CORE.register_platform_component("switch", var) await setup_switch_core_(var, config) diff --git a/esphome/components/text/__init__.py b/esphome/components/text/__init__.py index 224f4580d4a..06b5a108926 100644 --- a/esphome/components/text/__init__.py +++ b/esphome/components/text/__init__.py @@ -14,7 +14,11 @@ from esphome.const import ( CONF_WEB_SERVER, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority -from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity +from esphome.core.entity_helpers import ( + entity_duplicate_validator, + queue_entity_register, + setup_entity, +) from esphome.cpp_generator import MockObjClass CODEOWNERS = ["@mauritskorse"] @@ -122,7 +126,7 @@ async def register_text( ): if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) - cg.add(cg.App.register_text(var)) + queue_entity_register("text", config) CORE.register_platform_component("text", var) await setup_text_core_( var, config, min_length=min_length, max_length=max_length, pattern=pattern diff --git a/esphome/components/text_sensor/__init__.py b/esphome/components/text_sensor/__init__.py index 94014e8d206..01a57cbaa1b 100644 --- a/esphome/components/text_sensor/__init__.py +++ b/esphome/components/text_sensor/__init__.py @@ -22,6 +22,7 @@ from esphome.const import ( from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, + queue_entity_register, setup_device_class, setup_entity, ) @@ -221,7 +222,7 @@ async def setup_text_sensor_core_(var, config): async def register_text_sensor(var, config): if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) - cg.add(cg.App.register_text_sensor(var)) + queue_entity_register("text_sensor", config) CORE.register_platform_component("text_sensor", var) await setup_text_sensor_core_(var, config) diff --git a/esphome/components/update/__init__.py b/esphome/components/update/__init__.py index db6c1445e34..ddb471be18f 100644 --- a/esphome/components/update/__init__.py +++ b/esphome/components/update/__init__.py @@ -17,6 +17,7 @@ from esphome.const import ( from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, + queue_entity_register, setup_device_class, setup_entity, ) @@ -113,7 +114,7 @@ async def setup_update_core_(var, config): async def register_update(var, config): if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) - cg.add(cg.App.register_update(var)) + queue_entity_register("update", config) CORE.register_platform_component("update", var) await setup_update_core_(var, config) diff --git a/esphome/components/valve/__init__.py b/esphome/components/valve/__init__.py index 1930a7ad0c9..a6808c9da7b 100644 --- a/esphome/components/valve/__init__.py +++ b/esphome/components/valve/__init__.py @@ -24,6 +24,7 @@ from esphome.const import ( from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, + queue_entity_register, setup_device_class, setup_entity, ) @@ -162,7 +163,7 @@ async def _setup_valve_core(var, config): async def register_valve(var, config): if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) - cg.add(cg.App.register_valve(var)) + queue_entity_register("valve", config) CORE.register_platform_component("valve", var) await _setup_valve_core(var, config) diff --git a/esphome/components/water_heater/__init__.py b/esphome/components/water_heater/__init__.py index 58cf5a4054e..f3eec16a406 100644 --- a/esphome/components/water_heater/__init__.py +++ b/esphome/components/water_heater/__init__.py @@ -9,7 +9,11 @@ from esphome.const import ( CONF_VISUAL, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority -from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity +from esphome.core.entity_helpers import ( + entity_duplicate_validator, + queue_entity_register, + setup_entity, +) from esphome.cpp_generator import MockObjClass from esphome.types import ConfigType @@ -90,7 +94,7 @@ async def register_water_heater(var: cg.Pvariable, config: ConfigType) -> cg.Pva cg.add_define("USE_WATER_HEATER") - cg.add(cg.App.register_water_heater(var)) + queue_entity_register("water_heater", config) CORE.register_platform_component("water_heater", var) await setup_water_heater_core_(var, config) diff --git a/esphome/core/application.h b/esphome/core/application.h index c0b2639bd18..185ee4163b1 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -103,10 +103,19 @@ class Application { void set_current_component(Component *component) { this->current_component_ = component; } Component *get_current_component() { return this->current_component_; } -// Entity register methods (generated from entity_types.h) +// Entity register methods (generated from entity_types.h). +// Each entity type gets two overloads: +// - register_(obj) — bare push_back +// - register_(obj, name, hash, fields) — configure_entity_ + push_back +// The 4-arg form lets codegen collapse `App.register_(obj); obj->configure_entity_(...);` +// into a single call site, saving flash and a `main.cpp` line per entity. // NOLINTBEGIN(bugprone-macro-parentheses) #define ENTITY_TYPE_(type, singular, plural, count, upper) \ - void register_##singular(type *obj) { this->plural##_.push_back(obj); } + void register_##singular(type *obj) { this->plural##_.push_back(obj); } \ + void register_##singular(type *obj, const char *name, uint32_t object_id_hash, uint32_t entity_fields) { \ + obj->configure_entity_(name, object_id_hash, entity_fields); \ + this->plural##_.push_back(obj); \ + } #define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \ ENTITY_TYPE_(type, singular, plural, count, upper) #include "esphome/core/entity_types.h" diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index 5a69c9dd09b..2726a92c97a 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -238,6 +238,9 @@ class EntityBase { protected: friend void ::setup(); friend void ::original_setup(); + // Application's register_(obj, name, hash, fields) overloads call configure_entity_ + // before push_back, so codegen can emit a single combined call per entity. + friend class Application; /// Combined entity setup from codegen: set name, object_id hash, entity string indices, and flags. /// Bit layout of entity_fields is defined by the ENTITY_FIELD_*_SHIFT constants above. diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index f09dd013fe2..ff60260280a 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -23,6 +23,7 @@ from esphome.core.config import ( UNIT_OF_MEASUREMENT_MAX_LENGTH, ) from esphome.cpp_generator import MockObj, RawStatement, add, get_variable +from esphome.cpp_types import App import esphome.final_validate as fv from esphome.helpers import cpp_string_escape, fnv1_hash_object_id, sanitize, snake_case from esphome.types import ConfigType, EntityMetadata @@ -52,6 +53,12 @@ _KEY_INTERNAL = "_entity_internal" _KEY_DISABLED_BY_DEFAULT = "_entity_disabled_by_default" _KEY_ENTITY_CATEGORY = "_entity_category" +# Private config key for the App.register_ entry point. +# When set, finalize_entity_strings() emits a single combined call +# `App.register_(var, name, hash, packed)` instead of separate +# `App.register_(var)` and `var->configure_entity_(...)` calls. +_KEY_REGISTER_METHOD = "_entity_register_method" + # Maximum unique strings per category (8-bit index, 0 = not set) _MAX_DEVICE_CLASSES = 0xFF # 255 _MAX_UNITS = 0xFF # 255 @@ -271,11 +278,26 @@ def _describe_packed_flags(config: ConfigType, entity_category: int) -> str: return ", ".join(parts) +def queue_entity_register(method_name: str, config: ConfigType) -> None: + """Defer ``App.register_(var)`` emission to ``finalize_entity_strings``. + + When the deferred call is emitted, it is folded with ``configure_entity_`` into + a single ``App.register_(var, name, hash, packed)`` call site, + which removes one statement and one method dispatch per entity from the + generated ``main.cpp``. + """ + config[_KEY_REGISTER_METHOD] = method_name + + def finalize_entity_strings(var: MockObj, config: ConfigType) -> None: - """Emit a single configure_entity_() call with name, hash, packed string indices, and flags. + """Emit the entity-registration / configure_entity_ tail. Call this at the end of each component's setup function, after setup_entity() and any register_device_class/register_unit_of_measurement calls. + + If queue_entity_register() was called for this entity, emits one combined call + ``App.register_(var, name, hash, packed)``. Otherwise falls back to a + standalone ``var->configure_entity_(name, hash, packed)``. """ entity_name = config[_KEY_ENTITY_NAME] object_id_hash = config[_KEY_OBJECT_ID_HASH] @@ -295,7 +317,13 @@ def finalize_entity_strings(var: MockObj, config: ConfigType) -> None: ) # Build inline comment describing the packed flags for readability comment = _describe_packed_flags(config, entity_category) - expr = var.configure_entity_(entity_name, object_id_hash, packed) + register_method = config.get(_KEY_REGISTER_METHOD) + if register_method is not None: + expr = getattr(App, f"register_{register_method}")( + var, entity_name, object_id_hash, packed + ) + else: + expr = var.configure_entity_(entity_name, object_id_hash, packed) if comment: add(RawStatement(f"{expr}; // {comment}")) else: diff --git a/tests/component_tests/binary_sensor/test_binary_sensor.py b/tests/component_tests/binary_sensor/test_binary_sensor.py index 4f41f2cc704..e1d999abc7c 100644 --- a/tests/component_tests/binary_sensor/test_binary_sensor.py +++ b/tests/component_tests/binary_sensor/test_binary_sensor.py @@ -31,7 +31,7 @@ def test_binary_sensor_sets_mandatory_fields(generate_main): ) # Then - assert 'bs_1->configure_entity_("test bs1",' in main_cpp + assert 'App.register_binary_sensor(bs_1, "test bs1",' in main_cpp assert "bs_1->set_pin(" in main_cpp diff --git a/tests/component_tests/button/test_button.py b/tests/component_tests/button/test_button.py index 544e748f913..f8881a832ce 100644 --- a/tests/component_tests/button/test_button.py +++ b/tests/component_tests/button/test_button.py @@ -29,7 +29,7 @@ def test_button_sets_mandatory_fields(generate_main): main_cpp = generate_main("tests/component_tests/button/test_button.yaml") # Then - assert 'wol_1->configure_entity_("wol_test_1",' in main_cpp + assert 'App.register_button(wol_1, "wol_test_1",' in main_cpp assert "wol_2->set_macaddr(18, 52, 86, 120, 144, 171);" in main_cpp diff --git a/tests/component_tests/helpers.py b/tests/component_tests/helpers.py index 568d1639d0c..2eb588c0ca3 100644 --- a/tests/component_tests/helpers.py +++ b/tests/component_tests/helpers.py @@ -8,12 +8,22 @@ INTERNAL_BIT = 1 << 24 def extract_packed_value(main_cpp: str, var_name: str) -> int: - """Extract the third (packed) argument from a configure_entity_ call.""" - pattern = ( - rf"{re.escape(var_name)}->configure_entity_\(" + """Extract the packed-fields argument from the entity's configure call. + + Matches both legacy form ``var->configure_entity_(name, hash, packed)`` and the + combined form ``App.register_(var, name, hash, packed)``. + """ + escaped_var = re.escape(var_name) + legacy_pattern = ( + rf"{escaped_var}->configure_entity_\(" r'"(?:\\.|[^"\\])*"' r",\s*\w+,\s*(\d+)\)" ) - match = re.search(pattern, main_cpp) - assert match, f"configure_entity_ call not found for {var_name}" + combined_pattern = ( + rf"App\.register_\w+\(\s*{escaped_var}\s*,\s*" + r'"(?:\\.|[^"\\])*"' + r",\s*\w+,\s*(\d+)\)" + ) + match = re.search(combined_pattern, main_cpp) or re.search(legacy_pattern, main_cpp) + assert match, f"configure call not found for {var_name}" return int(match.group(1)) diff --git a/tests/component_tests/text/test_text.py b/tests/component_tests/text/test_text.py index 63eb4f19515..f5ac07c1cd1 100644 --- a/tests/component_tests/text/test_text.py +++ b/tests/component_tests/text/test_text.py @@ -28,7 +28,7 @@ def test_text_sets_mandatory_fields(generate_main): main_cpp = generate_main("tests/component_tests/text/test_text.yaml") # Then - assert 'it_1->configure_entity_("test 1 text",' in main_cpp + assert 'App.register_text(it_1, "test 1 text",' in main_cpp def test_text_config_value_internal_set(generate_main): diff --git a/tests/component_tests/text_sensor/test_text_sensor.py b/tests/component_tests/text_sensor/test_text_sensor.py index ae094fadf87..eb25af3095f 100644 --- a/tests/component_tests/text_sensor/test_text_sensor.py +++ b/tests/component_tests/text_sensor/test_text_sensor.py @@ -28,9 +28,9 @@ def test_text_sensor_sets_mandatory_fields(generate_main): main_cpp = generate_main("tests/component_tests/text_sensor/test_text_sensor.yaml") # Then - assert 'ts_1->configure_entity_("Template Text Sensor 1",' in main_cpp - assert 'ts_2->configure_entity_("Template Text Sensor 2",' in main_cpp - assert 'ts_3->configure_entity_("Template Text Sensor 3",' in main_cpp + assert 'App.register_text_sensor(ts_1, "Template Text Sensor 1",' in main_cpp + assert 'App.register_text_sensor(ts_2, "Template Text Sensor 2",' in main_cpp + assert 'App.register_text_sensor(ts_3, "Template Text Sensor 3",' in main_cpp def test_text_sensor_config_value_internal_set(generate_main): From dec5d0449bd1c844ba89f9c5900d48db8b17a466 Mon Sep 17 00:00:00 2001 From: plazarre Date: Mon, 27 Apr 2026 06:51:54 -0500 Subject: [PATCH 44/68] [esp32_ble_tracker] Hold COEX_PREFER_BT for the lifetime of any active connection (#16036) Co-authored-by: Paul Lazarre Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- .../esp32_ble_tracker/esp32_ble_tracker.cpp | 22 ++++++++++++++----- .../esp32_ble_tracker/esp32_ble_tracker.h | 10 ++++++++- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index c7f2319d69d..f57cb7f5dc5 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -166,8 +166,9 @@ void ESP32BLETracker::loop() { ClientStateCounts counts = this->count_client_states_(); if (counts != this->client_state_counts_) { this->client_state_counts_ = counts; - ESP_LOGD(TAG, "connecting: %d, discovered: %d, disconnecting: %d", this->client_state_counts_.connecting, - this->client_state_counts_.discovered, this->client_state_counts_.disconnecting); + ESP_LOGD(TAG, "connecting: %d, discovered: %d, disconnecting: %d, active: %d", + this->client_state_counts_.connecting, this->client_state_counts_.discovered, + this->client_state_counts_.disconnecting, this->client_state_counts_.active); } // Scanner failure: reached when set_scanner_state_(FAILED) or scan_set_param_failed_ set @@ -190,10 +191,18 @@ void ESP32BLETracker::loop() { */ // Start scan: reached when scanner_state_ becomes IDLE (via set_scanner_state_()) and - // all clients are idle (their state changes increment version when they finish) + // no clients are in the transient CONNECTING / DISCOVERED / DISCONNECTING states + // (their state changes increment version when they finish). CONNECTED / ESTABLISHED + // clients do NOT block this branch — the coex revert below has its own active-count gate. if (this->scanner_state_ == ScannerState::IDLE && !counts.connecting && !counts.disconnecting && !counts.discovered) { #ifdef USE_ESP32_BLE_SOFTWARE_COEXISTENCE - this->update_coex_preference_(false); + // Only revert to BALANCE when no connections are active. Established connections + // continue to need PREFER_BT so peer GATT responses can reach us while WiFi traffic + // (advertisement upload, log streaming) competes for the shared radio. Reverting too + // early causes Bluedroid to time out at ~20s and synthesize status=133. + if (!counts.active) { + this->update_coex_preference_(false); + } #endif if (this->scan_continuous_) { this->start_scan_(false); // first = false @@ -701,9 +710,10 @@ void ESP32BLETracker::dump_config() { this->scan_active_ ? "ACTIVE" : "PASSIVE", YESNO(this->scan_continuous_)); ESP_LOGCONFIG(TAG, " Scanner State: %s\n" - " Connecting: %d, discovered: %d, disconnecting: %d", + " Connecting: %d, discovered: %d, disconnecting: %d, active: %d", this->scanner_state_to_string_(this->scanner_state_), this->client_state_counts_.connecting, - this->client_state_counts_.discovered, this->client_state_counts_.disconnecting); + this->client_state_counts_.discovered, this->client_state_counts_.disconnecting, + this->client_state_counts_.active); if (this->scan_start_fail_count_) { ESP_LOGCONFIG(TAG, " Scan Start Fail Count: %d", this->scan_start_fail_count_); } diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index 43405b02b7f..78ff60f3741 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -160,9 +160,13 @@ struct ClientStateCounts { uint8_t connecting = 0; uint8_t discovered = 0; uint8_t disconnecting = 0; + // CONNECTED + ESTABLISHED clients. Tracked so coex stays at PREFER_BT + // while active connections may still need to send/receive GATT traffic. + uint8_t active = 0; bool operator==(const ClientStateCounts &other) const { - return connecting == other.connecting && discovered == other.discovered && disconnecting == other.disconnecting; + return connecting == other.connecting && discovered == other.discovered && disconnecting == other.disconnecting && + active == other.active; } bool operator!=(const ClientStateCounts &other) const { return !(*this == other); } @@ -381,6 +385,10 @@ class ESP32BLETracker : public Component, case ClientState::CONNECTING: counts.connecting++; break; + case ClientState::CONNECTED: + case ClientState::ESTABLISHED: + counts.active++; + break; default: break; } From 24c6a0d711ea99aee1bb57c9f8336a8597e89a1c Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Mon, 27 Apr 2026 08:17:02 -0400 Subject: [PATCH 45/68] [audio] Bump microDecoder library to v0.2.0 (#16054) --- esphome/components/audio/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py index fee582ca25c..fe111be31e7 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -220,7 +220,7 @@ async def to_code(config): data = _get_data() if data.micro_decoder_support: - add_idf_component(name="esphome/micro-decoder", ref="0.1.1") + add_idf_component(name="esphome/micro-decoder", ref="0.2.0") # All codecs are enabled by default in micro-decoder, so disable the ones that aren't requested to save flash if not data.flac_support: diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 11531e6d7b4..cb7f5903cfd 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -6,7 +6,7 @@ dependencies: esphome/esp-micro-speech-features: version: 1.2.3 esphome/micro-decoder: - version: 0.1.1 + version: 0.2.0 esphome/micro-flac: version: 0.1.1 esphome/micro-opus: From 7198c912c7095c0e33a3c237dc25aca02ec334c4 Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Mon, 27 Apr 2026 17:41:28 +0000 Subject: [PATCH 46/68] [esp32][wifi] Fix bootloop and WiFi connection issue if nvs partition is missing or has non-default label (#16025) Co-authored-by: J. Nick Koston --- esphome/components/esp32/preferences.cpp | 18 +++++++++++++++++- .../components/wifi/wifi_component_esp_idf.cpp | 5 ++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp32/preferences.cpp b/esphome/components/esp32/preferences.cpp index 925c4e76624..09835385ac2 100644 --- a/esphome/components/esp32/preferences.cpp +++ b/esphome/components/esp32/preferences.cpp @@ -18,6 +18,12 @@ struct NVSData { static std::vector s_pending_save; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +// open() runs from app_main() before the logger is initialized, so any failure +// must be deferred until after global_logger is set. This is emitted from the +// first make_preference() call, which runs from the generated setup() after +// log->pre_setup() has run at EARLY_INIT priority. +static esp_err_t s_open_err = ESP_OK; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + bool ESP32PreferenceBackend::save(const uint8_t *data, size_t len) { // try find in pending saves and update that for (auto &obj : s_pending_save) { @@ -70,12 +76,14 @@ bool ESP32PreferenceBackend::load(uint8_t *data, size_t len) { } void ESP32Preferences::open() { + // Runs from app_main() before the logger is initialized; any logging here + // must be deferred. See s_open_err and make_preference() below. nvs_flash_init(); esp_err_t err = nvs_open("esphome", NVS_READWRITE, &this->nvs_handle); if (err == 0) return; - ESP_LOGW(TAG, "nvs_open failed: %s - erasing NVS", esp_err_to_name(err)); + s_open_err = err; nvs_flash_deinit(); nvs_flash_erase(); nvs_flash_init(); @@ -87,6 +95,14 @@ void ESP32Preferences::open() { } ESPPreferenceObject ESP32Preferences::make_preference(size_t length, uint32_t type) { + if (s_open_err != ESP_OK) { + if (this->nvs_handle == 0) { + ESP_LOGW(TAG, "nvs_open failed: %s - NVS unavailable", esp_err_to_name(s_open_err)); + } else { + ESP_LOGW(TAG, "nvs_open failed: %s - erased NVS", esp_err_to_name(s_open_err)); + } + s_open_err = ESP_OK; + } auto *pref = new ESP32PreferenceBackend(); // NOLINT(cppcoreguidelines-owning-memory) pref->nvs_handle = this->nvs_handle; pref->key = type; diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index 29d135ce900..82ecc80811d 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -179,7 +179,10 @@ void WiFiComponent::wifi_pre_setup_() { #endif // USE_WIFI_AP wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); - // cfg.nvs_enable = false; + if (global_preferences->nvs_handle == 0) { + ESP_LOGW(TAG, "starting wifi without nvs"); + cfg.nvs_enable = false; + } err = esp_wifi_init(&cfg); if (err != ERR_OK) { ESP_LOGE(TAG, "esp_wifi_init failed: %s", esp_err_to_name(err)); From 01ac22391348410d4a806b5d7b45a23fe8236bbc Mon Sep 17 00:00:00 2001 From: Edward Firmo <94725493+edwardtfn@users.noreply.github.com> Date: Mon, 27 Apr 2026 22:30:40 +0200 Subject: [PATCH 47/68] [nextion] Unify TFT upload ack timeout to 5000ms (#15960) --- esphome/components/nextion/nextion_upload_arduino.cpp | 11 +++++++++-- esphome/components/nextion/nextion_upload_esp32.cpp | 11 +++++++++-- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/esphome/components/nextion/nextion_upload_arduino.cpp b/esphome/components/nextion/nextion_upload_arduino.cpp index c79c68552ea..e0d18352ff4 100644 --- a/esphome/components/nextion/nextion_upload_arduino.cpp +++ b/esphome/components/nextion/nextion_upload_arduino.cpp @@ -16,6 +16,13 @@ namespace esphome::nextion { static const char *const TAG = "nextion.upload.arduino"; static constexpr size_t NEXTION_MAX_RESPONSE_LOG_BYTES = 16; +// Timeout for display acknowledgment during TFT upload (ms). +// A single value is used for all chunks; the happy path returns as soon as +// 0x05/0x08 arrives, so this only bounds failed-detection latency. Field +// reports showed the previous 500ms steady-state value was too tight for +// some firmware variants. +static constexpr uint32_t NEXTION_UPLOAD_ACK_TIMEOUT_MS = 5000; + // Followed guide // https://unofficialnextion.com/t/nextion-upload-protocol-v1-2-the-fast-one/1044/2 @@ -80,14 +87,14 @@ int Nextion::upload_by_chunks_(HTTPClient &http_client, uint32_t &range_start) { recv_string.clear(); this->write_array(buffer, buffer_size); App.feed_wdt(); - this->recv_ret_string_(recv_string, this->upload_first_chunk_sent_ ? 500 : 5000, true); + this->recv_ret_string_(recv_string, NEXTION_UPLOAD_ACK_TIMEOUT_MS, true); this->content_length_ -= read_len; const float upload_percentage = 100.0f * (this->tft_size_ - this->content_length_) / this->tft_size_; ESP_LOGD(TAG, "Upload: %0.2f%% (%" PRIu32 " left, heap: %" PRIu32 ")", upload_percentage, this->content_length_, EspClass::getFreeHeap()); this->upload_first_chunk_sent_ = true; if (recv_string.empty()) { - ESP_LOGW(TAG, "No response from display during upload"); + ESP_LOGW(TAG, "No response from display after %" PRIu32 "ms", NEXTION_UPLOAD_ACK_TIMEOUT_MS); allocator.deallocate(buffer, 4096); buffer = nullptr; return -1; diff --git a/esphome/components/nextion/nextion_upload_esp32.cpp b/esphome/components/nextion/nextion_upload_esp32.cpp index 40a284dc46b..db4558e2fe2 100644 --- a/esphome/components/nextion/nextion_upload_esp32.cpp +++ b/esphome/components/nextion/nextion_upload_esp32.cpp @@ -19,6 +19,13 @@ namespace esphome::nextion { static const char *const TAG = "nextion.upload.esp32"; static constexpr size_t NEXTION_MAX_RESPONSE_LOG_BYTES = 16; +// Timeout for display acknowledgment during TFT upload (ms). +// A single value is used for all chunks; the happy path returns as soon as +// 0x05/0x08 arrives, so this only bounds failed-detection latency. Field +// reports showed the previous 500ms steady-state value was too tight for +// some firmware variants. +static constexpr uint32_t NEXTION_UPLOAD_ACK_TIMEOUT_MS = 5000; + // Followed guide // https://unofficialnextion.com/t/nextion-upload-protocol-v1-2-the-fast-one/1044/2 @@ -96,7 +103,7 @@ int Nextion::upload_by_chunks_(esp_http_client_handle_t http_client, uint32_t &r recv_string.clear(); this->write_array(buffer, buffer_size); App.feed_wdt(); - this->recv_ret_string_(recv_string, upload_first_chunk_sent_ ? 500 : 5000, true); + this->recv_ret_string_(recv_string, NEXTION_UPLOAD_ACK_TIMEOUT_MS, true); this->content_length_ -= read_len; const float upload_percentage = 100.0f * (this->tft_size_ - this->content_length_) / this->tft_size_; #ifdef USE_PSRAM @@ -109,7 +116,7 @@ int Nextion::upload_by_chunks_(esp_http_client_handle_t http_client, uint32_t &r #endif upload_first_chunk_sent_ = true; if (recv_string.empty()) { - ESP_LOGW(TAG, "No response from display during upload"); + ESP_LOGW(TAG, "No response from display after %" PRIu32 "ms", NEXTION_UPLOAD_ACK_TIMEOUT_MS); allocator.deallocate(buffer, 4096); buffer = nullptr; return -1; From a34836c2906bd5a1454bca01a135ed9cb1d9e2ea Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 28 Apr 2026 11:27:08 +1200 Subject: [PATCH 48/68] [esp32_touch] Feed wdt (#16066) --- esphome/components/esp32_touch/esp32_touch.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/esp32_touch/esp32_touch.cpp b/esphome/components/esp32_touch/esp32_touch.cpp index e44bc807e9a..54bbbe52ed8 100644 --- a/esphome/components/esp32_touch/esp32_touch.cpp +++ b/esphome/components/esp32_touch/esp32_touch.cpp @@ -216,6 +216,7 @@ void ESP32TouchComponent::setup() { // Do initial oneshot scans to populate baseline values for (uint32_t i = 0; i < ONESHOT_SCAN_COUNT; i++) { err = touch_sensor_trigger_oneshot_scanning(this->sens_handle_, ONESHOT_SCAN_TIMEOUT_MS); + App.feed_wdt(); // 3 scans with 2s timeout might exceed WDT, so feed it here to be safe if (err != ESP_OK) { ESP_LOGW(TAG, "Oneshot scan %" PRIu32 " failed: %s", i, esp_err_to_name(err)); } From 39a69385fba1ea825906e5ef8759f9a8c3a2351c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Apr 2026 19:57:42 -0500 Subject: [PATCH 49/68] [image] Fix RGB565+alpha rendering for multi-frame animations (#16017) Co-authored-by: Claude --- esphome/components/animation/animation.cpp | 7 ++- esphome/components/image/__init__.py | 21 ++++--- tests/component_tests/image/test_init.py | 69 ++++++++++++++++++++++ 3 files changed, 89 insertions(+), 8 deletions(-) diff --git a/esphome/components/animation/animation.cpp b/esphome/components/animation/animation.cpp index c2ae3b2f768..2f59a7fa5a7 100644 --- a/esphome/components/animation/animation.cpp +++ b/esphome/components/animation/animation.cpp @@ -62,7 +62,12 @@ void Animation::set_frame(int frame) { } void Animation::update_data_start_() { - const uint32_t image_size = this->get_width_stride() * this->height_; + uint32_t image_size = this->get_width_stride() * this->height_; + // RGB565 with an alpha channel stores the alpha plane immediately after the RGB + // plane within each frame, so the per-frame stride includes the alpha bytes. + if (this->type_ == image::IMAGE_TYPE_RGB565 && this->transparency_ == image::TRANSPARENCY_ALPHA_CHANNEL) { + image_size += static_cast(this->width_) * this->height_; + } this->data_start_ = this->animation_data_start_ + image_size * this->current_frame_; } diff --git a/esphome/components/image/__init__.py b/esphome/components/image/__init__.py index 8375ab91d3e..365554f7d2d 100644 --- a/esphome/components/image/__init__.py +++ b/esphome/components/image/__init__.py @@ -744,21 +744,28 @@ async def write_image(config, all_frames=False): if frame_count <= 1: _LOGGER.warning("Image file %s has no animation frames", path) - total_rows = height * frame_count - encoder = IMAGE_TYPE[type](width, total_rows, transparency, dither, invert_alpha) - if byte_order := config.get(CONF_BYTE_ORDER): - # Check for valid type has already been done in validate_settings - encoder.set_big_endian(byte_order == "BIG_ENDIAN") + # Encode each frame with its own encoder and concatenate. This keeps every + # frame self-contained on disk (e.g. RGB565+alpha emits [RGB plane | alpha plane] + # per frame) so animation frame stepping in image.cpp / animation.cpp stays + # correct without needing to know the total frame count. + byte_order = config.get(CONF_BYTE_ORDER) + combined_data: list[int] = [] + encoder: ImageEncoder | None = None for frame_index in range(frame_count): image.seek(frame_index) + encoder = IMAGE_TYPE[type](width, height, transparency, dither, invert_alpha) + if byte_order is not None: + # Check for valid type has already been done in validate_settings + encoder.set_big_endian(byte_order == "BIG_ENDIAN") pixels = encoder.convert(image.resize((width, height)), path).getdata() for row in range(height): for col in range(width): encoder.encode(pixels[row * width + col]) encoder.end_row() - encoder.end_image() + encoder.end_image() + combined_data.extend(encoder.data) - rhs = [HexInt(x) for x in encoder.data] + rhs = [HexInt(x) for x in combined_data] prog_arr = cg.progmem_array(config[CONF_RAW_DATA_ID], rhs) image_type = get_image_type_enum(type) trans_value = get_transparency_enum(encoder.transparency) diff --git a/tests/component_tests/image/test_init.py b/tests/component_tests/image/test_init.py index 6f73888c7d1..f7f60a1f4d5 100644 --- a/tests/component_tests/image/test_init.py +++ b/tests/component_tests/image/test_init.py @@ -7,10 +7,12 @@ from pathlib import Path from typing import Any from unittest.mock import MagicMock, patch +from PIL import Image as PILImage import pytest from esphome import config_validation as cv from esphome.components.image import ( + CONF_ALPHA_CHANNEL, CONF_INVERT_ALPHA, CONF_OPAQUE, CONF_TRANSPARENCY, @@ -411,3 +413,70 @@ async def test_svg_with_mm_dimensions_succeeds( assert 30 < height < 50, ( f"Height should be around 39 pixels for 10mm at 100dpi, got {height}" ) + + +@pytest.mark.asyncio +async def test_rgb565_alpha_animation_layout_per_frame( + tmp_path: Path, + mock_progmem_array: MagicMock, +) -> None: + """RGB565+alpha animations must store each frame as a self-contained + [RGB plane | alpha plane] block. Animation::update_data_start_ steps frames + with a single per-frame stride, so any cross-frame layout (all RGB then all + alpha) makes the C++ alpha read land in the next frame's RGB bytes — that + was the regression behind issue #15999. + """ + # Build a 2-frame APNG where each frame is a solid color with a known + # alpha. APNG preserves full RGBA per pixel (GIF only has 1-bit alpha so + # round-tripping mid-range alpha values does not work). Frame 0 is fully + # opaque red, frame 1 is fully transparent blue. + width = 4 + height = 3 + frame0 = PILImage.new("RGBA", (width, height), (255, 0, 0, 0xFF)) + frame1 = PILImage.new("RGBA", (width, height), (0, 0, 255, 0x00)) + apng_path = tmp_path / "anim.png" + frame0.save( + apng_path, + format="PNG", + save_all=True, + append_images=[frame1], + duration=100, + loop=0, + ) + + config = { + CONF_FILE: str(apng_path), + CONF_TYPE: "RGB565", + CONF_TRANSPARENCY: CONF_ALPHA_CHANNEL, + CONF_DITHER: "NONE", + CONF_INVERT_ALPHA: False, + CONF_RAW_DATA_ID: "test_raw_data_id", + } + + _, _, _, _, _, frame_count = await write_image(config, all_frames=True) + assert frame_count == 2 + + # Recover the bytes handed to progmem_array. Signature is (id_, rhs). + _, raw_data = mock_progmem_array.call_args.args + data = [int(x) for x in raw_data] + + rgb_size = width * height * 2 + alpha_size = width * height + frame_size = rgb_size + alpha_size + assert len(data) == frame_size * frame_count, ( + "RGB565+alpha animation buffer must be (RGB + alpha) per frame, not " + "all RGB followed by all alpha" + ) + + # Frame 0: RGB plane is red, alpha plane is 0xFF. Frame 1: alpha plane is + # 0x00. If the layout regresses to [all RGB | all alpha], the alpha bytes + # would all land at the tail of the buffer and the per-frame slices below + # would point at RGB565 noise instead. + frame0_alpha = data[rgb_size : rgb_size + alpha_size] + frame1_alpha = data[frame_size + rgb_size : frame_size + rgb_size + alpha_size] + assert all(a == 0xFF for a in frame0_alpha), ( + f"Frame 0 alpha plane should be opaque, got {frame0_alpha}" + ) + assert all(a == 0x00 for a in frame1_alpha), ( + f"Frame 1 alpha plane should be transparent, got {frame1_alpha}" + ) From c26ea52620a5f3a88a2eb7a2e4b5446dedf77195 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 23 Apr 2026 12:35:00 +1000 Subject: [PATCH 50/68] [lvgl] Triggers on tabview tabs fix (#15935) --- esphome/components/lvgl/widgets/tabview.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/lvgl/widgets/tabview.py b/esphome/components/lvgl/widgets/tabview.py index 108bb38df58..5e9e0494dd5 100644 --- a/esphome/components/lvgl/widgets/tabview.py +++ b/esphome/components/lvgl/widgets/tabview.py @@ -22,7 +22,7 @@ from ..defines import ( literal, ) from ..lv_validation import animated, lv_int, size -from ..lvcode import LocalVariable, lv, lv_assign, lv_expr, lv_obj +from ..lvcode import LocalVariable, lv, lv_assign, lv_expr, lv_obj, lv_Pvariable from ..schemas import container_schema, part_schema from ..types import LV_EVENT, LvType, ObjUpdateAction, lv_obj_t, lv_obj_t_ptr from . import Widget, WidgetType, add_widgets, get_widgets, set_obj_properties @@ -83,8 +83,8 @@ class TabviewType(WidgetType): await w.set_property("tab_bar_size", await size.process(config[CONF_SIZE])) for tab_conf in config[CONF_TABS]: w_id = tab_conf[CONF_ID] - tab_obj = cg.Pvariable(w_id, cg.nullptr, type_=lv_tab_t) - tab_widget = Widget.create(w_id, tab_obj, obj_spec) + tab_obj = lv_Pvariable(lv_tab_t, w_id) + tab_widget = Widget.create(w_id, tab_obj, obj_spec, tab_conf) lv_assign(tab_obj, lv_expr.tabview_add_tab(w.obj, tab_conf[CONF_NAME])) await set_obj_properties(tab_widget, tab_conf) await add_widgets(tab_widget, tab_conf) From b753ee4e94d060a55464c55e1a94a96b135aeed1 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 23 Apr 2026 14:35:13 +1200 Subject: [PATCH 51/68] [time] Handle Windows EINVAL when validating POSIX TZ strings (#15934) --- esphome/components/time/__init__.py | 7 +++ tests/unit_tests/components/test_time.py | 67 +++++++++++++++++++++++- 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/esphome/components/time/__init__.py b/esphome/components/time/__init__.py index 7ac0abeee0f..9e79c8e6c2e 100644 --- a/esphome/components/time/__init__.py +++ b/esphome/components/time/__init__.py @@ -1,3 +1,4 @@ +import errno from importlib import resources import logging @@ -74,6 +75,12 @@ def _load_tzdata(iana_key: str) -> bytes | None: return (resources.files(package) / resource).read_bytes() except (FileNotFoundError, ModuleNotFoundError, IsADirectoryError): return None + except OSError as e: + # Windows raises EINVAL for paths with NTFS-illegal chars (e.g. '<'/'>' + # in POSIX TZ strings like "<+08>-8" that validate_tz feeds back here). + if e.errno == errno.EINVAL: + return None + raise def _extract_tz_string(tzfile: bytes) -> str: diff --git a/tests/unit_tests/components/test_time.py b/tests/unit_tests/components/test_time.py index 48988fb03f9..6325bfbe75c 100644 --- a/tests/unit_tests/components/test_time.py +++ b/tests/unit_tests/components/test_time.py @@ -1,6 +1,11 @@ """Tests for time component cron expression parsing.""" -from esphome.components.time import _parse_cron_part +import errno +from unittest.mock import MagicMock, patch + +import pytest + +from esphome.components.time import _load_tzdata, _parse_cron_part, validate_tz def test_star_slash_seconds() -> None: @@ -78,3 +83,63 @@ def test_range() -> None: def test_single_value() -> None: assert _parse_cron_part("30", 0, 59, {}) == {30} + + +def _mock_resources_with_error(error: Exception) -> MagicMock: + """Return a mock of importlib.resources.files where read_bytes raises error.""" + leaf = MagicMock() + leaf.read_bytes.side_effect = error + package = MagicMock() + package.__truediv__.return_value = leaf + return MagicMock(return_value=package) + + +def test_load_tzdata_returns_none_on_windows_einval() -> None: + """On Windows, opening a tzdata path with NTFS-illegal chars raises OSError(EINVAL). + + Regression test for crash when the system TZ resolves to a POSIX string like + "<+08>-8" (Asia/Shanghai, IST, etc.) and is fed back into _load_tzdata by + validate_tz to check whether it is also a valid IANA key. + """ + err = OSError(errno.EINVAL, "Invalid argument") + with patch( + "esphome.components.time.resources.files", + _mock_resources_with_error(err), + ): + assert _load_tzdata("<+08>-8") is None + + +def test_load_tzdata_propagates_unexpected_oserror() -> None: + """Unrelated OSErrors (e.g. PermissionError) must not be swallowed.""" + with ( + patch( + "esphome.components.time.resources.files", + _mock_resources_with_error( + PermissionError(errno.EACCES, "Permission denied") + ), + ), + pytest.raises(PermissionError), + ): + _load_tzdata("Some/Zone") + + +def test_load_tzdata_returns_none_on_file_not_found() -> None: + """Existing behavior: missing tz file returns None rather than raising.""" + with patch( + "esphome.components.time.resources.files", + _mock_resources_with_error(FileNotFoundError()), + ): + assert _load_tzdata("Not/A/Zone") is None + + +def test_validate_tz_accepts_posix_string_when_read_bytes_raises_einval() -> None: + """validate_tz must not crash when _load_tzdata hits the Windows EINVAL path. + + Simulates the Windows case where the auto-detected POSIX TZ string is fed + back through _load_tzdata and the underlying read_bytes raises errno 22. + """ + with patch( + "esphome.components.time.resources.files", + _mock_resources_with_error(OSError(errno.EINVAL, "Invalid argument")), + ): + assert validate_tz("<+08>-8") == "<+08>-8" From 6a5919ee8764571ce4dff8c3d939d1230531e112 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Apr 2026 02:19:59 -0500 Subject: [PATCH 52/68] [deep_sleep] Fix sleep_duration codegen type to uint32_t (#15965) --- esphome/components/deep_sleep/__init__.py | 2 +- tests/components/deep_sleep/common.yaml | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/esphome/components/deep_sleep/__init__.py b/esphome/components/deep_sleep/__init__.py index 16329bb0fa0..a98b7e60ef2 100644 --- a/esphome/components/deep_sleep/__init__.py +++ b/esphome/components/deep_sleep/__init__.py @@ -413,7 +413,7 @@ async def deep_sleep_enter_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) if CONF_SLEEP_DURATION in config: - template_ = await cg.templatable(config[CONF_SLEEP_DURATION], args, cg.int32) + template_ = await cg.templatable(config[CONF_SLEEP_DURATION], args, cg.uint32) cg.add(var.set_sleep_duration(template_)) if CONF_UNTIL in config: diff --git a/tests/components/deep_sleep/common.yaml b/tests/components/deep_sleep/common.yaml index c090cb83e2d..7a1a709965b 100644 --- a/tests/components/deep_sleep/common.yaml +++ b/tests/components/deep_sleep/common.yaml @@ -4,3 +4,9 @@ esphome: - deep_sleep.prevent - delay: 1s - deep_sleep.allow + - if: + condition: + lambda: 'return false;' + then: + - deep_sleep.enter: + sleep_duration: 60min From 4137d93cbfc34778fed7b8b4697a8eb04c7e6777 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Apr 2026 03:42:36 -0500 Subject: [PATCH 53/68] [wifi] Fix stale wifi.connected after state transition (#15966) --- esphome/components/wifi/wifi_component.cpp | 2 ++ esphome/components/wifi/wifi_component_esp8266.cpp | 2 ++ esphome/components/wifi/wifi_component_esp_idf.cpp | 2 ++ esphome/components/wifi/wifi_component_libretiny.cpp | 2 ++ esphome/components/wifi/wifi_component_pico_w.cpp | 2 ++ 5 files changed, 10 insertions(+) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 7b31a22ed5b..6b49368933f 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -1570,6 +1570,8 @@ void WiFiComponent::check_connecting_finished(uint32_t now) { #endif this->state_ = WIFI_COMPONENT_STATE_STA_CONNECTED; + // Refresh is_connected() cache; loop()'s refresh ran before this transition. + this->update_connected_state_(); this->num_retried_ = 0; this->print_connect_params_(); diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index cb53d3ac1bd..d1a31cdfc94 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -948,6 +948,8 @@ void WiFiComponent::process_pending_callbacks_() { #ifdef USE_WIFI_CONNECT_STATE_LISTENERS if (this->pending_.disconnect) { this->pending_.disconnect = false; + // Refresh is_connected() cache here, not in the SDK callback (sys context). + this->update_connected_state_(); this->notify_disconnect_state_listeners_(); } #endif diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index 4097df80afd..e166fadb275 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -796,6 +796,8 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { s_sta_connected = false; s_sta_connecting = false; error_from_callback_ = true; + // Refresh is_connected() cache; error_from_callback_ makes it false. + this->update_connected_state_(); #ifdef USE_WIFI_CONNECT_STATE_LISTENERS this->notify_disconnect_state_listeners_(); #endif diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index 9565ffa7473..b721364631d 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -536,6 +536,8 @@ void WiFiComponent::wifi_process_event_(LTWiFiEvent *event) { this->error_from_callback_ = true; } + // Refresh is_connected() cache; sta_state_/error_from_callback_ make it false. + this->update_connected_state_(); #ifdef USE_WIFI_CONNECT_STATE_LISTENERS this->notify_disconnect_state_listeners_(); #endif diff --git a/esphome/components/wifi/wifi_component_pico_w.cpp b/esphome/components/wifi/wifi_component_pico_w.cpp index 1cfeee3c1bf..a50dfd8c807 100644 --- a/esphome/components/wifi/wifi_component_pico_w.cpp +++ b/esphome/components/wifi/wifi_component_pico_w.cpp @@ -342,6 +342,8 @@ void WiFiComponent::wifi_loop_() { s_sta_was_connected = false; s_sta_had_ip = false; ESP_LOGV(TAG, "Disconnected"); + // Refresh is_connected() cache; driver link status reports disconnected. + this->update_connected_state_(); #ifdef USE_WIFI_CONNECT_STATE_LISTENERS this->notify_disconnect_state_listeners_(); #endif From 433bbdb0163dabcbf31afb3d93d500a6a406020c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Apr 2026 07:23:41 -0500 Subject: [PATCH 54/68] [rotary_encoder][at581x] Fix templatable int field types (#16015) --- esphome/components/at581x/__init__.py | 8 ++++---- esphome/components/rotary_encoder/sensor.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/esphome/components/at581x/__init__.py b/esphome/components/at581x/__init__.py index 94b68db4b33..5031b72cceb 100644 --- a/esphome/components/at581x/__init__.py +++ b/esphome/components/at581x/__init__.py @@ -183,19 +183,19 @@ async def at581x_settings_to_code(config, action_id, template_arg, args): cg.add(var.set_sensing_distance(template_)) if selfcheck := config.get(CONF_POWERON_SELFCHECK_TIME): - template_ = await cg.templatable(selfcheck, args, cg.int32) + template_ = await cg.templatable(selfcheck, args, cg.int_) cg.add(var.set_poweron_selfcheck_time(template_)) if protect := config.get(CONF_PROTECT_TIME): - template_ = await cg.templatable(protect, args, cg.int32) + template_ = await cg.templatable(protect, args, cg.int_) cg.add(var.set_protect_time(template_)) if trig_base := config.get(CONF_TRIGGER_BASE): - template_ = await cg.templatable(trig_base, args, cg.int32) + template_ = await cg.templatable(trig_base, args, cg.int_) cg.add(var.set_trigger_base(template_)) if trig_keep := config.get(CONF_TRIGGER_KEEP): - template_ = await cg.templatable(trig_keep, args, cg.int32) + template_ = await cg.templatable(trig_keep, args, cg.int_) cg.add(var.set_trigger_keep(template_)) if (stage_gain := config.get(CONF_STAGE_GAIN)) is not None: diff --git a/esphome/components/rotary_encoder/sensor.py b/esphome/components/rotary_encoder/sensor.py index 21239863e45..0e5a03523df 100644 --- a/esphome/components/rotary_encoder/sensor.py +++ b/esphome/components/rotary_encoder/sensor.py @@ -129,6 +129,6 @@ async def to_code(config): async def sensor_template_publish_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_VALUE], args, cg.int32) + template_ = await cg.templatable(config[CONF_VALUE], args, cg.int_) cg.add(var.set_value(template_)) return var From aea88aef5e9fdd053ed61007e2a17ccbd4b6982e Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Mon, 27 Apr 2026 17:41:28 +0000 Subject: [PATCH 55/68] [esp32][wifi] Fix bootloop and WiFi connection issue if nvs partition is missing or has non-default label (#16025) Co-authored-by: J. Nick Koston --- esphome/components/esp32/preferences.cpp | 18 +++++++++++++++++- .../components/wifi/wifi_component_esp_idf.cpp | 5 ++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp32/preferences.cpp b/esphome/components/esp32/preferences.cpp index bc0a34ebe86..72a0d979d9c 100644 --- a/esphome/components/esp32/preferences.cpp +++ b/esphome/components/esp32/preferences.cpp @@ -22,6 +22,12 @@ struct NVSData { static std::vector s_pending_save; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +// open() runs from app_main() before the logger is initialized, so any failure +// must be deferred until after global_logger is set. This is emitted from the +// first make_preference() call, which runs from the generated setup() after +// log->pre_setup() has run at EARLY_INIT priority. +static esp_err_t s_open_err = ESP_OK; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + bool ESP32PreferenceBackend::save(const uint8_t *data, size_t len) { // try find in pending saves and update that for (auto &obj : s_pending_save) { @@ -74,12 +80,14 @@ bool ESP32PreferenceBackend::load(uint8_t *data, size_t len) { } void ESP32Preferences::open() { + // Runs from app_main() before the logger is initialized; any logging here + // must be deferred. See s_open_err and make_preference() below. nvs_flash_init(); esp_err_t err = nvs_open("esphome", NVS_READWRITE, &this->nvs_handle); if (err == 0) return; - ESP_LOGW(TAG, "nvs_open failed: %s - erasing NVS", esp_err_to_name(err)); + s_open_err = err; nvs_flash_deinit(); nvs_flash_erase(); nvs_flash_init(); @@ -91,6 +99,14 @@ void ESP32Preferences::open() { } ESPPreferenceObject ESP32Preferences::make_preference(size_t length, uint32_t type) { + if (s_open_err != ESP_OK) { + if (this->nvs_handle == 0) { + ESP_LOGW(TAG, "nvs_open failed: %s - NVS unavailable", esp_err_to_name(s_open_err)); + } else { + ESP_LOGW(TAG, "nvs_open failed: %s - erased NVS", esp_err_to_name(s_open_err)); + } + s_open_err = ESP_OK; + } auto *pref = new ESP32PreferenceBackend(); // NOLINT(cppcoreguidelines-owning-memory) pref->nvs_handle = this->nvs_handle; pref->key = type; diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index e166fadb275..a6a48409bca 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -179,7 +179,10 @@ void WiFiComponent::wifi_pre_setup_() { #endif // USE_WIFI_AP wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); - // cfg.nvs_enable = false; + if (global_preferences->nvs_handle == 0) { + ESP_LOGW(TAG, "starting wifi without nvs"); + cfg.nvs_enable = false; + } err = esp_wifi_init(&cfg); if (err != ERR_OK) { ESP_LOGE(TAG, "esp_wifi_init failed: %s", esp_err_to_name(err)); From a186f6fea9663d7daaf937021ea801073313f5d4 Mon Sep 17 00:00:00 2001 From: Edward Firmo <94725493+edwardtfn@users.noreply.github.com> Date: Mon, 27 Apr 2026 22:30:40 +0200 Subject: [PATCH 56/68] [nextion] Unify TFT upload ack timeout to 5000ms (#15960) --- esphome/components/nextion/nextion_upload_arduino.cpp | 11 +++++++++-- esphome/components/nextion/nextion_upload_esp32.cpp | 11 +++++++++-- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/esphome/components/nextion/nextion_upload_arduino.cpp b/esphome/components/nextion/nextion_upload_arduino.cpp index c79c68552ea..e0d18352ff4 100644 --- a/esphome/components/nextion/nextion_upload_arduino.cpp +++ b/esphome/components/nextion/nextion_upload_arduino.cpp @@ -16,6 +16,13 @@ namespace esphome::nextion { static const char *const TAG = "nextion.upload.arduino"; static constexpr size_t NEXTION_MAX_RESPONSE_LOG_BYTES = 16; +// Timeout for display acknowledgment during TFT upload (ms). +// A single value is used for all chunks; the happy path returns as soon as +// 0x05/0x08 arrives, so this only bounds failed-detection latency. Field +// reports showed the previous 500ms steady-state value was too tight for +// some firmware variants. +static constexpr uint32_t NEXTION_UPLOAD_ACK_TIMEOUT_MS = 5000; + // Followed guide // https://unofficialnextion.com/t/nextion-upload-protocol-v1-2-the-fast-one/1044/2 @@ -80,14 +87,14 @@ int Nextion::upload_by_chunks_(HTTPClient &http_client, uint32_t &range_start) { recv_string.clear(); this->write_array(buffer, buffer_size); App.feed_wdt(); - this->recv_ret_string_(recv_string, this->upload_first_chunk_sent_ ? 500 : 5000, true); + this->recv_ret_string_(recv_string, NEXTION_UPLOAD_ACK_TIMEOUT_MS, true); this->content_length_ -= read_len; const float upload_percentage = 100.0f * (this->tft_size_ - this->content_length_) / this->tft_size_; ESP_LOGD(TAG, "Upload: %0.2f%% (%" PRIu32 " left, heap: %" PRIu32 ")", upload_percentage, this->content_length_, EspClass::getFreeHeap()); this->upload_first_chunk_sent_ = true; if (recv_string.empty()) { - ESP_LOGW(TAG, "No response from display during upload"); + ESP_LOGW(TAG, "No response from display after %" PRIu32 "ms", NEXTION_UPLOAD_ACK_TIMEOUT_MS); allocator.deallocate(buffer, 4096); buffer = nullptr; return -1; diff --git a/esphome/components/nextion/nextion_upload_esp32.cpp b/esphome/components/nextion/nextion_upload_esp32.cpp index 40a284dc46b..db4558e2fe2 100644 --- a/esphome/components/nextion/nextion_upload_esp32.cpp +++ b/esphome/components/nextion/nextion_upload_esp32.cpp @@ -19,6 +19,13 @@ namespace esphome::nextion { static const char *const TAG = "nextion.upload.esp32"; static constexpr size_t NEXTION_MAX_RESPONSE_LOG_BYTES = 16; +// Timeout for display acknowledgment during TFT upload (ms). +// A single value is used for all chunks; the happy path returns as soon as +// 0x05/0x08 arrives, so this only bounds failed-detection latency. Field +// reports showed the previous 500ms steady-state value was too tight for +// some firmware variants. +static constexpr uint32_t NEXTION_UPLOAD_ACK_TIMEOUT_MS = 5000; + // Followed guide // https://unofficialnextion.com/t/nextion-upload-protocol-v1-2-the-fast-one/1044/2 @@ -96,7 +103,7 @@ int Nextion::upload_by_chunks_(esp_http_client_handle_t http_client, uint32_t &r recv_string.clear(); this->write_array(buffer, buffer_size); App.feed_wdt(); - this->recv_ret_string_(recv_string, upload_first_chunk_sent_ ? 500 : 5000, true); + this->recv_ret_string_(recv_string, NEXTION_UPLOAD_ACK_TIMEOUT_MS, true); this->content_length_ -= read_len; const float upload_percentage = 100.0f * (this->tft_size_ - this->content_length_) / this->tft_size_; #ifdef USE_PSRAM @@ -109,7 +116,7 @@ int Nextion::upload_by_chunks_(esp_http_client_handle_t http_client, uint32_t &r #endif upload_first_chunk_sent_ = true; if (recv_string.empty()) { - ESP_LOGW(TAG, "No response from display during upload"); + ESP_LOGW(TAG, "No response from display after %" PRIu32 "ms", NEXTION_UPLOAD_ACK_TIMEOUT_MS); allocator.deallocate(buffer, 4096); buffer = nullptr; return -1; From 191d3bc7e400ddfe228587c1a512b60f7c03706a Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 28 Apr 2026 11:27:08 +1200 Subject: [PATCH 57/68] [esp32_touch] Feed wdt (#16066) --- esphome/components/esp32_touch/esp32_touch.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/esp32_touch/esp32_touch.cpp b/esphome/components/esp32_touch/esp32_touch.cpp index e44bc807e9a..54bbbe52ed8 100644 --- a/esphome/components/esp32_touch/esp32_touch.cpp +++ b/esphome/components/esp32_touch/esp32_touch.cpp @@ -216,6 +216,7 @@ void ESP32TouchComponent::setup() { // Do initial oneshot scans to populate baseline values for (uint32_t i = 0; i < ONESHOT_SCAN_COUNT; i++) { err = touch_sensor_trigger_oneshot_scanning(this->sens_handle_, ONESHOT_SCAN_TIMEOUT_MS); + App.feed_wdt(); // 3 scans with 2s timeout might exceed WDT, so feed it here to be safe if (err != ESP_OK) { ESP_LOGW(TAG, "Oneshot scan %" PRIu32 " failed: %s", i, esp_err_to_name(err)); } From 3ac0939f55a79655f52648696473806d84109b22 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Apr 2026 19:57:42 -0500 Subject: [PATCH 58/68] [image] Fix RGB565+alpha rendering for multi-frame animations (#16017) Co-authored-by: Claude --- esphome/components/animation/animation.cpp | 7 ++- esphome/components/image/__init__.py | 21 ++++--- tests/component_tests/image/test_init.py | 69 ++++++++++++++++++++++ 3 files changed, 89 insertions(+), 8 deletions(-) diff --git a/esphome/components/animation/animation.cpp b/esphome/components/animation/animation.cpp index c2ae3b2f768..2f59a7fa5a7 100644 --- a/esphome/components/animation/animation.cpp +++ b/esphome/components/animation/animation.cpp @@ -62,7 +62,12 @@ void Animation::set_frame(int frame) { } void Animation::update_data_start_() { - const uint32_t image_size = this->get_width_stride() * this->height_; + uint32_t image_size = this->get_width_stride() * this->height_; + // RGB565 with an alpha channel stores the alpha plane immediately after the RGB + // plane within each frame, so the per-frame stride includes the alpha bytes. + if (this->type_ == image::IMAGE_TYPE_RGB565 && this->transparency_ == image::TRANSPARENCY_ALPHA_CHANNEL) { + image_size += static_cast(this->width_) * this->height_; + } this->data_start_ = this->animation_data_start_ + image_size * this->current_frame_; } diff --git a/esphome/components/image/__init__.py b/esphome/components/image/__init__.py index 8375ab91d3e..365554f7d2d 100644 --- a/esphome/components/image/__init__.py +++ b/esphome/components/image/__init__.py @@ -744,21 +744,28 @@ async def write_image(config, all_frames=False): if frame_count <= 1: _LOGGER.warning("Image file %s has no animation frames", path) - total_rows = height * frame_count - encoder = IMAGE_TYPE[type](width, total_rows, transparency, dither, invert_alpha) - if byte_order := config.get(CONF_BYTE_ORDER): - # Check for valid type has already been done in validate_settings - encoder.set_big_endian(byte_order == "BIG_ENDIAN") + # Encode each frame with its own encoder and concatenate. This keeps every + # frame self-contained on disk (e.g. RGB565+alpha emits [RGB plane | alpha plane] + # per frame) so animation frame stepping in image.cpp / animation.cpp stays + # correct without needing to know the total frame count. + byte_order = config.get(CONF_BYTE_ORDER) + combined_data: list[int] = [] + encoder: ImageEncoder | None = None for frame_index in range(frame_count): image.seek(frame_index) + encoder = IMAGE_TYPE[type](width, height, transparency, dither, invert_alpha) + if byte_order is not None: + # Check for valid type has already been done in validate_settings + encoder.set_big_endian(byte_order == "BIG_ENDIAN") pixels = encoder.convert(image.resize((width, height)), path).getdata() for row in range(height): for col in range(width): encoder.encode(pixels[row * width + col]) encoder.end_row() - encoder.end_image() + encoder.end_image() + combined_data.extend(encoder.data) - rhs = [HexInt(x) for x in encoder.data] + rhs = [HexInt(x) for x in combined_data] prog_arr = cg.progmem_array(config[CONF_RAW_DATA_ID], rhs) image_type = get_image_type_enum(type) trans_value = get_transparency_enum(encoder.transparency) diff --git a/tests/component_tests/image/test_init.py b/tests/component_tests/image/test_init.py index 6f73888c7d1..f7f60a1f4d5 100644 --- a/tests/component_tests/image/test_init.py +++ b/tests/component_tests/image/test_init.py @@ -7,10 +7,12 @@ from pathlib import Path from typing import Any from unittest.mock import MagicMock, patch +from PIL import Image as PILImage import pytest from esphome import config_validation as cv from esphome.components.image import ( + CONF_ALPHA_CHANNEL, CONF_INVERT_ALPHA, CONF_OPAQUE, CONF_TRANSPARENCY, @@ -411,3 +413,70 @@ async def test_svg_with_mm_dimensions_succeeds( assert 30 < height < 50, ( f"Height should be around 39 pixels for 10mm at 100dpi, got {height}" ) + + +@pytest.mark.asyncio +async def test_rgb565_alpha_animation_layout_per_frame( + tmp_path: Path, + mock_progmem_array: MagicMock, +) -> None: + """RGB565+alpha animations must store each frame as a self-contained + [RGB plane | alpha plane] block. Animation::update_data_start_ steps frames + with a single per-frame stride, so any cross-frame layout (all RGB then all + alpha) makes the C++ alpha read land in the next frame's RGB bytes — that + was the regression behind issue #15999. + """ + # Build a 2-frame APNG where each frame is a solid color with a known + # alpha. APNG preserves full RGBA per pixel (GIF only has 1-bit alpha so + # round-tripping mid-range alpha values does not work). Frame 0 is fully + # opaque red, frame 1 is fully transparent blue. + width = 4 + height = 3 + frame0 = PILImage.new("RGBA", (width, height), (255, 0, 0, 0xFF)) + frame1 = PILImage.new("RGBA", (width, height), (0, 0, 255, 0x00)) + apng_path = tmp_path / "anim.png" + frame0.save( + apng_path, + format="PNG", + save_all=True, + append_images=[frame1], + duration=100, + loop=0, + ) + + config = { + CONF_FILE: str(apng_path), + CONF_TYPE: "RGB565", + CONF_TRANSPARENCY: CONF_ALPHA_CHANNEL, + CONF_DITHER: "NONE", + CONF_INVERT_ALPHA: False, + CONF_RAW_DATA_ID: "test_raw_data_id", + } + + _, _, _, _, _, frame_count = await write_image(config, all_frames=True) + assert frame_count == 2 + + # Recover the bytes handed to progmem_array. Signature is (id_, rhs). + _, raw_data = mock_progmem_array.call_args.args + data = [int(x) for x in raw_data] + + rgb_size = width * height * 2 + alpha_size = width * height + frame_size = rgb_size + alpha_size + assert len(data) == frame_size * frame_count, ( + "RGB565+alpha animation buffer must be (RGB + alpha) per frame, not " + "all RGB followed by all alpha" + ) + + # Frame 0: RGB plane is red, alpha plane is 0xFF. Frame 1: alpha plane is + # 0x00. If the layout regresses to [all RGB | all alpha], the alpha bytes + # would all land at the tail of the buffer and the per-frame slices below + # would point at RGB565 noise instead. + frame0_alpha = data[rgb_size : rgb_size + alpha_size] + frame1_alpha = data[frame_size + rgb_size : frame_size + rgb_size + alpha_size] + assert all(a == 0xFF for a in frame0_alpha), ( + f"Frame 0 alpha plane should be opaque, got {frame0_alpha}" + ) + assert all(a == 0x00 for a in frame1_alpha), ( + f"Frame 1 alpha plane should be transparent, got {frame1_alpha}" + ) From 95b5ab7e78fabb794c69eb04669279fdfe9d767b Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 28 Apr 2026 12:58:29 +1200 Subject: [PATCH 59/68] Bump version to 2026.4.3 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 1cd12551dd1..0e6a845ed88 100644 --- a/Doxyfile +++ b/Doxyfile @@ -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.4.2 +PROJECT_NUMBER = 2026.4.3 # 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 diff --git a/esphome/const.py b/esphome/const.py index ef37cb2df6c..89b6ff15ee9 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.4.2" +__version__ = "2026.4.3" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From a03de7cea2fa0747a59fe7502f4d072fa68cf25e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Apr 2026 20:23:08 -0500 Subject: [PATCH 60/68] [core] Freshen loop_component_start_time_ before scheduler dispatch (#16064) --- esphome/core/application.h | 4 ++++ esphome/core/scheduler.cpp | 2 ++ 2 files changed, 6 insertions(+) diff --git a/esphome/core/application.h b/esphome/core/application.h index 185ee4163b1..221081a0e40 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -377,12 +377,16 @@ class Application { protected: friend Component; + friend class Scheduler; #ifdef USE_RUNTIME_STATS friend class runtime_stats::RuntimeStatsCollector; #endif friend void ::setup(); friend void ::original_setup(); + /// Freshen the cached loop component start time. Called by Scheduler before each dispatch. + void set_loop_component_start_time_(uint32_t now) { this->loop_component_start_time_ = now; } + /// Walk all registered components looking for any whose component_state_ /// has the given flag set. Used by Component::status_clear_*_slow_path_() /// (which is a friend) to decide whether to clear the corresponding bit on diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index d83d67d6e42..11884ce4ba9 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -772,6 +772,8 @@ Scheduler::SchedulerItem *HOT Scheduler::pop_raw_locked_() { // Helper to execute a scheduler item uint32_t HOT Scheduler::execute_item_(SchedulerItem *item, uint32_t now) { App.set_current_component(item->component); + // Freshen so callbacks reading App.get_loop_component_start_time() see this item's dispatch time. + App.set_loop_component_start_time_(now); WarnIfComponentBlockingGuard guard{item->component, now}; item->callback(); uint32_t end = guard.finish(); From 42c9fdc87ef42eb7e5f894abe587d2f321915d8a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Apr 2026 23:39:08 -0500 Subject: [PATCH 61/68] [feedback] Use App.get_loop_component_start_time() and constexpr timeout id (#16063) --- .../components/feedback/feedback_cover.cpp | 20 +++++++++---------- esphome/components/feedback/feedback_cover.h | 6 ++---- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/esphome/components/feedback/feedback_cover.cpp b/esphome/components/feedback/feedback_cover.cpp index 1dff210cd6b..672e99949b7 100644 --- a/esphome/components/feedback/feedback_cover.cpp +++ b/esphome/components/feedback/feedback_cover.cpp @@ -3,11 +3,12 @@ #include "esphome/core/log.h" #include "esphome/core/application.h" -namespace esphome { -namespace feedback { +namespace esphome::feedback { static const char *const TAG = "feedback.cover"; +static constexpr uint32_t DIRECTION_CHANGE_TIMEOUT_ID = 1; + using namespace esphome::cover; void FeedbackCover::setup() { @@ -37,7 +38,7 @@ void FeedbackCover::setup() { } #endif - this->last_recompute_time_ = this->start_dir_time_ = millis(); + this->last_recompute_time_ = this->start_dir_time_ = App.get_loop_component_start_time(); } CoverTraits FeedbackCover::get_traits() { @@ -135,7 +136,7 @@ void FeedbackCover::set_close_endstop(binary_sensor::BinarySensor *close_endstop #endif void FeedbackCover::endstop_reached_(bool open_endstop) { - const uint32_t now = millis(); + const uint32_t now = App.get_loop_component_start_time(); this->position = open_endstop ? COVER_OPEN : COVER_CLOSED; @@ -174,7 +175,7 @@ void FeedbackCover::set_current_operation_(cover::CoverOperation operation, bool if (!is_triggered || (this->open_feedback_ == nullptr || this->close_feedback_ == nullptr)) #endif { - auto now = millis(); + const uint32_t now = App.get_loop_component_start_time(); this->current_operation = operation; this->start_dir_time_ = this->last_recompute_time_ = now; this->publish_state(); @@ -306,7 +307,7 @@ void FeedbackCover::control(const CoverCall &call) { void FeedbackCover::stop_prev_trigger_() { if (this->direction_change_waittime_.has_value()) { - this->cancel_timeout("direction_change"); + this->cancel_timeout(DIRECTION_CHANGE_TIMEOUT_ID); } if (this->prev_command_trigger_ != nullptr) { this->prev_command_trigger_->stop_action(); @@ -377,7 +378,7 @@ void FeedbackCover::start_direction_(CoverOperation dir) { ESP_LOGD(TAG, "'%s' - Reversing direction.", this->name_.c_str()); this->start_direction_(COVER_OPERATION_IDLE); - this->set_timeout("direction_change", *this->direction_change_waittime_, + this->set_timeout(DIRECTION_CHANGE_TIMEOUT_ID, *this->direction_change_waittime_, [this, dir]() { this->start_direction_(dir); }); } else { @@ -395,7 +396,7 @@ void FeedbackCover::recompute_position_() { if (this->current_operation == COVER_OPERATION_IDLE) return; - const uint32_t now = millis(); + const uint32_t now = App.get_loop_component_start_time(); float dir; float action_dur; float min_pos; @@ -451,5 +452,4 @@ void FeedbackCover::recompute_position_() { this->last_recompute_time_ = now; } -} // namespace feedback -} // namespace esphome +} // namespace esphome::feedback diff --git a/esphome/components/feedback/feedback_cover.h b/esphome/components/feedback/feedback_cover.h index 6be8939413d..ed6f7490f8b 100644 --- a/esphome/components/feedback/feedback_cover.h +++ b/esphome/components/feedback/feedback_cover.h @@ -8,8 +8,7 @@ #endif #include "esphome/components/cover/cover.h" -namespace esphome { -namespace feedback { +namespace esphome::feedback { class FeedbackCover : public cover::Cover, public Component { public: @@ -85,5 +84,4 @@ class FeedbackCover : public cover::Cover, public Component { uint32_t update_interval_{1000}; }; -} // namespace feedback -} // namespace esphome +} // namespace esphome::feedback From 792f2e83630088536f33bf6d79d07fb75a15078b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Apr 2026 00:29:42 -0500 Subject: [PATCH 62/68] [ota] Add wall-clock timeout to OTA data transfer loop (#16047) --- esphome/components/esphome/ota/ota_esphome.cpp | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 47f661a8eaa..be771eb6899 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -292,6 +292,7 @@ void ESPHomeOTAComponent::handle_data_() { bool update_started = false; size_t total = 0; uint32_t last_progress = 0; + uint32_t last_data_ms = 0; uint8_t buf[OTA_BUFFER_SIZE]; char *sbuf = reinterpret_cast(buf); size_t ota_size; @@ -350,8 +351,18 @@ void ESPHomeOTAComponent::handle_data_() { // Acknowledge MD5 OK - 1 byte this->write_byte_(ota::OTA_RESPONSE_BIN_MD5_OK); + // Track when we last received data so a silently-vanished peer (no FIN/RST + // delivered, e.g. uploader killed mid-transfer or NAT/router dropped state) + // can't wedge the device indefinitely. Without this, the loop only exits + // on actual data, EOF, or a non-EWOULDBLOCK error from read(), and lwIP + // TCP keepalive isn't enabled here. + last_data_ms = millis(); while (total < ota_size) { - // TODO: timeout check + if (millis() - last_data_ms > OTA_SOCKET_TIMEOUT_DATA) { + ESP_LOGW(TAG, "No data received for %u ms", (unsigned) OTA_SOCKET_TIMEOUT_DATA); + error_code = ota::OTA_RESPONSE_ERROR_UNKNOWN; + goto error; // NOLINT(cppcoreguidelines-avoid-goto) + } size_t remaining = ota_size - total; size_t requested = remaining < OTA_BUFFER_SIZE ? remaining : OTA_BUFFER_SIZE; ssize_t read = this->client_->read(buf, requested); @@ -369,6 +380,7 @@ void ESPHomeOTAComponent::handle_data_() { goto error; // NOLINT(cppcoreguidelines-avoid-goto) } + last_data_ms = millis(); error_code = this->backend_->write(buf, read); if (error_code != ota::OTA_RESPONSE_OK) { ESP_LOGW(TAG, "Flash write err %d", error_code); From 49d3df2698b91f42cdeb8d723a5cc01c18467000 Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Tue, 28 Apr 2026 05:27:20 -0500 Subject: [PATCH 63/68] [automation] Fix codegen type for component.resume update_interval (#16069) Co-authored-by: Claude Opus 4.7 (1M context) --- esphome/automation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/automation.py b/esphome/automation.py index 97d9a0a47a8..20eb9358cad 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -597,7 +597,7 @@ async def component_resume_action_to_code( comp = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, comp) if CONF_UPDATE_INTERVAL in config: - template_ = await cg.templatable(config[CONF_UPDATE_INTERVAL], args, int) + template_ = await cg.templatable(config[CONF_UPDATE_INTERVAL], args, cg.uint32) cg.add(var.set_update_interval(template_)) return var From 41458d72e00f56f3a9850a2191b47bc4384c5d3b Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Tue, 28 Apr 2026 14:58:34 +0400 Subject: [PATCH 64/68] [esp32] Make Arduino app metadata reproducible (#16053) --- esphome/components/esp32/__init__.py | 9 +++--- .../config/reproducible_build_arduino.yaml | 8 ++++++ tests/component_tests/esp32/test_esp32.py | 28 +++++++++++++++++++ 3 files changed, 41 insertions(+), 4 deletions(-) create mode 100644 tests/component_tests/esp32/config/reproducible_build_arduino.yaml diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 78a1715ccfd..eb023ce32c3 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1724,15 +1724,16 @@ async def to_code(config): CORE.relative_internal_path(".espressif") ) + # Both ESP-IDF and ESP32 Arduino builds generate IDF app metadata. Keep + # volatile build path/time data out of the binary so equivalent projects can + # produce reproducible outputs and downstream tooling can reuse artifacts. + add_idf_sdkconfig_option("CONFIG_APP_REPRODUCIBLE_BUILD", True) + if conf[CONF_TYPE] == FRAMEWORK_ESP_IDF: cg.add_build_flag("-DUSE_ESP_IDF") cg.add_build_flag("-DUSE_ESP32_FRAMEWORK_ESP_IDF") if use_platformio: cg.add_platformio_option("framework", "espidf") - # Strip volatile build path/time metadata from PlatformIO-managed - # ESP-IDF builds so equivalent projects can produce reproducible - # outputs and downstream tooling can safely reuse artifacts. - add_idf_sdkconfig_option("CONFIG_APP_REPRODUCIBLE_BUILD", True) # Wrap std::__throw_* functions to abort immediately, eliminating ~3KB of # exception class overhead. See throw_stubs.cpp for implementation. diff --git a/tests/component_tests/esp32/config/reproducible_build_arduino.yaml b/tests/component_tests/esp32/config/reproducible_build_arduino.yaml new file mode 100644 index 00000000000..a5433a441d2 --- /dev/null +++ b/tests/component_tests/esp32/config/reproducible_build_arduino.yaml @@ -0,0 +1,8 @@ +esphome: + name: test + +esp32: + board: esp32dev + variant: esp32 + framework: + type: arduino diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index c39a4aafc88..203f4841072 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -16,6 +16,7 @@ from esphome.const import ( CONF_ESPHOME, CONF_IGNORE_PIN_VALIDATION_ERROR, CONF_NUMBER, + KEY_NATIVE_IDF, PlatformFramework, ) from esphome.core import CORE @@ -243,3 +244,30 @@ def test_platformio_idf_enables_reproducible_build( sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] assert sdkconfig.get("CONFIG_APP_REPRODUCIBLE_BUILD") is True + + +def test_platformio_arduino_enables_reproducible_build( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Test PlatformIO Arduino builds enable reproducible app metadata.""" + generate_main(component_config_path("reproducible_build_arduino.yaml")) + + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert sdkconfig.get("CONFIG_APP_REPRODUCIBLE_BUILD") is True + + +def test_native_idf_enables_reproducible_build( + component_config_path: Callable[[str], Path], +) -> None: + """Test native ESP-IDF builds enable reproducible app metadata.""" + from esphome.__main__ import generate_cpp_contents + from esphome.config import read_config + + CORE.config_path = component_config_path("reproducible_build.yaml") + CORE.config = read_config({}) + CORE.data[KEY_NATIVE_IDF] = True + generate_cpp_contents(CORE.config) + + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert sdkconfig.get("CONFIG_APP_REPRODUCIBLE_BUILD") is True From 876c8c4c2a160f0aa7cd558c1ea452cad0fb59a3 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 28 Apr 2026 22:59:02 +1200 Subject: [PATCH 65/68] [ci-custom] Lint imports of esphome.components.const outside components (#16068) Co-authored-by: Claude Opus 4.7 (1M context) --- script/ci-custom.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/script/ci-custom.py b/script/ci-custom.py index 4d71df74cfa..b257a3818bd 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -511,6 +511,40 @@ def lint_no_std_string_view(fname, match): ) +@lint_re_check( + r"(?:" + # `from esphome.components.const import ...` + r"from\s+esphome\.components\.const\s+import" + r"|" + # `import esphome.components.const` (with optional `as` alias) + r"import\s+esphome\.components\.const\b" + r"|" + # `from esphome.components import [(] ... const ... [)]` + # Handles parenthesized + multiline import lists by allowing newlines inside + # the parens via [^)]*. Single-line form falls back to the [^#\n]* branch. + r"from\s+esphome\.components\s+import\s*" + r"(?:\([^)]*\bconst\b[^)]*\)|(?:[^#\n]*[\s,])?\bconst\b)" + r")", + include=["*.py"], + exclude=[ + "esphome/components/*", + "tests/*", + "script/ci-custom.py", + ], +) +def lint_no_components_const_outside_components(fname, match): + return ( + f"Constants in {highlight('esphome/components/const/__init__.py')} are intended " + f"to be shared only between components in {highlight('esphome/components/')}. " + f"Code outside this folder must not import from " + f"{highlight('esphome.components.const')}.\n" + f"For core code (used outside {highlight('esphome/components/')}), define the " + f"constant in {highlight('esphome/const.py')} instead. When adding a new " + f"{highlight('CONF_')} constant there, bump {highlight('CONST_PY_MAX_CONF')} " + f"in this file accordingly (see {highlight('lint_const_py_frozen')})." + ) + + @lint_post_check def lint_constants_usage(): errs = [] From 52f80618d4b8e1b8f206bd360ca4f76116a69b7d Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Tue, 28 Apr 2026 22:00:29 +1000 Subject: [PATCH 66/68] [lvgl] Allow a binary sensor to report checked or pressed state (#16073) Co-authored-by: J. Nick Koston --- .../components/lvgl/binary_sensor/__init__.py | 35 ++++++++++++++----- tests/components/lvgl/lvgl-package.yaml | 15 ++++++++ 2 files changed, 41 insertions(+), 9 deletions(-) diff --git a/esphome/components/lvgl/binary_sensor/__init__.py b/esphome/components/lvgl/binary_sensor/__init__.py index f9df7d23fa3..aa68e764211 100644 --- a/esphome/components/lvgl/binary_sensor/__init__.py +++ b/esphome/components/lvgl/binary_sensor/__init__.py @@ -4,15 +4,25 @@ from esphome.components.binary_sensor import ( new_binary_sensor, ) import esphome.config_validation as cv +from esphome.const import CONF_STATE -from ..defines import CONF_WIDGET -from ..lvcode import EVENT_ARG, LambdaContext, LvContext, lvgl_static -from ..types import LV_EVENT, lv_pseudo_button_t +from ..defines import CONF_WIDGET, LV_OBJ_FLAG, LvConstant +from ..lvcode import EVENT_ARG, UPDATE_EVENT, LambdaContext, LvContext, lvgl_static +from ..types import LV_EVENT, LV_STATE, lv_pseudo_button_t from ..widgets import Widget, get_widgets, wait_for_widgets +STATE_PRESSED = "PRESSED" +STATE_CHECKED = "CHECKED" + +BS_STATE = LvConstant( + "LV_STATE_", + STATE_PRESSED, + STATE_CHECKED, +) CONFIG_SCHEMA = binary_sensor_schema(BinarySensor).extend( { cv.Required(CONF_WIDGET): cv.use_id(lv_pseudo_button_t), + cv.Optional(CONF_STATE, default=STATE_PRESSED): BS_STATE.one_of, } ) @@ -22,16 +32,23 @@ async def to_code(config): widget = await get_widgets(config, CONF_WIDGET) widget = widget[0] assert isinstance(widget, Widget) + state = await BS_STATE.process(config[CONF_STATE]) await wait_for_widgets() - async with LambdaContext(EVENT_ARG) as pressed_ctx: - pressed_ctx.add(sensor.publish_state(widget.is_pressed())) + is_pressed = str(state) == str(LV_STATE.PRESSED) + test_expr = widget.is_pressed() if is_pressed else widget.is_checked() + async with LambdaContext(EVENT_ARG) as test_ctx: + test_ctx.add(sensor.publish_state(test_expr)) async with LvContext() as ctx: - ctx.add(sensor.publish_initial_state(widget.is_pressed())) + ctx.add(sensor.publish_initial_state(test_expr)) + if is_pressed: + events = [LV_EVENT.PRESSED, LV_EVENT.RELEASED] + widget.add_flag(LV_OBJ_FLAG.CLICKABLE) + else: + events = [LV_EVENT.VALUE_CHANGED, UPDATE_EVENT] ctx.add( lvgl_static.add_event_cb( widget.obj, - await pressed_ctx.get_lambda(), - LV_EVENT.PRESSED, - LV_EVENT.RELEASED, + await test_ctx.get_lambda(), + *events, ) ) diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index d3565c6c59a..d6e237199ae 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -16,10 +16,19 @@ binary_sensor: platform: template - id: left_sensor platform: template + - platform: lvgl + name: Button A pressed + widget: button_a + state: pressed + - platform: lvgl + name: Button A checked + widget: button_a + state: checked - platform: lvgl id: button_checker name: LVGL button widget: button_button + state: checked on_state: then: - lvgl.checkbox.update: @@ -29,6 +38,12 @@ binary_sensor: auto y = x; // block inlining of one line return return y; + - platform: lvgl + id: button_presser + name: Button pressed + widget: button_button + state: pressed + lvgl: id: lvgl_id rotation: 90 From 8921e3bb3f821fff077300f04d7d52bc86f41997 Mon Sep 17 00:00:00 2001 From: Egor Vorontsov Date: Tue, 28 Apr 2026 15:49:16 +0300 Subject: [PATCH 67/68] [api] add open states for `lock` to `api.proto` (#15901) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- esphome/components/api/api.proto | 2 ++ esphome/components/api/api_pb2.h | 2 ++ esphome/components/api/api_pb2_dump.cpp | 4 ++++ 3 files changed, 8 insertions(+) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 1c33d92bea4..c0fd990eca9 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -1419,6 +1419,8 @@ enum LockState { LOCK_STATE_JAMMED = 3; LOCK_STATE_LOCKING = 4; LOCK_STATE_UNLOCKING = 5; + LOCK_STATE_OPENING = 6; + LOCK_STATE_OPEN = 7; } enum LockCommand { LOCK_UNLOCK = 0; diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index a8e01c017fe..7b82f1884d1 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -181,6 +181,8 @@ enum LockState : uint32_t { LOCK_STATE_JAMMED = 3, LOCK_STATE_LOCKING = 4, LOCK_STATE_UNLOCKING = 5, + LOCK_STATE_OPENING = 6, + LOCK_STATE_OPEN = 7, }; enum LockCommand : uint32_t { LOCK_UNLOCK = 0, diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 541f5d4d11c..5258b355ceb 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -487,6 +487,10 @@ template<> const char *proto_enum_to_string(enums::LockState v return ESPHOME_PSTR("LOCK_STATE_LOCKING"); case enums::LOCK_STATE_UNLOCKING: return ESPHOME_PSTR("LOCK_STATE_UNLOCKING"); + case enums::LOCK_STATE_OPENING: + return ESPHOME_PSTR("LOCK_STATE_OPENING"); + case enums::LOCK_STATE_OPEN: + return ESPHOME_PSTR("LOCK_STATE_OPEN"); default: return ESPHOME_PSTR("UNKNOWN"); } From 0759a3c6815e88fd333fef2ed843027ce24d445a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Apr 2026 08:48:13 -0500 Subject: [PATCH 68/68] [core] Split wake.{h,cpp} into per-platform files (#15978) --- esphome/core/config.py | 23 ++ esphome/core/defines.h | 15 +- esphome/core/time_64.cpp | 4 +- esphome/core/time_64.h | 14 +- esphome/core/wake.h | 214 ++---------------- esphome/core/wake/wake_esp8266.cpp | 21 ++ esphome/core/wake/wake_esp8266.h | 47 ++++ esphome/core/wake/wake_freertos.cpp | 33 +++ esphome/core/wake/wake_freertos.h | 60 +++++ esphome/core/wake/wake_generic.cpp | 17 ++ esphome/core/wake/wake_generic.h | 31 +++ esphome/core/{wake.cpp => wake/wake_host.cpp} | 89 +------- esphome/core/wake/wake_host.h | 64 ++++++ esphome/core/wake/wake_rp2040.cpp | 58 +++++ esphome/core/wake/wake_rp2040.h | 31 +++ esphome/loader.py | 50 ++-- tests/unit_tests/test_loader.py | 164 ++++++++++++++ 17 files changed, 632 insertions(+), 303 deletions(-) create mode 100644 esphome/core/wake/wake_esp8266.cpp create mode 100644 esphome/core/wake/wake_esp8266.h create mode 100644 esphome/core/wake/wake_freertos.cpp create mode 100644 esphome/core/wake/wake_freertos.h create mode 100644 esphome/core/wake/wake_generic.cpp create mode 100644 esphome/core/wake/wake_generic.h rename esphome/core/{wake.cpp => wake/wake_host.cpp} (74%) create mode 100644 esphome/core/wake/wake_host.h create mode 100644 esphome/core/wake/wake_rp2040.cpp create mode 100644 esphome/core/wake/wake_rp2040.h diff --git a/esphome/core/config.py b/esphome/core/config.py index 018e05f17b4..14161a7c8b6 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -792,6 +792,29 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( PlatformFramework.RTL87XX_ARDUINO, PlatformFramework.LN882X_ARDUINO, }, + # Per-platform wake implementations — wake.h dispatches to exactly one of + # these based on USE_*, so the others can be skipped at the source level + # too. Header files next to each .cpp are always copied (the dispatcher + # #include's them) but compile to empty TUs on the wrong platform anyway. + "wake/wake_freertos.cpp": { + PlatformFramework.ESP32_ARDUINO, + PlatformFramework.ESP32_IDF, + PlatformFramework.BK72XX_ARDUINO, + PlatformFramework.RTL87XX_ARDUINO, + PlatformFramework.LN882X_ARDUINO, + }, + "wake/wake_esp8266.cpp": { + PlatformFramework.ESP8266_ARDUINO, + }, + "wake/wake_rp2040.cpp": { + PlatformFramework.RP2040_ARDUINO, + }, + "wake/wake_host.cpp": { + PlatformFramework.HOST_NATIVE, + }, + "wake/wake_generic.cpp": { + PlatformFramework.NRF52_ZEPHYR, + }, # Note: lock_free_queue.h and event_pool.h are header files and don't need to be filtered # as they are only included when needed by the preprocessor } diff --git a/esphome/core/defines.h b/esphome/core/defines.h index f929b224ca4..daca55d68a0 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -17,8 +17,21 @@ #define ESPHOME_DEBUG_SCHEDULER #define ESPHOME_DEBUG_API -// Default threading model for static analysis (ESP32 is multi-threaded with atomics) +// Threading model for static analysis. Match what the real codegen picks per +// platform (see esphome/components//__init__.py ThreadModel.*): +// USE_ESP8266 / USE_RP2040 / USE_NRF52 → SINGLE +// USE_BK72XX (ARMv5TE, no LDREX/STREX) → MULTI_NO_ATOMICS +// everything else (ESP32, host, RTL87XX, LN882X) → MULTI_ATOMICS +// Without this the clang-tidy envs end up with USE_ +// + MULTI_ATOMICS simultaneously, a combination that can never occur in a +// real build. +#if defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_NRF52) +#define ESPHOME_THREAD_SINGLE +#elif defined(USE_BK72XX) +#define ESPHOME_THREAD_MULTI_NO_ATOMICS +#else #define ESPHOME_THREAD_MULTI_ATOMICS +#endif // logger #define ESPHOME_LOG_LEVEL ESPHOME_LOG_LEVEL_VERY_VERBOSE diff --git a/esphome/core/time_64.cpp b/esphome/core/time_64.cpp index cf651c3e91a..25076228d5e 100644 --- a/esphome/core/time_64.cpp +++ b/esphome/core/time_64.cpp @@ -22,8 +22,8 @@ static const char *const TAG = "time_64"; #ifdef ESPHOME_THREAD_SINGLE // Storage for Millis64Impl inline compute() — defined here so all TUs share one copy. -uint32_t Millis64Impl::last_millis_{0}; -uint16_t Millis64Impl::millis_major_{0}; +uint32_t Millis64Impl::last_millis{0}; +uint16_t Millis64Impl::millis_major{0}; #else uint64_t Millis64Impl::compute(uint32_t now) { diff --git a/esphome/core/time_64.h b/esphome/core/time_64.h index 592e645d41a..d82373dbfe9 100644 --- a/esphome/core/time_64.h +++ b/esphome/core/time_64.h @@ -21,8 +21,8 @@ class Millis64Impl { #ifdef ESPHOME_THREAD_SINGLE // Storage defined in time_64.cpp — declared here so the inline body can access them. - static uint32_t last_millis_; - static uint16_t millis_major_; + static uint32_t last_millis; + static uint16_t millis_major; static inline uint64_t ESPHOME_ALWAYS_INLINE compute(uint32_t now) { // Half the 32-bit range - used to detect rollovers vs normal time progression @@ -30,17 +30,17 @@ class Millis64Impl { // Single-core platforms have no concurrency, so this is a simple implementation // that just tracks 32-bit rollover (every 49.7 days) without any locking or atomics. - uint16_t major = millis_major_; - uint32_t last = last_millis_; + uint16_t major = millis_major; + uint32_t last = last_millis; // Check for rollover if (now < last && (last - now) > HALF_MAX_UINT32) { - millis_major_++; + millis_major++; major++; - last_millis_ = now; + last_millis = now; } else if (now > last) { // Only update if time moved forward - last_millis_ = now; + last_millis = now; } // Combine major (high 32 bits) and now (low 32 bits) into 64-bit time diff --git a/esphome/core/wake.h b/esphome/core/wake.h index 0cfca94a78e..a2f732fcdbf 100644 --- a/esphome/core/wake.h +++ b/esphome/core/wake.h @@ -3,6 +3,10 @@ /// @file wake.h /// Platform-specific main loop wake primitives. /// Always available on all platforms — no opt-in needed. +/// +/// The public API for callers lives here; the per-platform implementations +/// live under esphome/core/wake/ and are included at the bottom of this file +/// based on the active USE_* platform define. #include "esphome/core/defines.h" #include "esphome/core/hal.h" @@ -11,21 +15,6 @@ #include #endif -#if defined(USE_ESP32) || defined(USE_LIBRETINY) -#include "esphome/core/main_task.h" -#endif -#ifdef USE_ESP8266 -#include -#elif defined(USE_RP2040) -#include -#include -#endif - -#ifdef USE_HOST -#include -#include -#endif - namespace esphome { // === Wake flag for ESP8266/RP2040 === @@ -67,184 +56,19 @@ __attribute__((always_inline)) inline bool wake_request_take() { } #endif -// === ESP32 / LibreTiny (FreeRTOS) === -#if defined(USE_ESP32) || defined(USE_LIBRETINY) - -/// Wake the main loop from any context (ISR or task). -/// always_inline so callers placed in IRAM keep the whole wake path in IRAM. -__attribute__((always_inline)) inline void wake_main_task_any_context() { - // Set the wake-requested flag BEFORE the task notification so the consumer - // (Application::loop() gate) is guaranteed to see it on its next gate check. - wake_request_set(); - if (in_isr_context()) { - BaseType_t px_higher_priority_task_woken = pdFALSE; - esphome_main_task_notify_from_isr(&px_higher_priority_task_woken); -#ifdef portYIELD_FROM_ISR - portYIELD_FROM_ISR(px_higher_priority_task_woken); -#else - // ARM9 FreeRTOS port (BK72xx) does not define portYIELD_FROM_ISR; the IRQ - // exit sequence performs the context switch if one was requested. - (void) px_higher_priority_task_woken; -#endif - } else { - esphome_main_task_notify(); - } -} - -/// IRAM_ATTR entry points — defined in wake.cpp. -void wake_loop_isrsafe(BaseType_t *px_higher_priority_task_woken); -void wake_loop_any_context(); - -inline void wake_loop_threadsafe() { - wake_request_set(); - esphome_main_task_notify(); -} - -namespace internal { -inline void ESPHOME_ALWAYS_INLINE wakeable_delay(uint32_t ms) { - // Fast path (with USE_LWIP_FAST_SELECT): FreeRTOS task notifications posted by the lwip - // event_callback wrapper (see lwip_fast_select.c) are the single source of truth for - // socket wake-ups. Every NETCONN_EVT_RCVPLUS posts an xTaskNotifyGive, so any notification - // that lands between wakes keeps the counter non-zero (next ulTaskNotifyTake returns - // immediately) or wakes a blocked Take directly. Additional wake sources: - // wake_loop_threadsafe() from background tasks, and the ms timeout. - if (ms == 0) [[unlikely]] { - yield(); - return; - } - ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(ms)); -} -} // namespace internal - -// === ESP8266 === -#elif defined(USE_ESP8266) - -/// Inline implementation — IRAM callers inline this directly. -inline void ESPHOME_ALWAYS_INLINE wake_loop_impl() { - // Set the wake-requested flag BEFORE esp_schedule so the consumer is - // guaranteed to see it on its next gate check. - wake_request_set(); - g_main_loop_woke = true; - esp_schedule(); -} - -/// IRAM_ATTR entry point for ISR callers — defined in wake.cpp. -void wake_loop_any_context(); - -/// Non-ISR: always inline. -inline void wake_loop_threadsafe() { wake_loop_impl(); } - -/// ISR-safe: no task_woken arg because ESP8266 has no FreeRTOS. Caller must be IRAM_ATTR. -inline void ESPHOME_ALWAYS_INLINE wake_loop_isrsafe() { wake_loop_impl(); } - -namespace internal { -inline void ESPHOME_ALWAYS_INLINE wakeable_delay(uint32_t ms) { - if (ms == 0) [[unlikely]] { - delay(0); - return; - } - if (g_main_loop_woke) { - g_main_loop_woke = false; - return; - } - esp_delay(ms, []() { return !g_main_loop_woke; }); -} -} // namespace internal - -// === RP2040 === -#elif defined(USE_RP2040) - -inline void wake_loop_any_context() { - // Set the wake-requested flag BEFORE the SEV so the consumer is guaranteed - // to see it on its next gate check. - wake_request_set(); - g_main_loop_woke = true; - __sev(); -} - -inline void wake_loop_threadsafe() { wake_loop_any_context(); } - -/// RP2040 wakeable delay uses file-scope state (alarm callback + flag) — defined in wake.cpp. -namespace internal { -void wakeable_delay(uint32_t ms); -} // namespace internal - -// === Host / Zephyr / other === -#else - -#ifdef USE_HOST -/// Host: wakes select() via UDP loopback socket. Defined in wake.cpp. -void wake_loop_threadsafe(); - -/// Register a socket file descriptor with the host select() loop. Not -/// thread-safe — main loop only. Returns false if fd is invalid or -/// >= FD_SETSIZE. -bool wake_register_fd(int fd); - -/// Unregister a socket file descriptor. Not thread-safe — main loop only. -void wake_unregister_fd(int fd); - -/// One-time setup of the loopback wake socket. Called from Application::setup(). -void wake_setup(); - -// wake_fd_ready() and wake_drain_notifications() are defined inline at the -// bottom of this file — they need internal::g_read_fds / g_wake_socket_fd in -// scope, which depend on USE_HOST-only includes pulled in above. -#else -/// Zephyr is currently the only platform without a wake mechanism. -/// wake_loop_threadsafe() is a no-op and wakeable_delay() falls back to delay(). -/// TODO: implement proper Zephyr wake using k_poll / k_sem or similar. -inline void wake_loop_threadsafe() {} -#endif - -inline void wake_loop_any_context() { wake_loop_threadsafe(); } - -namespace internal { -#ifdef USE_HOST -/// Host wakeable_delay uses select() over the registered fds — defined in wake.cpp. -void wakeable_delay(uint32_t ms); -#else -inline void ESPHOME_ALWAYS_INLINE wakeable_delay(uint32_t ms) { - if (ms == 0) [[unlikely]] { - yield(); - return; - } - delay(ms); -} -#endif -} // namespace internal - -#endif - -#ifdef USE_HOST -namespace internal { -// File-scope state owned by wake.cpp. Accessed inline by wake_drain_notifications() -// and wake_fd_ready() so the hot path stays in the header. -// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) -extern int g_wake_socket_fd; -// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) -extern fd_set g_read_fds; -} // namespace internal - -inline bool ESPHOME_ALWAYS_INLINE wake_fd_ready(int fd) { return FD_ISSET(fd, &internal::g_read_fds); } - -// Small buffer for draining wake notification bytes (1 byte sent per wake). -// Sized to drain multiple notifications per recvfrom() without wasting stack. -inline constexpr size_t WAKE_NOTIFY_DRAIN_BUFFER_SIZE = 16; - -inline void ESPHOME_ALWAYS_INLINE wake_drain_notifications() { - // Called from main loop to drain any pending wake notifications. - // Must check wake_fd_ready() to avoid blocking on empty socket. - if (internal::g_wake_socket_fd >= 0 && wake_fd_ready(internal::g_wake_socket_fd)) { - char buffer[WAKE_NOTIFY_DRAIN_BUFFER_SIZE]; - // Drain all pending notifications with non-blocking reads. Multiple wake events - // may have triggered multiple writes, so drain until EWOULDBLOCK. We control - // both ends of this loopback socket (always 1 byte per wake), so no error - // checking — any error indicates catastrophic system failure. - while (::recvfrom(internal::g_wake_socket_fd, buffer, sizeof(buffer), 0, nullptr, nullptr) > 0) { - } - } -} -#endif // USE_HOST - } // namespace esphome + +// Per-platform implementations. Each header re-enters namespace esphome {} and +// guards its body with the matching USE_* check, so only one contributes code +// for the active target. +#if defined(USE_ESP32) || defined(USE_LIBRETINY) +#include "esphome/core/wake/wake_freertos.h" +#elif defined(USE_ESP8266) +#include "esphome/core/wake/wake_esp8266.h" +#elif defined(USE_RP2040) +#include "esphome/core/wake/wake_rp2040.h" +#elif defined(USE_HOST) +#include "esphome/core/wake/wake_host.h" +#else +#include "esphome/core/wake/wake_generic.h" +#endif diff --git a/esphome/core/wake/wake_esp8266.cpp b/esphome/core/wake/wake_esp8266.cpp new file mode 100644 index 00000000000..9ced43c6dff --- /dev/null +++ b/esphome/core/wake/wake_esp8266.cpp @@ -0,0 +1,21 @@ +#include "esphome/core/defines.h" + +#ifdef USE_ESP8266 + +#include "esphome/core/hal.h" +#include "esphome/core/wake.h" + +namespace esphome { + +// === Wake-requested flag + main-loop woke flag storage === +// ESP8266 is always ESPHOME_THREAD_SINGLE. +// NOLINTBEGIN(cppcoreguidelines-avoid-non-const-global-variables) +volatile uint8_t g_wake_requested = 0; +volatile bool g_main_loop_woke = false; +// NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables) + +void IRAM_ATTR wake_loop_any_context() { wake_loop_impl(); } + +} // namespace esphome + +#endif // USE_ESP8266 diff --git a/esphome/core/wake/wake_esp8266.h b/esphome/core/wake/wake_esp8266.h new file mode 100644 index 00000000000..80cd61035be --- /dev/null +++ b/esphome/core/wake/wake_esp8266.h @@ -0,0 +1,47 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_ESP8266 + +#include "esphome/core/hal.h" + +#include + +namespace esphome { + +/// Inline implementation — IRAM callers inline this directly. +inline void ESPHOME_ALWAYS_INLINE wake_loop_impl() { + // Set the wake-requested flag BEFORE esp_schedule so the consumer is + // guaranteed to see it on its next gate check. + wake_request_set(); + g_main_loop_woke = true; + esp_schedule(); +} + +/// IRAM_ATTR entry point for ISR callers — defined in wake_esp8266.cpp. +void wake_loop_any_context(); + +/// Non-ISR: always inline. +inline void wake_loop_threadsafe() { wake_loop_impl(); } + +/// ISR-safe: no task_woken arg because ESP8266 has no FreeRTOS. Caller must be IRAM_ATTR. +inline void ESPHOME_ALWAYS_INLINE wake_loop_isrsafe() { wake_loop_impl(); } + +namespace internal { +inline void ESPHOME_ALWAYS_INLINE wakeable_delay(uint32_t ms) { + if (ms == 0) [[unlikely]] { + delay(0); + return; + } + if (g_main_loop_woke) { + g_main_loop_woke = false; + return; + } + esp_delay(ms, []() { return !g_main_loop_woke; }); +} +} // namespace internal + +} // namespace esphome + +#endif // USE_ESP8266 diff --git a/esphome/core/wake/wake_freertos.cpp b/esphome/core/wake/wake_freertos.cpp new file mode 100644 index 00000000000..0bf700daa89 --- /dev/null +++ b/esphome/core/wake/wake_freertos.cpp @@ -0,0 +1,33 @@ +#include "esphome/core/defines.h" + +#if defined(USE_ESP32) || defined(USE_LIBRETINY) + +#include "esphome/core/hal.h" +#include "esphome/core/wake.h" + +namespace esphome { + +// === Wake-requested flag storage === +// ESP32 is always MULTI_ATOMICS; LibreTiny is MULTI_ATOMICS on chips with +// proper atomics (e.g. RTL8720) and MULTI_NO_ATOMICS on others (e.g. BK72XX). +#ifdef ESPHOME_THREAD_MULTI_ATOMICS +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +std::atomic g_wake_requested{0}; +#else +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +volatile uint8_t g_wake_requested = 0; +#endif + +void IRAM_ATTR wake_loop_isrsafe(BaseType_t *px_higher_priority_task_woken) { + // ISR-safe: set flag before notify so the wake is visible on the next gate + // check. wake_request_set() is just an aligned 8-bit store / atomic store + // and is safe from IRAM. + wake_request_set(); + esphome_main_task_notify_from_isr(px_higher_priority_task_woken); +} + +void IRAM_ATTR wake_loop_any_context() { wake_main_task_any_context(); } + +} // namespace esphome + +#endif // USE_ESP32 || USE_LIBRETINY diff --git a/esphome/core/wake/wake_freertos.h b/esphome/core/wake/wake_freertos.h new file mode 100644 index 00000000000..167a422c614 --- /dev/null +++ b/esphome/core/wake/wake_freertos.h @@ -0,0 +1,60 @@ +#pragma once + +#include "esphome/core/defines.h" + +#if defined(USE_ESP32) || defined(USE_LIBRETINY) + +#include "esphome/core/hal.h" +#include "esphome/core/main_task.h" + +namespace esphome { + +/// Wake the main loop from any context (ISR or task). +/// always_inline so callers placed in IRAM keep the whole wake path in IRAM. +__attribute__((always_inline)) inline void wake_main_task_any_context() { + // Set the wake-requested flag BEFORE the task notification so the consumer + // (Application::loop() gate) is guaranteed to see it on its next gate check. + wake_request_set(); + if (in_isr_context()) { + BaseType_t px_higher_priority_task_woken = pdFALSE; + esphome_main_task_notify_from_isr(&px_higher_priority_task_woken); +#ifdef portYIELD_FROM_ISR + portYIELD_FROM_ISR(px_higher_priority_task_woken); +#else + // ARM9 FreeRTOS port (BK72xx) does not define portYIELD_FROM_ISR; the IRQ + // exit sequence performs the context switch if one was requested. + (void) px_higher_priority_task_woken; +#endif + } else { + esphome_main_task_notify(); + } +} + +/// IRAM_ATTR entry points — defined in wake_freertos.cpp. +void wake_loop_isrsafe(BaseType_t *px_higher_priority_task_woken); +void wake_loop_any_context(); + +inline void wake_loop_threadsafe() { + wake_request_set(); + esphome_main_task_notify(); +} + +namespace internal { +inline void ESPHOME_ALWAYS_INLINE wakeable_delay(uint32_t ms) { + // Fast path (with USE_LWIP_FAST_SELECT): FreeRTOS task notifications posted by the lwip + // event_callback wrapper (see lwip_fast_select.c) are the single source of truth for + // socket wake-ups. Every NETCONN_EVT_RCVPLUS posts an xTaskNotifyGive, so any notification + // that lands between wakes keeps the counter non-zero (next ulTaskNotifyTake returns + // immediately) or wakes a blocked Take directly. Additional wake sources: + // wake_loop_threadsafe() from background tasks, and the ms timeout. + if (ms == 0) [[unlikely]] { + yield(); + return; + } + ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(ms)); +} +} // namespace internal + +} // namespace esphome + +#endif // USE_ESP32 || USE_LIBRETINY diff --git a/esphome/core/wake/wake_generic.cpp b/esphome/core/wake/wake_generic.cpp new file mode 100644 index 00000000000..40044e43115 --- /dev/null +++ b/esphome/core/wake/wake_generic.cpp @@ -0,0 +1,17 @@ +#include "esphome/core/defines.h" + +#if !defined(USE_ESP32) && !defined(USE_LIBRETINY) && !defined(USE_ESP8266) && !defined(USE_RP2040) && \ + !defined(USE_HOST) + +#include "esphome/core/wake.h" + +namespace esphome { + +// === Wake-requested flag storage === +// Fallback platforms (currently only Zephyr/NRF52) are ESPHOME_THREAD_SINGLE. +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +volatile uint8_t g_wake_requested = 0; + +} // namespace esphome + +#endif // fallback guard diff --git a/esphome/core/wake/wake_generic.h b/esphome/core/wake/wake_generic.h new file mode 100644 index 00000000000..85424b61387 --- /dev/null +++ b/esphome/core/wake/wake_generic.h @@ -0,0 +1,31 @@ +#pragma once + +#include "esphome/core/defines.h" + +#if !defined(USE_ESP32) && !defined(USE_LIBRETINY) && !defined(USE_ESP8266) && !defined(USE_RP2040) && \ + !defined(USE_HOST) + +#include "esphome/core/hal.h" + +namespace esphome { + +/// Zephyr is currently the only platform without a wake mechanism. +/// wake_loop_threadsafe() is a no-op and wakeable_delay() falls back to delay(). +/// TODO: implement proper Zephyr wake using k_poll / k_sem or similar. +inline void wake_loop_threadsafe() {} + +inline void wake_loop_any_context() { wake_loop_threadsafe(); } + +namespace internal { +inline void ESPHOME_ALWAYS_INLINE wakeable_delay(uint32_t ms) { + if (ms == 0) [[unlikely]] { + yield(); + return; + } + delay(ms); +} +} // namespace internal + +} // namespace esphome + +#endif // fallback guard diff --git a/esphome/core/wake.cpp b/esphome/core/wake/wake_host.cpp similarity index 74% rename from esphome/core/wake.cpp rename to esphome/core/wake/wake_host.cpp index cac88ae91ef..9d2a650ca24 100644 --- a/esphome/core/wake.cpp +++ b/esphome/core/wake/wake_host.cpp @@ -1,12 +1,11 @@ -#include "esphome/core/wake.h" -#include "esphome/core/hal.h" -#include "esphome/core/log.h" - -#ifdef USE_ESP8266 -#include -#endif +#include "esphome/core/defines.h" #ifdef USE_HOST + +#include "esphome/core/hal.h" +#include "esphome/core/log.h" +#include "esphome/core/wake.h" + #include #include #include @@ -15,88 +14,19 @@ #include #include #include -#endif namespace esphome { // === Wake-requested flag storage === -#ifdef ESPHOME_THREAD_MULTI_ATOMICS +// Host is always ESPHOME_THREAD_MULTI_ATOMICS. // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) std::atomic g_wake_requested{0}; -#else -// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) -volatile uint8_t g_wake_requested = 0; -#endif - -// === ESP32 / LibreTiny — IRAM_ATTR entry points === -#if defined(USE_ESP32) || defined(USE_LIBRETINY) -void IRAM_ATTR wake_loop_isrsafe(BaseType_t *px_higher_priority_task_woken) { - // ISR-safe: set flag before notify so the wake is visible on the next gate - // check. wake_request_set() is just an aligned 8-bit store / atomic store - // and is safe from IRAM. - wake_request_set(); - esphome_main_task_notify_from_isr(px_higher_priority_task_woken); -} -void IRAM_ATTR wake_loop_any_context() { wake_main_task_any_context(); } -#endif - -// === ESP8266 / RP2040 === -#if defined(USE_ESP8266) || defined(USE_RP2040) -// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) -volatile bool g_main_loop_woke = false; -#endif - -#ifdef USE_ESP8266 -void IRAM_ATTR wake_loop_any_context() { wake_loop_impl(); } -#endif - -// === RP2040 — wakeable_delay (needs file-scope state for alarm callback) === -#ifdef USE_RP2040 -// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) -static volatile bool s_delay_expired = false; - -static int64_t alarm_callback_(alarm_id_t id, void *user_data) { - (void) id; - (void) user_data; - s_delay_expired = true; - __sev(); - return 0; -} - -namespace internal { -void wakeable_delay(uint32_t ms) { - if (ms == 0) [[unlikely]] { - yield(); - return; - } - if (g_main_loop_woke) { - g_main_loop_woke = false; - return; - } - s_delay_expired = false; - alarm_id_t alarm = add_alarm_in_ms(ms, alarm_callback_, nullptr, true); - if (alarm <= 0) { - delay(ms); - return; - } - while (!g_main_loop_woke && !s_delay_expired) { - __wfe(); - } - if (!s_delay_expired) - cancel_alarm(alarm); - g_main_loop_woke = false; -} -} // namespace internal -#endif // USE_RP2040 - -// === Host (UDP loopback socket + select() based fd watcher) === -#ifdef USE_HOST static const char *const TAG = "wake"; namespace internal { // File-scope state — referenced inline by wake_drain_notifications() and -// wake_fd_ready() in wake.h, and by the bodies in this file. +// wake_fd_ready() in wake_host.h, and by the bodies in this file. // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) int g_wake_socket_fd = -1; // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) @@ -271,6 +201,7 @@ void wake_setup() { return; } } -#endif // USE_HOST } // namespace esphome + +#endif // USE_HOST diff --git a/esphome/core/wake/wake_host.h b/esphome/core/wake/wake_host.h new file mode 100644 index 00000000000..9756ed4c39b --- /dev/null +++ b/esphome/core/wake/wake_host.h @@ -0,0 +1,64 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_HOST + +#include "esphome/core/hal.h" + +#include +#include + +namespace esphome { + +/// Host: wakes select() via UDP loopback socket. Defined in wake_host.cpp. +void wake_loop_threadsafe(); + +/// Register a socket file descriptor with the host select() loop. Not +/// thread-safe — main loop only. Returns false if fd is invalid or +/// >= FD_SETSIZE. +bool wake_register_fd(int fd); + +/// Unregister a socket file descriptor. Not thread-safe — main loop only. +void wake_unregister_fd(int fd); + +/// One-time setup of the loopback wake socket. Called from Application::setup(). +void wake_setup(); + +inline void wake_loop_any_context() { wake_loop_threadsafe(); } + +namespace internal { +/// Host wakeable_delay uses select() over the registered fds — defined in wake_host.cpp. +void wakeable_delay(uint32_t ms); + +// File-scope state owned by wake_host.cpp. Accessed inline by +// wake_drain_notifications() and wake_fd_ready() so the hot path stays in the header. +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +extern int g_wake_socket_fd; +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +extern fd_set g_read_fds; +} // namespace internal + +inline bool ESPHOME_ALWAYS_INLINE wake_fd_ready(int fd) { return FD_ISSET(fd, &internal::g_read_fds); } + +// Small buffer for draining wake notification bytes (1 byte sent per wake). +// Sized to drain multiple notifications per recvfrom() without wasting stack. +inline constexpr size_t WAKE_NOTIFY_DRAIN_BUFFER_SIZE = 16; + +inline void ESPHOME_ALWAYS_INLINE wake_drain_notifications() { + // Called from main loop to drain any pending wake notifications. + // Must check wake_fd_ready() to avoid blocking on empty socket. + if (internal::g_wake_socket_fd >= 0 && wake_fd_ready(internal::g_wake_socket_fd)) { + char buffer[WAKE_NOTIFY_DRAIN_BUFFER_SIZE]; + // Drain all pending notifications with non-blocking reads. Multiple wake events + // may have triggered multiple writes, so drain until EWOULDBLOCK. We control + // both ends of this loopback socket (always 1 byte per wake), so no error + // checking — any error indicates catastrophic system failure. + while (::recvfrom(internal::g_wake_socket_fd, buffer, sizeof(buffer), 0, nullptr, nullptr) > 0) { + } + } +} + +} // namespace esphome + +#endif // USE_HOST diff --git a/esphome/core/wake/wake_rp2040.cpp b/esphome/core/wake/wake_rp2040.cpp new file mode 100644 index 00000000000..b18248dbd27 --- /dev/null +++ b/esphome/core/wake/wake_rp2040.cpp @@ -0,0 +1,58 @@ +#include "esphome/core/defines.h" + +#ifdef USE_RP2040 + +#include "esphome/core/hal.h" +#include "esphome/core/wake.h" + +#include +#include + +namespace esphome { + +// === Wake-requested flag + main-loop woke flag storage === +// RP2040 is always ESPHOME_THREAD_SINGLE. +// NOLINTBEGIN(cppcoreguidelines-avoid-non-const-global-variables) +volatile uint8_t g_wake_requested = 0; +volatile bool g_main_loop_woke = false; +// NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables) + +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +static volatile bool s_delay_expired = false; + +static int64_t alarm_callback_(alarm_id_t id, void *user_data) { + (void) id; + (void) user_data; + s_delay_expired = true; + __sev(); + return 0; +} + +namespace internal { +void wakeable_delay(uint32_t ms) { + if (ms == 0) [[unlikely]] { + yield(); + return; + } + if (g_main_loop_woke) { + g_main_loop_woke = false; + return; + } + s_delay_expired = false; + alarm_id_t alarm = add_alarm_in_ms(ms, alarm_callback_, nullptr, true); + if (alarm <= 0) { + delay(ms); + return; + } + while (!g_main_loop_woke && !s_delay_expired) { + __wfe(); + } + if (!s_delay_expired) + cancel_alarm(alarm); + g_main_loop_woke = false; +} +} // namespace internal + +} // namespace esphome + +#endif // USE_RP2040 diff --git a/esphome/core/wake/wake_rp2040.h b/esphome/core/wake/wake_rp2040.h new file mode 100644 index 00000000000..ea1242f535c --- /dev/null +++ b/esphome/core/wake/wake_rp2040.h @@ -0,0 +1,31 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_RP2040 + +#include "esphome/core/hal.h" + +#include +#include + +namespace esphome { + +inline void wake_loop_any_context() { + // Set the wake-requested flag BEFORE the SEV so the consumer is guaranteed + // to see it on its next gate check. + wake_request_set(); + g_main_loop_woke = true; + __sev(); +} + +inline void wake_loop_threadsafe() { wake_loop_any_context(); } + +/// RP2040 wakeable delay uses file-scope state (alarm callback + flag) — defined in wake_rp2040.cpp. +namespace internal { +void wakeable_delay(uint32_t ms); +} // namespace internal + +} // namespace esphome + +#endif // USE_RP2040 diff --git a/esphome/loader.py b/esphome/loader.py index 68664aaa265..9390b8094bb 100644 --- a/esphome/loader.py +++ b/esphome/loader.py @@ -31,8 +31,9 @@ class FileResource: class ComponentManifest: - def __init__(self, module: ModuleType): + def __init__(self, module: ModuleType, recursive_sources: bool = False): self.module = module + self.recursive_sources = recursive_sources @property def package(self) -> str: @@ -108,8 +109,10 @@ class ComponentManifest: def resources(self) -> list[FileResource]: """Return a list of all file resources defined in the package of this component. - This will return all cpp source files that are located in the same folder as the - loaded .py file (does not look through subdirectories) + By default only files directly in the package directory are returned. Manifests + constructed with ``recursive_sources=True`` also descend into non-subpackage + subdirectories (subdirectories without an ``__init__.py``), so core code can + live under ``esphome/core//`` without every component paying the cost. """ ret: list[FileResource] = [] @@ -121,23 +124,30 @@ class ComponentManifest: set(filter_source_files_func()) if filter_source_files_func else set() ) - # Process all resources - for resource in ( - r.name - for r in importlib.resources.files(self.package).iterdir() - if r.is_file() - ): - if Path(resource).suffix not in SOURCE_FILE_EXTENSIONS: - continue - if not importlib.resources.files(self.package).joinpath(resource).is_file(): - # Not a resource = this is a directory (yeah this is confusing) - continue + root = importlib.resources.files(self.package) - # Skip excluded files - if resource in excluded_files: - continue + for child in root.iterdir(): + name = child.name + if child.is_file(): + if Path(name).suffix not in SOURCE_FILE_EXTENSIONS: + continue + if name in excluded_files: + continue + ret.append(FileResource(self.package, name)) + elif self.recursive_sources and child.is_dir() and name != "__pycache__": + # Skip Python subpackages — they load as their own components. + if child.joinpath("__init__.py").is_file(): + continue + for sub in child.iterdir(): + if not sub.is_file(): + continue + if Path(sub.name).suffix not in SOURCE_FILE_EXTENSIONS: + continue + resource = f"{name}/{sub.name}" + if resource in excluded_files: + continue + ret.append(FileResource(self.package, resource)) - ret.append(FileResource(self.package, resource)) return ret @@ -237,7 +247,9 @@ def get_platform(domain: str, platform: str) -> ComponentManifest | None: _COMPONENT_CACHE: dict[str, ComponentManifest] = {} CORE_COMPONENTS_PATH = (Path(__file__).parent / "components").resolve() -_COMPONENT_CACHE["esphome"] = ComponentManifest(esphome.core.config) +_COMPONENT_CACHE["esphome"] = ComponentManifest( + esphome.core.config, recursive_sources=True +) def _replace_component_manifest(domain: str, manifest: ComponentManifest) -> None: diff --git a/tests/unit_tests/test_loader.py b/tests/unit_tests/test_loader.py index a42cc5cca73..3fb0eca4a06 100644 --- a/tests/unit_tests/test_loader.py +++ b/tests/unit_tests/test_loader.py @@ -158,3 +158,167 @@ def test_component_manifest_resources_with_filter_source_files() -> None: # Verify the correct number of resources assert len(resources) == 3 # test.cpp, test.h, common.cpp + + +# --------------------------------------------------------------------------- +# recursive_sources — used only by the core "esphome" manifest so that files +# in esphome/core//*.cpp (e.g. esphome/core/wake/wake_host.cpp) are +# discovered without promoting / to a Python subpackage. +# --------------------------------------------------------------------------- + + +def _mock_file(filename: str) -> MagicMock: + m = MagicMock() + m.name = filename + m.is_file.return_value = True + m.is_dir.return_value = False + return m + + +def _mock_dir(dirname: str, children: list, has_init: bool = False) -> MagicMock: + """Mock a directory entry with an iterdir() and joinpath('__init__.py').""" + d = MagicMock() + d.name = dirname + d.is_file.return_value = False + d.is_dir.return_value = True + d.iterdir.return_value = children + init_marker = MagicMock() + init_marker.is_file.return_value = has_init + d.joinpath.return_value = init_marker + return d + + +def test_component_manifest_resources_non_recursive_skips_subdirs() -> None: + """Default (recursive_sources=False) does not descend into subdirectories.""" + mock_module = MagicMock() + mock_module.__package__ = "esphome.components.test_component" + # No FILTER_SOURCE_FILES. + del mock_module.FILTER_SOURCE_FILES + + manifest = ComponentManifest(mock_module) # recursive_sources defaults to False + + top_level = [ + _mock_file("top.cpp"), + _mock_dir("subdir", [_mock_file("nested.cpp")]), + ] + with patch("importlib.resources.files") as mock_files_func: + pkg = MagicMock() + pkg.iterdir.return_value = top_level + mock_files_func.return_value = pkg + + names = [r.resource for r in manifest.resources] + + assert names == ["top.cpp"] + + +def test_component_manifest_resources_recursive_walks_non_subpackage_subdirs() -> None: + """With recursive_sources=True, a subdir without __init__.py is walked.""" + mock_module = MagicMock() + mock_module.__package__ = "esphome.core" + del mock_module.FILTER_SOURCE_FILES + + manifest = ComponentManifest(mock_module, recursive_sources=True) + + wake_dir = _mock_dir( + "wake", + [ + _mock_file("wake_host.cpp"), + _mock_file("wake_host.h"), + _mock_file("README.md"), # wrong suffix, excluded + ], + has_init=False, + ) + top_level = [ + _mock_file("wake.h"), + wake_dir, + ] + with patch("importlib.resources.files") as mock_files_func: + pkg = MagicMock() + pkg.iterdir.return_value = top_level + mock_files_func.return_value = pkg + + names = sorted(r.resource for r in manifest.resources) + + assert names == ["wake.h", "wake/wake_host.cpp", "wake/wake_host.h"] + + +def test_component_manifest_resources_recursive_skips_subpackages() -> None: + """Subdirectories that ARE Python subpackages (contain __init__.py) are + skipped even with recursive_sources=True — those load as their own + ComponentManifest and would otherwise be double-counted.""" + mock_module = MagicMock() + mock_module.__package__ = "esphome.components.haier" + del mock_module.FILTER_SOURCE_FILES + + manifest = ComponentManifest(mock_module, recursive_sources=True) + + button_pkg = _mock_dir( + "button", + [_mock_file("self_cleaning.cpp")], + has_init=True, # Python subpackage — must be skipped. + ) + top_level = [ + _mock_file("haier.cpp"), + button_pkg, + ] + with patch("importlib.resources.files") as mock_files_func: + pkg = MagicMock() + pkg.iterdir.return_value = top_level + mock_files_func.return_value = pkg + + names = [r.resource for r in manifest.resources] + + assert names == ["haier.cpp"] + + +def test_component_manifest_resources_recursive_skips_pycache() -> None: + """__pycache__ inside a recursive walk must never be descended into.""" + mock_module = MagicMock() + mock_module.__package__ = "esphome.core" + del mock_module.FILTER_SOURCE_FILES + + manifest = ComponentManifest(mock_module, recursive_sources=True) + + # __pycache__ is_dir=True but must be skipped without checking __init__.py + # or calling iterdir (would yield compiled artifacts). + pycache = _mock_dir("__pycache__", [_mock_file("wake.cpython-314.pyc")]) + top_level = [ + _mock_file("wake.h"), + pycache, + ] + with patch("importlib.resources.files") as mock_files_func: + pkg = MagicMock() + pkg.iterdir.return_value = top_level + mock_files_func.return_value = pkg + + names = [r.resource for r in manifest.resources] + + assert names == ["wake.h"] + + +def test_component_manifest_resources_recursive_filter_source_files_supports_subpaths() -> ( + None +): + """FILTER_SOURCE_FILES entries using '/'-joined subpaths exclude files + inside a recursively-walked subdir.""" + mock_module = MagicMock() + mock_module.__package__ = "esphome.core" + mock_module.FILTER_SOURCE_FILES = lambda: ["wake/wake_host.cpp"] + + manifest = ComponentManifest(mock_module, recursive_sources=True) + + wake_dir = _mock_dir( + "wake", + [ + _mock_file("wake_host.cpp"), # excluded + _mock_file("wake_freertos.cpp"), # kept + ], + ) + with patch("importlib.resources.files") as mock_files_func: + pkg = MagicMock() + pkg.iterdir.return_value = [wake_dir] + mock_files_func.return_value = pkg + + names = [r.resource for r in manifest.resources] + + assert names == ["wake/wake_freertos.cpp"]